mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-17 12:57:17 +00:00
Merge branch 'alerting-rules' into dev
This commit is contained in:
@@ -144,7 +144,19 @@ export enum ActionsEnum {
|
||||
createEventStreamingDestination = "createEventStreamingDestination",
|
||||
updateEventStreamingDestination = "updateEventStreamingDestination",
|
||||
deleteEventStreamingDestination = "deleteEventStreamingDestination",
|
||||
listEventStreamingDestinations = "listEventStreamingDestinations"
|
||||
listEventStreamingDestinations = "listEventStreamingDestinations",
|
||||
createAlertRule = "createAlertRule",
|
||||
updateAlertRule = "updateAlertRule",
|
||||
deleteAlertRule = "deleteAlertRule",
|
||||
listAlertRules = "listAlertRules",
|
||||
getAlertRule = "getAlertRule",
|
||||
createHealthCheck = "createHealthCheck",
|
||||
updateHealthCheck = "updateHealthCheck",
|
||||
deleteHealthCheck = "deleteHealthCheck",
|
||||
listHealthChecks = "listHealthChecks",
|
||||
triggerSiteAlert = "triggerSiteAlert",
|
||||
triggerResourceAlert = "triggerResourceAlert",
|
||||
triggerHealthCheckAlert = "triggerHealthCheckAlert"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -16,11 +16,14 @@ import {
|
||||
domains,
|
||||
orgs,
|
||||
targets,
|
||||
roles,
|
||||
users,
|
||||
exitNodes,
|
||||
sessions,
|
||||
clients,
|
||||
resources,
|
||||
siteResources,
|
||||
targetHealthCheck,
|
||||
sites
|
||||
} from "./schema";
|
||||
|
||||
@@ -425,7 +428,9 @@ export const eventStreamingDestinations = pgTable(
|
||||
orgId: varchar("orgId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false),
|
||||
sendConnectionLogs: boolean("sendConnectionLogs")
|
||||
.notNull()
|
||||
.default(false),
|
||||
sendRequestLogs: boolean("sendRequestLogs").notNull().default(false),
|
||||
sendActionLogs: boolean("sendActionLogs").notNull().default(false),
|
||||
sendAccessLogs: boolean("sendAccessLogs").notNull().default(false),
|
||||
@@ -447,7 +452,9 @@ export const eventStreamingCursors = pgTable(
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection"
|
||||
lastSentId: bigint("lastSentId", { mode: "number" }).notNull().default(0),
|
||||
lastSentId: bigint("lastSentId", { mode: "number" })
|
||||
.notNull()
|
||||
.default(0),
|
||||
lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent
|
||||
},
|
||||
(table) => [
|
||||
@@ -458,6 +465,104 @@ export const eventStreamingCursors = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const alertRules = pgTable("alertRules", {
|
||||
alertRuleId: serial("alertRuleId").primaryKey(),
|
||||
orgId: varchar("orgId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
// Single field encodes both source and trigger - no redundancy
|
||||
eventType: varchar("eventType", { length: 100 })
|
||||
.$type<
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle"
|
||||
>()
|
||||
.notNull(),
|
||||
// Nullable depending on eventType
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
|
||||
allSites: boolean("allSites").notNull().default(false),
|
||||
allHealthChecks: boolean("allHealthChecks").notNull().default(false),
|
||||
allResources: boolean("allResources").notNull().default(false),
|
||||
lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const alertSites = pgTable("alertSites", {
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const alertHealthChecks = pgTable("alertHealthChecks", {
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
healthCheckId: integer("healthCheckId")
|
||||
.notNull()
|
||||
.references(() => targetHealthCheck.targetHealthCheckId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const alertResources = pgTable("alertResources", {
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
// Separating channels by type avoids the mixed-shape problem entirely
|
||||
export const alertEmailActions = pgTable("alertEmailActions", {
|
||||
emailActionId: serial("emailActionId").primaryKey(),
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
|
||||
});
|
||||
|
||||
export const alertEmailRecipients = pgTable("alertEmailRecipients", {
|
||||
recipientId: serial("recipientId").primaryKey(),
|
||||
emailActionId: integer("emailActionId")
|
||||
.notNull()
|
||||
.references(() => alertEmailActions.emailActionId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
// At least one of these should be set - enforced at app level
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
roleId: integer("roleId").references(() => roles.roleId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
email: varchar("email", { length: 255 }) // external emails not tied to a user
|
||||
});
|
||||
|
||||
export const alertWebhookActions = pgTable("alertWebhookActions", {
|
||||
webhookActionId: serial("webhookActionId").primaryKey(),
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
webhookUrl: text("webhookUrl").notNull(),
|
||||
config: text("config"), // encrypted JSON with auth config (authType, credentials)
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -495,3 +600,4 @@ export type EventStreamingDestination = InferSelectModel<
|
||||
export type EventStreamingCursor = InferSelectModel<
|
||||
typeof eventStreamingCursors
|
||||
>;
|
||||
export type AlertResources = InferSelectModel<typeof alertResources>;
|
||||
|
||||
@@ -57,7 +57,9 @@ export const orgs = pgTable("orgs", {
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
settingsLogRetentionDaysConnection: integer(
|
||||
"settingsLogRetentionDaysConnection"
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
@@ -101,7 +103,9 @@ export const sites = pgTable("sites", {
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||
status: varchar("status").$type<"pending" | "approved">().default("approved")
|
||||
status: varchar("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
});
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
@@ -182,9 +186,18 @@ export const targets = pgTable("targets", {
|
||||
|
||||
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
|
||||
targetId: integer("targetId")
|
||||
.notNull()
|
||||
.references(() => targets.targetId, { onDelete: "cascade" }),
|
||||
targetId: integer("targetId").references(() => targets.targetId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}).notNull(),
|
||||
name: varchar("name"),
|
||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||
hcPath: varchar("hcPath"),
|
||||
hcScheme: varchar("hcScheme"),
|
||||
@@ -201,7 +214,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName")
|
||||
hcTlsServerName: text("hcTlsServerName"),
|
||||
hcHealthyThreshold: integer("hcHealthyThreshold").default(1),
|
||||
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1)
|
||||
});
|
||||
|
||||
export const exitNodes = pgTable("exitNodes", {
|
||||
@@ -222,16 +237,23 @@ export const exitNodes = pgTable("exitNodes", {
|
||||
export const siteResources = pgTable("siteResources", {
|
||||
// this is for the clients
|
||||
siteResourceId: serial("siteResourceId").primaryKey(),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
defaultNetworkId: integer("defaultNetworkId").references(
|
||||
() => networks.networkId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: varchar("protocol"), // only for port mode
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
||||
@@ -244,7 +266,38 @@ export const siteResources = pgTable("siteResources", {
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote">()
|
||||
.default("site")
|
||||
.default("site"),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain")
|
||||
});
|
||||
|
||||
export const networks = pgTable("networks", {
|
||||
networkId: serial("networkId").primaryKey(),
|
||||
niceId: text("niceId"),
|
||||
name: text("name"),
|
||||
scope: varchar("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const siteNetworks = pgTable("siteNetworks", {
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
networkId: integer("networkId")
|
||||
.notNull()
|
||||
.references(() => networks.networkId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const clientSiteResources = pgTable("clientSiteResources", {
|
||||
@@ -994,6 +1047,7 @@ export const requestAuditLog = pgTable(
|
||||
actor: text("actor"),
|
||||
actorId: text("actorId"),
|
||||
resourceId: integer("resourceId"),
|
||||
siteResourceId: integer("siteResourceId"),
|
||||
ip: text("ip"),
|
||||
location: text("location"),
|
||||
userAgent: text("userAgent"),
|
||||
@@ -1041,6 +1095,20 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
||||
complete: boolean("complete").notNull().default(false)
|
||||
});
|
||||
|
||||
export const statusHistory = pgTable("statusHistory", {
|
||||
id: serial("id").primaryKey(),
|
||||
entityType: varchar("entityType").notNull(),
|
||||
entityId: integer("entityId").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: varchar("status").notNull(),
|
||||
timestamp: integer("timestamp").notNull(),
|
||||
}, (table) => [
|
||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||
]);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1107,3 +1175,5 @@ export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
|
||||
@@ -13,9 +13,12 @@ import {
|
||||
domains,
|
||||
exitNodes,
|
||||
orgs,
|
||||
resources,
|
||||
roles,
|
||||
sessions,
|
||||
siteResources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
users
|
||||
} from "./schema";
|
||||
|
||||
@@ -455,6 +458,94 @@ export const eventStreamingCursors = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const alertRules = sqliteTable("alertRules", {
|
||||
alertRuleId: integer("alertRuleId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
eventType: text("eventType")
|
||||
.$type<
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle"
|
||||
>()
|
||||
.notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
|
||||
allSites: integer("allSites", { mode: "boolean" }).notNull().default(false),
|
||||
allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false),
|
||||
allResources: integer("allResources", { mode: "boolean" }).notNull().default(false),
|
||||
lastTriggeredAt: integer("lastTriggeredAt"),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const alertSites = sqliteTable("alertSites", {
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const alertHealthChecks = sqliteTable("alertHealthChecks", {
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
healthCheckId: integer("healthCheckId")
|
||||
.notNull()
|
||||
.references(() => targetHealthCheck.targetHealthCheckId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const alertResources = sqliteTable("alertResources", {
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const alertEmailActions = sqliteTable("alertEmailActions", {
|
||||
emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }),
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastSentAt: integer("lastSentAt")
|
||||
});
|
||||
|
||||
export const alertEmailRecipients = sqliteTable("alertEmailRecipients", {
|
||||
recipientId: integer("recipientId").primaryKey({ autoIncrement: true }),
|
||||
emailActionId: integer("emailActionId")
|
||||
.notNull()
|
||||
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
|
||||
userId: text("userId").references(() => users.userId, { onDelete: "cascade" }),
|
||||
roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
email: text("email")
|
||||
});
|
||||
|
||||
export const alertWebhookActions = sqliteTable("alertWebhookActions", {
|
||||
webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }),
|
||||
alertRuleId: integer("alertRuleId")
|
||||
.notNull()
|
||||
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
|
||||
webhookUrl: text("webhookUrl").notNull(),
|
||||
config: text("config"), // encrypted JSON with auth config (authType, credentials)
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastSentAt: integer("lastSentAt")
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -486,3 +577,4 @@ export type EventStreamingDestination = InferSelectModel<
|
||||
export type EventStreamingCursor = InferSelectModel<
|
||||
typeof eventStreamingCursors
|
||||
>;
|
||||
export type AlertResources = InferSelectModel<typeof alertResources>;
|
||||
|
||||
@@ -54,7 +54,9 @@ export const orgs = sqliteTable("orgs", {
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
settingsLogRetentionDaysConnection: integer(
|
||||
"settingsLogRetentionDaysConnection"
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
@@ -92,6 +94,9 @@ export const sites = sqliteTable("sites", {
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
subnet: text("subnet"),
|
||||
@@ -204,9 +209,18 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
targetId: integer("targetId")
|
||||
.notNull()
|
||||
.references(() => targets.targetId, { onDelete: "cascade" }),
|
||||
targetId: integer("targetId").references(() => targets.targetId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}).notNull(),
|
||||
name: text("name"),
|
||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
@@ -227,7 +241,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName")
|
||||
hcTlsServerName: text("hcTlsServerName"),
|
||||
hcHealthyThreshold: integer("hcHealthyThreshold").default(1),
|
||||
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1)
|
||||
});
|
||||
|
||||
export const exitNodes = sqliteTable("exitNodes", {
|
||||
@@ -250,16 +266,21 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
siteResourceId: integer("siteResourceId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
defaultNetworkId: integer("defaultNetworkId").references(
|
||||
() => networks.networkId,
|
||||
{ onDelete: "restrict" }
|
||||
),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: text("protocol"), // only for port mode
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||
@@ -274,7 +295,36 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote">()
|
||||
.default("site")
|
||||
.default("site"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain")
|
||||
});
|
||||
|
||||
export const networks = sqliteTable("networks", {
|
||||
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
|
||||
niceId: text("niceId"),
|
||||
name: text("name"),
|
||||
scope: text("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const siteNetworks = sqliteTable("siteNetworks", {
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
networkId: integer("networkId")
|
||||
.notNull()
|
||||
.references(() => networks.networkId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
||||
@@ -1096,6 +1146,7 @@ export const requestAuditLog = sqliteTable(
|
||||
actor: text("actor"),
|
||||
actorId: text("actorId"),
|
||||
resourceId: integer("resourceId"),
|
||||
siteResourceId: integer("siteResourceId"),
|
||||
ip: text("ip"),
|
||||
location: text("location"),
|
||||
userAgent: text("userAgent"),
|
||||
@@ -1143,6 +1194,20 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const statusHistory = sqliteTable("statusHistory", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
||||
}, (table) => [
|
||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||
]);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1195,6 +1260,7 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
export type SiteResource = InferSelectModel<typeof siteResources>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
||||
@@ -1209,3 +1275,4 @@ export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
|
||||
201
server/emails/templates/AlertNotification.tsx
Normal file
201
server/emails/templates/AlertNotification.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailInfoSection,
|
||||
EmailLetterHead,
|
||||
EmailSignature,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
|
||||
export type AlertEventType =
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle";
|
||||
|
||||
interface Props {
|
||||
eventType: AlertEventType;
|
||||
orgId: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getEventMeta(eventType: AlertEventType): {
|
||||
heading: string;
|
||||
previewText: string;
|
||||
summary: string;
|
||||
statusLabel: string;
|
||||
statusColor: string;
|
||||
} {
|
||||
switch (eventType) {
|
||||
case "site_online":
|
||||
return {
|
||||
heading: "Site Back Online",
|
||||
previewText: "A site in your organization is back online.",
|
||||
summary:
|
||||
"Good news – a site in your organization has come back online and is now reachable.",
|
||||
statusLabel: "Online",
|
||||
statusColor: "#16a34a"
|
||||
};
|
||||
case "site_offline":
|
||||
return {
|
||||
heading: "Site Offline",
|
||||
previewText: "A site in your organization has gone offline.",
|
||||
summary:
|
||||
"A site in your organization has gone offline and is no longer reachable. Please investigate as soon as possible.",
|
||||
statusLabel: "Offline",
|
||||
statusColor: "#dc2626"
|
||||
};
|
||||
case "site_toggle":
|
||||
return {
|
||||
heading: "Site Status Changed",
|
||||
previewText: "A site in your organization has changed status.",
|
||||
summary:
|
||||
"A site in your organization has changed status. Please review the details below and take action if needed.",
|
||||
statusLabel: "Status Changed",
|
||||
statusColor: "#f59e0b"
|
||||
};
|
||||
case "health_check_healthy":
|
||||
return {
|
||||
heading: "Health Check Recovered",
|
||||
previewText:
|
||||
"A health check in your organization is now healthy.",
|
||||
summary:
|
||||
"A health check in your organization has recovered and is now reporting a healthy status.",
|
||||
statusLabel: "Healthy",
|
||||
statusColor: "#16a34a"
|
||||
};
|
||||
case "health_check_unhealthy":
|
||||
return {
|
||||
heading: "Health Check Failing",
|
||||
previewText:
|
||||
"A health check in your organization is not healthy.",
|
||||
summary:
|
||||
"A health check in your organization is currently failing. Please review the details below and take action if needed.",
|
||||
statusLabel: "Not Healthy",
|
||||
statusColor: "#dc2626"
|
||||
};
|
||||
case "health_check_toggle":
|
||||
return {
|
||||
heading: "Health Check Status Changed",
|
||||
previewText:
|
||||
"A health check in your organization has changed status.",
|
||||
summary:
|
||||
"A health check in your organization has changed status. Please review the details below and take action if needed.",
|
||||
statusLabel: "Status Changed",
|
||||
statusColor: "#f59e0b"
|
||||
};
|
||||
case "resource_healthy":
|
||||
return {
|
||||
heading: "Resource Healthy",
|
||||
previewText: "A resource in your organization is now healthy.",
|
||||
summary:
|
||||
"A resource in your organization has recovered and is now reporting a healthy status.",
|
||||
statusLabel: "Healthy",
|
||||
statusColor: "#16a34a"
|
||||
};
|
||||
case "resource_unhealthy":
|
||||
return {
|
||||
heading: "Resource Unhealthy",
|
||||
previewText: "A resource in your organization is not healthy.",
|
||||
summary:
|
||||
"A resource in your organization is currently unhealthy. Please review the details below and take action if needed.",
|
||||
statusLabel: "Unhealthy",
|
||||
statusColor: "#dc2626"
|
||||
};
|
||||
case "resource_toggle":
|
||||
return {
|
||||
heading: "Resource Status Changed",
|
||||
previewText:
|
||||
"A resource in your organization has changed status.",
|
||||
summary:
|
||||
"A resource in your organization has changed status. Please review the details below and take action if needed.",
|
||||
statusLabel: "Status Changed",
|
||||
statusColor: "#f59e0b"
|
||||
};
|
||||
default:
|
||||
return {
|
||||
heading: "Alert Notification",
|
||||
previewText: "An alert event has occurred in your organization.",
|
||||
summary:
|
||||
"An alert event has occurred in your organization. Please review the details below and take action if needed.",
|
||||
statusLabel: "Alert",
|
||||
statusColor: "#f59e0b"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatDataItems(
|
||||
data: Record<string, unknown>
|
||||
): { label: string; value: React.ReactNode }[] {
|
||||
return Object.entries(data)
|
||||
.filter(([key]) => key !== "orgId")
|
||||
.map(([key, value]) => ({
|
||||
label: key
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (s) => s.toUpperCase())
|
||||
.trim(),
|
||||
value: String(value ?? "-")
|
||||
}));
|
||||
}
|
||||
|
||||
export const AlertNotification = ({ eventType, orgId, data }: Props) => {
|
||||
const meta = getEventMeta(eventType);
|
||||
const dataItems = formatDataItems(data);
|
||||
|
||||
const allItems: { label: string; value: React.ReactNode }[] = [
|
||||
{ label: "Organization", value: orgId },
|
||||
{ label: "Status", value: (
|
||||
<span style={{ color: meta.statusColor, fontWeight: 600 }}>
|
||||
{meta.statusLabel}
|
||||
</span>
|
||||
)},
|
||||
{ label: "Time", value: new Date().toUTCString() },
|
||||
...dataItems
|
||||
];
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{meta.previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailHeading>{meta.heading}</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>{meta.summary}</EmailText>
|
||||
|
||||
<EmailInfoSection
|
||||
title="Event Details"
|
||||
items={allItems}
|
||||
/>
|
||||
|
||||
<EmailText>
|
||||
Log in to your dashboard to view more details and
|
||||
manage your alert rules.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertNotification;
|
||||
@@ -32,7 +32,7 @@ export const EnterpriseEditionKeyGenerated = ({
|
||||
}: EnterpriseEditionKeyGeneratedProps) => {
|
||||
const previewText = personalUseOnly
|
||||
? "Your Enterprise Edition key for personal use is ready"
|
||||
: "Thank you for your purchase — your Enterprise Edition key is ready";
|
||||
: "Thank you for your purchase - your Enterprise Edition key is ready";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
||||
import { initCleanup } from "#dynamic/cleanup";
|
||||
import license from "#dynamic/license/license";
|
||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||
|
||||
async function startServers() {
|
||||
@@ -39,6 +40,7 @@ async function startServers() {
|
||||
initTelemetryClient();
|
||||
|
||||
initLogCleanupInterval();
|
||||
initAcmeCertSync();
|
||||
|
||||
// Start all servers
|
||||
const apiServer = createApiServer();
|
||||
|
||||
3
server/lib/acmeCertSync.ts
Normal file
3
server/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function initAcmeCertSync(): void {
|
||||
// stub
|
||||
}
|
||||
19
server/lib/alerts/events/healthCheckEvents.ts
Normal file
19
server/lib/alerts/events/healthCheckEvents.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// stub
|
||||
|
||||
export async function fireHealthCheckHealthyAlert(
|
||||
orgId: string,
|
||||
healthCheckId: number,
|
||||
healthCheckName?: string,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function fireHealthCheckNotHealthyAlert(
|
||||
orgId: string,
|
||||
healthCheckId: number,
|
||||
healthCheckName?: string,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
19
server/lib/alerts/events/siteEvents.ts
Normal file
19
server/lib/alerts/events/siteEvents.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// stub
|
||||
|
||||
export async function fireSiteOnlineAlert(
|
||||
orgId: string,
|
||||
siteId: number,
|
||||
siteName?: string,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function fireSiteOfflineAlert(
|
||||
orgId: string,
|
||||
siteId: number,
|
||||
siteName?: string,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
2
server/lib/alerts/index.ts
Normal file
2
server/lib/alerts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./events/siteEvents";
|
||||
export * from "./events/healthCheckEvents";
|
||||
@@ -20,7 +20,10 @@ export enum TierFeature {
|
||||
FullRbac = "fullRbac",
|
||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
||||
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
|
||||
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
|
||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||
AlertingRules = "alertingRules"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -58,5 +61,8 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||
[TierFeature.SIEM]: ["enterprise"],
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -121,8 +121,8 @@ export async function applyBlueprint({
|
||||
for (const result of clientResourcesResults) {
|
||||
if (
|
||||
result.oldSiteResource &&
|
||||
result.oldSiteResource.siteId !=
|
||||
result.newSiteResource.siteId
|
||||
JSON.stringify(result.newSites?.sort()) !==
|
||||
JSON.stringify(result.oldSites?.sort())
|
||||
) {
|
||||
// query existing associations
|
||||
const existingRoleIds = await trx
|
||||
@@ -222,38 +222,46 @@ export async function applyBlueprint({
|
||||
trx
|
||||
);
|
||||
} else {
|
||||
const [newSite] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, result.newSiteResource.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
let good = true;
|
||||
for (const newSite of result.newSites) {
|
||||
const [site] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, newSite.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
logger.debug(
|
||||
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||
);
|
||||
good = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!newSite) {
|
||||
logger.debug(
|
||||
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
|
||||
);
|
||||
if (!good) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await handleMessagingForUpdatedSiteResource(
|
||||
result.oldSiteResource,
|
||||
result.newSiteResource,
|
||||
{
|
||||
siteId: newSite.sites.siteId,
|
||||
orgId: newSite.sites.orgId
|
||||
},
|
||||
result.newSites.map((site) => ({
|
||||
siteId: site.siteId,
|
||||
orgId: result.newSiteResource.orgId
|
||||
})),
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,104 @@
|
||||
import {
|
||||
clients,
|
||||
clientSiteResources,
|
||||
domains,
|
||||
orgDomains,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
Site,
|
||||
SiteResource,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
Transaction,
|
||||
userOrgs,
|
||||
users,
|
||||
userSiteResources
|
||||
userSiteResources,
|
||||
networks
|
||||
} from "@server/db";
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and, ne, inArray, or } from "drizzle-orm";
|
||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||
import { Config } from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { getNextAvailableAliasAddress } from "../ip";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
|
||||
async function getDomainForSiteResource(
|
||||
siteResourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
trx: Transaction
|
||||
): Promise<{ subdomain: string | null; domainId: string }> {
|
||||
const [fullDomainExists] = await trx
|
||||
.select({ siteResourceId: siteResources.siteResourceId })
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.fullDomain, fullDomain),
|
||||
eq(siteResources.orgId, orgId),
|
||||
siteResourceId
|
||||
? ne(siteResources.siteResourceId, siteResourceId)
|
||||
: isNotNull(siteResources.siteResourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (fullDomainExists) {
|
||||
throw new Error(
|
||||
`Site resource already exists with domain: ${fullDomain} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const possibleDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId))
|
||||
.where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true)))
|
||||
.execute();
|
||||
|
||||
if (possibleDomains.length === 0) {
|
||||
throw new Error(
|
||||
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const validDomains = possibleDomains.filter((domain) => {
|
||||
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
|
||||
return (
|
||||
fullDomain === domain.domains.baseDomain ||
|
||||
fullDomain.endsWith(`.${domain.domains.baseDomain}`)
|
||||
);
|
||||
} else if (domain.domains.type == "cname") {
|
||||
return fullDomain === domain.domains.baseDomain;
|
||||
}
|
||||
});
|
||||
|
||||
if (validDomains.length === 0) {
|
||||
throw new Error(
|
||||
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const domainSelection = validDomains[0].domains;
|
||||
const baseDomain = domainSelection.baseDomain;
|
||||
|
||||
let subdomain: string | null = null;
|
||||
if (fullDomain !== baseDomain) {
|
||||
subdomain = fullDomain.replace(`.${baseDomain}`, "");
|
||||
}
|
||||
|
||||
await createCertificate(domainSelection.domainId, fullDomain, trx);
|
||||
|
||||
return {
|
||||
subdomain,
|
||||
domainId: domainSelection.domainId
|
||||
};
|
||||
}
|
||||
|
||||
export type ClientResourcesResults = {
|
||||
newSiteResource: SiteResource;
|
||||
oldSiteResource?: SiteResource;
|
||||
newSites: { siteId: number }[];
|
||||
oldSites: { siteId: number }[];
|
||||
}[];
|
||||
|
||||
export async function updateClientResources(
|
||||
@@ -43,53 +123,104 @@ export async function updateClientResources(
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const resourceSiteId = resourceData.site;
|
||||
let site;
|
||||
const existingSiteIds = existingResource?.networkId
|
||||
? await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, existingResource.networkId))
|
||||
: [];
|
||||
|
||||
if (resourceSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
let allSites: { siteId: number }[] = [];
|
||||
if (resourceData.site) {
|
||||
let siteSingle;
|
||||
const resourceSiteId = resourceData.site;
|
||||
|
||||
if (resourceSiteId) {
|
||||
// Look up site by niceId
|
||||
[siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
|
||||
if (!siteSingle) {
|
||||
throw new Error(
|
||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
allSites.push(siteSingle);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||
);
|
||||
if (resourceData.sites) {
|
||||
for (const siteNiceId of resourceData.sites) {
|
||||
const [site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, siteNiceId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${siteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
allSites.push(site);
|
||||
}
|
||||
}
|
||||
|
||||
if (existingResource) {
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
| undefined;
|
||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
||||
domainInfo = await getDomainForSiteResource(
|
||||
existingResource.siteResourceId,
|
||||
resourceData["full-domain"],
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// Update existing resource
|
||||
const [updatedResource] = await trx
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name: resourceData.name || resourceNiceId,
|
||||
siteId: site.siteId,
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceData.ssl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: true, // hardcoded for now
|
||||
// enabled: resourceData.enabled ?? true,
|
||||
alias: resourceData.alias || null,
|
||||
disableIcmp: resourceData["disable-icmp"],
|
||||
tcpPortRangeString: resourceData["tcp-ports"],
|
||||
udpPortRangeString: resourceData["udp-ports"]
|
||||
udpPortRangeString: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||
domainId: domainInfo ? domainInfo.domainId : null
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -100,7 +231,21 @@ export async function updateClientResources(
|
||||
.returning();
|
||||
|
||||
const siteResourceId = existingResource.siteResourceId;
|
||||
const orgId = existingResource.orgId;
|
||||
|
||||
if (updatedResource.networkId) {
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
eq(siteNetworks.networkId, updatedResource.networkId)
|
||||
);
|
||||
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: updatedResource.networkId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await trx
|
||||
.delete(clientSiteResources)
|
||||
@@ -204,37 +349,72 @@ export async function updateClientResources(
|
||||
|
||||
results.push({
|
||||
newSiteResource: updatedResource,
|
||||
oldSiteResource: existingResource
|
||||
oldSiteResource: existingResource,
|
||||
newSites: allSites,
|
||||
oldSites: existingSiteIds
|
||||
});
|
||||
} else {
|
||||
let aliasAddress: string | null = null;
|
||||
if (resourceData.mode == "host") {
|
||||
// we can only have an alias on a host
|
||||
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||
}
|
||||
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
| undefined;
|
||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
||||
domainInfo = await getDomainForSiteResource(
|
||||
undefined,
|
||||
resourceData["full-domain"],
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create new resource
|
||||
const [newResource] = await trx
|
||||
.insert(siteResources)
|
||||
.values({
|
||||
orgId: orgId,
|
||||
siteId: site.siteId,
|
||||
niceId: resourceNiceId,
|
||||
networkId: network.networkId,
|
||||
defaultNetworkId: network.networkId,
|
||||
name: resourceData.name || resourceNiceId,
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceData.ssl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: true, // hardcoded for now
|
||||
// enabled: resourceData.enabled ?? true,
|
||||
alias: resourceData.alias || null,
|
||||
aliasAddress: aliasAddress,
|
||||
disableIcmp: resourceData["disable-icmp"],
|
||||
tcpPortRangeString: resourceData["tcp-ports"],
|
||||
udpPortRangeString: resourceData["udp-ports"]
|
||||
udpPortRangeString: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||
domainId: domainInfo ? domainInfo.domainId : null
|
||||
})
|
||||
.returning();
|
||||
|
||||
const siteResourceId = newResource.siteResourceId;
|
||||
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -324,7 +504,11 @@ export async function updateClientResources(
|
||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||
);
|
||||
|
||||
results.push({ newSiteResource: newResource });
|
||||
results.push({
|
||||
newSiteResource: newResource,
|
||||
newSites: allSites,
|
||||
oldSites: existingSiteIds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,10 @@ export async function updateProxyResources(
|
||||
const [newHealthcheck] = await trx
|
||||
.insert(targetHealthCheck)
|
||||
.values({
|
||||
name: `${targetData.hostname}:${targetData.port}`,
|
||||
siteId: site.siteId,
|
||||
targetId: newTarget.targetId,
|
||||
orgId: orgId,
|
||||
hcEnabled: healthcheckData?.enabled || false,
|
||||
hcPath: healthcheckData?.path,
|
||||
hcScheme: healthcheckData?.scheme,
|
||||
@@ -158,7 +161,9 @@ export async function updateProxyResources(
|
||||
healthcheckData?.["follow-redirects"],
|
||||
hcMethod: healthcheckData?.method,
|
||||
hcStatus: healthcheckData?.status,
|
||||
hcHealth: "unknown"
|
||||
hcHealth: "unknown",
|
||||
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
|
||||
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -522,7 +527,9 @@ export async function updateProxyResources(
|
||||
healthcheckData?.followRedirects ||
|
||||
healthcheckData?.["follow-redirects"],
|
||||
hcMethod: healthcheckData?.method,
|
||||
hcStatus: healthcheckData?.status
|
||||
hcStatus: healthcheckData?.status,
|
||||
hcHealthyThreshold: healthcheckData?.["healthy-threshold"],
|
||||
hcUnhealthyThreshold: healthcheckData?.["unhealthy-threshold"]
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -1081,6 +1088,8 @@ function checkIfHealthcheckChanged(
|
||||
JSON.stringify(incoming.hcHeaders)
|
||||
)
|
||||
return true;
|
||||
if (existing.hcHealthyThreshold !== incoming.hcHealthyThreshold) return true;
|
||||
if (existing.hcUnhealthyThreshold !== incoming.hcUnhealthyThreshold) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1100,7 +1109,7 @@ function checkIfTargetChanged(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getDomain(
|
||||
export async function getDomain(
|
||||
resourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
|
||||
@@ -12,7 +12,7 @@ export const TargetHealthCheckSchema = z.object({
|
||||
hostname: z.string(),
|
||||
port: z.int().min(1).max(65535),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
path: z.string().optional().default("/"),
|
||||
path: z.string().optional(),
|
||||
scheme: z.string().optional(),
|
||||
mode: z.string().default("http"),
|
||||
interval: z.int().default(30),
|
||||
@@ -26,8 +26,10 @@ export const TargetHealthCheckSchema = z.object({
|
||||
.default(null),
|
||||
"follow-redirects": z.boolean().default(true),
|
||||
followRedirects: z.boolean().optional(), // deprecated alias
|
||||
method: z.string().default("GET"),
|
||||
status: z.int().optional()
|
||||
method: z.string().optional(),
|
||||
status: z.int().optional(),
|
||||
"healthy-threshold": z.int().min(1).optional().default(1),
|
||||
"unhealthy-threshold": z.int().min(1).optional().default(1)
|
||||
});
|
||||
|
||||
// Schema for individual target within a resource
|
||||
@@ -164,6 +166,7 @@ export const ResourceSchema = z
|
||||
name: z.string().optional(),
|
||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
"full-domain": z.string().optional(),
|
||||
"proxy-port": z.int().min(1).max(65535).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -325,16 +328,20 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
||||
export const ClientResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr"]),
|
||||
site: z.string(),
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
site: z.string(), // DEPRECATED IN FAVOR OF sites
|
||||
sites: z.array(z.string()).optional().default([]),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
// destinationPort: z.int().positive().optional(),
|
||||
"destination-port": z.int().positive().optional(),
|
||||
destination: z.string().min(1),
|
||||
// enabled: z.boolean().default(true),
|
||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"disable-icmp": z.boolean().optional().default(false),
|
||||
"full-domain": z.string().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional().nullable(),
|
||||
alias: z
|
||||
.string()
|
||||
.regex(
|
||||
@@ -477,6 +484,39 @@ export const ConfigSchema = z
|
||||
});
|
||||
}
|
||||
|
||||
// Enforce the full-domain uniqueness across client-resources in the same stack
|
||||
const clientFullDomainMap = new Map<string, string[]>();
|
||||
|
||||
Object.entries(config["client-resources"]).forEach(
|
||||
([resourceKey, resource]) => {
|
||||
const fullDomain = resource["full-domain"];
|
||||
if (fullDomain) {
|
||||
if (!clientFullDomainMap.has(fullDomain)) {
|
||||
clientFullDomainMap.set(fullDomain, []);
|
||||
}
|
||||
clientFullDomainMap.get(fullDomain)!.push(resourceKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const clientFullDomainDuplicates = Array.from(
|
||||
clientFullDomainMap.entries()
|
||||
)
|
||||
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
|
||||
.map(
|
||||
([fullDomain, resourceKeys]) =>
|
||||
`'${fullDomain}' used by resources: ${resourceKeys.join(", ")}`
|
||||
)
|
||||
.join("; ");
|
||||
|
||||
if (clientFullDomainDuplicates.length !== 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["client-resources"],
|
||||
message: `Duplicate 'full-domain' values found: ${clientFullDomainDuplicates}`
|
||||
});
|
||||
}
|
||||
|
||||
// Enforce proxy-port uniqueness within proxy-resources per protocol
|
||||
const protocolPortMap = new Map<string, string[]>();
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export function encryptData(data: string, key: Buffer): string {
|
||||
const algorithm = "aes-256-gcm";
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
|
||||
let encrypted = cipher.update(data, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine IV, auth tag, and encrypted data
|
||||
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
|
||||
}
|
||||
|
||||
// Helper function to decrypt data (you'll need this to read certificates)
|
||||
export function decryptData(encryptedData: string, key: Buffer): string {
|
||||
const algorithm = "aes-256-gcm";
|
||||
const parts = encryptedData.split(":");
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid encrypted data format");
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], "hex");
|
||||
const authTag = Buffer.from(parts[1], "hex");
|
||||
const encrypted = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// openssl rand -hex 32 > config/encryption.key
|
||||
@@ -5,6 +5,7 @@ import config from "@server/lib/config";
|
||||
import z from "zod";
|
||||
import logger from "@server/logger";
|
||||
import semver from "semver";
|
||||
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
|
||||
|
||||
interface IPRange {
|
||||
start: bigint;
|
||||
@@ -477,9 +478,9 @@ export type Alias = { alias: string | null; aliasAddress: string | null };
|
||||
|
||||
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
||||
return allSiteResources
|
||||
.filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host")
|
||||
.filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http")))
|
||||
.map((sr) => ({
|
||||
alias: sr.alias,
|
||||
alias: sr.alias || sr.fullDomain,
|
||||
aliasAddress: sr.aliasAddress
|
||||
}));
|
||||
}
|
||||
@@ -582,16 +583,26 @@ export type SubnetProxyTargetV2 = {
|
||||
protocol: "tcp" | "udp";
|
||||
}[];
|
||||
resourceId?: number;
|
||||
protocol?: "http" | "https"; // if set, this target only applies to the specified protocol
|
||||
httpTargets?: HTTPTarget[];
|
||||
tlsCert?: string;
|
||||
tlsKey?: string;
|
||||
};
|
||||
|
||||
export function generateSubnetProxyTargetV2(
|
||||
export type HTTPTarget = {
|
||||
destAddr: string; // must be an IP or hostname
|
||||
destPort: number;
|
||||
scheme: "http" | "https";
|
||||
};
|
||||
|
||||
export async function generateSubnetProxyTargetV2(
|
||||
siteResource: SiteResource,
|
||||
clients: {
|
||||
clientId: number;
|
||||
pubKey: string | null;
|
||||
subnet: string | null;
|
||||
}[]
|
||||
): SubnetProxyTargetV2[] | undefined {
|
||||
): Promise<SubnetProxyTargetV2[] | undefined> {
|
||||
if (clients.length === 0) {
|
||||
logger.debug(
|
||||
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
||||
@@ -642,6 +653,67 @@ export function generateSubnetProxyTargetV2(
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId
|
||||
});
|
||||
} else if (siteResource.mode == "http") {
|
||||
let destination = siteResource.destination;
|
||||
// check if this is a valid ip
|
||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||
if (ipSchema.safeParse(destination).success) {
|
||||
destination = `${destination}/32`;
|
||||
}
|
||||
|
||||
if (
|
||||
!siteResource.aliasAddress ||
|
||||
!siteResource.destinationPort ||
|
||||
!siteResource.scheme ||
|
||||
!siteResource.fullDomain
|
||||
) {
|
||||
logger.debug(
|
||||
`Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// also push a match for the alias address
|
||||
let tlsCert: string | undefined;
|
||||
let tlsKey: string | undefined;
|
||||
|
||||
if (siteResource.ssl && siteResource.fullDomain) {
|
||||
try {
|
||||
const certs = await getValidCertificatesForDomains(
|
||||
new Set([siteResource.fullDomain]),
|
||||
true
|
||||
);
|
||||
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
|
||||
tlsCert = certs[0].certFile;
|
||||
tlsKey = certs[0].keyFile;
|
||||
} else {
|
||||
logger.warn(
|
||||
`No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||
rewriteTo: destination,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId,
|
||||
protocol: siteResource.ssl ? "https" : "http",
|
||||
httpTargets: [
|
||||
{
|
||||
destAddr: siteResource.destination,
|
||||
destPort: siteResource.destinationPort,
|
||||
scheme: siteResource.scheme
|
||||
}
|
||||
],
|
||||
...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {})
|
||||
});
|
||||
}
|
||||
|
||||
if (targets.length == 0) {
|
||||
|
||||
@@ -11,17 +11,16 @@ import {
|
||||
roleSiteResources,
|
||||
Site,
|
||||
SiteResource,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites,
|
||||
Transaction,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
addPeer as newtAddPeer,
|
||||
deletePeer as newtDeletePeer
|
||||
} from "@server/routers/newt/peers";
|
||||
import {
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
generateRemoteSubnets,
|
||||
generateSubnetProxyTargetV2,
|
||||
parseEndpoint,
|
||||
formatEndpoint
|
||||
} from "@server/lib/ip";
|
||||
import {
|
||||
addPeerData,
|
||||
@@ -48,15 +46,27 @@ export async function getClientSiteResourceAccess(
|
||||
siteResource: SiteResource,
|
||||
trx: Transaction | typeof db = db
|
||||
) {
|
||||
// get the site
|
||||
const [site] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteResource.siteId))
|
||||
.limit(1);
|
||||
// get all sites associated with this siteResource via its network
|
||||
const sitesList = siteResource.networkId
|
||||
? await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.siteId, sites.siteId)
|
||||
)
|
||||
.where(eq(siteNetworks.networkId, siteResource.networkId))
|
||||
.then((rows) => rows.map((row) => row.sites))
|
||||
: [];
|
||||
|
||||
if (!site) {
|
||||
throw new Error(`Site with ID ${siteResource.siteId} not found`);
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
|
||||
);
|
||||
|
||||
if (sitesList.length === 0) {
|
||||
logger.warn(
|
||||
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
|
||||
);
|
||||
}
|
||||
|
||||
const roleIds = await trx
|
||||
@@ -136,8 +146,12 @@ export async function getClientSiteResourceAccess(
|
||||
const mergedAllClients = Array.from(allClientsMap.values());
|
||||
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length})`
|
||||
);
|
||||
|
||||
return {
|
||||
site,
|
||||
sitesList,
|
||||
mergedAllClients,
|
||||
mergedAllClientIds
|
||||
};
|
||||
@@ -153,40 +167,59 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
subnet: string | null;
|
||||
}[];
|
||||
}> {
|
||||
const siteId = siteResource.siteId;
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||
);
|
||||
|
||||
const { site, mergedAllClients, mergedAllClientIds } =
|
||||
const { sitesList, mergedAllClients, mergedAllClientIds } =
|
||||
await getClientSiteResourceAccess(siteResource, trx);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] access resolved siteResourceId=${siteResource.siteResourceId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}] mergedClientCount=${mergedAllClients.length} clientIds=[${mergedAllClientIds.join(", ")}]`
|
||||
);
|
||||
|
||||
/////////// process the client-siteResource associations ///////////
|
||||
|
||||
// get all of the clients associated with other resources on this site
|
||||
const allUpdatedClientsFromOtherResourcesOnThisSite = await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, siteId),
|
||||
ne(siteResources.siteResourceId, siteResource.siteResourceId)
|
||||
)
|
||||
);
|
||||
// get all of the clients associated with other resources in the same network,
|
||||
// joined through siteNetworks so we know which siteId each client belongs to
|
||||
const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId,
|
||||
siteId: siteNetworks.siteId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.networkId, siteResource.networkId),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const allClientIdsFromOtherResourcesOnThisSite = Array.from(
|
||||
new Set(
|
||||
allUpdatedClientsFromOtherResourcesOnThisSite.map(
|
||||
(row) => row.clientId
|
||||
)
|
||||
)
|
||||
);
|
||||
// Build a per-site map so the loop below can check by siteId rather than
|
||||
// across the entire network.
|
||||
const clientsFromOtherResourcesBySite = new Map<number, Set<number>>();
|
||||
for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) {
|
||||
if (!clientsFromOtherResourcesBySite.has(row.siteId)) {
|
||||
clientsFromOtherResourcesBySite.set(row.siteId, new Set());
|
||||
}
|
||||
clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
|
||||
}
|
||||
|
||||
const existingClientSiteResources = await trx
|
||||
.select({
|
||||
@@ -204,6 +237,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
(row) => row.clientId
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]`
|
||||
);
|
||||
|
||||
// Get full client details for existing resource clients (needed for sending delete messages)
|
||||
const existingResourceClients =
|
||||
existingClientSiteResourceIds.length > 0
|
||||
@@ -223,6 +260,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
(clientId) => !existingClientSiteResourceIds.includes(clientId)
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toAdd=[${clientSiteResourcesToAdd.join(", ")}]`
|
||||
);
|
||||
|
||||
const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map(
|
||||
(clientId) => ({
|
||||
clientId,
|
||||
@@ -231,17 +272,34 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
);
|
||||
|
||||
if (clientSiteResourcesToInsert.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserting ${clientSiteResourcesToInsert.length} clientSiteResource association(s)`
|
||||
);
|
||||
await trx
|
||||
.insert(clientSiteResourcesAssociationsCache)
|
||||
.values(clientSiteResourcesToInsert)
|
||||
.returning();
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} no clientSiteResource associations to insert`
|
||||
);
|
||||
}
|
||||
|
||||
const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter(
|
||||
(clientId) => !mergedAllClientIds.includes(clientId)
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toRemove=[${clientSiteResourcesToRemove.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSiteResourcesToRemove.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} deleting ${clientSiteResourcesToRemove.length} clientSiteResource association(s)`
|
||||
);
|
||||
await trx
|
||||
.delete(clientSiteResourcesAssociationsCache)
|
||||
.where(
|
||||
@@ -260,82 +318,127 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
|
||||
/////////// process the client-site associations ///////////
|
||||
|
||||
const existingClientSites = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId));
|
||||
|
||||
const existingClientSiteIds = existingClientSites.map(
|
||||
(row) => row.clientId
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)`
|
||||
);
|
||||
|
||||
// Get full client details for existing clients (needed for sending delete messages)
|
||||
const existingClients = await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.clientId, existingClientSiteIds));
|
||||
for (const site of sitesList) {
|
||||
const siteId = site.siteId;
|
||||
|
||||
const clientSitesToAdd = mergedAllClientIds.filter(
|
||||
(clientId) =>
|
||||
!existingClientSiteIds.includes(clientId) &&
|
||||
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
||||
);
|
||||
|
||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||
clientId,
|
||||
siteId
|
||||
}));
|
||||
const existingClientSites = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||
|
||||
if (clientSitesToInsert.length > 0) {
|
||||
await trx
|
||||
.insert(clientSitesAssociationsCache)
|
||||
.values(clientSitesToInsert)
|
||||
.returning();
|
||||
}
|
||||
const existingClientSiteIds = existingClientSites.map(
|
||||
(row) => row.clientId
|
||||
);
|
||||
|
||||
// Now remove any client-site associations that should no longer exist
|
||||
const clientSitesToRemove = existingClientSiteIds.filter(
|
||||
(clientId) =>
|
||||
!mergedAllClientIds.includes(clientId) &&
|
||||
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSitesToRemove.length > 0) {
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.siteId, siteId),
|
||||
inArray(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clientSitesToRemove
|
||||
)
|
||||
)
|
||||
// Get full client details for existing clients (needed for sending delete messages)
|
||||
const existingClients =
|
||||
existingClientSiteIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.clientId, existingClientSiteIds))
|
||||
: [];
|
||||
|
||||
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
||||
);
|
||||
|
||||
const clientSitesToAdd = mergedAllClientIds.filter(
|
||||
(clientId) =>
|
||||
!existingClientSiteIds.includes(clientId) &&
|
||||
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
|
||||
);
|
||||
|
||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||
clientId,
|
||||
siteId
|
||||
}));
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSitesToInsert.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
|
||||
);
|
||||
await trx
|
||||
.insert(clientSitesAssociationsCache)
|
||||
.values(clientSitesToInsert)
|
||||
.returning();
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
|
||||
);
|
||||
}
|
||||
|
||||
// Now remove any client-site associations that should no longer exist
|
||||
const clientSitesToRemove = existingClientSiteIds.filter(
|
||||
(clientId) =>
|
||||
!mergedAllClientIds.includes(clientId) &&
|
||||
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSitesToRemove.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
|
||||
);
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.siteId, siteId),
|
||||
inArray(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clientSitesToRemove
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Now handle the messages to add/remove peers on both the newt and olm sides
|
||||
await handleMessagesForSiteClients(
|
||||
site,
|
||||
siteId,
|
||||
mergedAllClients,
|
||||
existingClients,
|
||||
clientSitesToAdd,
|
||||
clientSitesToRemove,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
/////////// send the messages ///////////
|
||||
|
||||
// Now handle the messages to add/remove peers on both the newt and olm sides
|
||||
await handleMessagesForSiteClients(
|
||||
site,
|
||||
siteId,
|
||||
mergedAllClients,
|
||||
existingClients,
|
||||
clientSitesToAdd,
|
||||
clientSitesToRemove,
|
||||
trx
|
||||
);
|
||||
|
||||
// Handle subnet proxy target updates for the resource associations
|
||||
await handleSubnetProxyTargetUpdates(
|
||||
siteResource,
|
||||
sitesList,
|
||||
mergedAllClients,
|
||||
existingResourceClients,
|
||||
clientSiteResourcesToAdd,
|
||||
@@ -624,6 +727,7 @@ export async function updateClientSiteDestinations(
|
||||
|
||||
async function handleSubnetProxyTargetUpdates(
|
||||
siteResource: SiteResource,
|
||||
sitesList: Site[],
|
||||
allClients: {
|
||||
clientId: number;
|
||||
pubKey: string | null;
|
||||
@@ -638,125 +742,138 @@ async function handleSubnetProxyTargetUpdates(
|
||||
clientSiteResourcesToRemove: number[],
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
// Get the newt for this site
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteResource.siteId))
|
||||
.limit(1);
|
||||
const proxyJobs: Promise<any>[] = [];
|
||||
const olmJobs: Promise<any>[] = [];
|
||||
|
||||
if (!newt) {
|
||||
logger.warn(
|
||||
`Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates`
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const siteData of sitesList) {
|
||||
const siteId = siteData.siteId;
|
||||
|
||||
const proxyJobs = [];
|
||||
const olmJobs = [];
|
||||
// Generate targets for added associations
|
||||
if (clientSiteResourcesToAdd.length > 0) {
|
||||
const addedClients = allClients.filter((client) =>
|
||||
clientSiteResourcesToAdd.includes(client.clientId)
|
||||
);
|
||||
// Get the newt for this site
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (addedClients.length > 0) {
|
||||
const targetsToAdd = generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
addedClients
|
||||
if (!newt) {
|
||||
logger.warn(
|
||||
`Newt not found for site ${siteId}, skipping subnet proxy target updates`
|
||||
);
|
||||
|
||||
if (targetsToAdd) {
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targetsToAdd,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const client of addedClients) {
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
siteResource.siteId,
|
||||
generateRemoteSubnets([siteResource]),
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
|
||||
|
||||
// Generate targets for removed associations
|
||||
if (clientSiteResourcesToRemove.length > 0) {
|
||||
const removedClients = existingClients.filter((client) =>
|
||||
clientSiteResourcesToRemove.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (removedClients.length > 0) {
|
||||
const targetsToRemove = generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
removedClients
|
||||
// Generate targets for added associations
|
||||
if (clientSiteResourcesToAdd.length > 0) {
|
||||
const addedClients = allClients.filter((client) =>
|
||||
clientSiteResourcesToAdd.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (targetsToRemove) {
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targetsToRemove,
|
||||
newt.version
|
||||
)
|
||||
if (addedClients.length > 0) {
|
||||
const targetsToAdd = await generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
addedClients
|
||||
);
|
||||
}
|
||||
|
||||
for (const client of removedClients) {
|
||||
// Check if this client still has access to another resource on this site with the same destination
|
||||
const destinationStillInUse = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteResources.siteId, siteResource.siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
siteResource.destination
|
||||
),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
)
|
||||
if (targetsToAdd) {
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targetsToAdd,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Only remove remote subnet if no other resource uses the same destination
|
||||
const remoteSubnetsToRemove =
|
||||
destinationStillInUse.length > 0
|
||||
? []
|
||||
: generateRemoteSubnets([siteResource]);
|
||||
for (const client of addedClients) {
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
generateRemoteSubnets([siteResource]),
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
siteResource.siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
// here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
|
||||
|
||||
// Generate targets for removed associations
|
||||
if (clientSiteResourcesToRemove.length > 0) {
|
||||
const removedClients = existingClients.filter((client) =>
|
||||
clientSiteResourcesToRemove.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (removedClients.length > 0) {
|
||||
const targetsToRemove = await generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
removedClients
|
||||
);
|
||||
|
||||
if (targetsToRemove) {
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targetsToRemove,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const client of removedClients) {
|
||||
// Check if this client still has access to another resource
|
||||
// on this specific site with the same destination. We scope
|
||||
// by siteId (via siteNetworks) rather than networkId because
|
||||
// removePeerData operates per-site - a resource on a different
|
||||
// site sharing the same network should not block removal here.
|
||||
const destinationStillInUse = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
siteResource.destination
|
||||
),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Only remove remote subnet if no other resource uses the same destination
|
||||
const remoteSubnetsToRemove =
|
||||
destinationStillInUse.length > 0
|
||||
? []
|
||||
: generateRemoteSubnets([siteResource]);
|
||||
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -863,10 +980,25 @@ export async function rebuildClientAssociationsFromClient(
|
||||
)
|
||||
: [];
|
||||
|
||||
// Group by siteId for site-level associations
|
||||
const newSiteIds = Array.from(
|
||||
new Set(newSiteResources.map((sr) => sr.siteId))
|
||||
// Group by siteId for site-level associations - look up via siteNetworks since
|
||||
// siteResources no longer carries a direct siteId column.
|
||||
const networkIds = Array.from(
|
||||
new Set(
|
||||
newSiteResources
|
||||
.map((sr) => sr.networkId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
const newSiteIds =
|
||||
networkIds.length > 0
|
||||
? await trx
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(inArray(siteNetworks.networkId, networkIds))
|
||||
.then((rows) =>
|
||||
Array.from(new Set(rows.map((r) => r.siteId)))
|
||||
)
|
||||
: [];
|
||||
|
||||
/////////// Process client-siteResource associations ///////////
|
||||
|
||||
@@ -1139,13 +1271,45 @@ async function handleMessagesForClientResources(
|
||||
resourcesToAdd.includes(r.siteResourceId)
|
||||
);
|
||||
|
||||
// Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId
|
||||
const addedNetworkIds = Array.from(
|
||||
new Set(
|
||||
addedResources
|
||||
.map((r) => r.networkId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
const addedSiteNetworkRows =
|
||||
addedNetworkIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
networkId: siteNetworks.networkId,
|
||||
siteId: siteNetworks.siteId
|
||||
})
|
||||
.from(siteNetworks)
|
||||
.where(inArray(siteNetworks.networkId, addedNetworkIds))
|
||||
: [];
|
||||
const addedNetworkToSites = new Map<number, number[]>();
|
||||
for (const row of addedSiteNetworkRows) {
|
||||
if (!addedNetworkToSites.has(row.networkId)) {
|
||||
addedNetworkToSites.set(row.networkId, []);
|
||||
}
|
||||
addedNetworkToSites.get(row.networkId)!.push(row.siteId);
|
||||
}
|
||||
|
||||
// Group by site for proxy updates
|
||||
const addedBySite = new Map<number, SiteResource[]>();
|
||||
for (const resource of addedResources) {
|
||||
if (!addedBySite.has(resource.siteId)) {
|
||||
addedBySite.set(resource.siteId, []);
|
||||
const siteIds =
|
||||
resource.networkId != null
|
||||
? (addedNetworkToSites.get(resource.networkId) ?? [])
|
||||
: [];
|
||||
for (const siteId of siteIds) {
|
||||
if (!addedBySite.has(siteId)) {
|
||||
addedBySite.set(siteId, []);
|
||||
}
|
||||
addedBySite.get(siteId)!.push(resource);
|
||||
}
|
||||
addedBySite.get(resource.siteId)!.push(resource);
|
||||
}
|
||||
|
||||
// Add subnet proxy targets for each site
|
||||
@@ -1164,7 +1328,7 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
|
||||
for (const resource of resources) {
|
||||
const targets = generateSubnetProxyTargetV2(resource, [
|
||||
const targets = await generateSubnetProxyTargetV2(resource, [
|
||||
{
|
||||
clientId: client.clientId,
|
||||
pubKey: client.pubKey,
|
||||
@@ -1187,7 +1351,7 @@ async function handleMessagesForClientResources(
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
resource.siteId,
|
||||
siteId,
|
||||
generateRemoteSubnets([resource]),
|
||||
generateAliasConfig([resource])
|
||||
)
|
||||
@@ -1199,7 +1363,7 @@ async function handleMessagesForClientResources(
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
logger.debug(
|
||||
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
|
||||
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition`
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
@@ -1216,13 +1380,45 @@ async function handleMessagesForClientResources(
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, resourcesToRemove));
|
||||
|
||||
// Build (resource, siteId) pairs via siteNetworks
|
||||
const removedNetworkIds = Array.from(
|
||||
new Set(
|
||||
removedResources
|
||||
.map((r) => r.networkId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
const removedSiteNetworkRows =
|
||||
removedNetworkIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
networkId: siteNetworks.networkId,
|
||||
siteId: siteNetworks.siteId
|
||||
})
|
||||
.from(siteNetworks)
|
||||
.where(inArray(siteNetworks.networkId, removedNetworkIds))
|
||||
: [];
|
||||
const removedNetworkToSites = new Map<number, number[]>();
|
||||
for (const row of removedSiteNetworkRows) {
|
||||
if (!removedNetworkToSites.has(row.networkId)) {
|
||||
removedNetworkToSites.set(row.networkId, []);
|
||||
}
|
||||
removedNetworkToSites.get(row.networkId)!.push(row.siteId);
|
||||
}
|
||||
|
||||
// Group by site for proxy updates
|
||||
const removedBySite = new Map<number, SiteResource[]>();
|
||||
for (const resource of removedResources) {
|
||||
if (!removedBySite.has(resource.siteId)) {
|
||||
removedBySite.set(resource.siteId, []);
|
||||
const siteIds =
|
||||
resource.networkId != null
|
||||
? (removedNetworkToSites.get(resource.networkId) ?? [])
|
||||
: [];
|
||||
for (const siteId of siteIds) {
|
||||
if (!removedBySite.has(siteId)) {
|
||||
removedBySite.set(siteId, []);
|
||||
}
|
||||
removedBySite.get(siteId)!.push(resource);
|
||||
}
|
||||
removedBySite.get(resource.siteId)!.push(resource);
|
||||
}
|
||||
|
||||
// Remove subnet proxy targets for each site
|
||||
@@ -1241,7 +1437,7 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
|
||||
for (const resource of resources) {
|
||||
const targets = generateSubnetProxyTargetV2(resource, [
|
||||
const targets = await generateSubnetProxyTargetV2(resource, [
|
||||
{
|
||||
clientId: client.clientId,
|
||||
pubKey: client.pubKey,
|
||||
@@ -1260,7 +1456,11 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this client still has access to another resource on this site with the same destination
|
||||
// Check if this client still has access to another resource
|
||||
// on this specific site with the same destination. We scope
|
||||
// by siteId (via siteNetworks) rather than networkId because
|
||||
// removePeerData operates per-site - a resource on a different
|
||||
// site sharing the same network should not block removal here.
|
||||
const destinationStillInUse = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
@@ -1271,13 +1471,17 @@ async function handleMessagesForClientResources(
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteResources.siteId, resource.siteId),
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
resource.destination
|
||||
@@ -1299,7 +1503,7 @@ async function handleMessagesForClientResources(
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
resource.siteId,
|
||||
siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([resource])
|
||||
)
|
||||
@@ -1311,7 +1515,7 @@ async function handleMessagesForClientResources(
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
logger.debug(
|
||||
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
|
||||
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal`
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
|
||||
133
server/lib/statusHistory.ts
Normal file
133
server/lib/statusHistory.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const statusHistoryQuerySchema = z
|
||||
.object({
|
||||
days: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? parseInt(v, 10) : 90)),
|
||||
})
|
||||
.pipe(
|
||||
z.object({
|
||||
days: z.number().int().min(1).max(365),
|
||||
})
|
||||
);
|
||||
|
||||
export interface StatusHistoryDayBucket {
|
||||
date: string; // ISO date "YYYY-MM-DD"
|
||||
uptimePercent: number; // 0-100
|
||||
totalDowntimeSeconds: number;
|
||||
downtimeWindows: { start: number; end: number | null; status: string }[];
|
||||
status: "good" | "degraded" | "bad" | "no_data";
|
||||
}
|
||||
|
||||
export interface StatusHistoryResponse {
|
||||
entityType: string;
|
||||
entityId: number;
|
||||
days: StatusHistoryDayBucket[];
|
||||
overallUptimePercent: number;
|
||||
totalDowntimeSeconds: number;
|
||||
}
|
||||
|
||||
export function computeBuckets(
|
||||
events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[],
|
||||
days: number
|
||||
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const buckets: StatusHistoryDayBucket[] = [];
|
||||
let totalDowntime = 0;
|
||||
|
||||
for (let d = 0; d < days; d++) {
|
||||
const dayStartSec = nowSec - (days - d) * 86400;
|
||||
const dayEndSec = dayStartSec + 86400;
|
||||
|
||||
const dayEvents = events.filter(
|
||||
(e) => e.timestamp >= dayStartSec && e.timestamp < dayEndSec
|
||||
);
|
||||
|
||||
// Determine the status at the start of this day (last event before dayStart)
|
||||
const lastBeforeDay = [...events]
|
||||
.filter((e) => e.timestamp < dayStartSec)
|
||||
.at(-1);
|
||||
|
||||
const currentStatus = lastBeforeDay?.status ?? null;
|
||||
|
||||
const windows: { start: number; end: number | null; status: string }[] = [];
|
||||
let dayDowntime = 0;
|
||||
|
||||
let windowStart = dayStartSec;
|
||||
let windowStatus = currentStatus;
|
||||
|
||||
for (const evt of dayEvents) {
|
||||
if (windowStatus !== null && windowStatus !== evt.status) {
|
||||
const windowEnd = evt.timestamp;
|
||||
const isDown =
|
||||
windowStatus === "offline" ||
|
||||
windowStatus === "unhealthy" ||
|
||||
windowStatus === "unknown";
|
||||
if (isDown) {
|
||||
dayDowntime += windowEnd - windowStart;
|
||||
windows.push({
|
||||
start: windowStart,
|
||||
end: windowEnd,
|
||||
status: windowStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
windowStart = evt.timestamp;
|
||||
windowStatus = evt.status;
|
||||
}
|
||||
|
||||
// Close the final window at the end of the day (or now if day hasn't ended)
|
||||
if (windowStatus !== null) {
|
||||
const finalEnd = Math.min(dayEndSec, nowSec);
|
||||
const isDown =
|
||||
windowStatus === "offline" ||
|
||||
windowStatus === "unhealthy" ||
|
||||
windowStatus === "unknown";
|
||||
if (isDown && finalEnd > windowStart) {
|
||||
dayDowntime += finalEnd - windowStart;
|
||||
windows.push({
|
||||
start: windowStart,
|
||||
end: finalEnd,
|
||||
status: windowStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
totalDowntime += dayDowntime;
|
||||
|
||||
const effectiveDayLength = Math.max(
|
||||
0,
|
||||
Math.min(dayEndSec, nowSec) - dayStartSec
|
||||
);
|
||||
const uptimePct =
|
||||
effectiveDayLength > 0
|
||||
? Math.max(
|
||||
0,
|
||||
((effectiveDayLength - dayDowntime) /
|
||||
effectiveDayLength) *
|
||||
100
|
||||
)
|
||||
: 100;
|
||||
|
||||
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
|
||||
|
||||
let status: StatusHistoryDayBucket["status"] = "no_data";
|
||||
if (currentStatus !== null || dayEvents.length > 0) {
|
||||
if (uptimePct >= 99) status = "good";
|
||||
else if (uptimePct >= 50) status = "degraded";
|
||||
else status = "bad";
|
||||
}
|
||||
|
||||
buckets.push({
|
||||
date: dateStr,
|
||||
uptimePercent: Math.round(uptimePct * 100) / 100,
|
||||
totalDowntimeSeconds: dayDowntime,
|
||||
downtimeWindows: windows,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return { buckets, totalDowntime };
|
||||
}
|
||||
@@ -1011,7 +1011,7 @@ export class TraefikConfigManager {
|
||||
);
|
||||
|
||||
if (!isUnused) {
|
||||
// Domain is still active — remove from pending deletion if it was queued
|
||||
// Domain is still active - remove from pending deletion if it was queued
|
||||
if (this.pendingDeletion.has(dirName)) {
|
||||
logger.info(
|
||||
`Certificate ${dirName} is active again, cancelling pending deletion`
|
||||
@@ -1021,7 +1021,7 @@ export class TraefikConfigManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Domain is unused — add to pending deletion or decrement its counter
|
||||
// Domain is unused - add to pending deletion or decrement its counter
|
||||
if (!this.pendingDeletion.has(dirName)) {
|
||||
const graceCycles = 3;
|
||||
logger.info(
|
||||
@@ -1036,7 +1036,7 @@ export class TraefikConfigManager {
|
||||
);
|
||||
this.pendingDeletion.set(dirName, remaining);
|
||||
} else {
|
||||
// Grace period expired — actually delete now
|
||||
// Grace period expired - actually delete now
|
||||
this.pendingDeletion.delete(dirName);
|
||||
|
||||
const domainDir = path.join(certsPath, dirName);
|
||||
|
||||
@@ -24,7 +24,7 @@ function encodePath(path: string | null | undefined): string {
|
||||
|
||||
/**
|
||||
* Exact replica of the OLD key computation from upstream main.
|
||||
* Uses sanitize() for paths — this is what had the collision bug.
|
||||
* Uses sanitize() for paths - this is what had the collision bug.
|
||||
*/
|
||||
function oldKeyComputation(
|
||||
resourceId: number,
|
||||
@@ -44,7 +44,7 @@ function oldKeyComputation(
|
||||
|
||||
/**
|
||||
* Replica of the NEW key computation from our fix.
|
||||
* Uses encodePath() for paths — collision-free.
|
||||
* Uses encodePath() for paths - collision-free.
|
||||
*/
|
||||
function newKeyComputation(
|
||||
resourceId: number,
|
||||
@@ -195,11 +195,11 @@ function runTests() {
|
||||
true,
|
||||
"/a/b and /a-b MUST have different keys"
|
||||
);
|
||||
console.log(" PASS: collision fix — /a/b vs /a-b have different keys");
|
||||
console.log(" PASS: collision fix - /a/b vs /a-b have different keys");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key
|
||||
// Test 9: demonstrate the old bug - old code maps /a/b and /a-b to same key
|
||||
{
|
||||
const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null);
|
||||
const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null);
|
||||
@@ -208,11 +208,11 @@ function runTests() {
|
||||
oldKeyDash,
|
||||
"old code MUST have this collision (confirms the bug exists)"
|
||||
);
|
||||
console.log(" PASS: confirmed old code bug — /a/b and /a-b collided");
|
||||
console.log(" PASS: confirmed old code bug - /a/b and /a-b collided");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it
|
||||
// Test 10: /api/v1 and /api-v1 - old code collision, new code fixes it
|
||||
{
|
||||
const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||
const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||
@@ -229,11 +229,11 @@ function runTests() {
|
||||
true,
|
||||
"new code must separate /api/v1 and /api-v1"
|
||||
);
|
||||
console.log(" PASS: collision fix — /api/v1 vs /api-v1");
|
||||
console.log(" PASS: collision fix - /api/v1 vs /api-v1");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed
|
||||
// Test 11: /app.v2 and /app/v2 and /app-v2 - three-way collision fixed
|
||||
{
|
||||
const a = newKeyComputation(1, "/app.v2", "prefix", null, null);
|
||||
const b = newKeyComputation(1, "/app/v2", "prefix", null, null);
|
||||
@@ -245,14 +245,14 @@ function runTests() {
|
||||
"three paths must produce three unique keys"
|
||||
);
|
||||
console.log(
|
||||
" PASS: collision fix — three-way /app.v2, /app/v2, /app-v2"
|
||||
" PASS: collision fix - three-way /app.v2, /app/v2, /app-v2"
|
||||
);
|
||||
passed++;
|
||||
}
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────
|
||||
|
||||
// Test 12: same path in different resources — always separate
|
||||
// Test 12: same path in different resources - always separate
|
||||
{
|
||||
const key1 = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
const key2 = newKeyComputation(2, "/api", "prefix", null, null);
|
||||
@@ -261,11 +261,11 @@ function runTests() {
|
||||
true,
|
||||
"different resources with same path must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different resources");
|
||||
console.log(" PASS: edge case - same path, different resources");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 13: same resource, different pathMatchType — separate keys
|
||||
// Test 13: same resource, different pathMatchType - separate keys
|
||||
{
|
||||
const exact = newKeyComputation(1, "/api", "exact", null, null);
|
||||
const prefix = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
@@ -274,11 +274,11 @@ function runTests() {
|
||||
true,
|
||||
"exact vs prefix must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different match types");
|
||||
console.log(" PASS: edge case - same path, different match types");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 14: same resource and path, different rewrite config — separate keys
|
||||
// Test 14: same resource and path, different rewrite config - separate keys
|
||||
{
|
||||
const noRewrite = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
const withRewrite = newKeyComputation(
|
||||
@@ -293,7 +293,7 @@ function runTests() {
|
||||
true,
|
||||
"with vs without rewrite must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different rewrite config");
|
||||
console.log(" PASS: edge case - same path, different rewrite config");
|
||||
passed++;
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ function runTests() {
|
||||
paths.length,
|
||||
"special URL chars must produce unique keys"
|
||||
);
|
||||
console.log(" PASS: edge case — special URL characters in paths");
|
||||
console.log(" PASS: edge case - special URL characters in paths");
|
||||
passed++;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function verifyDomainAccess(
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const domainId =
|
||||
req.params.domainId || req.body.apiKeyId || req.query.apiKeyId;
|
||||
req.params.domainId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
|
||||
478
server/private/lib/acmeCertSync.ts
Normal file
478
server/private/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
certificates,
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
db,
|
||||
domains,
|
||||
newts,
|
||||
siteNetworks,
|
||||
SiteResource,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
generateSubnetProxyTargetV2,
|
||||
SubnetProxyTargetV2
|
||||
} from "@server/lib/ip";
|
||||
import { updateTargets } from "@server/routers/client/targets";
|
||||
import cache from "#private/lib/cache";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface AcmeCert {
|
||||
domain: { main: string; sans?: string[] };
|
||||
certificate: string;
|
||||
key: string;
|
||||
Store: string;
|
||||
}
|
||||
|
||||
interface AcmeJson {
|
||||
[resolver: string]: {
|
||||
Certificates: AcmeCert[];
|
||||
};
|
||||
}
|
||||
|
||||
async function pushCertUpdateToAffectedNewts(
|
||||
domain: string,
|
||||
domainId: string | null,
|
||||
oldCertPem: string | null,
|
||||
oldKeyPem: string | null
|
||||
): Promise<void> {
|
||||
// Find all SSL-enabled HTTP site resources that use this cert's domain
|
||||
let affectedResources: SiteResource[] = [];
|
||||
|
||||
if (domainId) {
|
||||
affectedResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.domainId, domainId),
|
||||
eq(siteResources.ssl, true)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Fallback: match by exact fullDomain when no domainId is available
|
||||
affectedResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.fullDomain, domain),
|
||||
eq(siteResources.ssl, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (affectedResources.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no affected site resources for cert domain "${domain}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
|
||||
);
|
||||
|
||||
for (const resource of affectedResources) {
|
||||
try {
|
||||
// Get all sites for this resource via siteNetworks
|
||||
const resourceSiteRows = resource.networkId
|
||||
? await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId))
|
||||
: [];
|
||||
|
||||
if (resourceSiteRows.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all clients with access to this resource
|
||||
const resourceClients = await db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clients.clientId,
|
||||
clientSiteResourcesAssociationsCache.clientId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceClients.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
|
||||
if (resource.fullDomain) {
|
||||
await cache.del(`cert:${resource.fullDomain}`);
|
||||
}
|
||||
|
||||
// Generate target once - same cert applies to all sites for this resource
|
||||
const newTargets = await generateSubnetProxyTargetV2(
|
||||
resource,
|
||||
resourceClients
|
||||
);
|
||||
|
||||
if (!newTargets) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Construct the old targets - same routing shape but with the previous cert/key.
|
||||
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
|
||||
// semantics correct so the update message accurately reflects what changed.
|
||||
const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({
|
||||
...t,
|
||||
tlsCert: oldCertPem ?? undefined,
|
||||
tlsKey: oldKeyPem ?? undefined
|
||||
}));
|
||||
|
||||
// Push update to each site's newt
|
||||
for (const { siteId } of resourceSiteRows) {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateTargets(
|
||||
newt.newtId,
|
||||
{ oldTargets: oldTargets, newTargets: newTargets },
|
||||
newt.version
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||
const lookupDomain = certDomain.startsWith("*.")
|
||||
? certDomain.slice(2)
|
||||
: certDomain;
|
||||
|
||||
// 1. Exact baseDomain match (any domain type)
|
||||
const exactMatch = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(eq(domains.baseDomain, lookupDomain))
|
||||
.limit(1);
|
||||
|
||||
if (exactMatch.length > 0) {
|
||||
return exactMatch[0].domainId;
|
||||
}
|
||||
|
||||
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
|
||||
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
|
||||
// matches a wildcard domain with baseDomain "example.com".
|
||||
const parts = lookupDomain.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const candidate = parts.slice(i).join(".");
|
||||
if (!candidate) continue;
|
||||
|
||||
const wildcardMatch = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.baseDomain, candidate),
|
||||
eq(domains.type, "wildcard")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (wildcardMatch.length > 0) {
|
||||
return wildcardMatch[0].domainId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFirstCert(pemBundle: string): string | null {
|
||||
const match = pemBundle.match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||
);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
async function syncAcmeCerts(
|
||||
acmeJsonPath: string,
|
||||
resolver: string
|
||||
): Promise<void> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||
} catch (err) {
|
||||
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let acmeJson: AcmeJson;
|
||||
try {
|
||||
acmeJson = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolverData = acmeJson[resolver];
|
||||
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cert of resolverData.Certificates) {
|
||||
const domain = cert.domain?.main;
|
||||
|
||||
if (!domain) {
|
||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!cert.certificate || !cert.key) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const certPem = Buffer.from(cert.certificate, "base64").toString(
|
||||
"utf8"
|
||||
);
|
||||
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||
|
||||
if (!certPem.trim() || !keyPem.trim()) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if cert already exists in DB
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.domain, domain))
|
||||
.limit(1);
|
||||
|
||||
let oldCertPem: string | null = null;
|
||||
let oldKeyPem: string | null = null;
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
if (storedCertPem === certPem) {
|
||||
logger.debug(
|
||||
`acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Cert has changed; capture old values so we can send a correct
|
||||
// update message to the newt after the DB write.
|
||||
oldCertPem = storedCertPem;
|
||||
if (existing[0].keyFile) {
|
||||
try {
|
||||
oldKeyPem = decrypt(
|
||||
existing[0].keyFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} catch (keyErr) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Decryption failure means we should proceed with the update
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse cert expiry from the first cert in the PEM bundle
|
||||
let expiresAt: number | null = null;
|
||||
const firstCertPem = extractFirstCert(certPem);
|
||||
if (firstCertPem) {
|
||||
try {
|
||||
const x509 = new crypto.X509Certificate(firstCertPem);
|
||||
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const wildcard = domain.startsWith("*.");
|
||||
const encryptedCert = encrypt(
|
||||
certPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const encryptedKey = encrypt(
|
||||
keyPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
if (domainId) {
|
||||
logger.debug(
|
||||
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
wildcard,
|
||||
...(domainId !== null && { domainId })
|
||||
})
|
||||
.where(eq(certificates.domain, domain));
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
domain,
|
||||
domainId,
|
||||
oldCertPem,
|
||||
oldKeyPem
|
||||
);
|
||||
} else {
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
domainId,
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
wildcard
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
// For a brand-new cert, push to any SSL resources that were waiting for it
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initAcmeCertSync(): void {
|
||||
if (build == "saas") {
|
||||
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
|
||||
return;
|
||||
}
|
||||
|
||||
const privateConfigData = privateConfig.getRawPrivateConfig();
|
||||
|
||||
if (!privateConfigData.flags?.enable_acme_cert_sync) {
|
||||
logger.debug(
|
||||
`acmeCertSync: ACME cert sync is disabled by config flag, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (privateConfigData.flags.use_pangolin_dns) {
|
||||
logger.debug(
|
||||
`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const acmeJsonPath =
|
||||
privateConfigData.acme?.acme_json_path ??
|
||||
"config/letsencrypt/acme.json";
|
||||
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
|
||||
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
||||
);
|
||||
|
||||
// Run immediately on init, then on the configured interval
|
||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
91
server/private/lib/alerts/events/healthCheckEvents.ts
Normal file
91
server/private/lib/alerts/events/healthCheckEvents.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import logger from "@server/logger";
|
||||
import { processAlerts } from "../processAlerts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fire a `health_check_healthy` alert for the given health check.
|
||||
*
|
||||
* Call this after a previously-failing health check has recovered so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the health check.
|
||||
* @param healthCheckId - Numeric primary key of the health check.
|
||||
* @param healthCheckName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireHealthCheckHealthyAlert(
|
||||
orgId: string,
|
||||
healthCheckId: number,
|
||||
healthCheckName?: string | null,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "health_check_healthy",
|
||||
orgId,
|
||||
healthCheckId,
|
||||
data: {
|
||||
healthCheckId,
|
||||
...(healthCheckName != null ? { healthCheckName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a `health_check_unhealthy` alert for the given health check.
|
||||
*
|
||||
* Call this after a health check has been detected as failing so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the health check.
|
||||
* @param healthCheckId - Numeric primary key of the health check.
|
||||
* @param healthCheckName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireHealthCheckNotHealthyAlert(
|
||||
orgId: string,
|
||||
healthCheckId: number,
|
||||
healthCheckName?: string | null,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "health_check_unhealthy",
|
||||
orgId,
|
||||
healthCheckId,
|
||||
data: {
|
||||
healthCheckId,
|
||||
...(healthCheckName != null ? { healthCheckName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
127
server/private/lib/alerts/events/resourceEvents.ts
Normal file
127
server/private/lib/alerts/events/resourceEvents.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import logger from "@server/logger";
|
||||
import { processAlerts } from "../processAlerts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fire a `resource_healthy` alert for the given resource.
|
||||
*
|
||||
* Call this after a previously-unhealthy resource has recovered so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the resource.
|
||||
* @param resourceId - Numeric primary key of the resource.
|
||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireResourceHealthyAlert(
|
||||
orgId: string,
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "resource_healthy",
|
||||
orgId,
|
||||
resourceId,
|
||||
data: {
|
||||
resourceId,
|
||||
...(resourceName != null ? { resourceName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a `resource_unhealthy` alert for the given resource.
|
||||
*
|
||||
* Call this after a resource has been detected as unhealthy so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the resource.
|
||||
* @param resourceId - Numeric primary key of the resource.
|
||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireResourceUnhealthyAlert(
|
||||
orgId: string,
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "resource_unhealthy",
|
||||
orgId,
|
||||
resourceId,
|
||||
data: {
|
||||
resourceId,
|
||||
...(resourceName != null ? { resourceName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a `resource_toggle` alert for the given resource.
|
||||
*
|
||||
* Call this when a resource's enabled/disabled status is toggled so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the resource.
|
||||
* @param resourceId - Numeric primary key of the resource.
|
||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireResourceToggleAlert(
|
||||
orgId: string,
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "resource_toggle",
|
||||
orgId,
|
||||
resourceId,
|
||||
data: {
|
||||
resourceId,
|
||||
...(resourceName != null ? { resourceName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
91
server/private/lib/alerts/events/siteEvents.ts
Normal file
91
server/private/lib/alerts/events/siteEvents.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import logger from "@server/logger";
|
||||
import { processAlerts } from "../processAlerts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fire a `site_online` alert for the given site.
|
||||
*
|
||||
* Call this after the site has been confirmed reachable / connected so that
|
||||
* any matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the site.
|
||||
* @param siteId - Numeric primary key of the site.
|
||||
* @param siteName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireSiteOnlineAlert(
|
||||
orgId: string,
|
||||
siteId: number,
|
||||
siteName?: string,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "site_online",
|
||||
orgId,
|
||||
siteId,
|
||||
data: {
|
||||
siteId,
|
||||
...(siteName != null ? { siteName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireSiteOnlineAlert: unexpected error for siteId ${siteId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a `site_offline` alert for the given site.
|
||||
*
|
||||
* Call this after the site has been detected as unreachable / disconnected so
|
||||
* that any matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the site.
|
||||
* @param siteId - Numeric primary key of the site.
|
||||
* @param siteName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireSiteOfflineAlert(
|
||||
orgId: string,
|
||||
siteId: number,
|
||||
siteName?: string,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "site_offline",
|
||||
orgId,
|
||||
siteId,
|
||||
data: {
|
||||
siteId,
|
||||
...(siteName != null ? { siteName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
19
server/private/lib/alerts/index.ts
Normal file
19
server/private/lib/alerts/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./processAlerts";
|
||||
export * from "./sendAlertWebhook";
|
||||
export * from "./sendAlertEmail";
|
||||
export * from "./events/siteEvents";
|
||||
export * from "./events/healthCheckEvents";
|
||||
333
server/private/lib/alerts/processAlerts.ts
Normal file
333
server/private/lib/alerts/processAlerts.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
alertRules,
|
||||
alertSites,
|
||||
alertHealthChecks,
|
||||
alertResources,
|
||||
alertEmailActions,
|
||||
alertEmailRecipients,
|
||||
alertWebhookActions,
|
||||
userOrgRoles,
|
||||
users
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import { AlertContext, WebhookAlertConfig } from "./types";
|
||||
import { sendAlertWebhook } from "./sendAlertWebhook";
|
||||
import { sendAlertEmail } from "./sendAlertEmail";
|
||||
|
||||
/**
|
||||
* Core alert processing pipeline.
|
||||
*
|
||||
* Given an `AlertContext`, this function:
|
||||
* 1. Finds all enabled `alertRules` whose `eventType` matches and whose
|
||||
* `siteId` / `healthCheckId` is listed in the `alertSites` /
|
||||
* `alertHealthChecks` junction tables (or has no junction entries,
|
||||
* meaning "match all").
|
||||
* 2. Applies per-rule cooldown gating.
|
||||
* 3. Dispatches emails and webhook POSTs for every attached action.
|
||||
* 4. Updates `lastTriggeredAt` and `lastSentAt` timestamps.
|
||||
*/
|
||||
export async function processAlerts(context: AlertContext): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Find matching alert rules
|
||||
// ------------------------------------------------------------------
|
||||
// Rules with allSites / allHealthChecks / allResources set to true match
|
||||
// ANY event of that type. Rules without these flags set match only the
|
||||
// specific IDs listed in the junction tables.
|
||||
const baseConditions = and(
|
||||
eq(alertRules.orgId, context.orgId),
|
||||
eq(alertRules.eventType, context.eventType),
|
||||
eq(alertRules.enabled, true)
|
||||
);
|
||||
|
||||
let rules: (typeof alertRules.$inferSelect)[];
|
||||
|
||||
if (context.siteId != null) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.leftJoin(
|
||||
alertSites,
|
||||
eq(alertSites.alertRuleId, alertRules.alertRuleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
baseConditions,
|
||||
or(
|
||||
eq(alertRules.allSites, true),
|
||||
eq(alertSites.siteId, context.siteId)
|
||||
)
|
||||
)
|
||||
);
|
||||
// Deduplicate in case a rule matched on multiple junction rows
|
||||
const seen = new Set<number>();
|
||||
rules = rows
|
||||
.map((r) => r.alertRules)
|
||||
.filter((r) => {
|
||||
if (seen.has(r.alertRuleId)) return false;
|
||||
seen.add(r.alertRuleId);
|
||||
return true;
|
||||
});
|
||||
} else if (context.healthCheckId != null) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.leftJoin(
|
||||
alertHealthChecks,
|
||||
eq(alertHealthChecks.alertRuleId, alertRules.alertRuleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
baseConditions,
|
||||
or(
|
||||
eq(alertRules.allHealthChecks, true),
|
||||
eq(alertHealthChecks.healthCheckId, context.healthCheckId)
|
||||
)
|
||||
)
|
||||
);
|
||||
const seen = new Set<number>();
|
||||
rules = rows
|
||||
.map((r) => r.alertRules)
|
||||
.filter((r) => {
|
||||
if (seen.has(r.alertRuleId)) return false;
|
||||
seen.add(r.alertRuleId);
|
||||
return true;
|
||||
});
|
||||
} else if (context.resourceId != null) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.leftJoin(
|
||||
alertResources,
|
||||
eq(alertResources.alertRuleId, alertRules.alertRuleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
baseConditions,
|
||||
or(
|
||||
eq(alertRules.allResources, true),
|
||||
eq(alertResources.resourceId, context.resourceId)
|
||||
)
|
||||
)
|
||||
);
|
||||
const seen = new Set<number>();
|
||||
rules = rows
|
||||
.map((r) => r.alertRules)
|
||||
.filter((r) => {
|
||||
if (seen.has(r.alertRuleId)) return false;
|
||||
seen.add(r.alertRuleId);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
rules = [];
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
logger.debug(
|
||||
`processAlerts: no matching rules for event "${context.eventType}" in org "${context.orgId}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
await processRule(rule, context, now);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`processAlerts: error processing rule ${rule.alertRuleId} for event "${context.eventType}"`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-rule processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processRule(
|
||||
rule: typeof alertRules.$inferSelect,
|
||||
context: AlertContext,
|
||||
now: number
|
||||
): Promise<void> {
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Cooldown check
|
||||
// ------------------------------------------------------------------
|
||||
if (
|
||||
rule.lastTriggeredAt != null &&
|
||||
now - rule.lastTriggeredAt < rule.cooldownSeconds * 1000
|
||||
) {
|
||||
const remainingSeconds = Math.ceil(
|
||||
(rule.cooldownSeconds * 1000 - (now - rule.lastTriggeredAt)) / 1000
|
||||
);
|
||||
logger.debug(
|
||||
`processAlerts: rule ${rule.alertRuleId} is in cooldown – ${remainingSeconds}s remaining`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. Mark rule as triggered (optimistic update – before sending so we
|
||||
// don't re-trigger if the send is slow)
|
||||
// ------------------------------------------------------------------
|
||||
await db
|
||||
.update(alertRules)
|
||||
.set({ lastTriggeredAt: now })
|
||||
.where(eq(alertRules.alertRuleId, rule.alertRuleId));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Process email actions
|
||||
// ------------------------------------------------------------------
|
||||
const emailActions = await db
|
||||
.select()
|
||||
.from(alertEmailActions)
|
||||
.where(
|
||||
and(
|
||||
eq(alertEmailActions.alertRuleId, rule.alertRuleId),
|
||||
eq(alertEmailActions.enabled, true)
|
||||
)
|
||||
);
|
||||
|
||||
for (const action of emailActions) {
|
||||
try {
|
||||
const recipients = await resolveEmailRecipients(action.emailActionId);
|
||||
if (recipients.length > 0) {
|
||||
await sendAlertEmail(recipients, context);
|
||||
await db
|
||||
.update(alertEmailActions)
|
||||
.set({ lastSentAt: now })
|
||||
.where(
|
||||
eq(alertEmailActions.emailActionId, action.emailActionId)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`processAlerts: failed to send alert email for action ${action.emailActionId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. Process webhook actions
|
||||
// ------------------------------------------------------------------
|
||||
const webhookActions = await db
|
||||
.select()
|
||||
.from(alertWebhookActions)
|
||||
.where(
|
||||
and(
|
||||
eq(alertWebhookActions.alertRuleId, rule.alertRuleId),
|
||||
eq(alertWebhookActions.enabled, true)
|
||||
)
|
||||
);
|
||||
|
||||
const serverSecret = config.getRawConfig().server.secret!;
|
||||
|
||||
for (const action of webhookActions) {
|
||||
try {
|
||||
let webhookConfig: WebhookAlertConfig = { authType: "none" };
|
||||
|
||||
if (action.config) {
|
||||
try {
|
||||
const decrypted = decrypt(action.config, serverSecret);
|
||||
webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`processAlerts: failed to decrypt webhook config for action ${action.webhookActionId}`,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await sendAlertWebhook(action.webhookUrl, webhookConfig, context);
|
||||
await db
|
||||
.update(alertWebhookActions)
|
||||
.set({ lastSentAt: now })
|
||||
.where(
|
||||
eq(
|
||||
alertWebhookActions.webhookActionId,
|
||||
action.webhookActionId
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`processAlerts: failed to send alert webhook for action ${action.webhookActionId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Email recipient resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves all email addresses for a given `emailActionId`.
|
||||
*
|
||||
* Recipients may be:
|
||||
* - Direct users (by `userId`)
|
||||
* - All users in a role (by `roleId`, resolved via `userOrgRoles`)
|
||||
* - Direct external email addresses
|
||||
*/
|
||||
async function resolveEmailRecipients(emailActionId: number): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertEmailRecipients)
|
||||
.where(eq(alertEmailRecipients.emailActionId, emailActionId));
|
||||
|
||||
const emailSet = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.email) {
|
||||
emailSet.add(row.email);
|
||||
}
|
||||
|
||||
if (row.userId) {
|
||||
const [user] = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.userId, row.userId))
|
||||
.limit(1);
|
||||
if (user?.email) {
|
||||
emailSet.add(user.email);
|
||||
}
|
||||
}
|
||||
|
||||
if (row.roleId) {
|
||||
// Find all users with this role via userOrgRoles
|
||||
const roleUsers = await db
|
||||
.select({ email: users.email })
|
||||
.from(userOrgRoles)
|
||||
.innerJoin(users, eq(userOrgRoles.userId, users.userId))
|
||||
.where(eq(userOrgRoles.roleId, Number(row.roleId)));
|
||||
|
||||
for (const u of roleUsers) {
|
||||
if (u.email) {
|
||||
emailSet.add(u.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(emailSet);
|
||||
}
|
||||
97
server/private/lib/alerts/sendAlertEmail.ts
Normal file
97
server/private/lib/alerts/sendAlertEmail.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { sendEmail } from "@server/emails";
|
||||
import AlertNotification from "@server/emails/templates/AlertNotification";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import { AlertContext } from "./types";
|
||||
|
||||
/**
|
||||
* Sends an alert notification email to every address in `recipients`.
|
||||
*
|
||||
* Each recipient receives an individual email (no BCC list) so that delivery
|
||||
* failures for one address do not affect the others. Failures per recipient
|
||||
* are logged and swallowed – the caller only sees an error if something goes
|
||||
* wrong before the send loop.
|
||||
*/
|
||||
export async function sendAlertEmail(
|
||||
recipients: string[],
|
||||
context: AlertContext
|
||||
): Promise<void> {
|
||||
if (recipients.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = config.getNoReplyEmail();
|
||||
const subject = buildSubject(context);
|
||||
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await sendEmail(
|
||||
AlertNotification({
|
||||
eventType: context.eventType,
|
||||
orgId: context.orgId,
|
||||
data: context.data
|
||||
}),
|
||||
{
|
||||
from,
|
||||
to,
|
||||
subject
|
||||
}
|
||||
);
|
||||
logger.debug(
|
||||
`Alert email sent to "${to}" for event "${context.eventType}"`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`sendAlertEmail: failed to send alert email to "${to}" for event "${context.eventType}"`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSubject(context: AlertContext): string {
|
||||
switch (context.eventType) {
|
||||
case "site_online":
|
||||
return "[Alert] Site Back Online";
|
||||
case "site_offline":
|
||||
return "[Alert] Site Offline";
|
||||
case "site_toggle":
|
||||
return "[Alert] Site Status Changed";
|
||||
case "health_check_healthy":
|
||||
return "[Alert] Health Check Recovered";
|
||||
case "health_check_unhealthy":
|
||||
return "[Alert] Health Check Failing";
|
||||
case "health_check_toggle":
|
||||
return "[Alert] Health Check Status Changed";
|
||||
case "resource_healthy":
|
||||
return "[Alert] Resource Healthy";
|
||||
case "resource_unhealthy":
|
||||
return "[Alert] Resource Unhealthy";
|
||||
case "resource_toggle":
|
||||
return "[Alert] Resource Status Changed";
|
||||
default: {
|
||||
// Exhaustiveness fallback – should never be reached with a
|
||||
// well-typed caller, but keeps runtime behaviour predictable.
|
||||
const _exhaustive: never = context.eventType;
|
||||
void _exhaustive;
|
||||
return "[Alert] Event Notification";
|
||||
}
|
||||
}
|
||||
}
|
||||
140
server/private/lib/alerts/sendAlertWebhook.ts
Normal file
140
server/private/lib/alerts/sendAlertWebhook.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import logger from "@server/logger";
|
||||
import { AlertContext, WebhookAlertConfig } from "./types";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Sends a single webhook POST for an alert event.
|
||||
*
|
||||
* The payload shape is:
|
||||
* ```json
|
||||
* {
|
||||
* "event": "site_online",
|
||||
* "timestamp": "2024-01-01T00:00:00.000Z",
|
||||
* "data": { ... }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Authentication headers are applied according to `config.authType`,
|
||||
* mirroring the same strategies supported by HttpLogDestination:
|
||||
* none | bearer | basic | custom.
|
||||
*/
|
||||
export async function sendAlertWebhook(
|
||||
url: string,
|
||||
webhookConfig: WebhookAlertConfig,
|
||||
context: AlertContext
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
event: context.eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
orgId: context.orgId,
|
||||
...context.data
|
||||
}
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const headers = buildHeaders(webhookConfig);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: webhookConfig.method ?? "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const isAbort = err instanceof Error && err.name === "AbortError";
|
||||
if (isAbort) {
|
||||
throw new Error(
|
||||
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
|
||||
);
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Alert webhook: request to "${url}" failed – ${msg}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let snippet = "";
|
||||
try {
|
||||
const text = await response.text();
|
||||
snippet = text.slice(0, 300);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
throw new Error(
|
||||
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
|
||||
(snippet ? ` – ${snippet}` : "")
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}"`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header construction (mirrors HttpLogDestination.buildHeaders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildHeaders(webhookConfig: WebhookAlertConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
switch (webhookConfig.authType) {
|
||||
case "bearer": {
|
||||
const token = webhookConfig.bearerToken?.trim();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "basic": {
|
||||
const creds = webhookConfig.basicCredentials?.trim();
|
||||
if (creds) {
|
||||
const encoded = Buffer.from(creds).toString("base64");
|
||||
headers["Authorization"] = `Basic ${encoded}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "custom": {
|
||||
const name = webhookConfig.customHeaderName?.trim();
|
||||
const value = webhookConfig.customHeaderValue ?? "";
|
||||
if (name) {
|
||||
headers[name] = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "none":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (webhookConfig.headers) {
|
||||
for (const { key, value } of webhookConfig.headers) {
|
||||
if (key.trim()) {
|
||||
headers[key.trim()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
70
server/private/lib/alerts/types.ts
Normal file
70
server/private/lib/alerts/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert event types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AlertEventType =
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook authentication config (stored as encrypted JSON in the DB)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WebhookAuthType = "none" | "bearer" | "basic" | "custom";
|
||||
|
||||
/**
|
||||
* Stored as an encrypted JSON blob in `alertWebhookActions.config`.
|
||||
*/
|
||||
export interface WebhookAlertConfig {
|
||||
/** Authentication strategy for the webhook endpoint */
|
||||
authType: WebhookAuthType;
|
||||
/** Bearer token – used when authType === "bearer" */
|
||||
bearerToken?: string;
|
||||
/** Basic credentials – "username:password" – used when authType === "basic" */
|
||||
basicCredentials?: string;
|
||||
/** Custom header name – used when authType === "custom" */
|
||||
customHeaderName?: string;
|
||||
/** Custom header value – used when authType === "custom" */
|
||||
customHeaderValue?: string;
|
||||
/** Extra headers to send with every webhook request */
|
||||
headers?: Array<{ key: string; value: string }>;
|
||||
/** HTTP method (default POST) */
|
||||
method?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal alert event passed through the processing pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AlertContext {
|
||||
eventType: AlertEventType;
|
||||
orgId: string;
|
||||
/** Set for site_online / site_offline events */
|
||||
siteId?: number;
|
||||
/** Set for health_check_* events */
|
||||
healthCheckId?: number;
|
||||
/** Set for resource_* events */
|
||||
resourceId?: number;
|
||||
/** Human-readable context data included in emails and webhook payloads */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
@@ -11,23 +11,15 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import config from "./config";
|
||||
import privateConfig from "./config";
|
||||
import config from "@server/lib/config";
|
||||
import { certificates, db } from "@server/db";
|
||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||
import { decryptData } from "@server/lib/encryption";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import cache from "#private/lib/cache";
|
||||
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
if (encryptionKey) {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
// Define the return type for clarity and type safety
|
||||
export type CertificateResult = {
|
||||
@@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains(
|
||||
domains: Set<string>,
|
||||
useCache: boolean = true
|
||||
): Promise<Array<CertificateResult>> {
|
||||
loadEncryptData(); // Ensure encryption key is loaded
|
||||
|
||||
|
||||
const finalResults: CertificateResult[] = [];
|
||||
const domainsToQuery = new Set<string>();
|
||||
@@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains(
|
||||
|
||||
// 2. If all domains were resolved from the cache, return early
|
||||
if (domainsToQuery.size === 0) {
|
||||
const decryptedResults = decryptFinalResults(finalResults);
|
||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
@@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains(
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedResults = decryptFinalResults(finalResults);
|
||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
function decryptFinalResults(
|
||||
finalResults: CertificateResult[]
|
||||
finalResults: CertificateResult[],
|
||||
secret: string
|
||||
): CertificateResult[] {
|
||||
const validCertsDecrypted = finalResults.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decryptData(
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
encryptionKey
|
||||
secret
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
||||
const decryptedKey = decrypt(cert.keyFile!, secret);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
|
||||
@@ -153,7 +153,7 @@ export async function flushConnectionLogToDb(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Stop processing further batches from this snapshot — they will
|
||||
// Stop processing further batches from this snapshot - they will
|
||||
// be picked up via the re-queued records on the next flush.
|
||||
const remaining = snapshot.slice(i + INSERT_BATCH_SIZE);
|
||||
if (remaining.length > 0) {
|
||||
@@ -180,7 +180,7 @@ const flushTimer = setInterval(async () => {
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
|
||||
// Calling unref() means this timer will not keep the Node.js event loop alive
|
||||
// on its own — the process can still exit normally when there is no other work
|
||||
// on its own - the process can still exit normally when there is no other work
|
||||
// left. The graceful-shutdown path will call flushConnectionLogToDb() explicitly
|
||||
// before process.exit(), so no data is lost.
|
||||
flushTimer.unref();
|
||||
@@ -223,7 +223,7 @@ export function logConnectionAudit(record: ConnectionLogRecord): void {
|
||||
buffer.push(record);
|
||||
|
||||
if (buffer.length >= MAX_BUFFERED_RECORDS) {
|
||||
// Fire and forget — errors are handled inside flushConnectionLogToDb
|
||||
// Fire and forget - errors are handled inside flushConnectionLogToDb
|
||||
flushConnectionLogToDb().catch((error) => {
|
||||
logger.error(
|
||||
"Unexpected error during size-triggered connection log flush:",
|
||||
@@ -231,4 +231,4 @@ export function logConnectionAudit(record: ConnectionLogRecord): void {
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array";
|
||||
*
|
||||
* **Payload formats** (controlled by `config.format`):
|
||||
*
|
||||
* - `json_array` (default) — one POST per batch, body is a JSON array:
|
||||
* - `json_array` (default) - one POST per batch, body is a JSON array:
|
||||
* ```json
|
||||
* [
|
||||
* { "event": "request", "timestamp": "2024-01-01T00:00:00.000Z", "data": { … } },
|
||||
@@ -46,7 +46,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array";
|
||||
* ```
|
||||
* `Content-Type: application/json`
|
||||
*
|
||||
* - `ndjson` — one POST per batch, body is newline-delimited JSON (one object
|
||||
* - `ndjson` - one POST per batch, body is newline-delimited JSON (one object
|
||||
* per line, no outer array). Required by Splunk HEC, Elastic/OpenSearch,
|
||||
* and Grafana Loki:
|
||||
* ```
|
||||
@@ -55,7 +55,7 @@ const DEFAULT_FORMAT: PayloadFormat = "json_array";
|
||||
* ```
|
||||
* `Content-Type: application/x-ndjson`
|
||||
*
|
||||
* - `json_single` — one POST **per event**, body is a plain JSON object.
|
||||
* - `json_single` - one POST **per event**, body is a plain JSON object.
|
||||
* Use only for endpoints that cannot handle batches at all.
|
||||
*
|
||||
* With a body template each event is rendered through the template before
|
||||
@@ -319,4 +319,4 @@ function epochSecondsToIso(epochSeconds: number): string {
|
||||
function escapeJsonString(value: string): string {
|
||||
// JSON.stringify produces `"<escaped>"` – strip the outer quotes.
|
||||
return JSON.stringify(value).slice(1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ export type AuthType = "none" | "bearer" | "basic" | "custom";
|
||||
/**
|
||||
* Controls how the batch of events is serialised into the HTTP request body.
|
||||
*
|
||||
* - `json_array` – `[{…}, {…}]` — default; one POST per batch wrapped in a
|
||||
* - `json_array` – `[{…}, {…}]` - default; one POST per batch wrapped in a
|
||||
* JSON array. Works with most generic webhooks and Datadog.
|
||||
* - `ndjson` – `{…}\n{…}` — newline-delimited JSON, one object per
|
||||
* - `ndjson` – `{…}\n{…}` - newline-delimited JSON, one object per
|
||||
* line. Required by Splunk HEC, Elastic/OpenSearch, Loki.
|
||||
* - `json_single` – one HTTP POST per event, body is a plain JSON object.
|
||||
* Use only for endpoints that cannot handle batches at all.
|
||||
@@ -131,4 +131,4 @@ export interface DestinationFailureState {
|
||||
nextRetryAt: number;
|
||||
/** Date.now() value of the very first failure in the current streak */
|
||||
firstFailedAt: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({
|
||||
}),
|
||||
server: z
|
||||
.object({
|
||||
encryption_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||
reo_client_id: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -95,10 +91,21 @@ export const privateConfigSchema = z.object({
|
||||
.object({
|
||||
enable_redis: z.boolean().optional().default(false),
|
||||
use_pangolin_dns: z.boolean().optional().default(false),
|
||||
use_org_only_idp: z.boolean().optional()
|
||||
use_org_only_idp: z.boolean().optional(),
|
||||
enable_acme_cert_sync: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
acme: z
|
||||
.object({
|
||||
acme_json_path: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("config/letsencrypt/acme.json"),
|
||||
resolver: z.string().optional().default("letsencrypt"),
|
||||
sync_interval_ms: z.number().optional().default(5000)
|
||||
})
|
||||
.optional(),
|
||||
branding: z
|
||||
.object({
|
||||
app_name: z.string().optional(),
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||
import { orgs, resources, sites, siteNetworks, siteResources, Target, targets } from "@server/db";
|
||||
import {
|
||||
sanitize,
|
||||
encodePath,
|
||||
@@ -267,6 +267,35 @@ export async function getTraefikConfig(
|
||||
});
|
||||
});
|
||||
|
||||
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
||||
const siteResourcesWithFullDomain = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
fullDomain: siteResources.fullDomain,
|
||||
mode: siteResources.mode
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
|
||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.enabled, true),
|
||||
isNotNull(siteResources.fullDomain),
|
||||
eq(siteResources.mode, "http"),
|
||||
eq(siteResources.ssl, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
and(
|
||||
isNull(sites.exitNodeId),
|
||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||
eq(sites.type, "local"),
|
||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||
)
|
||||
),
|
||||
inArray(sites.type, siteTypes)
|
||||
)
|
||||
);
|
||||
|
||||
let validCerts: CertificateResult[] = [];
|
||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
// create a list of all domains to get certs for
|
||||
@@ -276,6 +305,12 @@ export async function getTraefikConfig(
|
||||
domains.add(resource.fullDomain);
|
||||
}
|
||||
}
|
||||
// Include siteResource aliases so pangolin-dns also fetches certs for them
|
||||
for (const sr of siteResourcesWithFullDomain) {
|
||||
if (sr.fullDomain) {
|
||||
domains.add(sr.fullDomain);
|
||||
}
|
||||
}
|
||||
// get the valid certs for these domains
|
||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||
@@ -867,6 +902,139 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||
// Traefik generates TLS certificates for those domains even when no
|
||||
// matching resource exists yet.
|
||||
if (siteResourcesWithFullDomain.length > 0) {
|
||||
// Build a set of domains already covered by normal resources
|
||||
const existingFullDomains = new Set<string>();
|
||||
for (const resource of resourcesMap.values()) {
|
||||
if (resource.fullDomain) {
|
||||
existingFullDomains.add(resource.fullDomain);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sr of siteResourcesWithFullDomain) {
|
||||
if (!sr.fullDomain) continue;
|
||||
|
||||
// Skip if this alias is already handled by a resource router
|
||||
if (existingFullDomains.has(sr.fullDomain)) continue;
|
||||
|
||||
const fullDomain = sr.fullDomain;
|
||||
const srKey = `site-resource-cert-${sr.siteResourceId}`;
|
||||
const siteResourceServiceName = `${srKey}-service`;
|
||||
const siteResourceRouterName = `${srKey}-router`;
|
||||
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
|
||||
|
||||
const maintenancePort = config.getRawConfig().server.next_port;
|
||||
const maintenanceHost =
|
||||
config.getRawConfig().server.internal_hostname;
|
||||
|
||||
if (!config_output.http.routers) {
|
||||
config_output.http.routers = {};
|
||||
}
|
||||
if (!config_output.http.services) {
|
||||
config_output.http.services = {};
|
||||
}
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
|
||||
// Service pointing at the internal maintenance/Next.js page
|
||||
config_output.http.services[siteResourceServiceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `http://${maintenanceHost}:${maintenancePort}`
|
||||
}
|
||||
],
|
||||
passHostHeader: true
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware that rewrites any path to /maintenance-screen
|
||||
config_output.http.middlewares[
|
||||
siteResourceRewriteMiddlewareName
|
||||
] = {
|
||||
replacePathRegex: {
|
||||
regex: "^/(.*)",
|
||||
replacement: "/private-maintenance-screen"
|
||||
}
|
||||
};
|
||||
|
||||
// HTTP -> HTTPS redirect so the ACME challenge can be served
|
||||
config_output.http.routers[
|
||||
`${siteResourceRouterName}-redirect`
|
||||
] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100
|
||||
};
|
||||
|
||||
// Determine TLS / cert-resolver configuration
|
||||
let tls: any = {};
|
||||
if (
|
||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||
) {
|
||||
const domainParts = fullDomain.split(".");
|
||||
const wildCard =
|
||||
domainParts.length <= 2
|
||||
? `*.${domainParts.join(".")}`
|
||||
: `*.${domainParts.slice(1).join(".")}`;
|
||||
|
||||
const globalDefaultResolver =
|
||||
config.getRawConfig().traefik.cert_resolver;
|
||||
const globalDefaultPreferWildcard =
|
||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||
|
||||
tls = {
|
||||
certResolver: globalDefaultResolver,
|
||||
...(globalDefaultPreferWildcard
|
||||
? { domains: [{ main: wildCard }] }
|
||||
: {})
|
||||
};
|
||||
} else {
|
||||
// pangolin-dns: only add route if we already have a valid cert
|
||||
const matchingCert = validCerts.find(
|
||||
(cert) => cert.queriedDomain === fullDomain
|
||||
);
|
||||
if (!matchingCert) {
|
||||
logger.debug(
|
||||
`No matching certificate found for siteResource alias: ${fullDomain}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS router - presence of this entry triggers cert generation
|
||||
config_output.http.routers[siteResourceRouterName] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
service: siteResourceServiceName,
|
||||
middlewares: [siteResourceRewriteMiddlewareName],
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100,
|
||||
tls
|
||||
};
|
||||
|
||||
// Assets bypass router - lets Next.js static files load without rewrite
|
||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
priority: 101,
|
||||
tls
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (generateLoginPageRouters) {
|
||||
const exitNodeLoginPages = await db
|
||||
.select({
|
||||
|
||||
16
server/private/routers/alertEvents/index.ts
Normal file
16
server/private/routers/alertEvents/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./triggerSiteAlert";
|
||||
export * from "./triggerResourceAlert";
|
||||
export * from "./triggerHealthCheckAlert";
|
||||
129
server/private/routers/alertEvents/triggerHealthCheckAlert.ts
Normal file
129
server/private/routers/alertEvents/triggerHealthCheckAlert.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { targetHealthCheck, statusHistory } 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 { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireHealthCheckHealthyAlert,
|
||||
fireHealthCheckNotHealthyAlert
|
||||
} from "#private/lib/alerts/events/healthCheckEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
healthCheckId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["health_check_healthy", "health_check_unhealthy"])
|
||||
});
|
||||
|
||||
export type TriggerHealthCheckAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerHealthCheckAlert(
|
||||
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 { orgId, healthCheckId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the health check exists and belongs to the org
|
||||
const [healthCheck] = await db
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
targetHealthCheck.targetHealthCheckId,
|
||||
healthCheckId
|
||||
),
|
||||
eq(targetHealthCheck.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!healthCheck) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Health check ${healthCheckId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "healthCheck",
|
||||
entityId: healthCheckId,
|
||||
orgId,
|
||||
status: eventType === "health_check_healthy" ? "healthy" : "unhealthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
if (eventType === "health_check_healthy") {
|
||||
await fireHealthCheckHealthyAlert(
|
||||
orgId,
|
||||
healthCheckId,
|
||||
healthCheck.name ?? undefined
|
||||
);
|
||||
} else {
|
||||
await fireHealthCheckNotHealthyAlert(
|
||||
orgId,
|
||||
healthCheckId,
|
||||
healthCheck.name ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
return response<TriggerHealthCheckAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
135
server/private/routers/alertEvents/triggerResourceAlert.ts
Normal file
135
server/private/routers/alertEvents/triggerResourceAlert.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resources, statusHistory } 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 { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireResourceHealthyAlert,
|
||||
fireResourceUnhealthyAlert,
|
||||
fireResourceToggleAlert
|
||||
} from "#private/lib/alerts/events/resourceEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"])
|
||||
});
|
||||
|
||||
export type TriggerResourceAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerResourceAlert(
|
||||
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 { orgId, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the resource exists and belongs to the org
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === "resource_healthy" || eventType === "resource_unhealthy") {
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "resource",
|
||||
entityId: resourceId,
|
||||
orgId,
|
||||
status: eventType === "resource_healthy" ? "healthy" : "unhealthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
if (eventType === "resource_healthy") {
|
||||
await fireResourceHealthyAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
} else if (eventType === "resource_unhealthy") {
|
||||
await fireResourceUnhealthyAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
} else {
|
||||
await fireResourceToggleAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
return response<TriggerResourceAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
113
server/private/routers/alertEvents/triggerSiteAlert.ts
Normal file
113
server/private/routers/alertEvents/triggerSiteAlert.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { sites, statusHistory } 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 { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireSiteOnlineAlert,
|
||||
fireSiteOfflineAlert
|
||||
} from "#private/lib/alerts/events/siteEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
siteId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["site_online", "site_offline"])
|
||||
});
|
||||
|
||||
export type TriggerSiteAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerSiteAlert(
|
||||
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 { orgId, siteId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site ${siteId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: siteId,
|
||||
orgId,
|
||||
status: eventType === "site_online" ? "online" : "offline",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
if (eventType === "site_online") {
|
||||
await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined);
|
||||
} else {
|
||||
await fireSiteOfflineAlert(orgId, siteId, site.name ?? undefined);
|
||||
}
|
||||
|
||||
return response<TriggerSiteAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
354
server/private/routers/alertRule/createAlertRule.ts
Normal file
354
server/private/routers/alertRule/createAlertRule.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, roles } from "@server/db";
|
||||
import {
|
||||
alertRules,
|
||||
alertSites,
|
||||
alertHealthChecks,
|
||||
alertResources,
|
||||
alertEmailActions,
|
||||
alertEmailRecipients,
|
||||
alertWebhookActions
|
||||
} 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";
|
||||
|
||||
export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const;
|
||||
export const HC_EVENT_TYPES = [
|
||||
"health_check_healthy",
|
||||
"health_check_unhealthy",
|
||||
"health_check_toggle"
|
||||
] as const;
|
||||
export const RESOURCE_EVENT_TYPES = [
|
||||
"resource_healthy",
|
||||
"resource_unhealthy",
|
||||
"resource_toggle"
|
||||
] as const;
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const webhookActionSchema = z.strictObject({
|
||||
webhookUrl: z.string().url(),
|
||||
config: z.string().optional(),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().nonempty(),
|
||||
eventType: z.enum([
|
||||
...HC_EVENT_TYPES,
|
||||
...SITE_EVENT_TYPES,
|
||||
...RESOURCE_EVENT_TYPES
|
||||
]),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
cooldownSeconds: z.number().int().nonnegative().optional().default(300),
|
||||
// Source join tables - which is required depends on eventType
|
||||
siteIds: z.array(z.number().int().positive()).optional().default([]),
|
||||
allSites: z.boolean().optional().default(false),
|
||||
healthCheckIds: z
|
||||
.array(z.number().int().positive())
|
||||
.optional()
|
||||
.default([]),
|
||||
allHealthChecks: z.boolean().optional().default(false),
|
||||
resourceIds: z
|
||||
.array(z.number().int().positive())
|
||||
.optional()
|
||||
.default([]),
|
||||
allResources: z.boolean().optional().default(false),
|
||||
// Email recipients (flat)
|
||||
userIds: z.array(z.string().nonempty()).optional().default([]),
|
||||
roleIds: z.array(z.number()).optional().default([]),
|
||||
emails: z.array(z.string().email()).optional().default([]),
|
||||
// Webhook actions
|
||||
webhookActions: z.array(webhookActionSchema).optional().default([])
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
const isSiteEvent = (SITE_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
|
||||
if (isSiteEvent && !val.allSites && val.siteIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one siteId is required for site event types when allSites is false",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isSiteEvent && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "healthCheckIds must not be set for site event types",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isHcEvent && val.siteIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "siteIds must not be set for health check event types",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one resourceId is required for resource event types when allResources is false",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.siteIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "siteIds must not be set for resource event types",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "healthCheckIds must not be set for resource event types",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isSiteEvent && val.resourceIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "resourceIds must not be set for site event types",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isHcEvent && val.resourceIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "resourceIds must not be set for health check event types",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateAlertRuleResponse = {
|
||||
alertRuleId: number;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/alert-rule",
|
||||
description: "Create an alert rule for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createAlertRule(
|
||||
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 { orgId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
eventType,
|
||||
enabled,
|
||||
cooldownSeconds,
|
||||
siteIds,
|
||||
allSites,
|
||||
healthCheckIds,
|
||||
allHealthChecks,
|
||||
resourceIds,
|
||||
allResources,
|
||||
userIds,
|
||||
roleIds,
|
||||
emails,
|
||||
webhookActions
|
||||
} = parsedBody.data;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const [rule] = await db
|
||||
.insert(alertRules)
|
||||
.values({
|
||||
orgId,
|
||||
name,
|
||||
eventType,
|
||||
enabled,
|
||||
cooldownSeconds,
|
||||
allSites,
|
||||
allHealthChecks,
|
||||
allResources,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Insert site associations (skipped when allSites=true — empty junction = match all)
|
||||
if (!allSites && siteIds.length > 0) {
|
||||
await db.insert(alertSites).values(
|
||||
siteIds.map((siteId) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
siteId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Insert health check associations (skipped when allHealthChecks=true)
|
||||
if (!allHealthChecks && healthCheckIds.length > 0) {
|
||||
await db.insert(alertHealthChecks).values(
|
||||
healthCheckIds.map((healthCheckId) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
healthCheckId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Insert resource associations (skipped when allResources=true)
|
||||
if (!allResources && resourceIds.length > 0) {
|
||||
await db.insert(alertResources).values(
|
||||
resourceIds.map((resourceId) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
resourceId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Create the email action pivot row and recipients if any recipients
|
||||
// were supplied (userIds, roleIds, or raw emails).
|
||||
const hasRecipients =
|
||||
userIds.length > 0 ||
|
||||
roleIds.length > 0 ||
|
||||
emails.length > 0;
|
||||
|
||||
if (hasRecipients) {
|
||||
const [emailActionRow] = await db
|
||||
.insert(alertEmailActions)
|
||||
.values({ alertRuleId: rule.alertRuleId })
|
||||
.returning();
|
||||
|
||||
const recipientRows = [
|
||||
...userIds.map((userId) => ({
|
||||
emailActionId: emailActionRow.emailActionId,
|
||||
userId,
|
||||
roleId: null as number | null,
|
||||
email: null as string | null
|
||||
})),
|
||||
...roleIds.map((roleId) => ({
|
||||
emailActionId: emailActionRow.emailActionId,
|
||||
userId: null as string | null,
|
||||
roleId,
|
||||
email: null as string | null
|
||||
})),
|
||||
...emails.map((email) => ({
|
||||
emailActionId: emailActionRow.emailActionId,
|
||||
userId: null as string | null,
|
||||
roleId: null as number | null,
|
||||
email
|
||||
}))
|
||||
];
|
||||
|
||||
await db.insert(alertEmailRecipients).values(recipientRows);
|
||||
}
|
||||
|
||||
if (webhookActions.length > 0) {
|
||||
const serverSecret = config.getRawConfig().server.secret!;
|
||||
await db.insert(alertWebhookActions).values(
|
||||
webhookActions.map((wa) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
webhookUrl: wa.webhookUrl,
|
||||
config:
|
||||
wa.config != null
|
||||
? encrypt(wa.config, serverSecret)
|
||||
: null,
|
||||
enabled: wa.enabled
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateAlertRuleResponse>(res, {
|
||||
data: {
|
||||
alertRuleId: rule.alertRuleId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rule created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
100
server/private/routers/alertRule/deleteAlertRule.ts
Normal file
100
server/private/routers/alertRule/deleteAlertRule.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { alertRules } 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";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string().nonempty(),
|
||||
alertRuleId: z.coerce.number<number>()
|
||||
})
|
||||
.strict();
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||
description: "Delete an alert rule for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteAlertRule(
|
||||
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 { orgId, alertRuleId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.alertRuleId, alertRuleId),
|
||||
eq(alertRules.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Alert rule not found")
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.alertRuleId, alertRuleId),
|
||||
eq(alertRules.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rule deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
227
server/private/routers/alertRule/getAlertRule.ts
Normal file
227
server/private/routers/alertRule/getAlertRule.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
alertRules,
|
||||
alertSites,
|
||||
alertHealthChecks,
|
||||
alertResources,
|
||||
alertEmailActions,
|
||||
alertEmailRecipients,
|
||||
alertWebhookActions
|
||||
} 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 { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { WebhookAlertConfig } from "#private/lib/alerts/types";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string().nonempty(),
|
||||
alertRuleId: z.coerce.number<number>()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GetAlertRuleResponse = {
|
||||
alertRuleId: number;
|
||||
orgId: string;
|
||||
name: string;
|
||||
eventType:
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle";
|
||||
enabled: boolean;
|
||||
cooldownSeconds: number;
|
||||
lastTriggeredAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
siteIds: number[];
|
||||
healthCheckIds: number[];
|
||||
resourceIds: number[];
|
||||
recipients: {
|
||||
recipientId: number;
|
||||
userId: string | null;
|
||||
roleId: number | null;
|
||||
email: string | null;
|
||||
}[];
|
||||
webhookActions: {
|
||||
webhookActionId: number;
|
||||
webhookUrl: string;
|
||||
enabled: boolean;
|
||||
lastSentAt: number | null;
|
||||
config: WebhookAlertConfig | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||
description: "Get a specific alert rule for an organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getAlertRule(
|
||||
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 { orgId, alertRuleId } = parsedParams.data;
|
||||
|
||||
const [rule] = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.alertRuleId, alertRuleId),
|
||||
eq(alertRules.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (!rule) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Alert rule not found")
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch site associations
|
||||
const siteRows = await db
|
||||
.select()
|
||||
.from(alertSites)
|
||||
.where(eq(alertSites.alertRuleId, alertRuleId));
|
||||
|
||||
// Fetch health check associations
|
||||
const healthCheckRows = await db
|
||||
.select()
|
||||
.from(alertHealthChecks)
|
||||
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
||||
|
||||
// Fetch resource associations
|
||||
const resourceRows = await db
|
||||
.select()
|
||||
.from(alertResources)
|
||||
.where(eq(alertResources.alertRuleId, alertRuleId));
|
||||
|
||||
// Resolve the single email action row for this rule, then collect all
|
||||
// recipients into a flat list. The emailAction pivot row is an internal
|
||||
// implementation detail and is not surfaced to callers.
|
||||
const [emailAction] = await db
|
||||
.select()
|
||||
.from(alertEmailActions)
|
||||
.where(eq(alertEmailActions.alertRuleId, alertRuleId));
|
||||
|
||||
let recipients: GetAlertRuleResponse["recipients"] = [];
|
||||
if (emailAction) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertEmailRecipients)
|
||||
.where(
|
||||
eq(
|
||||
alertEmailRecipients.emailActionId,
|
||||
emailAction.emailActionId
|
||||
)
|
||||
);
|
||||
|
||||
recipients = rows.map((r) => ({
|
||||
recipientId: r.recipientId,
|
||||
userId: r.userId ?? null,
|
||||
roleId: r.roleId ?? null,
|
||||
email: r.email ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch webhook actions
|
||||
const webhooks = await db
|
||||
.select()
|
||||
.from(alertWebhookActions)
|
||||
.where(eq(alertWebhookActions.alertRuleId, alertRuleId));
|
||||
|
||||
return response<GetAlertRuleResponse>(res, {
|
||||
data: {
|
||||
alertRuleId: rule.alertRuleId,
|
||||
orgId: rule.orgId,
|
||||
name: rule.name,
|
||||
eventType: rule.eventType,
|
||||
enabled: rule.enabled,
|
||||
cooldownSeconds: rule.cooldownSeconds,
|
||||
lastTriggeredAt: rule.lastTriggeredAt ?? null,
|
||||
createdAt: rule.createdAt,
|
||||
updatedAt: rule.updatedAt,
|
||||
siteIds: siteRows.map((r) => r.siteId),
|
||||
healthCheckIds: healthCheckRows.map((r) => r.healthCheckId),
|
||||
resourceIds: resourceRows.map((r) => r.resourceId),
|
||||
recipients,
|
||||
webhookActions: webhooks.map((w) => {
|
||||
let parsedConfig: WebhookAlertConfig | null = null;
|
||||
if (w.config) {
|
||||
try {
|
||||
const serverSecret =
|
||||
config.getRawConfig().server.secret!;
|
||||
const decrypted = decrypt(w.config, serverSecret);
|
||||
parsedConfig = JSON.parse(
|
||||
decrypted
|
||||
) as WebhookAlertConfig;
|
||||
} catch {
|
||||
// best-effort – return null if decryption fails
|
||||
}
|
||||
}
|
||||
return {
|
||||
webhookActionId: w.webhookActionId,
|
||||
webhookUrl: w.webhookUrl,
|
||||
enabled: w.enabled,
|
||||
lastSentAt: w.lastSentAt ?? null,
|
||||
config: parsedConfig
|
||||
};
|
||||
})
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rule retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
18
server/private/routers/alertRule/index.ts
Normal file
18
server/private/routers/alertRule/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createAlertRule";
|
||||
export * from "./updateAlertRule";
|
||||
export * from "./deleteAlertRule";
|
||||
export * from "./listAlertRules";
|
||||
export * from "./getAlertRule";
|
||||
274
server/private/routers/alertRule/listAlertRules.ts
Normal file
274
server/private/routers/alertRule/listAlertRules.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { alertRules, alertSites, alertHealthChecks, alertResources } 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, inArray, like, sql } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const querySchema = z.strictObject({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative()),
|
||||
query: z.string().optional(),
|
||||
siteId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().int().positive().optional()),
|
||||
resourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().int().positive().optional())
|
||||
});
|
||||
|
||||
export type ListAlertRulesResponse = {
|
||||
alertRules: {
|
||||
alertRuleId: number;
|
||||
orgId: string;
|
||||
name: string;
|
||||
eventType: string;
|
||||
enabled: boolean;
|
||||
cooldownSeconds: number;
|
||||
lastTriggeredAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
siteIds: number[];
|
||||
healthCheckIds: number[];
|
||||
resourceIds: number[];
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/alert-rules",
|
||||
description: "List all alert rules for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: querySchema,
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listAlertRules(
|
||||
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 { orgId } = parsedParams.data;
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset, query, siteId, resourceId } = parsedQuery.data;
|
||||
|
||||
// Resolve siteId filter → matching alertRuleIds
|
||||
let siteFilterRuleIds: number[] | null = null;
|
||||
if (siteId !== undefined) {
|
||||
const rows = await db
|
||||
.select({ alertRuleId: alertSites.alertRuleId })
|
||||
.from(alertSites)
|
||||
.where(eq(alertSites.siteId, siteId));
|
||||
siteFilterRuleIds = rows.map((r) => r.alertRuleId);
|
||||
if (siteFilterRuleIds.length === 0) {
|
||||
return response<ListAlertRulesResponse>(res, {
|
||||
data: {
|
||||
alertRules: [],
|
||||
pagination: { total: 0, limit, offset }
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rules retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve resourceId filter → matching alertRuleIds
|
||||
let resourceFilterRuleIds: number[] | null = null;
|
||||
if (resourceId !== undefined) {
|
||||
const rows = await db
|
||||
.select({ alertRuleId: alertResources.alertRuleId })
|
||||
.from(alertResources)
|
||||
.where(eq(alertResources.resourceId, resourceId));
|
||||
resourceFilterRuleIds = rows.map((r) => r.alertRuleId);
|
||||
if (resourceFilterRuleIds.length === 0) {
|
||||
return response<ListAlertRulesResponse>(res, {
|
||||
data: {
|
||||
alertRules: [],
|
||||
pagination: { total: 0, limit, offset }
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rules retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = and(
|
||||
eq(alertRules.orgId, orgId),
|
||||
query
|
||||
? like(sql`LOWER(${alertRules.name})`, `%${query.toLowerCase()}%`)
|
||||
: undefined,
|
||||
siteFilterRuleIds !== null
|
||||
? inArray(alertRules.alertRuleId, siteFilterRuleIds)
|
||||
: undefined,
|
||||
resourceFilterRuleIds !== null
|
||||
? inArray(alertRules.alertRuleId, resourceFilterRuleIds)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const list = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(whereClause)
|
||||
.orderBy(sql`${alertRules.createdAt} DESC`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(alertRules)
|
||||
.where(whereClause);
|
||||
|
||||
// Batch-fetch site and health-check associations for all returned rules
|
||||
// in two queries rather than N+1 individual lookups.
|
||||
const ruleIds = list.map((r) => r.alertRuleId);
|
||||
|
||||
const siteRows =
|
||||
ruleIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(alertSites)
|
||||
.where(inArray(alertSites.alertRuleId, ruleIds))
|
||||
: [];
|
||||
|
||||
const healthCheckRows =
|
||||
ruleIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(alertHealthChecks)
|
||||
.where(
|
||||
inArray(alertHealthChecks.alertRuleId, ruleIds)
|
||||
)
|
||||
: [];
|
||||
|
||||
const resourceRows =
|
||||
ruleIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(alertResources)
|
||||
.where(inArray(alertResources.alertRuleId, ruleIds))
|
||||
: [];
|
||||
|
||||
// Index by alertRuleId for O(1) lookup when building the response
|
||||
const sitesByRule = new Map<number, number[]>();
|
||||
for (const row of siteRows) {
|
||||
const existing = sitesByRule.get(row.alertRuleId) ?? [];
|
||||
existing.push(row.siteId);
|
||||
sitesByRule.set(row.alertRuleId, existing);
|
||||
}
|
||||
|
||||
const healthChecksByRule = new Map<number, number[]>();
|
||||
for (const row of healthCheckRows) {
|
||||
const existing = healthChecksByRule.get(row.alertRuleId) ?? [];
|
||||
existing.push(row.healthCheckId);
|
||||
healthChecksByRule.set(row.alertRuleId, existing);
|
||||
}
|
||||
|
||||
const resourcesByRule = new Map<number, number[]>();
|
||||
for (const row of resourceRows) {
|
||||
const existing = resourcesByRule.get(row.alertRuleId) ?? [];
|
||||
existing.push(row.resourceId);
|
||||
resourcesByRule.set(row.alertRuleId, existing);
|
||||
}
|
||||
|
||||
return response<ListAlertRulesResponse>(res, {
|
||||
data: {
|
||||
alertRules: list.map((rule) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
orgId: rule.orgId,
|
||||
name: rule.name,
|
||||
eventType: rule.eventType,
|
||||
enabled: rule.enabled,
|
||||
cooldownSeconds: rule.cooldownSeconds,
|
||||
lastTriggeredAt: rule.lastTriggeredAt ?? null,
|
||||
createdAt: rule.createdAt,
|
||||
updatedAt: rule.updatedAt,
|
||||
siteIds: sitesByRule.get(rule.alertRuleId) ?? [],
|
||||
healthCheckIds:
|
||||
healthChecksByRule.get(rule.alertRuleId) ?? [],
|
||||
resourceIds: resourcesByRule.get(rule.alertRuleId) ?? []
|
||||
})),
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rules retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
403
server/private/routers/alertRule/updateAlertRule.ts
Normal file
403
server/private/routers/alertRule/updateAlertRule.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
alertRules,
|
||||
alertSites,
|
||||
alertHealthChecks,
|
||||
alertResources,
|
||||
alertEmailActions,
|
||||
alertEmailRecipients,
|
||||
alertWebhookActions
|
||||
} 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 { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
|
||||
import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string().nonempty(),
|
||||
alertRuleId: z.coerce.number<number>()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const webhookActionSchema = z.strictObject({
|
||||
webhookUrl: z.string().url(),
|
||||
config: z.string().optional(),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
// Alert rule fields - all optional for partial updates
|
||||
name: z.string().nonempty().optional(),
|
||||
eventType: z
|
||||
.enum([
|
||||
...HC_EVENT_TYPES,
|
||||
...SITE_EVENT_TYPES,
|
||||
...RESOURCE_EVENT_TYPES
|
||||
])
|
||||
.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
cooldownSeconds: z.number().int().nonnegative().optional(),
|
||||
// Source join tables - if provided the full set is replaced
|
||||
siteIds: z.array(z.number().int().positive()).optional(),
|
||||
allSites: z.boolean().optional(),
|
||||
healthCheckIds: z.array(z.number().int().positive()).optional(),
|
||||
allHealthChecks: z.boolean().optional(),
|
||||
resourceIds: z.array(z.number().int().positive()).optional(),
|
||||
allResources: z.boolean().optional(),
|
||||
// Recipient arrays - if any are provided the full recipient set is replaced
|
||||
userIds: z.array(z.string().nonempty()).optional(),
|
||||
roleIds: z.array(z.number()).optional(),
|
||||
emails: z.array(z.string().email()).optional(),
|
||||
// Webhook actions - if provided the full webhook set is replaced
|
||||
webhookActions: z.array(webhookActionSchema).optional()
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val.eventType) return;
|
||||
|
||||
const isSiteEvent = (SITE_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
|
||||
if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one siteId is required for site event types when allSites is false",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one resourceId is required for resource event types when allResources is false",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "healthCheckIds must not be set for site event types",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isHcEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "siteIds must not be set for health check event types",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "siteIds must not be set for resource event types",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "healthCheckIds must not be set for resource event types",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type UpdateAlertRuleResponse = {
|
||||
alertRuleId: number;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||
description: "Update an alert rule for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateAlertRule(
|
||||
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 { orgId, alertRuleId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.alertRuleId, alertRuleId),
|
||||
eq(alertRules.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Alert rule not found")
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
eventType,
|
||||
enabled,
|
||||
cooldownSeconds,
|
||||
siteIds,
|
||||
allSites,
|
||||
healthCheckIds,
|
||||
allHealthChecks,
|
||||
resourceIds,
|
||||
allResources,
|
||||
userIds,
|
||||
roleIds,
|
||||
emails,
|
||||
webhookActions
|
||||
} = parsedBody.data;
|
||||
|
||||
// --- Update rule fields ---
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (eventType !== undefined) updateData.eventType = eventType;
|
||||
if (enabled !== undefined) updateData.enabled = enabled;
|
||||
if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds;
|
||||
if (allSites !== undefined) updateData.allSites = allSites;
|
||||
if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks;
|
||||
if (allResources !== undefined) updateData.allResources = allResources;
|
||||
|
||||
await db
|
||||
.update(alertRules)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.alertRuleId, alertRuleId),
|
||||
eq(alertRules.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
// --- Full-replace site associations if siteIds was provided ---
|
||||
if (siteIds !== undefined || allSites !== undefined) {
|
||||
await db
|
||||
.delete(alertSites)
|
||||
.where(eq(alertSites.alertRuleId, alertRuleId));
|
||||
|
||||
// Only insert junction rows when allSites is not true
|
||||
const effectiveAllSites = allSites ?? false;
|
||||
if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) {
|
||||
await db.insert(alertSites).values(
|
||||
siteIds.map((siteId) => ({
|
||||
alertRuleId,
|
||||
siteId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-replace health check associations if healthCheckIds was provided ---
|
||||
if (healthCheckIds !== undefined || allHealthChecks !== undefined) {
|
||||
await db
|
||||
.delete(alertHealthChecks)
|
||||
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
||||
|
||||
const effectiveAllHealthChecks = allHealthChecks ?? false;
|
||||
if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) {
|
||||
await db.insert(alertHealthChecks).values(
|
||||
healthCheckIds.map((healthCheckId) => ({
|
||||
alertRuleId,
|
||||
healthCheckId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-replace resource associations if resourceIds was provided ---
|
||||
if (resourceIds !== undefined || allResources !== undefined) {
|
||||
await db
|
||||
.delete(alertResources)
|
||||
.where(eq(alertResources.alertRuleId, alertRuleId));
|
||||
|
||||
const effectiveAllResources = allResources ?? false;
|
||||
if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) {
|
||||
await db.insert(alertResources).values(
|
||||
resourceIds.map((resourceId) => ({
|
||||
alertRuleId,
|
||||
resourceId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-replace recipients if any recipient array was provided ---
|
||||
const recipientsProvided =
|
||||
userIds !== undefined ||
|
||||
roleIds !== undefined ||
|
||||
emails !== undefined;
|
||||
|
||||
if (recipientsProvided) {
|
||||
const newRecipients = [
|
||||
...(userIds ?? []).map((userId) => ({
|
||||
userId,
|
||||
roleId: null as number | null,
|
||||
email: null as string | null
|
||||
})),
|
||||
...(roleIds ?? []).map((roleId) => ({
|
||||
userId: null as string | null,
|
||||
roleId,
|
||||
email: null as string | null
|
||||
})),
|
||||
...(emails ?? []).map((email) => ({
|
||||
userId: null as string | null,
|
||||
roleId: null as number | null,
|
||||
email
|
||||
}))
|
||||
];
|
||||
|
||||
const [existingEmailAction] = await db
|
||||
.select()
|
||||
.from(alertEmailActions)
|
||||
.where(eq(alertEmailActions.alertRuleId, alertRuleId));
|
||||
|
||||
if (existingEmailAction) {
|
||||
await db
|
||||
.delete(alertEmailRecipients)
|
||||
.where(
|
||||
eq(
|
||||
alertEmailRecipients.emailActionId,
|
||||
existingEmailAction.emailActionId
|
||||
)
|
||||
);
|
||||
|
||||
if (newRecipients.length > 0) {
|
||||
await db.insert(alertEmailRecipients).values(
|
||||
newRecipients.map((r) => ({
|
||||
emailActionId: existingEmailAction.emailActionId,
|
||||
...r
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else if (newRecipients.length > 0) {
|
||||
const [emailActionRow] = await db
|
||||
.insert(alertEmailActions)
|
||||
.values({ alertRuleId, enabled: true })
|
||||
.returning();
|
||||
|
||||
await db.insert(alertEmailRecipients).values(
|
||||
newRecipients.map((r) => ({
|
||||
emailActionId: emailActionRow.emailActionId,
|
||||
...r
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-replace webhook actions if the array was provided ---
|
||||
if (webhookActions !== undefined) {
|
||||
await db
|
||||
.delete(alertWebhookActions)
|
||||
.where(eq(alertWebhookActions.alertRuleId, alertRuleId));
|
||||
|
||||
if (webhookActions.length > 0) {
|
||||
const serverSecret = config.getRawConfig().server.secret!;
|
||||
await db.insert(alertWebhookActions).values(
|
||||
webhookActions.map((wa) => ({
|
||||
alertRuleId,
|
||||
webhookUrl: wa.webhookUrl,
|
||||
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
|
||||
enabled: wa.enabled
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response<UpdateAlertRuleResponse>(res, {
|
||||
data: {
|
||||
alertRuleId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rule updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import * as ssh from "#private/routers/ssh";
|
||||
import * as user from "#private/routers/user";
|
||||
import * as siteProvisioning from "#private/routers/siteProvisioning";
|
||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -681,7 +683,96 @@ authenticated.delete(
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/event-streaming-destinations",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listEventStreamingDestinations),
|
||||
eventStreamingDestination.listEventStreamingDestinations
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/alert-rule",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createAlertRule),
|
||||
logActionAudit(ActionsEnum.createAlertRule),
|
||||
alertRule.createAlertRule
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/alert-rule/:alertRuleId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateAlertRule),
|
||||
logActionAudit(ActionsEnum.updateAlertRule),
|
||||
alertRule.updateAlertRule
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/alert-rule/:alertRuleId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteAlertRule),
|
||||
logActionAudit(ActionsEnum.deleteAlertRule),
|
||||
alertRule.deleteAlertRule
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/alert-rules",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listAlertRules),
|
||||
alertRule.listAlertRules
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/alert-rule/:alertRuleId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getAlertRule),
|
||||
alertRule.getAlertRule
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/health-checks",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listHealthChecks),
|
||||
healthChecks.listHealthChecks
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/health-check",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createHealthCheck),
|
||||
logActionAudit(ActionsEnum.createHealthCheck),
|
||||
healthChecks.createHealthCheck
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/health-check/:healthCheckId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateHealthCheck),
|
||||
logActionAudit(ActionsEnum.updateHealthCheck),
|
||||
healthChecks.updateHealthCheck
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/health-check/:healthCheckId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteHealthCheck),
|
||||
logActionAudit(ActionsEnum.deleteHealthCheck),
|
||||
healthChecks.deleteHealthCheck
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/health-check/:healthCheckId/status-history",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getTarget),
|
||||
healthChecks.getHealthCheckStatusHistory
|
||||
);
|
||||
|
||||
188
server/private/routers/healthChecks/createHealthCheck.ts
Normal file
188
server/private/routers/healthChecks/createHealthCheck.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck, newts, sites } from "@server/db";
|
||||
import { eq } 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 { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
name: z.string().nonempty(),
|
||||
siteId: z.number().int().positive(),
|
||||
hcEnabled: z.boolean().default(false),
|
||||
hcMode: z.string().default("http"),
|
||||
hcHostname: z.string().optional(),
|
||||
hcPort: z.number().int().min(1).max(65535).optional(),
|
||||
hcPath: z.string().optional(),
|
||||
hcScheme: z.string().optional(),
|
||||
hcMethod: z.string().default("GET"),
|
||||
hcInterval: z.number().int().positive().default(30),
|
||||
hcUnhealthyInterval: z.number().int().positive().default(30),
|
||||
hcTimeout: z.number().int().positive().default(5),
|
||||
hcHeaders: z.string().optional().nullable(),
|
||||
hcFollowRedirects: z.boolean().default(true),
|
||||
hcStatus: z.number().int().optional().nullable(),
|
||||
hcTlsServerName: z.string().optional(),
|
||||
hcHealthyThreshold: z.number().int().positive().default(1),
|
||||
hcUnhealthyThreshold: z.number().int().positive().default(1)
|
||||
});
|
||||
|
||||
export type CreateHealthCheckResponse = {
|
||||
targetHealthCheckId: number;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/health-check",
|
||||
description: "Create a health check for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createHealthCheck(
|
||||
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 { orgId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
siteId,
|
||||
hcEnabled,
|
||||
hcMode,
|
||||
hcHostname,
|
||||
hcPort,
|
||||
hcPath,
|
||||
hcScheme,
|
||||
hcMethod,
|
||||
hcInterval,
|
||||
hcUnhealthyInterval,
|
||||
hcTimeout,
|
||||
hcHeaders,
|
||||
hcFollowRedirects,
|
||||
hcStatus,
|
||||
hcTlsServerName,
|
||||
hcHealthyThreshold,
|
||||
hcUnhealthyThreshold
|
||||
} = parsedBody.data;
|
||||
|
||||
const [record] = await db
|
||||
.insert(targetHealthCheck)
|
||||
.values({
|
||||
targetId: null,
|
||||
orgId,
|
||||
siteId,
|
||||
name,
|
||||
hcEnabled,
|
||||
hcMode,
|
||||
hcHostname: hcHostname ?? null,
|
||||
hcPort: hcPort ?? null,
|
||||
hcPath: hcPath ?? null,
|
||||
hcScheme: hcScheme ?? null,
|
||||
hcMethod,
|
||||
hcInterval,
|
||||
hcUnhealthyInterval,
|
||||
hcTimeout,
|
||||
hcHeaders: hcHeaders ?? null,
|
||||
hcFollowRedirects,
|
||||
hcStatus: hcStatus ?? null,
|
||||
hcTlsServerName: hcTlsServerName ?? null,
|
||||
hcHealthyThreshold,
|
||||
hcUnhealthyThreshold
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Push health check to newt if the site is a newt site
|
||||
if (siteId) {
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (site && site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await addStandaloneHealthCheck(
|
||||
newt.newtId,
|
||||
record,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response<CreateHealthCheckResponse>(res, {
|
||||
data: {
|
||||
targetHealthCheckId: record.targetHealthCheckId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Standalone health check created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
123
server/private/routers/healthChecks/deleteHealthCheck.ts
Normal file
123
server/private/routers/healthChecks/deleteHealthCheck.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck, newts, sites } 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, isNull } from "drizzle-orm";
|
||||
import { removeStandaloneHealthCheck } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string().nonempty(),
|
||||
healthCheckId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/health-check/{healthCheckId}",
|
||||
description: "Delete a health check for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteHealthCheck(
|
||||
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 { orgId, healthCheckId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
.where(
|
||||
and(
|
||||
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
isNull(targetHealthCheck.targetId)
|
||||
)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Standalone health check not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(targetHealthCheck)
|
||||
.where(
|
||||
and(
|
||||
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
isNull(targetHealthCheck.targetId)
|
||||
)
|
||||
);
|
||||
|
||||
// Remove health check from newt if the site is a newt site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, existing.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await removeStandaloneHealthCheck(
|
||||
newt.newtId,
|
||||
healthCheckId,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Standalone health check deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
93
server/private/routers/healthChecks/getStatusHistory.ts
Normal file
93
server/private/routers/healthChecks/getStatusHistory.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, statusHistory } from "@server/db";
|
||||
import { and, eq, gte, asc } 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 { fromError } from "zod-validation-error";
|
||||
import {
|
||||
computeBuckets,
|
||||
statusHistoryQuerySchema,
|
||||
StatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
|
||||
const healthCheckParamsSchema = z.object({
|
||||
healthCheckId: z.string().transform((v) => parseInt(v, 10))
|
||||
});
|
||||
|
||||
export async function getHealthCheckStatusHistory(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = healthCheckParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const entityType = "healthCheck";
|
||||
const entityId = parsedParams.data.healthCheckId;
|
||||
const { days } = parsedQuery.data;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const startSec = nowSec - days * 86400;
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(statusHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(statusHistory.entityType, entityType),
|
||||
eq(statusHistory.entityId, entityId),
|
||||
gte(statusHistory.timestamp, startSec)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(statusHistory.timestamp));
|
||||
|
||||
const { buckets, totalDowntime } = computeBuckets(events, days);
|
||||
const totalWindow = days * 86400;
|
||||
const overallUptime =
|
||||
totalWindow > 0
|
||||
? Math.max(
|
||||
0,
|
||||
((totalWindow - totalDowntime) / totalWindow) * 100
|
||||
)
|
||||
: 100;
|
||||
|
||||
return response<StatusHistoryResponse>(res, {
|
||||
data: {
|
||||
entityType,
|
||||
entityId,
|
||||
days: buckets,
|
||||
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||
totalDowntimeSeconds: totalDowntime
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Status history retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
18
server/private/routers/healthChecks/index.ts
Normal file
18
server/private/routers/healthChecks/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./listHealthChecks";
|
||||
export * from "./createHealthCheck";
|
||||
export * from "./updateHealthCheck";
|
||||
export * from "./deleteHealthCheck";
|
||||
export * from "./getStatusHistory";
|
||||
187
server/private/routers/healthChecks/listHealthChecks.ts
Normal file
187
server/private/routers/healthChecks/listHealthChecks.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, targetHealthCheck, targets, resources, sites } 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 { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq, like, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const querySchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/health-checks",
|
||||
description: "List health checks for an organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: querySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listHealthChecks(
|
||||
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 { orgId } = parsedParams.data;
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset, query } = parsedQuery.data;
|
||||
|
||||
const whereClause = and(
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
query
|
||||
? like(
|
||||
sql`LOWER(${targetHealthCheck.name})`,
|
||||
`%${query.toLowerCase()}%`
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const list = await db
|
||||
.select({
|
||||
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
|
||||
name: targetHealthCheck.name,
|
||||
siteId: targetHealthCheck.siteId,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
hcEnabled: targetHealthCheck.hcEnabled,
|
||||
hcHealth: targetHealthCheck.hcHealth,
|
||||
hcMode: targetHealthCheck.hcMode,
|
||||
hcHostname: targetHealthCheck.hcHostname,
|
||||
hcPort: targetHealthCheck.hcPort,
|
||||
hcPath: targetHealthCheck.hcPath,
|
||||
hcScheme: targetHealthCheck.hcScheme,
|
||||
hcMethod: targetHealthCheck.hcMethod,
|
||||
hcInterval: targetHealthCheck.hcInterval,
|
||||
hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval,
|
||||
hcTimeout: targetHealthCheck.hcTimeout,
|
||||
hcHeaders: targetHealthCheck.hcHeaders,
|
||||
hcFollowRedirects: targetHealthCheck.hcFollowRedirects,
|
||||
hcStatus: targetHealthCheck.hcStatus,
|
||||
hcTlsServerName: targetHealthCheck.hcTlsServerName,
|
||||
hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold,
|
||||
hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold,
|
||||
resourceId: resources.resourceId,
|
||||
resourceName: resources.name,
|
||||
resourceNiceId: resources.niceId
|
||||
})
|
||||
.from(targetHealthCheck)
|
||||
.leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId))
|
||||
.leftJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(sites, eq(targetHealthCheck.siteId, sites.siteId))
|
||||
.where(whereClause)
|
||||
.orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(targetHealthCheck)
|
||||
.where(whereClause);
|
||||
|
||||
return response<ListHealthChecksResponse>(res, {
|
||||
data: {
|
||||
healthChecks: list.map((row) => ({
|
||||
targetHealthCheckId: row.targetHealthCheckId,
|
||||
name: row.name ?? "",
|
||||
siteId: row.siteId ?? null,
|
||||
siteName: row.siteName ?? null,
|
||||
siteNiceId: row.siteNiceId ?? null,
|
||||
hcEnabled: row.hcEnabled,
|
||||
hcHealth: (row.hcHealth ?? "unknown") as
|
||||
| "unknown"
|
||||
| "healthy"
|
||||
| "unhealthy",
|
||||
hcMode: row.hcMode ?? null,
|
||||
hcHostname: row.hcHostname ?? null,
|
||||
hcPort: row.hcPort ?? null,
|
||||
hcPath: row.hcPath ?? null,
|
||||
hcScheme: row.hcScheme ?? null,
|
||||
hcMethod: row.hcMethod ?? null,
|
||||
hcInterval: row.hcInterval ?? null,
|
||||
hcUnhealthyInterval: row.hcUnhealthyInterval ?? null,
|
||||
hcTimeout: row.hcTimeout ?? null,
|
||||
hcHeaders: row.hcHeaders ?? null,
|
||||
hcFollowRedirects: row.hcFollowRedirects ?? null,
|
||||
hcStatus: row.hcStatus ?? null,
|
||||
hcTlsServerName: row.hcTlsServerName ?? null,
|
||||
hcHealthyThreshold: row.hcHealthyThreshold ?? null,
|
||||
hcUnhealthyThreshold: row.hcUnhealthyThreshold ?? null,
|
||||
resourceId: row.resourceId ?? null,
|
||||
resourceName: row.resourceName ?? null,
|
||||
resourceNiceId: row.resourceNiceId ?? null
|
||||
})),
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Standalone health checks retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
250
server/private/routers/healthChecks/updateHealthCheck.ts
Normal file
250
server/private/routers/healthChecks/updateHealthCheck.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck, newts, sites } 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, isNull } from "drizzle-orm";
|
||||
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string().nonempty(),
|
||||
healthCheckId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
})
|
||||
.strict();
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
name: z.string().nonempty().optional(),
|
||||
siteId: z.number().int().positive().optional(),
|
||||
hcEnabled: z.boolean().optional(),
|
||||
hcMode: z.string().optional(),
|
||||
hcHostname: z.string().optional(),
|
||||
hcPort: z.number().int().min(1).max(65535).optional(),
|
||||
hcPath: z.string().optional(),
|
||||
hcScheme: z.string().optional(),
|
||||
hcMethod: z.string().optional(),
|
||||
hcInterval: z.number().int().positive().optional(),
|
||||
hcUnhealthyInterval: z.number().int().positive().optional(),
|
||||
hcTimeout: z.number().int().positive().optional(),
|
||||
hcHeaders: z.string().optional().nullable(),
|
||||
hcFollowRedirects: z.boolean().optional(),
|
||||
hcStatus: z.number().int().optional().nullable(),
|
||||
hcTlsServerName: z.string().optional(),
|
||||
hcHealthyThreshold: z.number().int().positive().optional(),
|
||||
hcUnhealthyThreshold: z.number().int().positive().optional()
|
||||
});
|
||||
|
||||
export type UpdateHealthCheckResponse = {
|
||||
targetHealthCheckId: number;
|
||||
name: string | null;
|
||||
siteId: number | null;
|
||||
hcEnabled: boolean;
|
||||
hcHealth: string | null;
|
||||
hcMode: string | null;
|
||||
hcHostname: string | null;
|
||||
hcPort: number | null;
|
||||
hcPath: string | null;
|
||||
hcScheme: string | null;
|
||||
hcMethod: string | null;
|
||||
hcInterval: number | null;
|
||||
hcUnhealthyInterval: number | null;
|
||||
hcTimeout: number | null;
|
||||
hcHeaders: string | null;
|
||||
hcFollowRedirects: boolean | null;
|
||||
hcStatus: number | null;
|
||||
hcTlsServerName: string | null;
|
||||
hcHealthyThreshold: number | null;
|
||||
hcUnhealthyThreshold: number | null;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/health-check/{healthCheckId}",
|
||||
description: "Update a health check for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateHealthCheck(
|
||||
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 { orgId, healthCheckId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
.where(
|
||||
and(
|
||||
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
isNull(targetHealthCheck.targetId)
|
||||
)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Standalone health check not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
siteId,
|
||||
hcEnabled,
|
||||
hcMode,
|
||||
hcHostname,
|
||||
hcPort,
|
||||
hcPath,
|
||||
hcScheme,
|
||||
hcMethod,
|
||||
hcInterval,
|
||||
hcUnhealthyInterval,
|
||||
hcTimeout,
|
||||
hcHeaders,
|
||||
hcFollowRedirects,
|
||||
hcStatus,
|
||||
hcTlsServerName,
|
||||
hcHealthyThreshold,
|
||||
hcUnhealthyThreshold
|
||||
} = parsedBody.data;
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (siteId !== undefined) updateData.siteId = siteId;
|
||||
if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled;
|
||||
if (hcMode !== undefined) updateData.hcMode = hcMode;
|
||||
if (hcHostname !== undefined) updateData.hcHostname = hcHostname;
|
||||
if (hcPort !== undefined) updateData.hcPort = hcPort;
|
||||
if (hcPath !== undefined) updateData.hcPath = hcPath;
|
||||
if (hcScheme !== undefined) updateData.hcScheme = hcScheme;
|
||||
if (hcMethod !== undefined) updateData.hcMethod = hcMethod;
|
||||
if (hcInterval !== undefined) updateData.hcInterval = hcInterval;
|
||||
if (hcUnhealthyInterval !== undefined)
|
||||
updateData.hcUnhealthyInterval = hcUnhealthyInterval;
|
||||
if (hcTimeout !== undefined) updateData.hcTimeout = hcTimeout;
|
||||
if (hcHeaders !== undefined) updateData.hcHeaders = hcHeaders;
|
||||
if (hcFollowRedirects !== undefined)
|
||||
updateData.hcFollowRedirects = hcFollowRedirects;
|
||||
if (hcStatus !== undefined) updateData.hcStatus = hcStatus;
|
||||
if (hcTlsServerName !== undefined)
|
||||
updateData.hcTlsServerName = hcTlsServerName;
|
||||
if (hcHealthyThreshold !== undefined)
|
||||
updateData.hcHealthyThreshold = hcHealthyThreshold;
|
||||
if (hcUnhealthyThreshold !== undefined)
|
||||
updateData.hcUnhealthyThreshold = hcUnhealthyThreshold;
|
||||
|
||||
const [updated] = await db
|
||||
.update(targetHealthCheck)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
isNull(targetHealthCheck.targetId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
// Push updated health check to newt if the site is a newt site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, updated.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await addStandaloneHealthCheck(newt.newtId, updated, newt.version);
|
||||
}
|
||||
|
||||
return response<UpdateHealthCheckResponse>(res, {
|
||||
data: {
|
||||
targetHealthCheckId: updated.targetHealthCheckId,
|
||||
siteId: updated.siteId ?? null,
|
||||
name: updated.name ?? null,
|
||||
hcEnabled: updated.hcEnabled,
|
||||
hcHealth: updated.hcHealth ?? null,
|
||||
hcMode: updated.hcMode ?? null,
|
||||
hcHostname: updated.hcHostname ?? null,
|
||||
hcPort: updated.hcPort ?? null,
|
||||
hcPath: updated.hcPath ?? null,
|
||||
hcScheme: updated.hcScheme ?? null,
|
||||
hcMethod: updated.hcMethod ?? null,
|
||||
hcInterval: updated.hcInterval ?? null,
|
||||
hcUnhealthyInterval: updated.hcUnhealthyInterval ?? null,
|
||||
hcTimeout: updated.hcTimeout ?? null,
|
||||
hcHeaders: updated.hcHeaders ?? null,
|
||||
hcFollowRedirects: updated.hcFollowRedirects ?? null,
|
||||
hcStatus: updated.hcStatus ?? null,
|
||||
hcTlsServerName: updated.hcTlsServerName ?? null,
|
||||
hcHealthyThreshold: updated.hcHealthyThreshold ?? null,
|
||||
hcUnhealthyThreshold: updated.hcUnhealthyThreshold ?? null
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Standalone health check updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,8 @@ import {
|
||||
User,
|
||||
certificates,
|
||||
exitNodeOrgs,
|
||||
RemoteExitNode,
|
||||
olms,
|
||||
newts,
|
||||
clients,
|
||||
sites,
|
||||
domains,
|
||||
orgDomains,
|
||||
targets,
|
||||
loginPage,
|
||||
loginPageOrg,
|
||||
LoginPage,
|
||||
@@ -70,12 +64,9 @@ import {
|
||||
updateAndGenerateEndpointDestinations,
|
||||
updateSiteBandwidth
|
||||
} from "@server/routers/gerbil";
|
||||
import * as gerbil from "@server/routers/gerbil";
|
||||
import logger from "@server/logger";
|
||||
import { decryptData } from "@server/lib/encryption";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import * as fs from "fs";
|
||||
import { exchangeSession } from "@server/routers/badger";
|
||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
||||
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
||||
@@ -298,25 +289,11 @@ hybridRouter.get(
|
||||
}
|
||||
);
|
||||
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
if (encryptionKey) {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyHex =
|
||||
privateConfig.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
// Get valid certificates for given domains (supports wildcard certs)
|
||||
hybridRouter.get(
|
||||
"/certificates/domains",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
loadEncryptData(); // Ensure encryption key is loaded
|
||||
|
||||
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
|
||||
req.query
|
||||
);
|
||||
@@ -447,13 +424,13 @@ hybridRouter.get(
|
||||
|
||||
const result = filtered.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decryptData(
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
encryptionKey
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
||||
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
@@ -833,9 +810,12 @@ hybridRouter.get(
|
||||
)
|
||||
);
|
||||
|
||||
logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows);
|
||||
logger.debug(
|
||||
`User ${userId} has roles in org ${orgId}:`,
|
||||
userOrgRoleRows
|
||||
);
|
||||
|
||||
return response<{ roleId: number, roleName: string }[]>(res, {
|
||||
return response<{ roleId: number; roleName: string }[]>(res, {
|
||||
data: userOrgRoleRows,
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import * as orgIdp from "#private/routers/orgIdp";
|
||||
import * as org from "#private/routers/org";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import * as alertEvents from "#private/routers/alertEvents";
|
||||
|
||||
import {
|
||||
verifyApiKeyHasAction,
|
||||
@@ -40,6 +41,27 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
export const unauthenticated = ua;
|
||||
export const authenticated = a;
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/site/:siteId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerSiteAlert),
|
||||
alertEvents.triggerSiteAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/resource/:resourceId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerResourceAlert),
|
||||
alertEvents.triggerResourceAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/health-check/:healthCheckId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerHealthCheckAlert),
|
||||
alertEvents.triggerHealthCheckAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/org/:orgId/send-usage-notification`,
|
||||
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
||||
|
||||
@@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the org for this site
|
||||
// Look up the org for this site and check retention settings
|
||||
const [site] = await db
|
||||
.select({ orgId: sites.orgId, orgSubnet: orgs.subnet })
|
||||
.select({
|
||||
orgId: sites.orgId,
|
||||
orgSubnet: orgs.subnet,
|
||||
settingsLogRetentionDaysConnection:
|
||||
orgs.settingsLogRetentionDaysConnection
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
.where(eq(sites.siteId, newt.siteId));
|
||||
@@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||
|
||||
const orgId = site.orgId;
|
||||
|
||||
if (site.settingsLogRetentionDaysConnection === 0) {
|
||||
logger.debug(
|
||||
`Connection log retention is disabled for org ${orgId}, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
|
||||
// reconstruct the exact subnet string stored on each client record.
|
||||
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||
|
||||
238
server/private/routers/newt/handleRequestLogMessage.ts
Normal file
238
server/private/routers/newt/handleRequestLogMessage.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { inflate } from "zlib";
|
||||
import { promisify } from "util";
|
||||
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
|
||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||
|
||||
export async function flushRequestLogToDb(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const zlibInflate = promisify(inflate);
|
||||
|
||||
interface HTTPRequestLogData {
|
||||
requestId: string;
|
||||
resourceId: number; // siteResourceId
|
||||
timestamp: string; // ISO 8601
|
||||
method: string;
|
||||
scheme: string; // "http" or "https"
|
||||
host: string;
|
||||
path: string;
|
||||
rawQuery?: string;
|
||||
userAgent?: string;
|
||||
sourceAddr: string; // ip:port
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
|
||||
*/
|
||||
async function decompressRequestLog(
|
||||
compressed: string
|
||||
): Promise<HTTPRequestLogData[]> {
|
||||
const compressedBuffer = Buffer.from(compressed, "base64");
|
||||
const decompressed = await zlibInflate(compressedBuffer);
|
||||
const jsonString = decompressed.toString("utf-8");
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Decompressed request log data is not an array");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||
const { message, client } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Request log received but no newt client in context");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Request log received but newt has no siteId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.data?.compressed) {
|
||||
logger.warn("Request log message missing compressed data");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the org for this site and check retention settings
|
||||
const [site] = await db
|
||||
.select({
|
||||
orgId: sites.orgId,
|
||||
orgSubnet: orgs.subnet,
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
.where(eq(sites.siteId, newt.siteId));
|
||||
|
||||
if (!site) {
|
||||
logger.warn(
|
||||
`Request log received but site ${newt.siteId} not found in database`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = site.orgId;
|
||||
|
||||
if (site.settingsLogRetentionDaysRequest === 0) {
|
||||
logger.debug(
|
||||
`Request log retention is disabled for org ${orgId}, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: HTTPRequestLogData[];
|
||||
try {
|
||||
entries = await decompressRequestLog(message.data.compressed);
|
||||
} catch (error) {
|
||||
logger.error("Failed to decompress request log data:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
|
||||
|
||||
// Build a map from sourceIp → external endpoint string by joining clients
|
||||
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
|
||||
// of the client device and is used for GeoIP lookup.
|
||||
const ipToEndpoint = new Map<string, string>();
|
||||
|
||||
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
|
||||
: null;
|
||||
|
||||
if (cidrSuffix) {
|
||||
const uniqueSourceAddrs = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.sourceAddr) {
|
||||
uniqueSourceAddrs.add(entry.sourceAddr);
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueSourceAddrs.size > 0) {
|
||||
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
|
||||
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
|
||||
return `${ip}${cidrSuffix}`;
|
||||
});
|
||||
|
||||
const matchedClients = await db
|
||||
.select({
|
||||
subnet: clients.subnet,
|
||||
endpoint: clientSitesAssociationsCache.endpoint
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSitesAssociationsCache,
|
||||
and(
|
||||
eq(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clients.clientId
|
||||
),
|
||||
eq(clientSitesAssociationsCache.siteId, newt.siteId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.orgId, orgId),
|
||||
inArray(clients.subnet, subnetQueries)
|
||||
)
|
||||
);
|
||||
|
||||
for (const c of matchedClients) {
|
||||
if (c.endpoint) {
|
||||
const ip = c.subnet.split("/")[0];
|
||||
ipToEndpoint.set(ip, c.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!entry.requestId ||
|
||||
!entry.resourceId ||
|
||||
!entry.method ||
|
||||
!entry.scheme ||
|
||||
!entry.host ||
|
||||
!entry.path ||
|
||||
!entry.sourceAddr
|
||||
) {
|
||||
logger.debug(
|
||||
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalRequestURL =
|
||||
entry.scheme +
|
||||
"://" +
|
||||
entry.host +
|
||||
entry.path +
|
||||
(entry.rawQuery ? "?" + entry.rawQuery : "");
|
||||
|
||||
// Resolve the client's external endpoint for GeoIP lookup.
|
||||
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
|
||||
const sourceIp = entry.sourceAddr.includes(":")
|
||||
? entry.sourceAddr.split(":")[0]
|
||||
: entry.sourceAddr;
|
||||
const endpoint = ipToEndpoint.get(sourceIp);
|
||||
let location: string | undefined;
|
||||
if (endpoint) {
|
||||
const endpointIp = endpoint.includes(":")
|
||||
? endpoint.split(":")[0]
|
||||
: endpoint;
|
||||
location = await getCountryCodeForIp(endpointIp);
|
||||
}
|
||||
|
||||
await logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 108,
|
||||
siteResourceId: entry.resourceId,
|
||||
orgId,
|
||||
location
|
||||
},
|
||||
{
|
||||
path: entry.path,
|
||||
originalRequestURL,
|
||||
scheme: entry.scheme,
|
||||
host: entry.host,
|
||||
method: entry.method,
|
||||
tls: entry.tls,
|
||||
requestIp: entry.sourceAddr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
|
||||
);
|
||||
};
|
||||
@@ -12,3 +12,4 @@
|
||||
*/
|
||||
|
||||
export * from "./handleConnectionLogMessage";
|
||||
export * from "./handleRequestLogMessage";
|
||||
|
||||
@@ -11,78 +11,12 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, exitNodes, sites } from "@server/db";
|
||||
import { db, exitNodes } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { clients, RemoteExitNode } from "@server/db";
|
||||
import { eq, lt, isNull, and, or, inArray } from "drizzle-orm";
|
||||
import { RemoteExitNode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for clients that haven't pinged recently
|
||||
* and marks them as offline
|
||||
*/
|
||||
export const startRemoteExitNodeOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
offlineCheckerInterval = setInterval(async () => {
|
||||
try {
|
||||
const twoMinutesAgo = Math.floor(
|
||||
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||
);
|
||||
|
||||
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
||||
const offlineNodes = await db
|
||||
.update(exitNodes)
|
||||
.set({ online: false })
|
||||
.where(
|
||||
and(
|
||||
eq(exitNodes.online, true),
|
||||
eq(exitNodes.type, "remoteExitNode"),
|
||||
or(
|
||||
lt(exitNodes.lastPing, twoMinutesAgo),
|
||||
isNull(exitNodes.lastPing)
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (offlineNodes.length > 0) {
|
||||
logger.info(
|
||||
`checkRemoteExitNodeOffline: Marked ${offlineNodes.length} remoteExitNode client(s) offline due to inactivity`
|
||||
);
|
||||
|
||||
for (const offlineClient of offlineNodes) {
|
||||
logger.debug(
|
||||
`checkRemoteExitNodeOffline: Client ${offlineClient.exitNodeId} marked offline (lastPing: ${offlineClient.lastPing})`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in offline checker interval", { error });
|
||||
}
|
||||
}, OFFLINE_CHECK_INTERVAL);
|
||||
|
||||
logger.debug("Started offline checker interval");
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the background interval that checks for offline clients
|
||||
*/
|
||||
export const stopRemoteExitNodeOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
clearInterval(offlineCheckerInterval);
|
||||
offlineCheckerInterval = null;
|
||||
logger.info("Stopped offline checker interval");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles ping messages from clients and responds with pong
|
||||
*/
|
||||
|
||||
@@ -21,3 +21,4 @@ export * from "./deleteRemoteExitNode";
|
||||
export * from "./listRemoteExitNodes";
|
||||
export * from "./pickRemoteExitNodeDefaults";
|
||||
export * from "./quickStartRemoteExitNode";
|
||||
export * from "./offlineChecker";
|
||||
|
||||
82
server/private/routers/remoteExitNode/offlineChecker.ts
Normal file
82
server/private/routers/remoteExitNode/offlineChecker.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, exitNodes } from "@server/db";
|
||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for clients that haven't pinged recently
|
||||
* and marks them as offline
|
||||
*/
|
||||
export const startRemoteExitNodeOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
offlineCheckerInterval = setInterval(async () => {
|
||||
try {
|
||||
const twoMinutesAgo = Math.floor(
|
||||
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||
);
|
||||
|
||||
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
||||
const offlineNodes = await db
|
||||
.update(exitNodes)
|
||||
.set({ online: false })
|
||||
.where(
|
||||
and(
|
||||
eq(exitNodes.online, true),
|
||||
eq(exitNodes.type, "remoteExitNode"),
|
||||
or(
|
||||
lt(exitNodes.lastPing, twoMinutesAgo),
|
||||
isNull(exitNodes.lastPing)
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (offlineNodes.length > 0) {
|
||||
logger.info(
|
||||
`checkRemoteExitNodeOffline: Marked ${offlineNodes.length} remoteExitNode client(s) offline due to inactivity`
|
||||
);
|
||||
|
||||
for (const offlineClient of offlineNodes) {
|
||||
logger.debug(
|
||||
`checkRemoteExitNodeOffline: Client ${offlineClient.exitNodeId} marked offline (lastPing: ${offlineClient.lastPing})`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in offline checker interval", { error });
|
||||
}
|
||||
}, OFFLINE_CHECK_INTERVAL);
|
||||
|
||||
logger.debug("Started offline checker interval");
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the background interval that checks for offline clients
|
||||
*/
|
||||
export const stopRemoteExitNodeOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
clearInterval(offlineCheckerInterval);
|
||||
offlineCheckerInterval = null;
|
||||
logger.info("Stopped offline checker interval");
|
||||
}
|
||||
};
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
roles,
|
||||
roundTripMessageTracker,
|
||||
siteResources,
|
||||
sites,
|
||||
siteNetworks,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
@@ -63,10 +63,12 @@ const bodySchema = z
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
messageIds: number[];
|
||||
messageId: number;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
siteIds: number[];
|
||||
siteId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
@@ -260,10 +262,7 @@ export async function signSshKey(
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, userId)
|
||||
)
|
||||
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
@@ -395,21 +394,12 @@ export async function signSshKey(
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, resource.siteId))
|
||||
.limit(1);
|
||||
const sites = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
const siteIds = sites.map((site) => site.siteId);
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
@@ -423,43 +413,64 @@ export async function signSshKey(
|
||||
validBefore: now + validFor
|
||||
});
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
const messageIds: number[] = [];
|
||||
for (const siteId of siteIds) {
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
messageIds.push(message.messageId);
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const expiresIn = Number(validFor); // seconds
|
||||
|
||||
@@ -480,7 +491,7 @@ export async function signSshKey(
|
||||
metadata: JSON.stringify({
|
||||
resourceId: resource.siteResourceId,
|
||||
resource: resource.name,
|
||||
siteId: resource.siteId,
|
||||
siteIds: siteIds
|
||||
})
|
||||
});
|
||||
|
||||
@@ -494,7 +505,7 @@ export async function signSshKey(
|
||||
: undefined,
|
||||
metadata: {
|
||||
resourceName: resource.name,
|
||||
siteId: resource.siteId,
|
||||
siteId: siteIds[0],
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost
|
||||
},
|
||||
@@ -505,11 +516,13 @@ export async function signSshKey(
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
messageId: message.messageId,
|
||||
messageIds: messageIds,
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost,
|
||||
resourceId: resource.siteResourceId,
|
||||
siteId: resource.siteId,
|
||||
siteIds: siteIds,
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
} from "#private/routers/remoteExitNode";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { build } from "@server/build";
|
||||
import { handleConnectionLogMessage } from "#private/routers/newt";
|
||||
import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt";
|
||||
|
||||
export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
||||
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
|
||||
"newt/access-log": handleConnectionLogMessage,
|
||||
"newt/request-log": handleRequestLogMessage,
|
||||
};
|
||||
|
||||
if (build != "saas") {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db";
|
||||
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
|
||||
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { z } from "zod";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -92,7 +92,10 @@ function getWhere(data: Q) {
|
||||
lt(requestAuditLog.timestamp, data.timeEnd),
|
||||
eq(requestAuditLog.orgId, data.orgId),
|
||||
data.resourceId
|
||||
? eq(requestAuditLog.resourceId, data.resourceId)
|
||||
? or(
|
||||
eq(requestAuditLog.resourceId, data.resourceId),
|
||||
eq(requestAuditLog.siteResourceId, data.resourceId)
|
||||
)
|
||||
: undefined,
|
||||
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
||||
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
||||
@@ -110,15 +113,16 @@ export function queryRequest(data: Q) {
|
||||
return primaryLogsDb
|
||||
.select({
|
||||
id: requestAuditLog.id,
|
||||
timestamp: requestAuditLog.timestamp,
|
||||
orgId: requestAuditLog.orgId,
|
||||
action: requestAuditLog.action,
|
||||
reason: requestAuditLog.reason,
|
||||
actorType: requestAuditLog.actorType,
|
||||
actor: requestAuditLog.actor,
|
||||
actorId: requestAuditLog.actorId,
|
||||
resourceId: requestAuditLog.resourceId,
|
||||
ip: requestAuditLog.ip,
|
||||
timestamp: requestAuditLog.timestamp,
|
||||
orgId: requestAuditLog.orgId,
|
||||
action: requestAuditLog.action,
|
||||
reason: requestAuditLog.reason,
|
||||
actorType: requestAuditLog.actorType,
|
||||
actor: requestAuditLog.actor,
|
||||
actorId: requestAuditLog.actorId,
|
||||
resourceId: requestAuditLog.resourceId,
|
||||
siteResourceId: requestAuditLog.siteResourceId,
|
||||
ip: requestAuditLog.ip,
|
||||
location: requestAuditLog.location,
|
||||
userAgent: requestAuditLog.userAgent,
|
||||
metadata: requestAuditLog.metadata,
|
||||
@@ -137,37 +141,73 @@ export function queryRequest(data: Q) {
|
||||
}
|
||||
|
||||
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
|
||||
// If logs database is the same as main database, we can do a join
|
||||
// Otherwise, we need to fetch resource details separately
|
||||
const resourceIds = logs
|
||||
.map(log => log.resourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
if (resourceIds.length === 0) {
|
||||
const siteResourceIds = logs
|
||||
.filter(log => log.resourceId == null && log.siteResourceId != null)
|
||||
.map(log => log.siteResourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
|
||||
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
||||
}
|
||||
|
||||
// Fetch resource details from main database
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||
|
||||
// Create a map for quick lookup
|
||||
const resourceMap = new Map(
|
||||
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
|
||||
);
|
||||
if (resourceIds.length > 0) {
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
for (const r of resourceDetails) {
|
||||
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
|
||||
}
|
||||
}
|
||||
|
||||
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name,
|
||||
niceId: siteResources.niceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
for (const r of siteResourceDetails) {
|
||||
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich logs with resource details
|
||||
return logs.map(log => ({
|
||||
...log,
|
||||
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
|
||||
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
|
||||
}));
|
||||
return logs.map(log => {
|
||||
if (log.resourceId != null) {
|
||||
const details = resourceMap.get(log.resourceId);
|
||||
return {
|
||||
...log,
|
||||
resourceName: details?.name ?? null,
|
||||
resourceNiceId: details?.niceId ?? null
|
||||
};
|
||||
} else if (log.siteResourceId != null) {
|
||||
const details = siteResourceMap.get(log.siteResourceId);
|
||||
return {
|
||||
...log,
|
||||
resourceId: log.siteResourceId,
|
||||
resourceName: details?.name ?? null,
|
||||
resourceNiceId: details?.niceId ?? null
|
||||
};
|
||||
}
|
||||
return { ...log, resourceName: null, resourceNiceId: null };
|
||||
});
|
||||
}
|
||||
|
||||
export function countRequestQuery(data: Q) {
|
||||
@@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes(
|
||||
uniqueLocations,
|
||||
uniqueHosts,
|
||||
uniquePaths,
|
||||
uniqueResources
|
||||
uniqueResources,
|
||||
uniqueSiteResources
|
||||
] = await Promise.all([
|
||||
primaryLogsDb
|
||||
.selectDistinct({ actor: requestAuditLog.actor })
|
||||
@@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes(
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
primaryLogsDb
|
||||
.selectDistinct({
|
||||
id: requestAuditLog.siteResourceId
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(and(baseConditions, isNull(requestAuditLog.resourceId)))
|
||||
.limit(DISTINCT_LIMIT + 1)
|
||||
]);
|
||||
|
||||
@@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes(
|
||||
.map(row => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
const siteResourceIds = uniqueSiteResources
|
||||
.map(row => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||
|
||||
if (resourceIds.length > 0) {
|
||||
@@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes(
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
resourcesWithNames = resourceDetails.map(r => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}));
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...resourceDetails.map(r => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...siteResourceDetails.map(r => ({
|
||||
id: r.siteResourceId,
|
||||
name: r.name
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = {
|
||||
actor: string | null;
|
||||
actorId: string | null;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceName: string | null;
|
||||
ip: string | null;
|
||||
|
||||
@@ -18,6 +18,7 @@ Reasons:
|
||||
105 - Valid Password
|
||||
106 - Valid email
|
||||
107 - Valid SSO
|
||||
108 - Connected Client
|
||||
|
||||
201 - Resource Not Found
|
||||
202 - Resource Blocked
|
||||
@@ -38,6 +39,7 @@ const auditLogBuffer: Array<{
|
||||
metadata: any;
|
||||
action: boolean;
|
||||
resourceId?: number;
|
||||
siteResourceId?: number;
|
||||
reason: number;
|
||||
location?: string;
|
||||
originalRequestURL: string;
|
||||
@@ -186,6 +188,7 @@ export async function logRequestAudit(
|
||||
action: boolean;
|
||||
reason: number;
|
||||
resourceId?: number;
|
||||
siteResourceId?: number;
|
||||
orgId?: string;
|
||||
location?: string;
|
||||
user?: { username: string; userId: string };
|
||||
@@ -262,6 +265,7 @@ export async function logRequestAudit(
|
||||
metadata: sanitizeString(metadata),
|
||||
action: data.action,
|
||||
resourceId: data.resourceId,
|
||||
siteResourceId: data.siteResourceId,
|
||||
reason: data.reason,
|
||||
location: sanitizeString(data.location),
|
||||
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
|
||||
|
||||
@@ -285,6 +285,13 @@ authenticated.get(
|
||||
site.listContainers
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/site/:siteId/status-history",
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.getSite),
|
||||
site.getSiteStatusHistory
|
||||
);
|
||||
|
||||
// Site Resource endpoints
|
||||
authenticated.put(
|
||||
"/org/:orgId/site-resource",
|
||||
@@ -420,6 +427,13 @@ authenticated.get(
|
||||
resource.listResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/status-history",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResource),
|
||||
resource.getResourceStatusHistory
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resources",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -88,11 +88,11 @@ async function dbQueryRows<T extends Record<string, unknown>>(
|
||||
): Promise<T[]> {
|
||||
const anyDb = db as any;
|
||||
if (typeof anyDb.execute === "function") {
|
||||
// PostgreSQL (node-postgres via Drizzle) — returns { rows: [...] } or an array
|
||||
// PostgreSQL (node-postgres via Drizzle) - returns { rows: [...] } or an array
|
||||
const result = await anyDb.execute(query);
|
||||
return (Array.isArray(result) ? result : (result.rows ?? [])) as T[];
|
||||
}
|
||||
// SQLite (better-sqlite3 via Drizzle) — returns an array directly
|
||||
// SQLite (better-sqlite3 via Drizzle) - returns an array directly
|
||||
return (await anyDb.all(query)) as T[];
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ function isSQLite(): boolean {
|
||||
* Swaps out the accumulator before writing so that any bandwidth messages
|
||||
* received during the flush are captured in the new accumulator rather than
|
||||
* being lost or causing contention. Sites are updated in chunks via a single
|
||||
* batch UPDATE per chunk. Failed chunks are discarded — exact per-flush
|
||||
* batch UPDATE per chunk. Failed chunks are discarded - exact per-flush
|
||||
* accuracy is not critical and re-queuing is not worth the added complexity.
|
||||
*
|
||||
* This function is exported so that the application's graceful-shutdown
|
||||
@@ -125,7 +125,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||
const currentTime = new Date().toISOString();
|
||||
|
||||
// Sort by publicKey for consistent lock ordering across concurrent
|
||||
// writers — deadlock-prevention strategy.
|
||||
// writers - deadlock-prevention strategy.
|
||||
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
@@ -150,7 +150,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||
try {
|
||||
rows = await withDeadlockRetry(async () => {
|
||||
if (isSQLite()) {
|
||||
// SQLite: one UPDATE per row — no need for batch efficiency here.
|
||||
// SQLite: one UPDATE per row - no need for batch efficiency here.
|
||||
const results: { orgId: string; pubKey: string }[] = [];
|
||||
for (const [publicKey, { bytesIn, bytesOut }] of chunk) {
|
||||
const result = await dbQueryRows<{
|
||||
@@ -170,7 +170,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||
return results;
|
||||
}
|
||||
|
||||
// PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk.
|
||||
// PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk.
|
||||
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
|
||||
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
|
||||
);
|
||||
@@ -191,7 +191,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||
`Failed to flush bandwidth chunk [${i}–${chunkEnd}], discarding ${chunk.length} site(s):`,
|
||||
error
|
||||
);
|
||||
// Discard the chunk — exact per-flush accuracy is not critical.
|
||||
// Discard the chunk - exact per-flush accuracy is not critical.
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||
totalBandwidth
|
||||
);
|
||||
if (bandwidthUsage) {
|
||||
// Fire-and-forget — don't block the flush on limit checking.
|
||||
// Fire-and-forget - don't block the flush on limit checking.
|
||||
usageService
|
||||
.checkLimitSet(
|
||||
orgId,
|
||||
@@ -298,7 +298,7 @@ export async function updateSiteBandwidth(
|
||||
exitNodeId?: number
|
||||
): Promise<void> {
|
||||
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
|
||||
// Skip peers that haven't transferred any data — writing zeros to the
|
||||
// Skip peers that haven't transferred any data - writing zeros to the
|
||||
// database would be a no-op anyway.
|
||||
if (bytesIn <= 0 && bytesOut <= 0) {
|
||||
continue;
|
||||
|
||||
34
server/routers/healthChecks/types.ts
Normal file
34
server/routers/healthChecks/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type ListHealthChecksResponse = {
|
||||
healthChecks: {
|
||||
targetHealthCheckId: number;
|
||||
name: string;
|
||||
siteId: number | null;
|
||||
siteName: string | null;
|
||||
siteNiceId: string | null;
|
||||
hcEnabled: boolean;
|
||||
hcHealth: "unknown" | "healthy" | "unhealthy";
|
||||
hcMode: string | null;
|
||||
hcHostname: string | null;
|
||||
hcPort: number | null;
|
||||
hcPath: string | null;
|
||||
hcScheme: string | null;
|
||||
hcMethod: string | null;
|
||||
hcInterval: number | null;
|
||||
hcUnhealthyInterval: number | null;
|
||||
hcTimeout: number | null;
|
||||
hcHeaders: string | null;
|
||||
hcFollowRedirects: boolean | null;
|
||||
hcStatus: number | null;
|
||||
hcTlsServerName: string | null;
|
||||
hcHealthyThreshold: number | null;
|
||||
hcUnhealthyThreshold: number | null;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
};
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
ExitNode,
|
||||
networks,
|
||||
resources,
|
||||
Site,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
targetHealthCheck,
|
||||
targets
|
||||
@@ -84,7 +86,8 @@ export async function buildClientConfigurationForNewtClient(
|
||||
// )
|
||||
// );
|
||||
|
||||
if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm
|
||||
if (!client.clientSitesAssociationsCache.isJitMode) {
|
||||
// if we are adding sites through jit then dont add the site to the olm
|
||||
// update the peer info on the olm
|
||||
// if the peer has not been added yet this will be a no-op
|
||||
await updatePeer(client.clients.clientId, {
|
||||
@@ -137,11 +140,14 @@ export async function buildClientConfigurationForNewtClient(
|
||||
// Filter out any null values from peers that didn't have an olm
|
||||
const validPeers = peers.filter((peer) => peer !== null);
|
||||
|
||||
// Get all enabled site resources for this site
|
||||
// Get all enabled site resources for this site by joining through siteNetworks and networks
|
||||
const allSiteResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.siteId, siteId));
|
||||
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||
.where(eq(siteNetworks.siteId, siteId))
|
||||
.then((rows) => rows.map((r) => r.siteResources));
|
||||
|
||||
const targetsToSend: SubnetProxyTargetV2[] = [];
|
||||
|
||||
@@ -168,7 +174,7 @@ export async function buildClientConfigurationForNewtClient(
|
||||
)
|
||||
);
|
||||
|
||||
const resourceTargets = generateSubnetProxyTargetV2(
|
||||
const resourceTargets = await generateSubnetProxyTargetV2(
|
||||
resource,
|
||||
resourceClients
|
||||
);
|
||||
@@ -184,7 +190,10 @@ export async function buildClientConfigurationForNewtClient(
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
export async function buildTargetConfigurationForNewtClient(
|
||||
siteId: number,
|
||||
version?: string | null
|
||||
) {
|
||||
// Get all enabled targets with their resource protocol information
|
||||
const allTargets = await db
|
||||
.select({
|
||||
@@ -195,7 +204,15 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled,
|
||||
protocol: resources.protocol,
|
||||
protocol: resources.protocol
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
||||
|
||||
const allHealthChecks = await db
|
||||
.select({
|
||||
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
|
||||
hcEnabled: targetHealthCheck.hcEnabled,
|
||||
hcPath: targetHealthCheck.hcPath,
|
||||
hcScheme: targetHealthCheck.hcScheme,
|
||||
@@ -206,17 +223,15 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval,
|
||||
hcTimeout: targetHealthCheck.hcTimeout,
|
||||
hcHeaders: targetHealthCheck.hcHeaders,
|
||||
hcFollowRedirects: targetHealthCheck.hcFollowRedirects,
|
||||
hcMethod: targetHealthCheck.hcMethod,
|
||||
hcTlsServerName: targetHealthCheck.hcTlsServerName,
|
||||
hcStatus: targetHealthCheck.hcStatus
|
||||
hcStatus: targetHealthCheck.hcStatus,
|
||||
hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold,
|
||||
hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(
|
||||
targetHealthCheck,
|
||||
eq(targets.targetId, targetHealthCheck.targetId)
|
||||
)
|
||||
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
||||
.from(targetHealthCheck)
|
||||
.where(eq(targetHealthCheck.siteId, siteId));
|
||||
|
||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||
(acc, target) => {
|
||||
@@ -240,19 +255,14 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
|
||||
);
|
||||
|
||||
const healthCheckTargets = allTargets.map((target) => {
|
||||
const healthCheckTargets = allHealthChecks.map((target) => {
|
||||
// make sure the stuff is defined
|
||||
if (
|
||||
!target.hcPath ||
|
||||
!target.hcHostname ||
|
||||
!target.hcPort ||
|
||||
!target.hcInterval ||
|
||||
!target.hcMethod
|
||||
) {
|
||||
// logger.debug(
|
||||
// `Skipping adding target health check ${target.targetId} due to missing health check fields`
|
||||
// );
|
||||
return null; // Skip targets with missing health check fields
|
||||
const isTCP = target.hcMode?.toLowerCase() === "tcp";
|
||||
if (!target.hcHostname || !target.hcPort || !target.hcInterval) {
|
||||
return null;
|
||||
}
|
||||
if (!isTCP && (!target.hcPath || !target.hcMethod)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// parse headers
|
||||
@@ -269,7 +279,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
}
|
||||
|
||||
return {
|
||||
id: target.targetId,
|
||||
id: target.targetHealthCheckId,
|
||||
hcEnabled: target.hcEnabled,
|
||||
hcPath: target.hcPath,
|
||||
hcScheme: target.hcScheme,
|
||||
@@ -280,9 +290,12 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds
|
||||
hcTimeout: target.hcTimeout, // in seconds
|
||||
hcHeaders: hcHeadersSend,
|
||||
hcFollowRedirects: target.hcFollowRedirects,
|
||||
hcMethod: target.hcMethod,
|
||||
hcTlsServerName: target.hcTlsServerName,
|
||||
hcStatus: target.hcStatus
|
||||
hcStatus: target.hcStatus,
|
||||
hcHealthyThreshold: target.hcHealthyThreshold,
|
||||
hcUnhealthyThreshold: target.hcUnhealthyThreshold
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { convertTargetsIfNessicary } from "../client/targets";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
@@ -56,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
|
||||
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
|
||||
logger.warn(
|
||||
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
||||
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
exitNode
|
||||
);
|
||||
|
||||
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets);
|
||||
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
|
||||
|
||||
return {
|
||||
message: {
|
||||
@@ -1,180 +1,12 @@
|
||||
import { db, newts, sites, targetHealthCheck, targets } from "@server/db";
|
||||
import {
|
||||
hasActiveConnections,
|
||||
getClientConfigVersion
|
||||
} from "#dynamic/routers/ws";
|
||||
import { db, sites } from "@server/db";
|
||||
import { getClientConfigVersion } from "#dynamic/routers/ws";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { Newt } from "@server/db";
|
||||
import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { sendNewtSyncMessage } from "./sync";
|
||||
import { recordPing } from "./pingAccumulator";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for newt sites that haven't
|
||||
* pinged recently and marks them as offline. For backward compatibility,
|
||||
* a site is only marked offline when there is no active WebSocket connection
|
||||
* either — so older newt versions that don't send pings but remain connected
|
||||
* continue to be treated as online.
|
||||
*/
|
||||
export const startNewtOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
offlineCheckerInterval = setInterval(async () => {
|
||||
try {
|
||||
const twoMinutesAgo = Math.floor(
|
||||
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||
);
|
||||
|
||||
// Find all online newt-type sites that haven't pinged recently
|
||||
// (or have never pinged at all). Join newts to obtain the newtId
|
||||
// needed for the WebSocket connection check.
|
||||
const staleSites = await db
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
newtId: newts.newtId,
|
||||
lastPing: sites.lastPing
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(newts.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.online, true),
|
||||
eq(sites.type, "newt"),
|
||||
or(
|
||||
lt(sites.lastPing, twoMinutesAgo),
|
||||
isNull(sites.lastPing)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const staleSite of staleSites) {
|
||||
// Backward-compatibility check: if the newt still has an
|
||||
// active WebSocket connection (older clients that don't send
|
||||
// pings), keep the site online.
|
||||
const isConnected = await hasActiveConnections(
|
||||
staleSite.newtId
|
||||
);
|
||||
if (isConnected) {
|
||||
logger.debug(
|
||||
`Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(sites)
|
||||
.set({ online: false })
|
||||
.where(eq(sites.siteId, staleSite.siteId));
|
||||
|
||||
const healthChecksOnSite = await db
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
.innerJoin(
|
||||
targets,
|
||||
eq(targets.targetId, targetHealthCheck.targetId)
|
||||
)
|
||||
.innerJoin(sites, eq(sites.siteId, targets.siteId))
|
||||
.where(eq(sites.siteId, staleSite.siteId));
|
||||
|
||||
for (const healthCheck of healthChecksOnSite) {
|
||||
logger.info(
|
||||
`Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline`
|
||||
);
|
||||
await db
|
||||
.update(targetHealthCheck)
|
||||
.set({ hcHealth: "unknown" })
|
||||
.where(
|
||||
eq(
|
||||
targetHealthCheck.targetHealthCheckId,
|
||||
healthCheck.targetHealthCheck
|
||||
.targetHealthCheckId
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
|
||||
// select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update
|
||||
const allWireguardSites = await db
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
online: sites.online,
|
||||
lastBandwidthUpdate: sites.lastBandwidthUpdate
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.type, "wireguard"),
|
||||
not(isNull(sites.lastBandwidthUpdate))
|
||||
)
|
||||
);
|
||||
|
||||
const wireguardOfflineThreshold = Math.floor(
|
||||
(Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000
|
||||
);
|
||||
|
||||
// loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline
|
||||
for (const site of allWireguardSites) {
|
||||
const lastBandwidthUpdate =
|
||||
new Date(site.lastBandwidthUpdate!).getTime() / 1000;
|
||||
if (
|
||||
lastBandwidthUpdate < wireguardOfflineThreshold &&
|
||||
site.online
|
||||
) {
|
||||
logger.info(
|
||||
`Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(sites)
|
||||
.set({ online: false })
|
||||
.where(eq(sites.siteId, site.siteId));
|
||||
} else if (
|
||||
lastBandwidthUpdate >= wireguardOfflineThreshold &&
|
||||
!site.online
|
||||
) {
|
||||
logger.info(
|
||||
`Marking wireguard site ${site.siteId} online: recent bandwidth update`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(sites)
|
||||
.set({ online: true })
|
||||
.where(eq(sites.siteId, site.siteId));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in newt offline checker interval", { error });
|
||||
}
|
||||
}, OFFLINE_CHECK_INTERVAL);
|
||||
|
||||
logger.debug("Started newt offline checker interval");
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the background interval that checks for offline newt sites.
|
||||
*/
|
||||
export const stopNewtOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
clearInterval(offlineCheckerInterval);
|
||||
offlineCheckerInterval = null;
|
||||
logger.info("Stopped newt offline checker interval");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles ping messages from newt clients.
|
||||
*
|
||||
|
||||
@@ -192,7 +192,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
}
|
||||
|
||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||
await buildTargetConfigurationForNewtClient(siteId);
|
||||
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||
|
||||
logger.debug(
|
||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function flushBandwidthToDb(): Promise<void> {
|
||||
const currentTime = new Date().toISOString();
|
||||
|
||||
// Sort by publicKey for consistent lock ordering across concurrent
|
||||
// writers — this is the same deadlock-prevention strategy used in the
|
||||
// writers - this is the same deadlock-prevention strategy used in the
|
||||
// original per-message implementation.
|
||||
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
|
||||
a.localeCompare(b)
|
||||
@@ -143,7 +143,7 @@ const flushTimer = setInterval(async () => {
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
|
||||
// Calling unref() means this timer will not keep the Node.js event loop alive
|
||||
// on its own — the process can still exit normally when there is no other work
|
||||
// on its own - the process can still exit normally when there is no other work
|
||||
// left. The graceful-shutdown path (see server/cleanup.ts) will call
|
||||
// flushBandwidthToDb() explicitly before process.exit(), so no data is lost.
|
||||
flushTimer.unref();
|
||||
@@ -167,7 +167,7 @@ export const handleReceiveBandwidthMessage: MessageHandler = async (
|
||||
// Accumulate the incoming data in memory; the periodic timer (and the
|
||||
// shutdown hook) will take care of writing it to the database.
|
||||
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
|
||||
// Skip peers that haven't transferred any data — writing zeros to the
|
||||
// Skip peers that haven't transferred any data - writing zeros to the
|
||||
// database would be a no-op anyway.
|
||||
if (bytesIn <= 0 && bytesOut <= 0) {
|
||||
continue;
|
||||
|
||||
9
server/routers/newt/handleRequestLogMessage.ts
Normal file
9
server/routers/newt/handleRequestLogMessage.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
|
||||
export async function flushRequestLogToDb(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
};
|
||||
@@ -2,11 +2,13 @@ export * from "./createNewt";
|
||||
export * from "./getNewtToken";
|
||||
export * from "./handleNewtRegisterMessage";
|
||||
export * from "./handleReceiveBandwidthMessage";
|
||||
export * from "./handleGetConfigMessage";
|
||||
export * from "./handleNewtGetConfigMessage";
|
||||
export * from "./handleSocketMessages";
|
||||
export * from "./handleNewtPingRequestMessage";
|
||||
export * from "./handleApplyBlueprintMessage";
|
||||
export * from "./handleNewtPingMessage";
|
||||
export * from "./handleNewtDisconnectingMessage";
|
||||
export * from "./handleConnectionLogMessage";
|
||||
export * from "./handleRequestLogMessage";
|
||||
export * from "./registerNewt";
|
||||
export * from "./offlineChecker";
|
||||
|
||||
208
server/routers/newt/offlineChecker.ts
Normal file
208
server/routers/newt/offlineChecker.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { db, newts, sites, targetHealthCheck, targets, statusHistory } from "@server/db";
|
||||
import {
|
||||
hasActiveConnections,
|
||||
} from "#dynamic/routers/ws";
|
||||
import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for newt sites that haven't
|
||||
* pinged recently and marks them as offline. For backward compatibility,
|
||||
* a site is only marked offline when there is no active WebSocket connection
|
||||
* either - so older newt versions that don't send pings but remain connected
|
||||
* continue to be treated as online.
|
||||
*/
|
||||
export const startNewtOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
offlineCheckerInterval = setInterval(async () => {
|
||||
try {
|
||||
const twoMinutesAgo = Math.floor(
|
||||
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||
);
|
||||
|
||||
// Find all online newt-type sites that haven't pinged recently
|
||||
// (or have never pinged at all). Join newts to obtain the newtId
|
||||
// needed for the WebSocket connection check.
|
||||
const staleSites = await db
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
orgId: sites.orgId,
|
||||
name: sites.name,
|
||||
newtId: newts.newtId,
|
||||
lastPing: sites.lastPing
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(newts.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.online, true),
|
||||
eq(sites.type, "newt"),
|
||||
or(
|
||||
lt(sites.lastPing, twoMinutesAgo),
|
||||
isNull(sites.lastPing)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const staleSite of staleSites) {
|
||||
// Backward-compatibility check: if the newt still has an
|
||||
// active WebSocket connection (older clients that don't send
|
||||
// pings), keep the site online.
|
||||
const isConnected = await hasActiveConnections(
|
||||
staleSite.newtId
|
||||
);
|
||||
if (isConnected) {
|
||||
logger.debug(
|
||||
`Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket - keeping site ${staleSite.siteId} online`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(sites)
|
||||
.set({ online: false })
|
||||
.where(eq(sites.siteId, staleSite.siteId));
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: staleSite.siteId,
|
||||
orgId: staleSite.orgId,
|
||||
status: "offline",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}).execute();
|
||||
|
||||
const healthChecksOnSite = await db
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
.innerJoin(
|
||||
targets,
|
||||
eq(targets.targetId, targetHealthCheck.targetId)
|
||||
)
|
||||
.innerJoin(sites, eq(sites.siteId, targets.siteId))
|
||||
.where(eq(sites.siteId, staleSite.siteId));
|
||||
|
||||
for (const healthCheck of healthChecksOnSite) {
|
||||
logger.info(
|
||||
`Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline`
|
||||
);
|
||||
await db
|
||||
.update(targetHealthCheck)
|
||||
.set({ hcHealth: "unknown" })
|
||||
.where(
|
||||
eq(
|
||||
targetHealthCheck.targetHealthCheckId,
|
||||
healthCheck.targetHealthCheck
|
||||
.targetHealthCheckId
|
||||
)
|
||||
);
|
||||
|
||||
// TODO: should we be firing an alert here when the health check goes to unknown?
|
||||
}
|
||||
|
||||
await fireSiteOfflineAlert(staleSite.orgId, staleSite.siteId, staleSite.name);
|
||||
}
|
||||
|
||||
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
|
||||
// select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update
|
||||
const allWireguardSites = await db
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
orgId: sites.orgId,
|
||||
name: sites.name,
|
||||
online: sites.online,
|
||||
lastBandwidthUpdate: sites.lastBandwidthUpdate
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.type, "wireguard"),
|
||||
not(isNull(sites.lastBandwidthUpdate))
|
||||
)
|
||||
);
|
||||
|
||||
const wireguardOfflineThreshold = Math.floor(
|
||||
(Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000
|
||||
);
|
||||
|
||||
// loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline
|
||||
for (const site of allWireguardSites) {
|
||||
const lastBandwidthUpdate =
|
||||
new Date(site.lastBandwidthUpdate!).getTime() / 1000;
|
||||
if (
|
||||
lastBandwidthUpdate < wireguardOfflineThreshold &&
|
||||
site.online
|
||||
) {
|
||||
logger.info(
|
||||
`Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(sites)
|
||||
.set({ online: false })
|
||||
.where(eq(sites.siteId, site.siteId));
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: site.siteId,
|
||||
orgId: site.orgId,
|
||||
status: "offline",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}).execute();
|
||||
|
||||
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name);
|
||||
} else if (
|
||||
lastBandwidthUpdate >= wireguardOfflineThreshold &&
|
||||
!site.online
|
||||
) {
|
||||
logger.info(
|
||||
`Marking wireguard site ${site.siteId} online: recent bandwidth update`
|
||||
);
|
||||
|
||||
await db
|
||||
.update(sites)
|
||||
.set({ online: true })
|
||||
.where(eq(sites.siteId, site.siteId));
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: site.siteId,
|
||||
orgId: site.orgId,
|
||||
status: "online",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}).execute();
|
||||
|
||||
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in newt offline checker interval", { error });
|
||||
}
|
||||
}, OFFLINE_CHECK_INTERVAL);
|
||||
|
||||
logger.debug("Started newt offline checker interval");
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the background interval that checks for offline newt sites.
|
||||
*/
|
||||
export const stopNewtOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
clearInterval(offlineCheckerInterval);
|
||||
offlineCheckerInterval = null;
|
||||
logger.info("Stopped newt offline checker interval");
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { db } from "@server/db";
|
||||
import { sites, clients, olms } from "@server/db";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { sites, clients, olms, statusHistory } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
|
||||
|
||||
/**
|
||||
* Ping Accumulator
|
||||
@@ -110,15 +111,51 @@ async function flushSitePingsToDb(): Promise<void> {
|
||||
const siteIds = batch.map(([id]) => id);
|
||||
|
||||
try {
|
||||
await withRetry(async () => {
|
||||
await db
|
||||
const newlyOnlineSites = await withRetry(async () => {
|
||||
// Only update sites that were offline - these are the
|
||||
// offline→online transitions. .returning() gives us exactly
|
||||
// the site IDs that changed state.
|
||||
const transitioned = await db
|
||||
.update(sites)
|
||||
.set({
|
||||
online: true,
|
||||
lastPing: maxTimestamp
|
||||
})
|
||||
.where(inArray(sites.siteId, siteIds));
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, siteIds),
|
||||
eq(sites.online, false)
|
||||
)
|
||||
)
|
||||
.returning({ siteId: sites.siteId, orgId: sites.orgId, name: sites.name });
|
||||
|
||||
// Update lastPing for sites that were already online.
|
||||
// After the update above, the newly-online sites now have
|
||||
// online = true, so this catches all remaining sites in the
|
||||
// batch and keeps lastPing current for them too.
|
||||
await db
|
||||
.update(sites)
|
||||
.set({ lastPing: maxTimestamp })
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, siteIds),
|
||||
eq(sites.online, true)
|
||||
)
|
||||
);
|
||||
|
||||
return transitioned;
|
||||
}, "flushSitePingsToDb");
|
||||
|
||||
for (const site of newlyOnlineSites) {
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: site.siteId,
|
||||
orgId: site.orgId,
|
||||
status: "online",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}).execute();
|
||||
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
|
||||
@@ -219,7 +256,7 @@ async function flushClientPingsToDb(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush everything — called by the interval timer and during shutdown.
|
||||
* Flush everything - called by the interval timer and during shutdown.
|
||||
*/
|
||||
export async function flushPingsToDb(): Promise<void> {
|
||||
await flushSitePingsToDb();
|
||||
@@ -284,7 +321,7 @@ function isTransientError(error: any): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PostgreSQL deadlock detected — always safe to retry (one winner guaranteed)
|
||||
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
||||
if (code === "40P01" || message.includes("deadlock")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ export async function registerNewt(
|
||||
dateCreated: moment().toISOString()
|
||||
});
|
||||
|
||||
// Consume the provisioning key — cascade removes siteProvisioningKeyOrg
|
||||
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
|
||||
await trx
|
||||
.update(siteProvisioningKeys)
|
||||
.set({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db";
|
||||
import { Target, TargetHealthCheck } from "@server/db";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import logger from "@server/logger";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
|
||||
export async function addTargets(
|
||||
@@ -18,17 +17,23 @@ export async function addTargets(
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
await sendToClient(newtId, {
|
||||
type: `newt/${protocol}/add`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: `newt/${protocol}/add`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
|
||||
// Create a map for quick lookup
|
||||
const healthCheckMap = new Map<number, TargetHealthCheck>();
|
||||
healthCheckData.forEach((hc) => {
|
||||
healthCheckMap.set(hc.targetId, hc);
|
||||
if (hc.targetId !== null) {
|
||||
healthCheckMap.set(hc.targetId, hc);
|
||||
}
|
||||
});
|
||||
|
||||
const healthCheckTargets = targets.map((target) => {
|
||||
@@ -43,17 +48,18 @@ export async function addTargets(
|
||||
}
|
||||
|
||||
// Ensure all necessary fields are present
|
||||
if (
|
||||
!hc.hcPath ||
|
||||
!hc.hcHostname ||
|
||||
!hc.hcPort ||
|
||||
!hc.hcInterval ||
|
||||
!hc.hcMethod
|
||||
) {
|
||||
const isTCP = hc.hcMode?.toLowerCase() === "tcp";
|
||||
if (!hc.hcHostname || !hc.hcPort || !hc.hcInterval) {
|
||||
logger.debug(
|
||||
`Skipping target ${target.targetId} due to missing health check fields`
|
||||
);
|
||||
return null; // Skip targets with missing health check fields
|
||||
return null;
|
||||
}
|
||||
if (!isTCP && (!hc.hcPath || !hc.hcMethod)) {
|
||||
logger.debug(
|
||||
`Skipping target ${target.targetId} due to missing HTTP health check fields`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hcHeadersParse = hc.hcHeaders ? JSON.parse(hc.hcHeaders) : null;
|
||||
@@ -77,7 +83,7 @@ export async function addTargets(
|
||||
}
|
||||
|
||||
return {
|
||||
id: target.targetId,
|
||||
id: hc.targetHealthCheckId,
|
||||
hcEnabled: hc.hcEnabled,
|
||||
hcPath: hc.hcPath,
|
||||
hcScheme: hc.hcScheme,
|
||||
@@ -88,9 +94,12 @@ export async function addTargets(
|
||||
hcUnhealthyInterval: hc.hcUnhealthyInterval, // in seconds
|
||||
hcTimeout: hc.hcTimeout, // in seconds
|
||||
hcHeaders: hcHeadersSend,
|
||||
hcFollowRedirects: hc.hcFollowRedirects,
|
||||
hcMethod: hc.hcMethod,
|
||||
hcStatus: hcStatus,
|
||||
hcTlsServerName: hc.hcTlsServerName
|
||||
hcTlsServerName: hc.hcTlsServerName,
|
||||
hcHealthyThreshold: hc.hcHealthyThreshold,
|
||||
hcUnhealthyThreshold: hc.hcUnhealthyThreshold
|
||||
};
|
||||
});
|
||||
|
||||
@@ -99,12 +108,106 @@ export async function addTargets(
|
||||
(target) => target !== null
|
||||
);
|
||||
|
||||
await sendToClient(newtId, {
|
||||
type: `newt/healthcheck/add`,
|
||||
data: {
|
||||
targets: validHealthCheckTargets
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: `newt/healthcheck/add`,
|
||||
data: {
|
||||
targets: validHealthCheckTargets
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
export async function addStandaloneHealthCheck(
|
||||
newtId: string,
|
||||
healthCheck: TargetHealthCheck,
|
||||
version?: string | null
|
||||
) {
|
||||
const isTCP = healthCheck.hcMode?.toLowerCase() === "tcp";
|
||||
if (
|
||||
!healthCheck.hcHostname ||
|
||||
!healthCheck.hcPort ||
|
||||
!healthCheck.hcInterval
|
||||
) {
|
||||
logger.debug(
|
||||
`Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing fields`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isTCP && (!healthCheck.hcPath || !healthCheck.hcMethod)) {
|
||||
logger.debug(
|
||||
`Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing HTTP health check fields`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hcHeadersParse = healthCheck.hcHeaders
|
||||
? JSON.parse(healthCheck.hcHeaders)
|
||||
: null;
|
||||
const hcHeadersSend: { [key: string]: string } = {};
|
||||
if (hcHeadersParse) {
|
||||
hcHeadersParse.forEach((header: { name: string; value: string }) => {
|
||||
hcHeadersSend[header.name] = header.value;
|
||||
});
|
||||
}
|
||||
|
||||
let hcStatus: number | undefined = undefined;
|
||||
if (healthCheck.hcStatus) {
|
||||
const parsedStatus = parseInt(healthCheck.hcStatus.toString());
|
||||
if (!isNaN(parsedStatus)) {
|
||||
hcStatus = parsedStatus;
|
||||
}
|
||||
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||
}
|
||||
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: `newt/healthcheck/add`,
|
||||
data: {
|
||||
targets: [
|
||||
{
|
||||
id: healthCheck.targetHealthCheckId,
|
||||
hcEnabled: healthCheck.hcEnabled,
|
||||
hcPath: healthCheck.hcPath,
|
||||
hcScheme: healthCheck.hcScheme,
|
||||
hcMode: healthCheck.hcMode,
|
||||
hcHostname: healthCheck.hcHostname,
|
||||
hcPort: healthCheck.hcPort,
|
||||
hcInterval: healthCheck.hcInterval,
|
||||
hcUnhealthyInterval: healthCheck.hcUnhealthyInterval,
|
||||
hcTimeout: healthCheck.hcTimeout,
|
||||
hcHeaders: hcHeadersSend,
|
||||
hcFollowRedirects: healthCheck.hcFollowRedirects,
|
||||
hcMethod: healthCheck.hcMethod,
|
||||
hcStatus: hcStatus,
|
||||
hcTlsServerName: healthCheck.hcTlsServerName,
|
||||
hcHealthyThreshold: healthCheck.hcHealthyThreshold,
|
||||
hcUnhealthyThreshold: healthCheck.hcUnhealthyThreshold
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeStandaloneHealthCheck(
|
||||
newtId: string,
|
||||
healthCheckId: number,
|
||||
version?: string | null
|
||||
) {
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: `newt/healthcheck/remove`,
|
||||
data: {
|
||||
ids: [healthCheckId]
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeTargets(
|
||||
@@ -120,21 +223,29 @@ export async function removeTargets(
|
||||
}:${target.port}`;
|
||||
});
|
||||
|
||||
await sendToClient(newtId, {
|
||||
type: `newt/${protocol}/remove`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
}, { incrementConfigVersion: true });
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: `newt/${protocol}/remove`,
|
||||
data: {
|
||||
targets: payloadTargets
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true }
|
||||
);
|
||||
|
||||
const healthCheckTargets = targets.map((target) => {
|
||||
return target.targetId;
|
||||
});
|
||||
|
||||
await sendToClient(newtId, {
|
||||
type: `newt/healthcheck/remove`,
|
||||
data: {
|
||||
ids: healthCheckTargets
|
||||
}
|
||||
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: `newt/healthcheck/remove`,
|
||||
data: {
|
||||
ids: healthCheckTargets
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
exitNodes,
|
||||
networks,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
networks,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(networks.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, site.siteId),
|
||||
eq(siteNetworks.siteId, site.siteId),
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
if (jitMode) {
|
||||
// Add site configuration to the array
|
||||
siteConfigurations.push({
|
||||
|
||||
@@ -1,104 +1,17 @@
|
||||
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
|
||||
import { getClientConfigVersion } from "#dynamic/routers/ws";
|
||||
import { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { clients, olms, Olm } from "@server/db";
|
||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||
import { clients, Olm } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { recordClientPing } from "@server/routers/newt/pingAccumulator";
|
||||
import logger from "@server/logger";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { sendTerminateClient } from "../client/terminate";
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { sendOlmSyncMessage } from "./sync";
|
||||
import { OlmErrorCodes } from "./error";
|
||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for clients that haven't pinged recently
|
||||
* and marks them as offline
|
||||
*/
|
||||
export const startOlmOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
offlineCheckerInterval = setInterval(async () => {
|
||||
try {
|
||||
const twoMinutesAgo = Math.floor(
|
||||
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||
);
|
||||
|
||||
// TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
|
||||
|
||||
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
||||
const offlineClients = await db
|
||||
.update(clients)
|
||||
.set({ online: false })
|
||||
.where(
|
||||
and(
|
||||
eq(clients.online, true),
|
||||
or(
|
||||
lt(clients.lastPing, twoMinutesAgo),
|
||||
isNull(clients.lastPing)
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
for (const offlineClient of offlineClients) {
|
||||
logger.info(
|
||||
`Kicking offline olm client ${offlineClient.clientId} due to inactivity`
|
||||
);
|
||||
|
||||
if (!offlineClient.olmId) {
|
||||
logger.warn(
|
||||
`Offline client ${offlineClient.clientId} has no olmId, cannot disconnect`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send a disconnect message to the client if connected
|
||||
try {
|
||||
await sendTerminateClient(
|
||||
offlineClient.clientId,
|
||||
OlmErrorCodes.TERMINATED_INACTIVITY,
|
||||
offlineClient.olmId
|
||||
); // terminate first
|
||||
// wait a moment to ensure the message is sent
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await disconnectClient(offlineClient.olmId);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error sending disconnect to offline olm ${offlineClient.clientId}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in offline checker interval", { error });
|
||||
}
|
||||
}, OFFLINE_CHECK_INTERVAL);
|
||||
|
||||
logger.debug("Started offline checker interval");
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the background interval that checks for offline clients
|
||||
*/
|
||||
export const stopOlmOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
clearInterval(offlineCheckerInterval);
|
||||
offlineCheckerInterval = null;
|
||||
logger.info("Stopped offline checker interval");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles ping messages from clients and responds with pong
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,6 @@ import { getUserDeviceName } from "@server/db/names";
|
||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
import { Alias } from "@server/lib/ip";
|
||||
import { build } from "@server/build";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
db,
|
||||
exitNodes,
|
||||
Site,
|
||||
siteResources
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { clients, Olm, sites } from "@server/db";
|
||||
import { clients, Olm } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { initPeerAddHandshake } from "./peers";
|
||||
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
const { siteId, resourceId, chainId } = message.data;
|
||||
|
||||
let site: Site | null = null;
|
||||
const sendCancel = async () => {
|
||||
await sendToClient(
|
||||
olm.olmId,
|
||||
{
|
||||
type: "olm/wg/peer/chain/cancel",
|
||||
data: { chainId }
|
||||
},
|
||||
{ incrementConfigVersion: false }
|
||||
).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
};
|
||||
|
||||
let sitesToProcess: Site[] = [];
|
||||
|
||||
if (siteId) {
|
||||
// get the site
|
||||
const [siteRes] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
if (siteRes) {
|
||||
site = siteRes;
|
||||
sitesToProcess = [siteRes];
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceId && !site) {
|
||||
} else if (resourceId) {
|
||||
const resources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
|
||||
// cancel the request from the olm side to not keep doing this
|
||||
await sendToClient(
|
||||
olm.olmId,
|
||||
{
|
||||
type: "olm/wg/peer/chain/cancel",
|
||||
data: {
|
||||
chainId
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: false }
|
||||
).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resources.length > 1) {
|
||||
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
|
||||
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
if (currentResourceAssociationCaches.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||
);
|
||||
// cancel the request from the olm side to not keep doing this
|
||||
await sendToClient(
|
||||
olm.olmId,
|
||||
{
|
||||
type: "olm/wg/peer/chain/cancel",
|
||||
data: {
|
||||
chainId
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: false }
|
||||
).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const siteIdFromResource = resource.siteId;
|
||||
|
||||
// get the site
|
||||
const [siteRes] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteIdFromResource));
|
||||
if (!siteRes) {
|
||||
if (!resource.networkId) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
|
||||
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
site = siteRes;
|
||||
// Get all sites associated with this resource's network via siteNetworks
|
||||
const siteRows = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId));
|
||||
|
||||
if (!siteRows || siteRows.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch full site objects for all network members
|
||||
const foundSites = await Promise.all(
|
||||
siteRows.map(async ({ siteId: sid }) => {
|
||||
const [s] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, sid))
|
||||
.limit(1);
|
||||
return s ?? null;
|
||||
})
|
||||
);
|
||||
|
||||
sitesToProcess = foundSites.filter((s): s is Site => s !== null);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
|
||||
if (sitesToProcess.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the client can access this site using the cache
|
||||
const currentSiteAssociationCaches = await db
|
||||
.select()
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.clientId, client.clientId),
|
||||
eq(clientSitesAssociationsCache.siteId, site.siteId)
|
||||
)
|
||||
);
|
||||
let handshakeInitiated = false;
|
||||
|
||||
if (currentSiteAssociationCaches.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
|
||||
);
|
||||
// cancel the request from the olm side to not keep doing this
|
||||
await sendToClient(
|
||||
olm.olmId,
|
||||
for (const site of sitesToProcess) {
|
||||
// Check if the client can access this site using the cache
|
||||
const currentSiteAssociationCaches = await db
|
||||
.select()
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.clientId, client.clientId),
|
||||
eq(clientSitesAssociationsCache.siteId, site.siteId)
|
||||
)
|
||||
);
|
||||
|
||||
if (currentSiteAssociationCaches.length === 0) {
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!site.exitNodeId) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||
|
||||
if (!exitNode) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Trigger the peer add handshake - if the peer was already added this will be a no-op
|
||||
await initPeerAddHandshake(
|
||||
client.clientId,
|
||||
{
|
||||
type: "olm/wg/peer/chain/cancel",
|
||||
data: {
|
||||
chainId
|
||||
siteId: site.siteId,
|
||||
exitNode: {
|
||||
publicKey: exitNode.publicKey,
|
||||
endpoint: exitNode.endpoint
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: false }
|
||||
).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!site.exitNodeId) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
||||
);
|
||||
// cancel the request from the olm side to not keep doing this
|
||||
await sendToClient(
|
||||
olm.olmId,
|
||||
{
|
||||
type: "olm/wg/peer/chain/cancel",
|
||||
data: {
|
||||
chainId
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: false }
|
||||
).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// get the exit node from the side
|
||||
const [exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||
|
||||
if (!exitNode) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
||||
chainId
|
||||
);
|
||||
return;
|
||||
|
||||
handshakeInitiated = true;
|
||||
}
|
||||
|
||||
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
||||
// if it has already been added this will be a no-op
|
||||
await initPeerAddHandshake(
|
||||
// this will kick off the add peer process for the client
|
||||
client.clientId,
|
||||
{
|
||||
siteId: site.siteId,
|
||||
exitNode: {
|
||||
publicKey: exitNode.publicKey,
|
||||
endpoint: exitNode.endpoint
|
||||
}
|
||||
},
|
||||
olm.olmId,
|
||||
chainId
|
||||
);
|
||||
if (!handshakeInitiated) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||
);
|
||||
await sendCancel();
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1,43 +1,25 @@
|
||||
import {
|
||||
Client,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
db,
|
||||
ExitNode,
|
||||
Org,
|
||||
orgs,
|
||||
roleClients,
|
||||
roles,
|
||||
networks,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
Transaction,
|
||||
userClients,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import {
|
||||
clients,
|
||||
clientSitesAssociationsCache,
|
||||
exitNodes,
|
||||
Olm,
|
||||
olms,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../newt/peers";
|
||||
import logger from "@server/logger";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import {
|
||||
generateAliasConfig,
|
||||
getNextAvailableClientSubnet
|
||||
} from "@server/lib/ip";
|
||||
import { generateRemoteSubnets } from "@server/lib/ip";
|
||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
addPeer as newtAddPeer,
|
||||
deletePeer as newtDeletePeer
|
||||
} from "@server/routers/newt/peers";
|
||||
|
||||
export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
||||
@@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
||||
clientSiteResourcesAssociationsCache.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
.innerJoin(
|
||||
networks,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
and(
|
||||
eq(siteResources.siteId, site.siteId),
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
eq(networks.networkId, siteNetworks.networkId),
|
||||
eq(siteNetworks.siteId, site.siteId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "./handleOlmUnRelayMessage";
|
||||
export * from "./recoverOlmWithFingerprint";
|
||||
export * from "./handleOlmDisconnectingMessage";
|
||||
export * from "./handleOlmServerInitAddPeerHandshake";
|
||||
export * from "./offlineChecker";
|
||||
|
||||
92
server/routers/olm/offlineChecker.ts
Normal file
92
server/routers/olm/offlineChecker.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
|
||||
import { db } from "@server/db";
|
||||
import { clients } from "@server/db";
|
||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { sendTerminateClient } from "../client/terminate";
|
||||
import { OlmErrorCodes } from "./error";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for clients that haven't pinged recently
|
||||
* and marks them as offline
|
||||
*/
|
||||
export const startOlmOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
offlineCheckerInterval = setInterval(async () => {
|
||||
try {
|
||||
const twoMinutesAgo = Math.floor(
|
||||
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||
);
|
||||
|
||||
// TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
|
||||
|
||||
// Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
||||
const offlineClients = await db
|
||||
.update(clients)
|
||||
.set({ online: false })
|
||||
.where(
|
||||
and(
|
||||
eq(clients.online, true),
|
||||
or(
|
||||
lt(clients.lastPing, twoMinutesAgo),
|
||||
isNull(clients.lastPing)
|
||||
)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
for (const offlineClient of offlineClients) {
|
||||
logger.info(
|
||||
`Kicking offline olm client ${offlineClient.clientId} due to inactivity`
|
||||
);
|
||||
|
||||
if (!offlineClient.olmId) {
|
||||
logger.warn(
|
||||
`Offline client ${offlineClient.clientId} has no olmId, cannot disconnect`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send a disconnect message to the client if connected
|
||||
try {
|
||||
await sendTerminateClient(
|
||||
offlineClient.clientId,
|
||||
OlmErrorCodes.TERMINATED_INACTIVITY,
|
||||
offlineClient.olmId
|
||||
); // terminate first
|
||||
// wait a moment to ensure the message is sent
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await disconnectClient(offlineClient.olmId);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error sending disconnect to offline olm ${offlineClient.clientId}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error in offline checker interval", { error });
|
||||
}
|
||||
}, OFFLINE_CHECK_INTERVAL);
|
||||
|
||||
logger.debug("Started offline checker interval");
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the background interval that checks for offline clients
|
||||
*/
|
||||
export const stopOlmOfflineChecker = (): void => {
|
||||
if (offlineCheckerInterval) {
|
||||
clearInterval(offlineCheckerInterval);
|
||||
offlineCheckerInterval = null;
|
||||
logger.info("Stopped offline checker interval");
|
||||
}
|
||||
};
|
||||
93
server/routers/resource/getStatusHistory.ts
Normal file
93
server/routers/resource/getStatusHistory.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, statusHistory } from "@server/db";
|
||||
import { and, eq, gte, asc } 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 { fromError } from "zod-validation-error";
|
||||
import {
|
||||
computeBuckets,
|
||||
statusHistoryQuerySchema,
|
||||
StatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
|
||||
const resourceParamsSchema = z.object({
|
||||
resourceId: z.string().transform((v) => parseInt(v, 10))
|
||||
});
|
||||
|
||||
export async function getResourceStatusHistory(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = resourceParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const entityType = "resource";
|
||||
const entityId = parsedParams.data.resourceId;
|
||||
const { days } = parsedQuery.data;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const startSec = nowSec - days * 86400;
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(statusHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(statusHistory.entityType, entityType),
|
||||
eq(statusHistory.entityId, entityId),
|
||||
gte(statusHistory.timestamp, startSec)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(statusHistory.timestamp));
|
||||
|
||||
const { buckets, totalDowntime } = computeBuckets(events, days);
|
||||
const totalWindow = days * 86400;
|
||||
const overallUptime =
|
||||
totalWindow > 0
|
||||
? Math.max(
|
||||
0,
|
||||
((totalWindow - totalDowntime) / totalWindow) * 100
|
||||
)
|
||||
: 100;
|
||||
|
||||
return response<StatusHistoryResponse>(res, {
|
||||
data: {
|
||||
entityType,
|
||||
entityId,
|
||||
days: buckets,
|
||||
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||
totalDowntimeSeconds: totalDowntime
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Status history retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export async function getUserResources(
|
||||
niceId: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
scheme: string | null;
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
@@ -158,7 +158,7 @@ export async function getUserResources(
|
||||
niceId: siteResources.niceId,
|
||||
destination: siteResources.destination,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
scheme: siteResources.scheme,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
aliasAddress: siteResources.aliasAddress
|
||||
@@ -242,7 +242,7 @@ export async function getUserResources(
|
||||
name: siteResource.name,
|
||||
destination: siteResource.destination,
|
||||
mode: siteResource.mode,
|
||||
protocol: siteResource.protocol,
|
||||
protocol: siteResource.scheme,
|
||||
enabled: siteResource.enabled,
|
||||
alias: siteResource.alias,
|
||||
aliasAddress: siteResource.aliasAddress,
|
||||
@@ -291,7 +291,7 @@ export type GetUserResourcesResponse = {
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
type: 'site';
|
||||
type: "site";
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from "./addUserToResource";
|
||||
export * from "./removeUserFromResource";
|
||||
export * from "./listAllResourceNames";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
export * from "./getStatusHistory";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Site, siteResources } from "@server/db";
|
||||
import { db, Site, siteNetworks, siteResources } from "@server/db";
|
||||
import { newts, newtSessions, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -71,18 +71,23 @@ export async function deleteSite(
|
||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||
}
|
||||
} else if (site.type == "newt") {
|
||||
// delete all of the site resources on this site
|
||||
const siteResourcesOnSite = trx
|
||||
.delete(siteResources)
|
||||
.where(eq(siteResources.siteId, siteId))
|
||||
.returning();
|
||||
const networks = await trx
|
||||
.select({ networkId: siteNetworks.networkId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.siteId, siteId));
|
||||
|
||||
// loop through them
|
||||
for (const removedSiteResource of await siteResourcesOnSite) {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
trx
|
||||
);
|
||||
for (const network of await networks) {
|
||||
const [siteResource] = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.networkId, network.networkId));
|
||||
if (siteResource) {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
siteResource,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// get the newt on the site by querying the newt table for siteId
|
||||
|
||||
93
server/routers/site/getStatusHistory.ts
Normal file
93
server/routers/site/getStatusHistory.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, statusHistory } from "@server/db";
|
||||
import { and, eq, gte, asc } 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 { fromError } from "zod-validation-error";
|
||||
import {
|
||||
computeBuckets,
|
||||
statusHistoryQuerySchema,
|
||||
StatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
|
||||
const siteParamsSchema = z.object({
|
||||
siteId: z.string().transform((v) => parseInt(v, 10))
|
||||
});
|
||||
|
||||
export async function getSiteStatusHistory(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = siteParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const entityType = "site";
|
||||
const entityId = parsedParams.data.siteId;
|
||||
const { days } = parsedQuery.data;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const startSec = nowSec - days * 86400;
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(statusHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(statusHistory.entityType, entityType),
|
||||
eq(statusHistory.entityId, entityId),
|
||||
gte(statusHistory.timestamp, startSec)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(statusHistory.timestamp));
|
||||
|
||||
const { buckets, totalDowntime } = computeBuckets(events, days);
|
||||
const totalWindow = days * 86400;
|
||||
const overallUptime =
|
||||
totalWindow > 0
|
||||
? Math.max(
|
||||
0,
|
||||
((totalWindow - totalDowntime) / totalWindow) * 100
|
||||
)
|
||||
: 100;
|
||||
|
||||
return response<StatusHistoryResponse>(res, {
|
||||
data: {
|
||||
entityType,
|
||||
entityId,
|
||||
days: buckets,
|
||||
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||
totalDowntimeSeconds: totalDowntime
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Status history retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./getSite";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./createSite";
|
||||
export * from "./deleteSite";
|
||||
export * from "./updateSite";
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
orgs,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
siteNetworks,
|
||||
networks,
|
||||
SiteResource,
|
||||
siteResources,
|
||||
sites,
|
||||
@@ -17,17 +19,18 @@ import {
|
||||
portRangeStringSchema
|
||||
} from "@server/lib/ip";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
|
||||
const createSiteResourceParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -36,12 +39,14 @@ const createSiteResourceParamsSchema = z.strictObject({
|
||||
const createSiteResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "port"]),
|
||||
siteId: z.int(),
|
||||
niceId: z.string().optional(),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
ssl: z.boolean().optional(), // only used for http mode
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
siteIds: z.array(z.int()),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
// destinationPort: z.int().positive().optional(),
|
||||
destinationPort: z.int().positive().optional(),
|
||||
destination: z.string().min(1),
|
||||
enabled: z.boolean().default(true),
|
||||
alias: z
|
||||
@@ -58,20 +63,24 @@ const createSiteResourceSchema = z
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
authDaemonPort: z.int().positive().optional(),
|
||||
authDaemonMode: z.enum(["site", "remote"]).optional()
|
||||
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
||||
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
||||
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "host") {
|
||||
// Check if it's a valid IP address using zod (v4 or v6)
|
||||
const isValidIP = z
|
||||
// .union([z.ipv4(), z.ipv6()])
|
||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||
.safeParse(data.destination).success;
|
||||
if (data.mode == "host") {
|
||||
// Check if it's a valid IP address using zod (v4 or v6)
|
||||
const isValidIP = z
|
||||
// .union([z.ipv4(), z.ipv6()])
|
||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||
.safeParse(data.destination).success;
|
||||
|
||||
if (isValidIP) {
|
||||
return true;
|
||||
if (isValidIP) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a valid domain (hostname pattern, TLD not required)
|
||||
@@ -106,6 +115,21 @@ const createSiteResourceSchema = z
|
||||
{
|
||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode !== "http") return true;
|
||||
return (
|
||||
data.scheme !== undefined &&
|
||||
data.destinationPort !== undefined &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||
}
|
||||
);
|
||||
|
||||
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
|
||||
@@ -160,14 +184,15 @@ export async function createSiteResource(
|
||||
const { orgId } = parsedParams.data;
|
||||
const {
|
||||
name,
|
||||
siteId,
|
||||
niceId,
|
||||
siteIds,
|
||||
mode,
|
||||
// protocol,
|
||||
scheme,
|
||||
// proxyPort,
|
||||
// destinationPort,
|
||||
destinationPort,
|
||||
destination,
|
||||
enabled,
|
||||
ssl,
|
||||
alias,
|
||||
userIds,
|
||||
roleIds,
|
||||
@@ -176,18 +201,36 @@ export async function createSiteResource(
|
||||
udpPortRangeString,
|
||||
disableIcmp,
|
||||
authDaemonPort,
|
||||
authDaemonMode
|
||||
authDaemonMode,
|
||||
domainId,
|
||||
subdomain
|
||||
} = parsedBody.data;
|
||||
|
||||
if (mode == "http") {
|
||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.HTTPPrivateResources]
|
||||
);
|
||||
if (!hasHttpFeature) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const [site] = await db
|
||||
const sitesToAssign = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
.where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)));
|
||||
|
||||
if (!site) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
if (sitesToAssign.length !== siteIds.length) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
@@ -228,29 +271,50 @@ export async function createSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
// // check if resource with same protocol and proxy port already exists (only for port mode)
|
||||
// if (mode === "port" && protocol && proxyPort) {
|
||||
// const [existingResource] = await db
|
||||
// .select()
|
||||
// .from(siteResources)
|
||||
// .where(
|
||||
// and(
|
||||
// eq(siteResources.siteId, siteId),
|
||||
// eq(siteResources.orgId, orgId),
|
||||
// eq(siteResources.protocol, protocol),
|
||||
// eq(siteResources.proxyPort, proxyPort)
|
||||
// )
|
||||
// )
|
||||
// .limit(1);
|
||||
// if (existingResource && existingResource.siteResourceId) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.CONFLICT,
|
||||
// "A resource with the same protocol and proxy port already exists"
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
if (domainId && alias) {
|
||||
// throw an error because we can only have one or the other
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Alias and domain cannot both be set. Please choose one or the other."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let fullDomain: string | null = null;
|
||||
let finalSubdomain: string | null = null;
|
||||
if (domainId) {
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
orgId,
|
||||
subdomain
|
||||
);
|
||||
|
||||
if (!domainResult.success) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
|
||||
);
|
||||
}
|
||||
|
||||
fullDomain = domainResult.fullDomain;
|
||||
finalSubdomain = domainResult.subdomain;
|
||||
|
||||
// make sure the full domain is unique
|
||||
const existingResource = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.fullDomain, fullDomain));
|
||||
|
||||
if (existingResource.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that domain already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the alias is unique within the org if provided
|
||||
if (alias) {
|
||||
@@ -286,27 +350,49 @@ export async function createSiteResource(
|
||||
}
|
||||
|
||||
let aliasAddress: string | null = null;
|
||||
if (mode == "host") {
|
||||
// we can only have an alias on a host
|
||||
if (mode === "host" || mode === "http") {
|
||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||
}
|
||||
|
||||
let newSiteResource: SiteResource | undefined;
|
||||
await db.transaction(async (trx) => {
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!network) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Failed to create network`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Create the site resource
|
||||
const insertValues: typeof siteResources.$inferInsert = {
|
||||
siteId,
|
||||
niceId: updatedNiceId!,
|
||||
orgId,
|
||||
name,
|
||||
mode: mode as "host" | "cidr",
|
||||
mode,
|
||||
ssl,
|
||||
networkId: network.networkId,
|
||||
destination,
|
||||
scheme,
|
||||
destinationPort,
|
||||
enabled,
|
||||
alias,
|
||||
alias: alias ? alias.trim() : null,
|
||||
aliasAddress,
|
||||
tcpPortRangeString,
|
||||
udpPortRangeString,
|
||||
disableIcmp
|
||||
disableIcmp,
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain
|
||||
};
|
||||
if (isLicensedSshPam) {
|
||||
if (authDaemonPort !== undefined)
|
||||
@@ -323,6 +409,13 @@ export async function createSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -365,16 +458,21 @@ export async function createSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
for (const siteToAssign of sitesToAssign) {
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteToAssign.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
);
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Newt not found for site ${siteToAssign.siteId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
@@ -393,7 +491,7 @@ export async function createSiteResource(
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
|
||||
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -70,17 +70,18 @@ export async function deleteSiteResource(
|
||||
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||
.returning();
|
||||
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, removedSiteResource.siteId))
|
||||
.limit(1);
|
||||
// not sure why this is here...
|
||||
// const [newt] = await trx
|
||||
// .select()
|
||||
// .from(newts)
|
||||
// .where(eq(newts.siteId, removedSiteResource.siteId))
|
||||
// .limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
);
|
||||
}
|
||||
// if (!newt) {
|
||||
// return next(
|
||||
// createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
// );
|
||||
// }
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
|
||||
@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
|
||||
.transform((val) => (val ? Number(val) : undefined))
|
||||
.pipe(z.int().positive().optional())
|
||||
.optional(),
|
||||
siteId: z.string().transform(Number).pipe(z.int().positive()),
|
||||
niceId: z.string().optional(),
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
async function query(
|
||||
siteResourceId?: number,
|
||||
siteId?: number,
|
||||
niceId?: string,
|
||||
orgId?: string
|
||||
) {
|
||||
if (siteResourceId && siteId && orgId) {
|
||||
if (siteResourceId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
} else if (niceId && siteId && orgId) {
|
||||
} else if (niceId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.niceId, niceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
@@ -84,7 +80,6 @@ registry.registerPath({
|
||||
request: {
|
||||
params: z.object({
|
||||
niceId: z.string(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
@@ -107,10 +102,10 @@ export async function getSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
|
||||
const { siteResourceId, niceId, orgId } = parsedParams.data;
|
||||
|
||||
// Get the site resource
|
||||
const siteResource = await query(siteResourceId, siteId, niceId, orgId);
|
||||
const siteResource = await query(siteResourceId, niceId, orgId);
|
||||
|
||||
if (!siteResource) {
|
||||
return next(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, SiteResource, siteResources, sites } from "@server/db";
|
||||
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
mode: z
|
||||
.enum(["host", "cidr"])
|
||||
.enum(["host", "cidr", "http"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["host", "cidr"],
|
||||
enum: ["host", "cidr", "http"],
|
||||
description: "Filter site resources by mode"
|
||||
}),
|
||||
sort_by: z
|
||||
@@ -73,22 +73,58 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
siteResources: (SiteResource & {
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
siteAddress: string | null;
|
||||
siteOnlines: boolean[];
|
||||
siteIds: number[];
|
||||
siteNames: string[];
|
||||
siteNiceIds: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
})[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Returns an aggregation expression compatible with both SQLite and PostgreSQL.
|
||||
* - SQLite: json_group_array(col) → returns a JSON array string, parsed after fetch
|
||||
* - PostgreSQL: array_agg(col) → returns a native array
|
||||
*/
|
||||
function aggCol<T>(column: any) {
|
||||
if (DB_TYPE === "sqlite") {
|
||||
return sql<T>`json_group_array(${column})`;
|
||||
}
|
||||
return sql<T>`array_agg(${column})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For SQLite the aggregated columns come back as JSON strings; parse them into
|
||||
* proper arrays. For PostgreSQL the driver already returns native arrays, so
|
||||
* the row is returned unchanged.
|
||||
*/
|
||||
function transformSiteResourceRow(row: any) {
|
||||
if (DB_TYPE !== "sqlite") {
|
||||
return row;
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
siteNames: JSON.parse(row.siteNames) as string[],
|
||||
siteNiceIds: JSON.parse(row.siteNiceIds) as string[],
|
||||
siteIds: JSON.parse(row.siteIds) as number[],
|
||||
siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[],
|
||||
// SQLite stores booleans as 0/1 integers
|
||||
siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map(
|
||||
(v) => v === 1
|
||||
) as boolean[]
|
||||
};
|
||||
}
|
||||
|
||||
function querySiteResourcesBase() {
|
||||
return db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
siteId: siteResources.siteId,
|
||||
orgId: siteResources.orgId,
|
||||
niceId: siteResources.niceId,
|
||||
name: siteResources.name,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
ssl: siteResources.ssl,
|
||||
scheme: siteResources.scheme,
|
||||
proxyPort: siteResources.proxyPort,
|
||||
destinationPort: siteResources.destinationPort,
|
||||
destination: siteResources.destination,
|
||||
@@ -100,12 +136,24 @@ function querySiteResourcesBase() {
|
||||
disableIcmp: siteResources.disableIcmp,
|
||||
authDaemonMode: siteResources.authDaemonMode,
|
||||
authDaemonPort: siteResources.authDaemonPort,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteAddress: sites.address
|
||||
subdomain: siteResources.subdomain,
|
||||
domainId: siteResources.domainId,
|
||||
fullDomain: siteResources.fullDomain,
|
||||
networkId: siteResources.networkId,
|
||||
defaultNetworkId: siteResources.defaultNetworkId,
|
||||
siteNames: aggCol<string[]>(sites.name),
|
||||
siteNiceIds: aggCol<string[]>(sites.niceId),
|
||||
siteIds: aggCol<number[]>(sites.siteId),
|
||||
siteAddresses: aggCol<(string | null)[]>(sites.address),
|
||||
siteOnlines: aggCol<boolean[]>(sites.online)
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteResources.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.groupBy(siteResources.siteResourceId);
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
@@ -193,10 +241,12 @@ export async function listAllSiteResourcesByOrg(
|
||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
|
||||
querySiteResourcesBase()
|
||||
.where(and(...conditions))
|
||||
.as("filtered_site_resources")
|
||||
);
|
||||
|
||||
const [siteResourcesList, totalCount] = await Promise.all([
|
||||
const [siteResourcesRaw, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
@@ -210,6 +260,8 @@ export async function listAllSiteResourcesByOrg(
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
|
||||
|
||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||
data: {
|
||||
siteResources: siteResourcesList,
|
||||
@@ -233,4 +285,4 @@ export async function listAllSiteResourcesByOrg(
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user