Merge branch 'fosrl:dev' into dev

This commit is contained in:
Mustafa
2026-05-03 18:53:48 +02:00
committed by GitHub
634 changed files with 38068 additions and 10086 deletions

View File

@@ -121,7 +121,7 @@ export function createApiServer() {
const httpServer = apiServer.listen(externalPort, (err?: any) => {
if (err) throw err;
logger.info(
`API server is running on http://localhost:${externalPort}`
`Dashboard API server is running on http://localhost:${externalPort}`
);
});

View File

@@ -122,7 +122,6 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain",
sendUsageNotification = "sendUsageNotification",
createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode",
@@ -144,7 +143,16 @@ 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"
}
export async function checkUserActionPermission(

View File

@@ -1,94 +1,53 @@
{
"PowerMac4,4": "eMac",
"PowerMac6,4": "eMac",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook4,1": "iBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook6,3": "iBook",
"PowerBook6,5": "iBook",
"PowerBook6,7": "iBook",
"iMac,1": "iMac",
"PowerMac2,1": "iMac",
"PowerMac2,2": "iMac",
"PowerMac4,1": "iMac",
"PowerMac4,2": "iMac",
"PowerMac4,5": "iMac",
"PowerMac6,1": "iMac",
"PowerMac6,3*": "iMac",
"PowerMac6,3": "iMac",
"PowerMac8,1": "iMac",
"PowerMac8,2": "iMac",
"PowerMac12,1": "iMac",
"iMac4,1": "iMac",
"iMac4,2": "iMac",
"iMac5,2": "iMac",
"iMac5,1": "iMac",
"iMac6,1": "iMac",
"iMac7,1": "iMac",
"iMac8,1": "iMac",
"iMac9,1": "iMac",
"iMac10,1": "iMac",
"iMac11,1": "iMac",
"iMac11,2": "iMac",
"iMac11,3": "iMac",
"iMac12,1": "iMac",
"iMac12,2": "iMac",
"iMac13,1": "iMac",
"iMac13,2": "iMac",
"iMac14,1": "iMac",
"iMac14,3": "iMac",
"iMac14,2": "iMac",
"iMac14,4": "iMac",
"iMac15,1": "iMac",
"iMac16,1": "iMac",
"iMac16,2": "iMac",
"iMac17,1": "iMac",
"iMac18,1": "iMac",
"iMac18,2": "iMac",
"iMac18,3": "iMac",
"iMac19,2": "iMac",
"iMac19,1": "iMac",
"iMac20,1": "iMac",
"iMac20,2": "iMac",
"iMac21,2": "iMac",
"iMac21,1": "iMac",
"iMacPro1,1": "iMac Pro",
"PowerMac10,1": "Mac mini",
"PowerMac10,2": "Mac mini",
"Macmini1,1": "Mac mini",
"Macmini2,1": "Mac mini",
"Macmini3,1": "Mac mini",
"Macmini4,1": "Mac mini",
"Macmini5,1": "Mac mini",
"Macmini5,2": "Mac mini",
"Macmini5,3": "Mac mini",
"Macmini6,1": "Mac mini",
"Macmini6,2": "Mac mini",
"Macmini7,1": "Mac mini",
"Macmini8,1": "Mac mini",
"ADP3,2": "Mac mini",
"Macmini9,1": "Mac mini",
"Mac14,3": "Mac mini",
"Mac14,12": "Mac mini",
"MacPro1,1*": "Mac Pro",
"MacPro2,1": "Mac Pro",
"MacPro3,1": "Mac Pro",
"MacPro4,1": "Mac Pro",
"MacPro5,1": "Mac Pro",
"MacPro6,1": "Mac Pro",
"MacPro7,1": "Mac Pro",
"N/A*": "Power Macintosh",
"PowerMac1,1": "Power Macintosh",
"PowerMac3,1": "Power Macintosh",
"PowerMac3,3": "Power Macintosh",
"PowerMac3,4": "Power Macintosh",
"PowerMac3,5": "Power Macintosh",
"PowerMac3,6": "Power Macintosh",
"Mac13,1": "Mac Studio",
"Mac13,2": "Mac Studio",
"Mac14,10": "MacBook Pro",
"Mac14,12": "Mac mini",
"Mac14,13": "Mac Studio",
"Mac14,14": "Mac Studio",
"Mac14,15": "MacBook Air",
"Mac14,2": "MacBook Air",
"Mac14,3": "Mac mini",
"Mac14,5": "MacBook Pro",
"Mac14,6": "MacBook Pro",
"Mac14,7": "MacBook Pro",
"Mac14,8": "Mac Pro",
"Mac14,9": "MacBook Pro",
"Mac15,10": "MacBook Pro",
"Mac15,11": "MacBook Pro",
"Mac15,12": "MacBook Air",
"Mac15,13": "MacBook Air",
"Mac15,14": "Mac Studio",
"Mac15,3": "MacBook Pro",
"Mac15,4": "iMac",
"Mac15,5": "iMac",
"Mac15,6": "MacBook Pro",
"Mac15,7": "MacBook Pro",
"Mac15,8": "MacBook Pro",
"Mac15,9": "MacBook Pro",
"Mac16,1": "MacBook Pro",
"Mac16,10": "Mac mini",
"Mac16,11": "Mac mini",
"Mac16,12": "MacBook Air",
"Mac16,13": "MacBook Air",
"Mac16,2": "iMac",
"Mac16,3": "iMac",
"Mac16,5": "MacBook Pro",
"Mac16,6": "MacBook Pro",
"Mac16,7": "MacBook Pro",
"Mac16,8": "MacBook Pro",
"Mac16,9": "Mac Studio",
"Mac17,2": "MacBook Pro",
"Mac17,3": "MacBook Air",
"Mac17,4": "MacBook Air",
"Mac17,5": "MacBook Neo",
"Mac17,6": "MacBook Pro",
"Mac17,7": "MacBook Pro",
"Mac17,8": "MacBook Pro",
"Mac17,9": "MacBook Pro",
"MacBook1,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBook2,1": "MacBook",
"MacBook3,1": "MacBook",
"MacBook4,1": "MacBook",
@@ -98,8 +57,8 @@
"MacBook7,1": "MacBook",
"MacBook8,1": "MacBook",
"MacBook9,1": "MacBook",
"MacBook10,1": "MacBook",
"MacBookAir1,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"MacBookAir2,1": "MacBook Air",
"MacBookAir3,1": "MacBook Air",
"MacBookAir3,2": "MacBook Air",
@@ -114,88 +73,163 @@
"MacBookAir8,1": "MacBook Air",
"MacBookAir8,2": "MacBook Air",
"MacBookAir9,1": "MacBook Air",
"MacBookAir10,1": "MacBook Air",
"Mac14,2": "MacBook Air",
"MacBookPro1,1": "MacBook Pro",
"MacBookPro1,2": "MacBook Pro",
"MacBookPro2,2": "MacBook Pro",
"MacBookPro2,1": "MacBook Pro",
"MacBookPro3,1": "MacBook Pro",
"MacBookPro4,1": "MacBook Pro",
"MacBookPro5,1": "MacBook Pro",
"MacBookPro5,2": "MacBook Pro",
"MacBookPro5,5": "MacBook Pro",
"MacBookPro5,4": "MacBook Pro",
"MacBookPro5,3": "MacBook Pro",
"MacBookPro7,1": "MacBook Pro",
"MacBookPro6,2": "MacBook Pro",
"MacBookPro6,1": "MacBook Pro",
"MacBookPro8,1": "MacBook Pro",
"MacBookPro8,2": "MacBook Pro",
"MacBookPro8,3": "MacBook Pro",
"MacBookPro9,2": "MacBook Pro",
"MacBookPro9,1": "MacBook Pro",
"MacBookPro10,1": "MacBook Pro",
"MacBookPro10,2": "MacBook Pro",
"MacBookPro11,1": "MacBook Pro",
"MacBookPro11,2": "MacBook Pro",
"MacBookPro11,3": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro11,4": "MacBook Pro",
"MacBookPro11,5": "MacBook Pro",
"MacBookPro12,1": "MacBook Pro",
"MacBookPro13,1": "MacBook Pro",
"MacBookPro13,2": "MacBook Pro",
"MacBookPro13,3": "MacBook Pro",
"MacBookPro14,1": "MacBook Pro",
"MacBookPro14,2": "MacBook Pro",
"MacBookPro14,3": "MacBook Pro",
"MacBookPro15,2": "MacBook Pro",
"MacBookPro15,1": "MacBook Pro",
"MacBookPro15,2": "MacBook Pro",
"MacBookPro15,3": "MacBook Pro",
"MacBookPro15,4": "MacBook Pro",
"MacBookPro16,1": "MacBook Pro",
"MacBookPro16,3": "MacBook Pro",
"MacBookPro16,2": "MacBook Pro",
"MacBookPro16,3": "MacBook Pro",
"MacBookPro16,4": "MacBook Pro",
"MacBookPro17,1": "MacBook Pro",
"MacBookPro18,3": "MacBook Pro",
"MacBookPro18,4": "MacBook Pro",
"MacBookPro18,1": "MacBook Pro",
"MacBookPro18,2": "MacBook Pro",
"Mac14,7": "MacBook Pro",
"Mac14,9": "MacBook Pro",
"Mac14,5": "MacBook Pro",
"Mac14,10": "MacBook Pro",
"Mac14,6": "MacBook Pro",
"PowerMac1,2": "Power Macintosh",
"PowerMac5,1": "Power Macintosh",
"PowerMac7,2": "Power Macintosh",
"PowerMac7,3": "Power Macintosh",
"PowerMac9,1": "Power Macintosh",
"PowerMac11,2": "Power Macintosh",
"MacBookPro18,3": "MacBook Pro",
"MacBookPro18,4": "MacBook Pro",
"MacBookPro2,1": "MacBook Pro",
"MacBookPro2,2": "MacBook Pro",
"MacBookPro3,1": "MacBook Pro",
"MacBookPro4,1": "MacBook Pro",
"MacBookPro5,1": "MacBook Pro",
"MacBookPro5,2": "MacBook Pro",
"MacBookPro5,3": "MacBook Pro",
"MacBookPro5,4": "MacBook Pro",
"MacBookPro5,5": "MacBook Pro",
"MacBookPro6,1": "MacBook Pro",
"MacBookPro6,2": "MacBook Pro",
"MacBookPro7,1": "MacBook Pro",
"MacBookPro8,1": "MacBook Pro",
"MacBookPro8,2": "MacBook Pro",
"MacBookPro8,3": "MacBook Pro",
"MacBookPro9,1": "MacBook Pro",
"MacBookPro9,2": "MacBook Pro",
"MacPro1,1": "Mac Pro",
"MacPro2,1": "Mac Pro",
"MacPro3,1": "Mac Pro",
"MacPro4,1": "Mac Pro",
"MacPro5,1": "Mac Pro",
"MacPro6,1": "Mac Pro",
"MacPro7,1": "Mac Pro",
"Macmini1,1": "Mac mini",
"Macmini2,1": "Mac mini",
"Macmini3,1": "Mac mini",
"Macmini4,1": "Mac mini",
"Macmini5,1": "Mac mini",
"Macmini5,2": "Mac mini",
"Macmini5,3": "Mac mini",
"Macmini6,1": "Mac mini",
"Macmini6,2": "Mac mini",
"Macmini7,1": "Mac mini",
"Macmini8,1": "Mac mini",
"Macmini9,1": "Mac mini",
"PowerBook1,1": "PowerBook",
"PowerBook2,1": "iBook",
"PowerBook2,2": "iBook",
"PowerBook3,1": "PowerBook",
"PowerBook3,2": "PowerBook",
"PowerBook3,3": "PowerBook",
"PowerBook3,4": "PowerBook",
"PowerBook3,5": "PowerBook",
"PowerBook6,1": "PowerBook",
"PowerBook4,1": "iBook",
"PowerBook4,2": "iBook",
"PowerBook4,3": "iBook",
"PowerBook5,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook5,2": "PowerBook",
"PowerBook5,3": "PowerBook",
"PowerBook6,4": "PowerBook",
"PowerBook5,4": "PowerBook",
"PowerBook5,5": "PowerBook",
"PowerBook6,8": "PowerBook",
"PowerBook5,6": "PowerBook",
"PowerBook5,7": "PowerBook",
"PowerBook5,8": "PowerBook",
"PowerBook5,9": "PowerBook",
"PowerBook6,1": "PowerBook",
"PowerBook6,2": "PowerBook",
"PowerBook6,3": "iBook",
"PowerBook6,4": "PowerBook",
"PowerBook6,5": "iBook",
"PowerBook6,7": "iBook",
"PowerBook6,8": "PowerBook",
"PowerMac1,1": "Power Macintosh",
"PowerMac1,2": "Power Macintosh",
"PowerMac10,1": "Mac mini",
"PowerMac10,2": "Mac mini",
"PowerMac11,2": "Power Macintosh",
"PowerMac12,1": "iMac",
"PowerMac2,1": "iMac",
"PowerMac2,2": "iMac",
"PowerMac3,1": "Mac Server",
"PowerMac3,3": "Power Macintosh",
"PowerMac3,4": "Power Macintosh",
"PowerMac3,5": "Power Macintosh",
"PowerMac3,6": "Power Macintosh",
"PowerMac4,1": "iMac",
"PowerMac4,2": "iMac",
"PowerMac4,4": "eMac",
"PowerMac4,5": "iMac",
"PowerMac5,1": "Power Macintosh",
"PowerMac6,1": "iMac",
"PowerMac6,3": "iMac",
"PowerMac6,4": "eMac",
"PowerMac7,2": "Power Macintosh",
"PowerMac7,3": "Power Macintosh",
"PowerMac8,1": "iMac",
"PowerMac8,2": "iMac",
"PowerMac9,1": "Power Macintosh",
"RackMac1,1": "Xserve",
"RackMac1,2": "Xserve",
"RackMac3,1": "Xserve",
"Xserve1,1": "Xserve",
"Xserve2,1": "Xserve",
"Xserve3,1": "Xserve"
}
"Xserve3,1": "Xserve",
"iMac,1": "iMac",
"iMac10,1": "iMac",
"iMac11,1": "iMac",
"iMac11,2": "iMac",
"iMac11,3": "iMac",
"iMac12,1": "iMac",
"iMac12,2": "iMac",
"iMac13,1": "iMac",
"iMac13,2": "iMac",
"iMac14,1": "iMac",
"iMac14,2": "iMac",
"iMac14,3": "iMac",
"iMac14,4": "iMac",
"iMac15,1": "iMac",
"iMac16,1": "iMac",
"iMac16,2": "iMac",
"iMac17,1": "iMac",
"iMac18,1": "iMac",
"iMac18,2": "iMac",
"iMac18,3": "iMac",
"iMac19,1": "iMac",
"iMac19,2": "iMac",
"iMac20,1": "iMac",
"iMac20,2": "iMac",
"iMac21,1": "iMac",
"iMac21,2": "iMac",
"iMac4,1": "iMac",
"iMac4,2": "iMac",
"iMac5,1": "iMac",
"iMac5,2": "iMac",
"iMac6,1": "iMac",
"iMac7,1": "iMac",
"iMac8,1": "iMac",
"iMac9,1": "iMac",
"iMacPro1,1": "iMac Pro"
}

View File

@@ -16,11 +16,14 @@ import {
domains,
orgs,
targets,
roles,
users,
exitNodes,
sessions,
clients,
resources,
siteResources,
targetHealthCheck,
sites
} from "./schema";
@@ -88,6 +91,8 @@ export const subscriptions = pgTable("subscriptions", {
updatedAt: bigint("updatedAt", { mode: "number" }),
version: integer("version"),
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
expiresAt: bigint("expiresAt", { mode: "number" }),
trial: boolean("trial").default(false),
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
});
@@ -425,7 +430,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 +454,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 +467,116 @@ 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_degraded"
| "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 const trialNotifications = pgTable("trialNotifications", {
notificationId: serial("notificationId").primaryKey(),
subscriptionId: varchar("subscriptionId", { length: 255 })
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -495,3 +614,13 @@ export type EventStreamingDestination = InferSelectModel<
export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors
>;
export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRules = InferSelectModel<typeof alertRules>;
export type AlertEmailActions = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipients = InferSelectModel<
typeof alertEmailRecipients
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;

View File

@@ -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", {
@@ -153,7 +157,9 @@ export const resources = pgTable("resources", {
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false)
});
export const targets = pgTable("targets", {
@@ -182,9 +188,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 +216,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 +239,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 +268,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 +1049,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 +1097,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>;
@@ -1080,6 +1150,7 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
@@ -1106,3 +1177,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>;

View File

@@ -25,7 +25,7 @@ import {
ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility
} from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, or, sql } from "drizzle-orm";
export type ResourceWithAuth = {
resource: Resource | null;
@@ -47,7 +47,17 @@ export type UserSessionWithUser = {
export async function getResourceByDomain(
domain: string
): Promise<ResourceWithAuth | null> {
const [result] = await db
// Build wildcard domain variants to match against.
// For a domain like "me.example.test.com", we want to match:
// - "*.example.test.com" (subdomain wildcard)
// - "*.test.com" (parent wildcard, i.e. just "*" subdomain on parent)
const parts = domain.split(".");
const wildcardCandidates: string[] = [];
for (let i = 1; i < parts.length; i++) {
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
}
const potentialResults = await db
.select()
.from(resources)
.leftJoin(
@@ -70,8 +80,29 @@ export async function getResourceByDomain(
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where(eq(resources.fullDomain, domain))
.limit(1);
.where(
or(
// Exact match
eq(resources.fullDomain, domain),
// Wildcard match: resource fullDomain is one of the wildcard candidates
wildcardCandidates.length > 0
? and(
eq(resources.wildcard, true),
inArray(resources.fullDomain, wildcardCandidates)
)
: sql`false`
)
);
if (!potentialResults.length) {
return null;
}
// Prefer exact match over wildcard match
const exactMatch = potentialResults.find(
(r) => r.resources?.fullDomain === domain
);
const result = exactMatch ?? potentialResults[0];
if (!result) {
return null;

View File

@@ -13,11 +13,17 @@ import {
domains,
exitNodes,
orgs,
resources,
roles,
sessions,
siteResources,
sites,
targetHealthCheck,
users
} from "./schema";
import { serial, varchar } from "drizzle-orm/mysql-core";
import { pgTable } from "drizzle-orm/pg-core";
import { bigint } from "zod";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -82,6 +88,8 @@ export const subscriptions = sqliteTable("subscriptions", {
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt"),
version: integer("version"),
expiresAt: integer("expiresAt"),
trial: integer("trial", { mode: "boolean" }).default(false),
billingCycleAnchor: integer("billingCycleAnchor"),
type: text("type") // tier1, tier2, tier3, or license
});
@@ -420,10 +428,18 @@ export const eventStreamingDestinations = sqliteTable(
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false),
sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false),
sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false),
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false),
sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" })
.notNull()
.default(false),
sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" })
.notNull()
.default(false),
sendActionLogs: integer("sendActionLogs", { mode: "boolean" })
.notNull()
.default(false),
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" })
.notNull()
.default(false),
type: text("type").notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: integer("enabled", { mode: "boolean" })
@@ -455,6 +471,120 @@ 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_degraded"
| "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 const trialNotifications = sqliteTable("trialNotifications", {
notificationId: integer("notificationId").primaryKey({
autoIncrement: true
}),
subscriptionId: text("subscriptionId")
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: integer("sentAt").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -486,3 +616,11 @@ export type EventStreamingDestination = InferSelectModel<
export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors
>;
export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRule = InferSelectModel<typeof alertRules>;
export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;

View File

@@ -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"),
@@ -173,7 +178,9 @@ export const resources = sqliteTable("resources", {
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
});
export const targets = sqliteTable("targets", {
@@ -204,9 +211,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 +243,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 +268,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 +297,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 +1148,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 +1196,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 +1262,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 +1277,4 @@ export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;

View File

@@ -0,0 +1,262 @@
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,
EmailSection,
EmailSignature,
EmailText
} from "./components/Email";
import ButtonLink from "./components/ButtonLink";
export type AlertEventType =
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
export type AlertNotificationProps = {
eventType: AlertEventType;
orgId: string;
data: Record<string, unknown>;
dashboardLink: string;
};
function getEventMeta(eventType: AlertEventType): {
heading: string;
previewText: string;
summary: string;
statusLabel: string | null;
statusColor: string | null;
} {
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.",
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.",
statusLabel: null,
statusColor: null
};
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.",
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.",
statusLabel: null,
statusColor: null
};
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.",
statusLabel: "Unhealthy",
statusColor: "#dc2626"
};
case "resource_degraded":
return {
heading: "Resource Degraded",
previewText: "A resource in your organization is degraded.",
summary:
"A resource in your organization is currently degraded.",
statusLabel: "Degraded",
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.",
statusLabel: null,
statusColor: null
};
default:
return {
heading: "Alert Notification",
previewText:
"An alert event has occurred in your organization.",
summary: "An alert event has occurred in your organization.",
statusLabel: "Alert",
statusColor: "#f59e0b"
};
}
}
function resolveToggleStatus(status: unknown): {
label: string;
color: string;
} {
switch (String(status).toLowerCase()) {
case "online":
return { label: "Online", color: "#16a34a" };
case "offline":
return { label: "Offline", color: "#dc2626" };
case "healthy":
return { label: "Healthy", color: "#16a34a" };
case "unhealthy":
return { label: "Unhealthy", color: "#dc2626" };
case "degraded":
return { label: "Degraded", color: "#dc2626" };
default:
return { label: String(status ?? "Unknown"), color: "#f59e0b" };
}
}
function formatDataItems(
data: Record<string, unknown>
): { label: string; value: React.ReactNode }[] {
return Object.entries(data)
.filter(([key]) => key !== "orgId" && key !== "status")
.map(([key, value]) => ({
label: key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (s) => s.toUpperCase())
.trim(),
value: String(value ?? "-")
}));
}
export const AlertNotification = (props: AlertNotificationProps) => {
const { eventType, orgId, data, dashboardLink } = props;
const meta = getEventMeta(eventType);
const dataItems = formatDataItems(data);
const isToggle =
eventType === "site_toggle" ||
eventType === "health_check_toggle" ||
eventType === "resource_toggle";
const resolvedStatus = isToggle
? resolveToggleStatus(data.status)
: meta.statusLabel != null
? { label: meta.statusLabel, color: meta.statusColor! }
: null;
const allItems: { label: string; value: React.ReactNode }[] = [
{ label: "Organization", value: orgId },
...(resolvedStatus != null
? [
{
label: "Status",
value: (
<span
style={{
color: resolvedStatus.color,
fontWeight: 600
}}
>
{resolvedStatus.label}
</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>
Open your dashboard to view more details and manage
your alert rules.
</EmailText>
<EmailSection>
<ButtonLink href={dashboardLink}>
Open Dashboard
</ButtonLink>
</EmailSection>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default AlertNotification;

View File

@@ -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>

View File

@@ -0,0 +1,126 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailLetterHead,
EmailSignature,
EmailText
} from "./components/Email";
interface Props {
email: string;
orgName: string;
trialEndsAt: string;
daysRemaining: number | null;
billingLink: string;
}
export const NotifyTrialExpiring = ({
email,
orgName,
trialEndsAt,
daysRemaining,
billingLink
}: Props) => {
const hasEnded = daysRemaining === null || daysRemaining === 0;
const isLastDay = daysRemaining === 1;
const previewText = hasEnded
? `Your trial for ${orgName} has ended.`
: isLastDay
? `Your trial for ${orgName} ends tomorrow.`
: `Your trial for ${orgName} ends in ${daysRemaining} days.`;
const heading = hasEnded
? "Your Trial Ended"
: "Your Trial is Ending Soon";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>{heading}</EmailHeading>
<EmailGreeting>Hi there,</EmailGreeting>
{hasEnded ? (
<>
<EmailText>
Your free trial for{" "}
<strong>{orgName}</strong> ended on{" "}
<strong>{trialEndsAt}</strong>. Your account
has been moved to the free plan, which
includes limited functionality.
</EmailText>
<EmailText>
Some features and resources may now be
restricted. To restore full
access and continue using all the features
you had during your trial, please upgrade to
a paid plan.
</EmailText>
<EmailText>
You can{" "}
<a href={billingLink}>
upgrade your plan here
</a>{" "}
to get back up and running right away.
</EmailText>
</>
) : (
<>
<EmailText>
Just a reminder that your free trial for{" "}
<strong>{orgName}</strong> will end on{" "}
<strong>{trialEndsAt}</strong>
{isLastDay
? " - that's tomorrow!"
: `, in ${daysRemaining} days`}
.
</EmailText>
<EmailText>
After your trial ends, your account will be
moved to the free plan and some
functionality may be restricted.
</EmailText>
<EmailText>
To avoid any interruption to your service,
we encourage you to upgrade before your
trial expires. You can{" "}
<a href={billingLink}>
upgrade your plan here
</a>
.
</EmailText>
</>
)}
<EmailText>
If you have any questions or need assistance, please
don't hesitate to reach out to our support team.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default NotifyTrialExpiring;

View File

@@ -5,7 +5,7 @@ import { build } from "@server/build";
// EmailContainer: Wraps the entire email layout
export function EmailContainer({ children }: { children: React.ReactNode }) {
return (
<Container className="bg-white border border-solid border-gray-200 max-w-lg mx-auto my-8 rounded-lg overflow-hidden shadow-sm">
<Container className="bg-white border border-solid border-gray-200 max-w-lg mx-auto my-8 rounded-xl overflow-hidden">
{children}
</Container>
);
@@ -18,7 +18,7 @@ export function EmailLetterHead() {
<Img
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
alt="Pangolin Logo"
width="120"
width="180"
height="auto"
className="mx-auto"
/>

View File

@@ -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();

View File

@@ -36,7 +36,7 @@ export function createInternalServer() {
internalServer.listen(internalPort, (err?: any) => {
if (err) throw err;
logger.info(
`Internal server is running on http://localhost:${internalPort}`
`Internal API server is running on http://localhost:${internalPort}`
);
});

View File

@@ -0,0 +1,3 @@
export function initAcmeCertSync(): void {
// stub
}

View File

@@ -0,0 +1,293 @@
import logger from "@server/logger";
import { processAlerts } from "#dynamic/lib/alerts";
import {
db,
statusHistory,
targetHealthCheck,
targets,
resources,
Transaction,
logsDb
} from "@server/db";
import { eq } from "drizzle-orm";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import {
fireResourceDegradedAlert,
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceUnknownAlert
} from "./resourceEvents";
// ---------------------------------------------------------------------------
// 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,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_healthy",
orgId,
healthCheckId,
data: {
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
await processAlerts({
eventType: "health_check_toggle",
orgId,
healthCheckId,
data: {
healthCheckId,
status: "healthy",
...(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 fireHealthCheckUnhealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_unhealthy",
orgId,
healthCheckId,
data: {
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
await processAlerts({
eventType: "health_check_toggle",
orgId,
healthCheckId,
data: {
healthCheckId,
status: "unhealthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireHealthCheckUnhealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
export async function fireHealthCheckUnknownAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("health_check", healthCheckId);
await handleResource(orgId, healthCheckTargetId, send, trx);
if (!send) {
return;
}
} catch (err) {
logger.error(
`fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
async function handleResource(
orgId: string,
healthCheckTargetId?: number | null,
send: boolean = true,
trx: Transaction | typeof db = db
) {
if (!healthCheckTargetId) {
return;
}
// we have targets lets get them
const [target] = await trx
.select()
.from(targets)
.where(eq(targets.targetId, healthCheckTargetId))
.limit(1);
if (!target) {
return;
}
const [resource] = await trx
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId))
.limit(1);
if (!resource) {
return;
}
const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(eq(targets.resourceId, resource.resourceId));
let health = "healthy";
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
if (allUnknown) {
logger.debug(
`Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
);
health = "unknown";
} else if (allHealthy) {
health = "healthy";
} else if (allUnhealthy) {
logger.debug(
`Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
);
health = "unhealthy";
} else {
logger.debug(
`Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
);
health = "degraded";
}
if (health != resource.health) {
// it changed
await trx
.update(resources)
.set({ health })
.where(eq(resources.resourceId, resource.resourceId));
if (health === "unknown") {
await fireResourceUnknownAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "healthy") {
await fireResourceHealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
} else if (health === "degraded") {
await fireResourceDegradedAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
send,
trx
);
}
}
}

View File

@@ -0,0 +1,243 @@
import logger from "@server/logger";
import { processAlerts } from "#dynamic/lib/alerts";
import { db, logsDb, statusHistory, Transaction } from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
// ---------------------------------------------------------------------------
// 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>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_healthy",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "healthy",
...(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>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_unhealthy",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unhealthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_degraded` alert for the given resource.
*
* Call this after a resource has been detected as degraded 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 fireResourceDegradedAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "degraded",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_degraded",
orgId,
resourceId,
data: {
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "degraded",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_unknown` alert for the given resource.
*
* Call this when all health checks on a resource are disabled so that the
* resource status transitions to unknown.
*
* @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 fireResourceUnknownAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("resource", resourceId);
if (!send) {
return;
}
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unknown",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}

View File

@@ -0,0 +1,156 @@
import logger from "@server/logger";
import { processAlerts } from "#dynamic/lib/alerts";
import {
db,
logsDb,
statusHistory,
targetHealthCheck,
Transaction
} from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
// ---------------------------------------------------------------------------
// 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>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
await processAlerts({
eventType: "site_online",
orgId,
siteId,
data: {
...(siteName != null ? { siteName } : {}),
...extra
}
});
await processAlerts({
eventType: "site_toggle",
orgId,
siteId,
data: {
siteId,
status: "online",
...(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>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
await invalidateStatusHistoryCache("site", siteId);
const unhealthyHealthChecks = await trx
.update(targetHealthCheck)
.set({ hcHealth: "unhealthy" })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
eq(targetHealthCheck.siteId, siteId),
eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled
)
)
.returning();
for (const healthCheck of unhealthyHealthChecks) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
);
await fireHealthCheckUnhealthyAlert(
healthCheck.orgId,
healthCheck.targetHealthCheckId,
healthCheck.name,
healthCheck.targetId, // for the resource if we have one
undefined,
true,
trx
);
}
await processAlerts({
eventType: "site_offline",
orgId,
siteId,
data: {
...(siteName != null ? { siteName } : {}),
...extra
}
});
await processAlerts({
eventType: "site_toggle",
orgId,
siteId,
data: {
siteId,
status: "offline",
...(siteName != null ? { siteName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireSiteOfflineAlert: unexpected error for siteId ${siteId}`,
err
);
}
}

View File

@@ -0,0 +1,4 @@
export * from "./events/siteEvents";
export * from "./events/healthCheckEvents";
export * from "./events/resourceEvents";
export * from "./processAlerts";

View File

@@ -0,0 +1,5 @@
import { AlertContext } from "@server/routers/alertRule/types";
export async function processAlerts(context: AlertContext): Promise<void> {
return;
}

View File

@@ -1,8 +1,9 @@
export async function getOrgTierData(
orgId: string
): Promise<{ tier: string | null; active: boolean }> {
): Promise<{ tier: string | null; active: boolean; isTrial: boolean }> {
const tier = null;
const active = false;
const isTrial = false;
return { tier, active };
return { tier, active, isTrial };
}

View File

@@ -9,8 +9,8 @@ export type LicensePriceSet = {
export const licensePriceSet: LicensePriceSet = {
// Free license matches the freeLimitSet
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8",
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y"
[LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
[LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
};
export const licensePriceSetSandbox: LicensePriceSet = {

View File

@@ -25,7 +25,7 @@ export const tier1LimitSet: LimitSet = {
export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: {
value: 100,
value: 50,
description: "Team limit"
},
[FeatureId.SITES]: {
@@ -48,7 +48,7 @@ export const tier2LimitSet: LimitSet = {
export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: {
value: 500,
value: 250,
description: "Business limit"
},
[FeatureId.SITES]: {

View File

@@ -20,7 +20,11 @@ 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",
WildcardSubdomain = "wildcardSubdomain"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -58,5 +62,9 @@ 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]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
};

View File

@@ -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
);
}
@@ -285,7 +293,7 @@ export async function applyBlueprint({
orgId,
name:
name ??
`${faker.word.adjective()} ${faker.word.adjective()} ${faker.word.noun()}`,
`${faker.word.adjective()}-${faker.word.adjective()}-${faker.word.noun()}`,
contents: stringifyYaml(configData),
createdAt: Math.floor(Date.now() / 1000),
succeeded: blueprintSucceeded,

View File

@@ -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,109 @@ export async function updateClientResources(
)
.limit(1);
const resourceSiteId = resourceData.site;
let site;
const existingSiteIds = existingResource?.networkId
? await trx
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, existingResource.networkId))
: [];
if (resourceSiteId) {
const allSites: { siteId: number }[] = [];
if (resourceData.site) {
// Look up site by niceId
[site] = await trx
const [siteSingle] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.niceId, resourceSiteId),
eq(sites.niceId, resourceData.site),
eq(sites.orgId, orgId)
)
)
.limit(1);
} else if (siteId) {
if (siteSingle) {
allSites.push(siteSingle);
}
}
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) {
allSites.push(site);
}
}
}
if (siteId && allSites.length === 0) {
// only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org
[site] = await trx
const [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) {
allSites.push(siteSingle);
}
}
if (!site) {
if (allSites.length === 0) {
throw new Error(
`Site not found: ${resourceSiteId} in org ${orgId}`
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
);
}
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"]
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
})
.where(
eq(
@@ -100,7 +236,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 +354,80 @@ 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"]
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
? ""
: 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 +517,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
});
}
}

View File

@@ -1,5 +1,6 @@
import {
domains,
domainNamespaces,
orgDomains,
Resource,
resourceHeaderAuth,
@@ -33,6 +34,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix";
export type ProxyResourcesResults = {
@@ -140,7 +142,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,11 +163,27 @@ 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();
healthchecksToUpdate.push(newHealthcheck);
// Insert unknown status history when HC is created in disabled state
if (!healthcheckData?.enabled) {
await fireHealthCheckUnknownAlert(
orgId,
newHealthcheck.targetHealthCheckId,
newHealthcheck.name,
newHealthcheck.targetId,
undefined,
true,
trx
);
}
}
// Find existing resource by niceId and orgId
@@ -231,6 +252,7 @@ export async function updateProxyResources(
fullDomain: http ? resourceData["full-domain"] : null,
subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false,
enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId:
@@ -522,7 +544,11 @@ 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(
@@ -548,6 +574,21 @@ export async function updateProxyResources(
targetsToUpdate.push(updatedTarget);
}
}
// Insert unknown status history when HC is disabled
const isDisablingHc =
!healthcheckData?.enabled && oldHealthcheck?.hcEnabled;
if (isDisablingHc) {
await fireHealthCheckUnknownAlert(
orgId,
newHealthcheck.targetHealthCheckId,
newHealthcheck.name,
newHealthcheck.targetId,
undefined,
true,
trx
);
}
} else {
await createTarget(existingResource.resourceId, targetData);
}
@@ -676,6 +717,7 @@ export async function updateProxyResources(
fullDomain: http ? resourceData["full-domain"] : null,
subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false,
enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
@@ -1081,6 +1123,10 @@ 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 +1146,7 @@ function checkIfTargetChanged(
return false;
}
async function getDomain(
export async function getDomain(
resourceId: number | undefined,
fullDomain: string,
orgId: string,
@@ -1143,7 +1189,13 @@ async function getDomainId(
orgId: string,
fullDomain: string,
trx: Transaction
): Promise<{ subdomain: string | null; domainId: string } | null> {
): Promise<{
subdomain: string | null;
domainId: string;
wildcard: boolean;
} | null> {
const isWildcardFullDomain = fullDomain.startsWith("*.");
const possibleDomains = await trx
.select()
.from(domains)
@@ -1156,6 +1208,11 @@ async function getDomainId(
}
const validDomains = possibleDomains.filter((domain) => {
// Wildcard full-domains are not allowed on CNAME domains
if (isWildcardFullDomain && domain.domains.type === "cname") {
return false;
}
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
return (
fullDomain === domain.domains.baseDomain ||
@@ -1173,6 +1230,21 @@ async function getDomainId(
const domainSelection = validDomains[0].domains;
const baseDomain = domainSelection.baseDomain;
// Wildcard full-domains are not allowed on namespace (provided/free) domains
if (isWildcardFullDomain) {
const [namespaceDomain] = await trx
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainSelection.domainId))
.limit(1);
if (namespaceDomain) {
throw new Error(
`Wildcard full-domains are not supported for provided or free domains: ${fullDomain}`
);
}
}
// remove the base domain of the domain
let subdomain = null;
if (fullDomain != baseDomain) {
@@ -1182,6 +1254,7 @@ async function getDomainId(
// Return the first valid domain
return {
subdomain: subdomain,
domainId: domainSelection.domainId
domainId: domainSelection.domainId,
wildcard: isWildcardFullDomain
};
}

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
export const SiteSchema = z.object({
name: z.string().min(1).max(100),
@@ -12,7 +13,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 +27,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 +167,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(),
@@ -316,6 +320,34 @@ export const ResourceSchema = z
message:
"Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)"
}
)
.refine(
(resource) => {
const fullDomain = resource["full-domain"];
if (!fullDomain || !fullDomain.includes("*")) return true;
// A wildcard full-domain must be of the form *.labels.basedomain
// Extract the leftmost label(s) before the first non-wildcard segment.
// e.g. "*.level1.example.com" → subdomain candidate is "*.level1"
// We do this by finding the base domain: everything after the first
// real (non-wildcard) dot-separated segment pair.
//
// Simple rule: split on ".", first token must be "*", rest must be
// valid hostname labels, and there must be at least 2 remaining labels
// (so the full domain has a real base domain).
const parts = fullDomain.split(".");
if (parts[0] !== "*") return false; // * must be the very first label
if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label));
},
{
path: ["full-domain"],
message:
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
}
);
export function isTargetsOnlyResource(resource: any): boolean {
@@ -325,16 +357,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().optional(), // 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 +513,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[]>();

View File

@@ -1,5 +1,6 @@
export async function getValidCertificatesForDomains(
domains: Set<string>
domains: Set<string>,
useCache: boolean = true
): Promise<
Array<{
id: number;

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.17.0";
export const APP_VERSION = "1.18.2";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -1,14 +1,16 @@
import { db } from "@server/db";
import { domains, orgDomains } from "@server/db";
import { eq, and } from "drizzle-orm";
import { subdomainSchema } from "@server/lib/schemas";
import { domains, orgDomains, domainNamespaces, resources } from "@server/db";
import { eq, and, like, not } from "drizzle-orm";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import { fromError } from "zod-validation-error";
import config from "./config";
export type DomainValidationResult =
| {
success: true;
fullDomain: string;
subdomain: string | null;
wildcard: boolean;
}
| {
success: false;
@@ -66,6 +68,62 @@ export async function validateAndConstructDomain(
};
}
// Detect wildcard subdomain request
const isWildcard =
subdomain !== undefined &&
subdomain !== null &&
subdomain.includes("*") &&
domainRes.domains.type !== "cname";
// Wildcard subdomains are not allowed on CNAME domains
if (isWildcard && domainRes.domains.type === "cname") {
return {
success: false,
error: "Wildcard subdomains are not supported for CNAME domains. CNAME domains must use a specific hostname."
};
}
// Wildcard subdomains are not allowed on namespace (provided/free) domains
if (isWildcard) {
const [namespaceDomain] = await db
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (namespaceDomain) {
return {
success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
};
}
}
if (
isWildcard &&
domainRes.domains.type == "wildcard" &&
!(
domainRes.domains.preferWildcardCert ||
config.getRawConfig().traefik.prefer_wildcard_cert
)
) {
return {
success: false,
error: "Wildcard domains are not supported without configuring certificate resolver for wildcard certs and marking it as prefered."
};
}
// Validate wildcard subdomain format
if (isWildcard) {
const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain);
if (!parsedWildcard.success) {
return {
success: false,
error: fromError(parsedWildcard.error).toString()
};
}
}
// Construct full domain based on domain type
let fullDomain = "";
let finalSubdomain = subdomain;
@@ -81,13 +139,16 @@ export async function validateAndConstructDomain(
finalSubdomain = null; // CNAME domains don't use subdomains
} else if (domainRes.domains.type === "wildcard") {
if (subdomain !== undefined && subdomain !== null) {
// Validate subdomain format for wildcard domains
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
if (!parsedSubdomain.success) {
return {
success: false,
error: fromError(parsedSubdomain.error).toString()
};
if (!isWildcard) {
// Validate regular subdomain format for wildcard domains
const parsedSubdomain =
subdomainSchema.safeParse(subdomain);
if (!parsedSubdomain.success) {
return {
success: false,
error: fromError(parsedSubdomain.error).toString()
};
}
}
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
@@ -100,13 +161,14 @@ export async function validateAndConstructDomain(
finalSubdomain = null;
}
// Convert to lowercase
// Convert to lowercase (preserve * as-is)
fullDomain = fullDomain.toLowerCase();
return {
success: true,
fullDomain,
subdomain: finalSubdomain ?? null
subdomain: finalSubdomain ?? null,
wildcard: isWildcard
};
} catch (error) {
return {
@@ -115,3 +177,81 @@ export async function validateAndConstructDomain(
};
}
}
/**
* Checks whether a given fullDomain conflicts with any existing wildcard resources,
* or (if the fullDomain is itself a wildcard) whether any existing resources would
* be matched by it.
*
* @param fullDomain - The fully-constructed domain to check (may contain a leading `*`)
* @param excludeResourceId - Optional resource ID to exclude from the check (for updates)
* @returns An object with `conflict: true` and a human-readable `message`, or `conflict: false`
*/
export async function checkWildcardDomainConflict(
fullDomain: string,
excludeResourceId?: number
): Promise<{ conflict: false } | { conflict: true; message: string }> {
const isWildcard = fullDomain.startsWith("*.");
if (isWildcard) {
// e.g. fullDomain = "*.example.com" → suffix = ".example.com"
const suffix = fullDomain.slice(1); // ".example.com"
// Find any existing non-wildcard resource whose fullDomain ends with this suffix
// e.g. "test.example.com" or "foo.example.com"
const conflicting = await db
.select({
resourceId: resources.resourceId,
fullDomain: resources.fullDomain
})
.from(resources)
.where(like(resources.fullDomain, `%${suffix}`));
const matches = conflicting.filter(
(r) =>
!r.fullDomain!.startsWith("*.") &&
r.fullDomain!.endsWith(suffix) &&
(excludeResourceId === undefined ||
r.resourceId !== excludeResourceId)
);
if (matches.length > 0) {
return {
conflict: true,
message: `Wildcard domain ${fullDomain} conflicts with existing resource(s): ${matches.map((r) => r.fullDomain).join(", ")}`
};
}
} else {
// Specific domain — check if any existing wildcard would match it.
// e.g. fullDomain = "test.example.com"
// We look for a wildcard "*.example.com" which means fullDomain ends with ".example.com"
const dotIndex = fullDomain.indexOf(".");
if (dotIndex !== -1) {
const suffix = fullDomain.slice(dotIndex); // ".example.com"
const wildcardPattern = `*.${fullDomain.slice(dotIndex + 1)}`; // "*.example.com"
const conflicting = await db
.select({
resourceId: resources.resourceId,
fullDomain: resources.fullDomain
})
.from(resources)
.where(eq(resources.fullDomain, wildcardPattern));
const matches = conflicting.filter(
(r) =>
excludeResourceId === undefined ||
r.resourceId !== excludeResourceId
);
if (matches.length > 0) {
return {
conflict: true,
message: `Domain ${fullDomain} conflicts with existing wildcard resource(s): ${matches.map((r) => r.fullDomain).join(", ")}`
};
}
}
}
return { conflict: false };
}

View File

@@ -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

View File

@@ -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.`
@@ -599,7 +610,7 @@ export function generateSubnetProxyTargetV2(
return;
}
let target: SubnetProxyTargetV2 | null = null;
let targets: SubnetProxyTargetV2[] = [];
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -614,52 +625,114 @@ export function generateSubnetProxyTargetV2(
if (ipSchema.safeParse(destination).success) {
destination = `${destination}/32`;
target = {
targets.push({
sourcePrefixes: [],
destPrefix: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId,
};
resourceId: siteResource.siteResourceId
});
}
if (siteResource.alias && siteResource.aliasAddress) {
// also push a match for the alias address
target = {
targets.push({
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId,
};
resourceId: siteResource.siteResourceId
});
}
} else if (siteResource.mode == "cidr") {
target = {
targets.push({
sourcePrefixes: [],
destPrefix: siteResource.destination,
portRange,
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`,
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 (!target) {
if (targets.length == 0) {
return;
}
for (const clientSite of clients) {
if (!clientSite.subnet) {
logger.debug(
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
);
continue;
for (const target of targets) {
for (const clientSite of clients) {
if (!clientSite.subnet) {
logger.debug(
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
);
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
// add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix);
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
// add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix);
}
// print a nice representation of the targets
@@ -667,36 +740,34 @@ export function generateSubnetProxyTargetV2(
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
// );
return target;
return targets;
}
/**
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
* by expanding each source prefix into its own target entry.
* @param targetV2 - The v2 target to convert
* @returns Array of v1 SubnetProxyTarget objects
*/
export function convertSubnetProxyTargetsV2ToV1(
targetsV2: SubnetProxyTargetV2[]
): SubnetProxyTarget[] {
return targetsV2.flatMap((targetV2) =>
targetV2.sourcePrefixes.map((sourcePrefix) => ({
sourcePrefix,
destPrefix: targetV2.destPrefix,
...(targetV2.disableIcmp !== undefined && {
disableIcmp: targetV2.disableIcmp
}),
...(targetV2.rewriteTo !== undefined && {
rewriteTo: targetV2.rewriteTo
}),
...(targetV2.portRange !== undefined && {
portRange: targetV2.portRange
})
}))
);
}
export function convertSubnetProxyTargetsV2ToV1(
targetsV2: SubnetProxyTargetV2[]
): SubnetProxyTarget[] {
return targetsV2.flatMap((targetV2) =>
targetV2.sourcePrefixes.map((sourcePrefix) => ({
sourcePrefix,
destPrefix: targetV2.destPrefix,
...(targetV2.disableIcmp !== undefined && {
disableIcmp: targetV2.disableIcmp
}),
...(targetV2.rewriteTo !== undefined && {
rewriteTo: targetV2.rewriteTo
}),
...(targetV2.portRange !== undefined && {
portRange: targetV2.portRange
})
}))
);
}
// Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string

View File

@@ -1,3 +1,5 @@
// Normalizes
/**
* Normalizes a post-authentication path for safe use when building redirect URLs.
* Returns a path that starts with / and does not allow open redirects (no //, no :).

View File

@@ -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,64 @@ 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 site resources that share
// any of the same sites as this site resource (via siteNetworks). We can't
// simply filter by networkId since each site resource has its own network;
// two site resources serving the same site typically belong to different
// networks that both happen to include the site through siteNetworks.
const sitesListSiteIds = sitesList.map((s) => s.siteId);
const allUpdatedClientsFromOtherResourcesOnThisSite =
sitesListSiteIds.length > 0
? 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(
inArray(siteNetworks.siteId, sitesListSiteIds),
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 +242,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 +265,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 +277,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 +323,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 +732,7 @@ export async function updateClientSiteDestinations(
async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource,
sitesList: Site[],
allClients: {
clientId: number;
pubKey: string | null;
@@ -638,125 +747,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 targetToAdd = generateSubnetProxyTargetV2(
siteResource,
addedClients
if (!newt) {
logger.warn(
`Newt not found for site ${siteId}, skipping subnet proxy target updates`
);
if (targetToAdd) {
proxyJobs.push(
addSubnetProxyTargets(
newt.newtId,
[targetToAdd],
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 targetToRemove = generateSubnetProxyTargetV2(
siteResource,
removedClients
// Generate targets for added associations
if (clientSiteResourcesToAdd.length > 0) {
const addedClients = allClients.filter((client) =>
clientSiteResourcesToAdd.includes(client.clientId)
);
if (targetToRemove) {
proxyJobs.push(
removeSubnetProxyTargets(
newt.newtId,
[targetToRemove],
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 +985,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 +1276,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 +1333,7 @@ async function handleMessagesForClientResources(
}
for (const resource of resources) {
const target = generateSubnetProxyTargetV2(resource, [
const targets = await generateSubnetProxyTargetV2(resource, [
{
clientId: client.clientId,
pubKey: client.pubKey,
@@ -1172,11 +1341,11 @@ async function handleMessagesForClientResources(
}
]);
if (target) {
if (targets) {
proxyJobs.push(
addSubnetProxyTargets(
newt.newtId,
[target],
targets,
newt.version
)
);
@@ -1187,7 +1356,7 @@ async function handleMessagesForClientResources(
olmJobs.push(
addPeerData(
client.clientId,
resource.siteId,
siteId,
generateRemoteSubnets([resource]),
generateAliasConfig([resource])
)
@@ -1199,7 +1368,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 +1385,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 +1442,7 @@ async function handleMessagesForClientResources(
}
for (const resource of resources) {
const target = generateSubnetProxyTargetV2(resource, [
const targets = await generateSubnetProxyTargetV2(resource, [
{
clientId: client.clientId,
pubKey: client.pubKey,
@@ -1249,18 +1450,22 @@ async function handleMessagesForClientResources(
}
]);
if (target) {
if (targets) {
proxyJobs.push(
removeSubnetProxyTargets(
newt.newtId,
[target],
targets,
newt.version
)
);
}
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 +1476,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 +1508,7 @@ async function handleMessagesForClientResources(
olmJobs.push(
removePeerData(
client.clientId,
resource.siteId,
siteId,
remoteSubnetsToRemove,
generateAliasConfig([resource])
)
@@ -1311,7 +1520,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;

View File

@@ -1,3 +1,5 @@
// Sanitizes
/**
* Sanitize a string field before inserting into a database TEXT column.
*
@@ -37,4 +39,4 @@ export function sanitizeString(
// Strip null bytes, C0 control chars (except HT/LF/CR), and DEL.
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
);
}
}

View File

@@ -1,5 +1,41 @@
import { z } from "zod";
/**
* Validates a wildcard subdomain passed as the leftmost component of a full domain.
*
* The value represents everything to the left of the base domain, so when combined
* with e.g. "example.com" it must produce a valid SSL-style wildcard hostname.
*
* Valid:
* "*" → *.example.com
* "*.level1" → *.level1.example.com
*
* Invalid:
* "*example" → *example.com (no dot after *)
* "level2.*.level1" → wildcard not in leftmost position
* "*.level1.*" → multiple wildcards
*/
export const wildcardSubdomainSchema = z
.string()
.refine(
(val) => {
// Must start with "*."; the remainder (if any) must be valid hostname labels.
// A bare "*" is also valid (becomes *.baseDomain directly).
if (val === "*") return true;
if (!val.startsWith("*.")) return false;
const rest = val.slice(2); // everything after "*."
// rest must not be empty, must not contain another "*",
// and every label must be a valid hostname label.
if (!rest || rest.includes("*")) return false;
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
return rest.split(".").every((label) => labelRegex.test(label));
},
{
message:
'Invalid wildcard subdomain. The wildcard "*" must be the leftmost label followed by a dot and valid hostname labels (e.g. "*" or "*.level1"). Patterns like "*example", "level2.*.level1", or multiple wildcards are not supported.'
}
);
export const subdomainSchema = z
.string()
.regex(

239
server/lib/statusHistory.ts Normal file
View File

@@ -0,0 +1,239 @@
import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import cache from "@server/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
function statusHistoryCacheKey(
entityType: string,
entityId: number,
days: number
): string {
return `statusHistory:${entityType}:${entityId}:${days}`;
}
export async function getCachedStatusHistory(
entityType: string,
entityId: number,
days: number
): Promise<StatusHistoryResponse> {
const cacheKey = statusHistoryCacheKey(entityType, entityId, days);
const cached = await cache.get<StatusHistoryResponse>(cacheKey);
if (cached !== undefined) {
return cached;
}
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await logsDb
.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;
const result: StatusHistoryResponse = {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
};
await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL);
return result;
}
export async function invalidateStatusHistoryCache(
entityType: string,
entityId: number
): Promise<void> {
const prefix = `statusHistory:${entityType}:${entityId}:`;
const keys = cache.keys().filter((k) => k.startsWith(prefix));
if (keys.length > 0) {
await cache.del(keys);
}
}
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" | "unknown";
}
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 dayDegradedTime = 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";
const isDegraded = windowStatus === "degraded";
if (isDown) {
dayDowntime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus
});
} else if (isDegraded) {
dayDegradedTime += 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";
const isDegraded = windowStatus === "degraded";
if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart;
windows.push({
start: windowStart,
end: finalEnd,
status: windowStatus
});
} else if (isDegraded && finalEnd > windowStart) {
dayDegradedTime += 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 - dayDegradedTime) /
effectiveDayLength) *
100
)
: 100;
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
const hasAnyData = currentStatus !== null || dayEvents.length > 0;
// The whole observable window is "unknown" if every status we have seen is unknown
const allStatuses = [
...(currentStatus !== null ? [currentStatus] : []),
...dayEvents.map((e) => e.status)
];
const onlyUnknownData =
hasAnyData && allStatuses.every((s) => s === "unknown");
let status: StatusHistoryDayBucket["status"] = "no_data";
if (hasAnyData) {
if (onlyUnknownData) {
status = "unknown";
} else if (dayDowntime > 0 && uptimePct < 50) {
status = "bad";
} else if (dayDowntime > 0 || dayDegradedTime > 0) {
status = "degraded";
} else {
status = "good";
}
}
buckets.push({
date: dateStr,
uptimePercent: Math.round(uptimePct * 100) / 100,
totalDowntimeSeconds: dayDowntime,
downtimeWindows: windows,
status
});
}
return { buckets, totalDowntime };
}

View File

@@ -2,7 +2,7 @@ import { PostHog } from "posthog-node";
import config from "./config";
import { getHostMeta } from "./hostMeta";
import logger from "@server/logger";
import { apiKeys, db, roles, siteResources } from "@server/db";
import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
import { APP_VERSION } from "./consts";
@@ -15,6 +15,7 @@ class TelemetryClient {
private client: PostHog | null = null;
private enabled: boolean;
private intervalId: NodeJS.Timeout | null = null;
private collectionIntervalDays = 14;
constructor() {
const enabled = config.getRawConfig().app.telemetry.anonymous_usage;
@@ -33,7 +34,7 @@ class TelemetryClient {
this.client = new PostHog(
"phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX",
{
host: "https://pangolin.net/relay-O7yI"
host: "https://telemetry.fossorial.io/relay-O7yI"
}
);
@@ -72,7 +73,7 @@ class TelemetryClient {
logger.debug("Successfully sent analytics data");
});
},
48 * 60 * 60 * 1000
this.collectionIntervalDays * 24 * 60 * 60 * 1000 // Convert days to milliseconds
);
this.collectAndSendAnalytics().catch((err) => {
@@ -157,6 +158,14 @@ class TelemetryClient {
})
.from(sites);
const [numAlertRules] = await db
.select({ count: count() })
.from(alertRules);
const [blueprintsCount] = await db
.select({ count: count() })
.from(blueprints);
const supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources);
@@ -165,11 +174,14 @@ class TelemetryClient {
let numPrivResourceAliases = 0;
let numPrivResourceHosts = 0;
let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0;
for (const res of allPrivateResources) {
if (res.mode === "host") {
numPrivResourceHosts += 1;
} else if (res.mode === "cidr") {
numPrivResourceCidr += 1;
} else if (res.mode === "http") {
numPrivResourceHttp += 1;
}
if (res.alias) {
@@ -187,6 +199,9 @@ class TelemetryClient {
numPrivateResources: numPrivResources,
numPrivateResourceAliases: numPrivResourceAliases,
numPrivateResourceHosts: numPrivResourceHosts,
numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp,
numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count,
numIdentityProviders: idpCount.count,
@@ -197,6 +212,7 @@ class TelemetryClient {
appVersion: APP_VERSION,
numApiKeys: numApiKeys.count,
numCustomRoles: customRoles.count,
numBlueprints: blueprintsCount.count,
supporterStatus: {
valid: supporterKey?.valid || false,
tier: supporterKey?.tier || "None",
@@ -285,10 +301,12 @@ class TelemetryClient {
num_private_resource_aliases:
stats.numPrivateResourceAliases,
num_private_resource_hosts: stats.numPrivateResourceHosts,
num_private_resource_cidr: stats.numPrivateResourceCidr,
num_user_devices: stats.numUserDevices,
num_machine_clients: stats.numMachineClients,
num_identity_providers: stats.numIdentityProviders,
num_sites_online: stats.numSitesOnline,
num_blueprint_runs: stats.numBlueprints,
num_resources_sso_enabled: stats.resources.filter(
(r) => r.sso
).length,

View File

@@ -1,3 +1,5 @@
// tokenCache
/**
* Returns a cached plaintext token from Redis if one exists and decrypts
* cleanly, otherwise calls `createSession` to mint a fresh token, stores the

View File

@@ -19,6 +19,7 @@ export class TraefikConfigManager {
private timeoutId: NodeJS.Timeout | null = null;
private lastCertificateFetch: Date | null = null;
private lastKnownDomains = new Set<string>();
private pendingDeletion = new Map<string, number>(); // domain -> cycles remaining before delete
private lastLocalCertificateState = new Map<
string,
{
@@ -415,7 +416,8 @@ export class TraefikConfigManager {
// Get valid certificates for domains not covered by wildcards
validCertificates =
await getValidCertificatesForDomains(
domainsToFetch
domainsToFetch,
true
);
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
@@ -533,6 +535,24 @@ export class TraefikConfigManager {
if (match && match[1]) {
domains.add(match[1]);
}
// Match HostRegexp(`^[^.]+\.parent.domain$`) generated for wildcard resources
const hostRegexpMatch = router.rule.match(
/HostRegexp\(`([^`]+)`\)/
);
if (hostRegexpMatch && hostRegexpMatch[1]) {
const innerRegex = hostRegexpMatch[1];
// Pattern is always ^[^.]+\.PARENT_DOMAIN$ where dots are escaped as \.
const domainMatch = innerRegex.match(
/^\^\[\^\.\]\+\\\.(.+)\$$/
);
if (domainMatch && domainMatch[1]) {
const parentDomain = domainMatch[1].replace(
/\\\./g,
"."
);
domains.add(`*.${parentDomain}`);
}
}
}
}
}
@@ -1004,33 +1024,62 @@ export class TraefikConfigManager {
const dirName = dirent.name;
// Only delete if NO current domain is exactly the same or ends with `.${dirName}`
const shouldDelete = !Array.from(currentActiveDomains).some(
const isUnused = !Array.from(currentActiveDomains).some(
(domain) =>
domain === dirName || domain.endsWith(`.${dirName}`)
);
if (shouldDelete) {
const domainDir = path.join(certsPath, dirName);
logger.info(
`Cleaning up unused certificate directory: ${dirName}`
);
fs.rmSync(domainDir, { recursive: true, force: true });
// Remove from local state tracking
this.lastLocalCertificateState.delete(dirName);
// Remove from dynamic config
const certFilePath = path.join(domainDir, "cert.pem");
const keyFilePath = path.join(domainDir, "key.pem");
const before = dynamicConfig.tls.certificates.length;
dynamicConfig.tls.certificates =
dynamicConfig.tls.certificates.filter(
(entry: any) =>
entry.certFile !== certFilePath &&
entry.keyFile !== keyFilePath
if (!isUnused) {
// 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`
);
if (dynamicConfig.tls.certificates.length !== before) {
configChanged = true;
this.pendingDeletion.delete(dirName);
}
continue;
}
// Domain is unused - add to pending deletion or decrement its counter
if (!this.pendingDeletion.has(dirName)) {
const graceCycles = 3;
logger.info(
`Certificate ${dirName} is no longer in use. Will delete after ${graceCycles} more cycles.`
);
this.pendingDeletion.set(dirName, graceCycles);
} else {
const remaining = this.pendingDeletion.get(dirName)! - 1;
if (remaining > 0) {
logger.info(
`Certificate ${dirName} pending deletion: ${remaining} cycle(s) remaining`
);
this.pendingDeletion.set(dirName, remaining);
} else {
// Grace period expired - actually delete now
this.pendingDeletion.delete(dirName);
const domainDir = path.join(certsPath, dirName);
logger.info(
`Cleaning up unused certificate directory: ${dirName}`
);
fs.rmSync(domainDir, { recursive: true, force: true });
// Remove from local state tracking
this.lastLocalCertificateState.delete(dirName);
// Remove from dynamic config
const certFilePath = path.join(domainDir, "cert.pem");
const keyFilePath = path.join(domainDir, "key.pem");
const before = dynamicConfig.tls.certificates.length;
dynamicConfig.tls.certificates =
dynamicConfig.tls.certificates.filter(
(entry: any) =>
entry.certFile !== certFilePath &&
entry.keyFile !== keyFilePath
);
if (dynamicConfig.tls.certificates.length !== before) {
configChanged = true;
}
}
}
}

View File

@@ -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++;
}

View File

@@ -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) {

View File

@@ -11,7 +11,7 @@ export async function createNextServer() {
// const app = next({ dev });
const app = next({
dev: process.env.ENVIRONMENT !== "prod",
turbopack: true
turbopack: false
});
const handle = app.getRequestHandler();
@@ -29,7 +29,7 @@ export async function createNextServer() {
nextServer.listen(nextPort, (err?: any) => {
if (err) throw err;
logger.info(
`Next.js server is running on http://localhost:${nextPort}`
`Dashboard Web UI server is running on http://localhost:${nextPort}`
);
});

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -0,0 +1,839 @@
/*
* 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 path from "path";
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[];
};
}
export 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.debug(
`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.debug(
`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;
}
/**
* Determine whether an ACME cert entry represents a wildcard cert by checking
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
* Traefik) store the bare apex in `main` and only put the wildcard form in
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
*/
function detectWildcard(
main: string,
sans: string[] | undefined
): { wildcard: boolean; wildcardSan: string | null } {
if (main.startsWith("*.")) {
return { wildcard: true, wildcardSan: null };
}
if (Array.isArray(sans)) {
for (const san of sans) {
if (typeof san !== "string") continue;
if (san === `*.${main}` || san.startsWith("*.")) {
return { wildcard: true, wildcardSan: san };
}
}
}
return { wildcard: false, wildcardSan: null };
}
interface HttpCert {
wildcard: boolean;
altName: string;
certName: string;
commonName: string;
certFile: string;
keyFile: string;
}
async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
let response: Response;
try {
response = await fetch(endpoint);
} catch (err) {
logger.debug(
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
);
return;
}
if (!response.ok) {
logger.debug(
`acmeCertSync: HTTP endpoint returned status ${response.status}`
);
return;
}
let httpCerts: HttpCert[];
try {
httpCerts = await response.json();
} catch (err) {
logger.debug(
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
);
return;
}
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
logger.debug(
`acmeCertSync: no certificates returned from HTTP endpoint`
);
return;
}
for (const cert of httpCerts) {
const domain = cert?.certName;
if (!domain || typeof domain !== "string") {
logger.debug(
`acmeCertSync: skipping HTTP cert with missing certName`
);
continue;
}
const certPem = cert.certFile;
const keyPem = cert.keyFile;
if (!certPem?.trim() || !keyPem?.trim()) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile`
);
continue;
}
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}`
);
continue;
}
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}`
);
continue;
}
const wildcard = cert.wildcard ?? false;
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!
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
continue;
}
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) {
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
}
}
let expiresAt: number | null = null;
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
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 HTTP cert domain "${domain}"`
);
} else {
logger.debug(
`acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"`
);
}
if (existing.length > 0) {
logger.debug(
`acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
}
function findAcmeJsonFiles(dirPath: string): string[] {
const results: string[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch (err) {
logger.warn(
`acmeCertSync: could not read directory "${dirPath}": ${err}`
);
return results;
}
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
results.push(...findAcmeJsonFiles(fullPath));
} else if (entry.isFile() && entry.name === "acme.json") {
results.push(fullPath);
}
}
return results;
}
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
let raw: string;
try {
raw = fs.readFileSync(acmeJsonPath, "utf8");
} catch (err) {
logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
return;
}
let acmeJson: AcmeJson;
try {
acmeJson = JSON.parse(raw);
} catch (err) {
logger.warn(
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
);
return;
}
const resolvers = Object.keys(acmeJson || {});
if (resolvers.length === 0) {
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
return;
}
// Collect certificates from every resolver. If the same domain appears in
// multiple resolvers, the last one wins (resolvers iterated in object order).
const allCerts: AcmeCert[] = [];
for (const resolver of resolvers) {
const resolverData = acmeJson[resolver];
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
logger.debug(
`acmeCertSync: no certificates found for resolver "${resolver}"`
);
continue;
}
logger.debug(
`acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
);
for (const cert of resolverData.Certificates) {
allCerts.push(cert);
}
}
for (const cert of allCerts) {
const domain = cert?.domain?.main;
if (!domain || typeof domain !== "string") {
logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue;
}
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
if (!cert.certificate || !cert.key) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
);
continue;
}
let certPem: string;
let keyPem: string;
try {
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}`
);
continue;
}
if (!certPem.trim() || !keyPem.trim()) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
);
continue;
}
// Validate that the decoded data actually parses as a real X.509 cert
// before we touch the database. This prevents importing partially-written
// or corrupted entries from acme.json.
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
);
continue;
}
// Sanity-check the private key parses too
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
);
continue;
}
// Check if cert already exists in DB
const existing = await db
.select()
.from(certificates)
.where(and(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!
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
// 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 validated X.509 certificate
let expiresAt: number | null = null;
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
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) {
logger.debug(
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
logger.debug(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
logger.debug(
`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 intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
const httpEndpoint = privateConfigData.acme?.acme_http_endpoint;
logger.debug(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
);
if (httpEndpoint) {
logger.debug(
`acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms`
);
}
const runSync = () => {
if (httpEndpoint) {
syncAcmeCertsFromHttp(httpEndpoint).catch((err) => {
logger.error(`acmeCertSync: error during HTTP sync: ${err}`);
});
} else {
// only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up
let stat: fs.Stats | null = null;
try {
stat = fs.statSync(acmeJsonPath);
} catch (err) {
logger.warn(
`acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}`
);
return;
}
if (stat.isDirectory()) {
const files = findAcmeJsonFiles(acmeJsonPath);
if (files.length === 0) {
logger.debug(
`acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"`
);
return;
}
logger.debug(
`acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
);
for (const file of files) {
syncAcmeCerts(file).catch((err) => {
logger.error(
`acmeCertSync: error during sync of "${file}": ${err}`
);
});
}
} else {
syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}
}
};
// Run immediately on init, then on the configured interval
runSync();
setInterval(runSync, intervalMs);
}

View 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 "./processAlerts";
export * from "./sendAlertWebhook";
export * from "./sendAlertEmail";

View 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 { sendAlertWebhook } from "./sendAlertWebhook";
import { sendAlertEmail } from "./sendAlertEmail";
import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types";
/**
* 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);
}

View File

@@ -0,0 +1,103 @@
/*
* 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 "@server/routers/alertRule/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);
const baseUrl = config.getRawConfig().app.dashboard_url!.replace(/\/$/, "");
const dashboardLink = `${baseUrl}/${context.orgId}/settings`;
for (const to of recipients) {
try {
await sendEmail(
AlertNotification({
eventType: context.eventType,
orgId: context.orgId,
data: context.data,
dashboardLink
}),
{
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_degraded":
return "[Alert] Resource Degraded";
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";
}
}
}

View 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 logger from "@server/logger";
import {
AlertContext,
WebhookAlertConfig
} from "@server/routers/alertRule/types";
const REQUEST_TIMEOUT_MS = 15_000;
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 500;
/**
* 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 eventType = context.eventType;
const timestamp = new Date().toISOString();
const status = deriveStatus(eventType, context.data);
const data = { orgId: context.orgId, ...context.data };
let body: string;
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
body = renderTemplate(webhookConfig.bodyTemplate, {
event: eventType,
timestamp,
status,
data
});
} else {
body = JSON.stringify({ event: eventType, timestamp, status, data });
}
const headers = buildHeaders(webhookConfig);
let lastError: Error | undefined;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
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) {
clearTimeout(timeoutHandle);
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
lastError = new Error(
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
);
} else {
const msg = err instanceof Error ? err.message : String(err);
lastError = new Error(
`Alert webhook: request to "${url}" failed ${msg}`
);
}
if (attempt < MAX_RETRIES) {
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
logger.warn(
`Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed retrying in ${delay} ms. ${lastError.message}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
continue;
} finally {
clearTimeout(timeoutHandle);
}
if (!response.ok) {
let snippet = "";
try {
const text = await response.text();
snippet = text.slice(0, 300);
} catch {
// best-effort
}
lastError = new Error(
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
(snippet ? ` ${snippet}` : "")
);
if (attempt < MAX_RETRIES) {
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
logger.warn(
`Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed retrying in ${delay} ms. ${lastError.message}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
continue;
}
logger.debug(
`Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})`
);
return;
}
throw (
lastError ??
new Error(
`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`
)
);
}
// ---------------------------------------------------------------------------
// Status derivation
// ---------------------------------------------------------------------------
function deriveStatus(
eventType: AlertContext["eventType"],
data: Record<string, unknown>
): string {
switch (eventType) {
case "site_online":
return "online";
case "site_offline":
return "offline";
case "site_toggle":
return String(data.status ?? "unknown");
case "health_check_healthy":
case "resource_healthy":
return "healthy";
case "health_check_unhealthy":
case "resource_unhealthy":
return "unhealthy";
case "resource_degraded":
return "degraded";
case "health_check_toggle":
case "resource_toggle":
return String(data.status ?? "unknown");
default: {
const _exhaustive: never = eventType;
void _exhaustive;
return "unknown";
}
}
}
// ---------------------------------------------------------------------------
// 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;
}
// ---------------------------------------------------------------------------
// Body template rendering
// ---------------------------------------------------------------------------
interface TemplateContext {
event: string;
timestamp: string;
status: string;
data: Record<string, unknown>;
}
/**
* Render a body template with {{event}}, {{timestamp}}, {{status}}, and
* {{data}} placeholders, mirroring the logic in HttpLogDestination.
*
* {{data}} is replaced first (as raw JSON) so that any literal "{{…}}"
* strings inside data values are not re-expanded.
*/
function renderTemplate(template: string, ctx: TemplateContext): string {
const rendered = template
.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data))
.replace(/\{\{event\}\}/g, escapeJsonString(ctx.event))
.replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp))
.replace(/\{\{status\}\}/g, escapeJsonString(ctx.status));
// Validate the rendered result is valid JSON; if not, log a warning and
// fall back to the default payload so the webhook still fires.
try {
JSON.parse(rendered);
return rendered;
} catch {
logger.warn(
`sendAlertWebhook: body template produced invalid JSON for event ` +
`"${ctx.event}" destined for a webhook. Falling back to default ` +
`payload. Check that {{data}} is NOT wrapped in quotes in your template.`
);
return JSON.stringify({
event: ctx.event,
timestamp: ctx.timestamp,
status: ctx.status,
data: ctx.data
});
}
}
function escapeJsonString(value: string): string {
return JSON.stringify(value).slice(1, -1);
}

View File

@@ -0,0 +1,67 @@
/*
* 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"
| "health_check_healthy"
| "health_check_not_healthy";
// ---------------------------------------------------------------------------
// 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;
/** Whether to use a custom body template */
useBodyTemplate?: boolean;
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
bodyTemplate?: 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;
/** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>;
}

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
@@ -19,12 +19,13 @@ import { eq, and, ne } from "drizzle-orm";
export async function getOrgTierData(
orgId: string
): Promise<{ tier: Tier | null; active: boolean }> {
): Promise<{ tier: Tier | null; active: boolean; isTrial: boolean }> {
let tier: Tier | null = null;
let active = false;
let isTrial = false;
if (build !== "saas") {
return { tier, active };
return { tier, active, isTrial };
}
try {
@@ -35,7 +36,7 @@ export async function getOrgTierData(
.limit(1);
if (!org) {
return { tier, active };
return { tier, active, isTrial };
}
let orgIdToUse = org.orgId;
@@ -44,7 +45,7 @@ export async function getOrgTierData(
logger.warn(
`Org ${orgId} is not a billing org and does not have a billingOrgId`
);
return { tier, active };
return { tier, active, isTrial };
}
orgIdToUse = org.billingOrgId;
}
@@ -57,7 +58,7 @@ export async function getOrgTierData(
.limit(1);
if (!customer) {
return { tier, active };
return { tier, active, isTrial };
}
// Query for active subscriptions that are not license type
@@ -84,11 +85,13 @@ export async function getOrgTierData(
tier = subscription.type;
active = true;
}
isTrial = subscription.trial ?? false;
}
} catch (error) {
// If org not found or error occurs, return null tier and inactive
// This is acceptable behavior as per the function signature
}
return { tier, active };
return { tier, active, isTrial };
}

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
@@ -11,23 +11,14 @@
* 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");
}
import { build } from "@server/build";
// Define the return type for clarity and type safety
export type CertificateResult = {
@@ -45,7 +36,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 +59,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;
}
@@ -86,6 +77,9 @@ export async function getValidCertificatesForDomains(
const parentDomainsArray = Array.from(parentDomainsToQuery);
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
// 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip.
const potentialCerts = await db
@@ -99,10 +93,13 @@ export async function getValidCertificatesForDomains(
or(
// Condition for exact matches on the requested domains
inArray(certificates.domain, domainsToQueryArray),
// Condition for wildcard matches on the parent domains
// Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com")
parentDomainsArray.length > 0
? and(
inArray(certificates.domain, parentDomainsArray),
inArray(certificates.domain, [
...parentDomainsArray,
...wildcardPrefixedArray
]),
eq(certificates.wildcard, true)
)
: // If there are no possible parent domains, this condition is false
@@ -111,13 +108,18 @@ export async function getValidCertificatesForDomains(
)
);
// Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.")
const normalizeWildcardDomain = (domain: string): string =>
domain.startsWith("*.") ? domain.slice(2) : domain;
// 5. Process the database results, prioritizing exact matches over wildcards
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
for (const cert of potentialCerts) {
if (cert.wildcard) {
wildcardMatches.set(cert.domain, cert);
// Normalize to bare parent domain so lookups are consistent regardless of storage format
wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert);
} else {
exactMatches.set(cert.domain, cert);
}
@@ -130,14 +132,15 @@ export async function getValidCertificatesForDomains(
if (exactMatches.has(domain)) {
foundCert = exactMatches.get(domain);
}
// Priority 2: Check for a wildcard certificate that matches the exact domain
// Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain
else {
if (wildcardMatches.has(domain)) {
foundCert = wildcardMatches.get(domain);
const normalizedDomain = normalizeWildcardDomain(domain);
if (wildcardMatches.has(normalizedDomain)) {
foundCert = wildcardMatches.get(normalizedDomain);
}
// Priority 3: Check for a wildcard match on the parent domain
else {
const parts = domain.split(".");
const parts = normalizedDomain.split(".");
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
if (wildcardMatches.has(parentDomain)) {
@@ -173,22 +176,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 {

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
@@ -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 {
);
});
}
}
}

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
@@ -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);
}
}

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
@@ -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;
}
}

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
@@ -21,167 +21,172 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535);
export const privateConfigSchema = z.object({
app: z
.object({
region: z.string().optional().default("default"),
base_domain: z.string().optional(),
identity_provider_mode: z.enum(["global", "org"]).optional()
})
.optional()
.default({
region: "default"
}),
server: z
.object({
encryption_key: z
.string()
.optional()
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
reo_client_id: z
.string()
.optional()
.transform(getEnvOrYaml("REO_CLIENT_ID")),
fossorial_api: z
.string()
.optional()
.default("https://api.fossorial.io"),
fossorial_api_key: z
.string()
.optional()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
})
.optional()
.prefault({}),
redis: z
.object({
host: z.string(),
port: portSchema,
password: z
.string()
.optional()
.transform(getEnvOrYaml("REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0),
replicas: z
.array(
z.object({
host: z.string(),
port: portSchema,
password: z.string().optional(),
db: z.int().nonnegative().optional().default(0)
export const privateConfigSchema = z
.object({
app: z
.object({
region: z.string().optional().default("default"),
base_domain: z.string().optional(),
identity_provider_mode: z.enum(["global", "org"]).optional()
})
.optional()
.default({
region: "default"
}),
server: z
.object({
reo_client_id: z
.string()
.optional()
.transform(getEnvOrYaml("REO_CLIENT_ID")),
fossorial_api: z
.string()
.optional()
.default("https://api.fossorial.io"),
fossorial_api_key: z
.string()
.optional()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
})
.optional()
.prefault({}),
redis: z
.object({
host: z.string(),
port: portSchema,
password: z
.string()
.optional()
.transform(getEnvOrYaml("REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0),
replicas: z
.array(
z.object({
host: z.string(),
port: portSchema,
password: z.string().optional(),
db: z.int().nonnegative().optional().default(0)
})
)
.optional(),
tls: z
.object({
rejectUnauthorized: z.boolean().optional().default(true)
})
)
.optional(),
tls: z
.object({
rejectUnauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
.optional(),
gerbil: z
.object({
local_exit_node_reachable_at: z
.string()
.optional()
.default("http://gerbil:3004")
})
.optional()
.prefault({}),
flags: z
.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional()
})
.optional()
.prefault({}),
branding: z
.object({
app_name: z.string().optional(),
background_image_path: z.string().optional(),
colors: z
.object({
light: colorsSchema.optional(),
dark: colorsSchema.optional()
})
.optional(),
logo: z
.object({
light_path: z.string().optional(),
dark_path: z.string().optional(),
auth_page: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional(),
navbar: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional()
})
.optional(),
footer: z
.array(
z.object({
text: z.string(),
href: z.string().optional()
.optional()
})
.optional(),
gerbil: z
.object({
local_exit_node_reachable_at: z
.string()
.optional()
.default("http://gerbil:3004")
})
.optional()
.prefault({}),
flags: z
.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
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"),
acme_http_endpoint: z.string().optional(),
sync_interval_ms: z.number().optional().default(5000)
})
.optional(),
branding: z
.object({
app_name: z.string().optional(),
background_image_path: z.string().optional(),
colors: z
.object({
light: colorsSchema.optional(),
dark: colorsSchema.optional()
})
)
.optional(),
hide_auth_layout_footer: z.boolean().optional().default(false),
login_page: z
.object({
subtitle_text: z.string().optional()
})
.optional(),
signup_page: z
.object({
subtitle_text: z.string().optional()
})
.optional(),
resource_auth_page: z
.object({
show_logo: z.boolean().optional(),
hide_powered_by: z.boolean().optional(),
title_text: z.string().optional(),
subtitle_text: z.string().optional()
})
.optional(),
emails: z
.object({
signature: z.string().optional(),
colors: z
.object({
primary: z.string().optional()
.optional(),
logo: z
.object({
light_path: z.string().optional(),
dark_path: z.string().optional(),
auth_page: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional(),
navbar: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional()
})
.optional(),
footer: z
.array(
z.object({
text: z.string(),
href: z.string().optional()
})
.optional()
})
.optional()
})
.optional(),
stripe: z
.object({
secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
// s3Bucket: z.string(),
// s3Region: z.string().default("us-east-1"),
// localFilePath: z.string().optional()
})
.optional()
})
)
.optional(),
hide_auth_layout_footer: z.boolean().optional().default(false),
login_page: z
.object({
subtitle_text: z.string().optional()
})
.optional(),
signup_page: z
.object({
subtitle_text: z.string().optional()
})
.optional(),
resource_auth_page: z
.object({
show_logo: z.boolean().optional(),
hide_powered_by: z.boolean().optional(),
title_text: z.string().optional(),
subtitle_text: z.string().optional()
})
.optional(),
emails: z
.object({
signature: z.string().optional(),
colors: z
.object({
primary: z.string().optional()
})
.optional()
})
.optional()
})
.optional(),
stripe: z
.object({
secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET"))
// s3Bucket: z.string(),
// s3Region: z.string().default("us-east-1"),
// localFilePath: z.string().optional()
})
.optional()
})
.transform((data) => {
// this to maintain backwards compatibility with the old config file
const identityProviderMode = data.app?.identity_provider_mode;

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
@@ -33,7 +33,15 @@ 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,
@@ -100,6 +108,7 @@ export async function getTraefikConfig(
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
@@ -238,6 +247,7 @@ export async function getTraefikConfig(
priority: priority, // may be null, we fallback later
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
wildcard: row.wildcard,
maintenanceModeEnabled: row.maintenanceModeEnabled,
maintenanceModeType: row.maintenanceModeType,
@@ -267,6 +277,38 @@ export async function getTraefikConfig(
});
});
let siteResourcesWithFullDomain: {
siteResourceId: number;
fullDomain: string | null;
mode: "http" | "host" | "cidr";
}[] = [];
if (build == "enterprise") {
// we dont want to do this on the cloud
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
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),
eq(sites.exitNodeId, exitNodeId),
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 +318,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)}`);
@@ -341,7 +389,16 @@ export async function getTraefikConfig(
...additionalMiddlewares
];
let rule = `Host(\`${fullDomain}\`)`;
let rule: string;
if (resource.wildcard && fullDomain.startsWith("*.")) {
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
const escaped = fullDomain
.slice(2) // remove leading "*."
.replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
// priority logic
let priority: number;
@@ -384,7 +441,8 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.prefer_wildcard_cert;
const domainCertResolver = resource.domainCertResolver;
const preferWildcardCert = resource.preferWildcardCert;
const preferWildcardCert =
resource.preferWildcardCert || resource.wildcard;
let resolverName: string | undefined;
let preferWildcard: boolean | undefined;
@@ -531,7 +589,7 @@ export async function getTraefikConfig(
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 2001,
...(resource.ssl ? { tls } : {})
};
@@ -867,6 +925,128 @@ 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({

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View File

@@ -1,7 +1,7 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.

View 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";

View File

@@ -0,0 +1,118 @@
/*
* 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 } 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,
fireHealthCheckUnhealthyAlert
} from "@server/lib/alerts";
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}`
)
);
}
if (eventType === "health_check_healthy") {
await fireHealthCheckHealthyAlert(
orgId,
healthCheckId,
healthCheck.name ?? undefined
);
} else {
await fireHealthCheckUnhealthyAlert(
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")
);
}
}

View File

@@ -0,0 +1,130 @@
/*
* 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 } 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,
fireResourceDegradedAlert
} from "@server/lib/alerts";
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_degraded",
"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") {
await fireResourceHealthyAlert(
orgId,
resourceId,
resource.name ?? undefined
);
} else if (eventType === "resource_unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
resourceId,
resource.name ?? undefined
);
} else if (eventType === "resource_degraded") {
await fireResourceDegradedAlert(
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")
);
}
}

View File

@@ -0,0 +1,102 @@
/*
* 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 } 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 "@server/lib/alerts";
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}`
)
);
}
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")
);
}
}

View File

@@ -0,0 +1,366 @@
/*
* 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";
import { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
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_degraded",
"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(0),
// 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"]
});
}
});
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")
);
}
}

View 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")
);
}
}

Some files were not shown because too many files have changed in this diff Show More