alerting for gateways and relays

This commit is contained in:
x032205
2025-10-08 23:46:47 -04:00
parent 12b59fc2c0
commit c6696d4fde
12 changed files with 365 additions and 18 deletions
@@ -10,11 +10,16 @@ export type TGatewayV2DALFactory = ReturnType<typeof gatewayV2DalFactory>;
export const gatewayV2DalFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.GatewayV2);
const find = async (filter: TFindFilter<TGatewaysV2>, { offset, limit, sort, tx }: TFindOpt<TGatewaysV2> = {}) => {
const find = async (
filter: TFindFilter<TGatewaysV2> & { isHeartbeatStale?: boolean },
{ offset, limit, sort, tx }: TFindOpt<TGatewaysV2> = {}
) => {
try {
const { isHeartbeatStale, ...regularFilter } = filter;
const query = (tx || db.replicaNode())(TableName.GatewayV2)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(filter, TableName.GatewayV2))
.where(buildFindFilter(regularFilter, TableName.GatewayV2))
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.GatewayV2}.identityId`)
.join(
TableName.IdentityOrgMembership,
@@ -24,6 +29,21 @@ export const gatewayV2DalFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.GatewayV2))
.select(db.ref("name").withSchema(TableName.Identity).as("identityName"));
if (isHeartbeatStale) {
const oneHourAgo = new Date();
oneHourAgo.setHours(oneHourAgo.getHours() - 1);
void query.where(`${TableName.GatewayV2}.heartbeat`, "<", oneHourAgo);
void query.where((v) => {
void v
.whereNull(`${TableName.GatewayV2}.healthAlertedAt`)
.orWhere(
`${TableName.GatewayV2}.healthAlertedAt`,
"<",
db.ref("heartbeat").withSchema(TableName.GatewayV2)
);
});
}
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
@@ -2,14 +2,16 @@ import net from "node:net";
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { CronJob } from "cron";
import { TRelays } from "@app/db/schemas";
import { OrgMembershipRole, TRelays } from "@app/db/schemas";
import { PgSqlLock } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { GatewayProxyProtocol } from "@app/lib/gateway/types";
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns";
@@ -20,6 +22,10 @@ import {
} from "@app/services/certificate-authority/certificate-authority-fns";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { NotificationType } from "@app/services/notification/notification-types";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { PamResource } from "../pam-resource/pam-resource-enums";
@@ -39,6 +45,9 @@ type TGatewayV2ServiceFactoryDep = {
gatewayV2DAL: TGatewayV2DALFactory;
relayDAL: TRelayDALFactory;
permissionService: TPermissionServiceFactory;
orgDAL: Pick<TOrgDALFactory, "findOrgMembersByRole">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
smtpService: Pick<TSmtpService, "sendMail">;
};
export type TGatewayV2ServiceFactory = ReturnType<typeof gatewayV2ServiceFactory>;
@@ -50,7 +59,10 @@ export const gatewayV2ServiceFactory = ({
relayService,
gatewayV2DAL,
relayDAL,
permissionService
permissionService,
orgDAL,
notificationService,
smtpService
}: TGatewayV2ServiceFactoryDep) => {
const $validateIdentityAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => {
const orgLicensePlan = await licenseService.getPlan(orgId);
@@ -878,6 +890,78 @@ export const gatewayV2ServiceFactory = ({
});
};
const $healthcheckNotify = async () => {
const unhealthyGateways = await gatewayV2DAL.find({
isHeartbeatStale: true
});
if (unhealthyGateways.length === 0) return;
logger.warn(
{ gatewayIds: unhealthyGateways.map((g) => g.id) },
"Found gateways with last heartbeat over an hour ago. Sending notifications."
);
await Promise.all(unhealthyGateways.map((gw) => gatewayV2DAL.updateById(gw.id, { healthAlertedAt: new Date() })));
const gatewaysByOrg = unhealthyGateways.reduce<Record<string, (typeof unhealthyGateways)[number][]>>((acc, gw) => {
if (!acc[gw.orgId]) {
acc[gw.orgId] = [];
}
acc[gw.orgId].push(gw);
return acc;
}, {});
for await (const [orgId, gateways] of Object.entries(gatewaysByOrg)) {
try {
const admins = await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin);
if (admins.length === 0) {
logger.warn({ orgId }, "Organization has no admins to notify about unhealthy gateway.");
// eslint-disable-next-line no-continue
continue;
}
const gatewayNames = gateways.map((g) => `"${g.name}"`).join(", ");
const body = `The following gateway(s) in your organization may be offline as they haven't reported a heartbeat in over an hour: ${gatewayNames}. Please check their status.`;
await notificationService.createUserNotifications(
admins.map((admin) => ({
userId: admin.user.id,
orgId,
type: NotificationType.GATEWAY_HEALTH_ALERT,
title: "Gateway Health Alert",
body,
link: "/organization/networking"
}))
);
await smtpService.sendMail({
recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v),
subjectLine: "Gateway Health Alert",
substitutions: {
type: "gateway",
names: gatewayNames
},
template: SmtpTemplates.HealthAlert
});
} catch (error) {
logger.error(error, `Failed to send gateway health notifications for organization [orgId=${orgId}]`);
}
}
};
const initializeHealthcheckNotify = async () => {
logger.info("Setting up background notification process for gateway v2 health-checks");
await $healthcheckNotify();
// run every 5 minutes
const job = new CronJob("*/5 * * * *", $healthcheckNotify);
job.start();
return job;
};
return {
listGateways,
registerGateway,
@@ -885,6 +969,7 @@ export const gatewayV2ServiceFactory = ({
getPAMConnectionDetails,
deleteGatewayById,
heartbeat,
getPamSessionKey
getPamSessionKey,
initializeHealthcheckNotify
};
};
+39 -3
View File
@@ -1,11 +1,47 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
import { TableName, TRelays } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, TFindFilter, TFindOpt } from "@app/lib/knex";
export type TRelayDALFactory = ReturnType<typeof relayDalFactory>;
export const relayDalFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.Relay);
return orm;
const find = async (
filter: TFindFilter<TRelays> & { isHeartbeatStale?: boolean },
{ offset, limit, sort, tx }: TFindOpt<TRelays> = {}
) => {
try {
const { isHeartbeatStale, ...regularFilter } = filter;
const query = (tx || db.replicaNode())(TableName.Relay)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(regularFilter, TableName.Relay));
if (isHeartbeatStale) {
const oneHourAgo = new Date();
oneHourAgo.setHours(oneHourAgo.getHours() - 1);
void query.where(`${TableName.Relay}.heartbeat`, "<", oneHourAgo);
void query.where((v) => {
void v
.whereNull(`${TableName.Relay}.healthAlertedAt`)
.orWhere(`${TableName.Relay}.healthAlertedAt`, "<", db.ref("heartbeat").withSchema(TableName.Relay));
});
}
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
}
const docs = await query;
return docs;
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.Relay}: Find` });
}
};
return { ...orm, find };
};
+114 -3
View File
@@ -2,12 +2,14 @@ import { isIP } from "node:net";
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { CronJob } from "cron";
import { TRelays } from "@app/db/schemas";
import { OrgMembershipRole, TRelays } from "@app/db/schemas";
import { PgSqlLock } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { createRelayConnection } from "@app/lib/gateway-v2/gateway-v2";
import { logger } from "@app/lib/logger";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
@@ -17,6 +19,11 @@ import {
} from "@app/services/certificate-authority/certificate-authority-fns";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { NotificationType } from "@app/services/notification/notification-types";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { verifyHostInputValidity } from "../dynamic-secret/dynamic-secret-fns";
import { TLicenseServiceFactory } from "../license/license-service";
@@ -40,7 +47,11 @@ export const relayServiceFactory = ({
relayDAL,
kmsService,
licenseService,
permissionService
permissionService,
orgDAL,
notificationService,
smtpService,
userDAL
}: {
instanceRelayConfigDAL: TInstanceRelayConfigDALFactory;
orgRelayConfigDAL: TOrgRelayConfigDALFactory;
@@ -48,6 +59,10 @@ export const relayServiceFactory = ({
kmsService: TKmsServiceFactory;
licenseService: TLicenseServiceFactory;
permissionService: TPermissionServiceFactory;
orgDAL: Pick<TOrgDALFactory, "findOrgMembersByRole">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
smtpService: Pick<TSmtpService, "sendMail">;
userDAL: Pick<TUserDALFactory, "find">;
}) => {
const $getInstanceCAs = async () => {
const instanceConfig = await instanceRelayConfigDAL.transaction(async (tx) => {
@@ -1193,12 +1208,108 @@ export const relayServiceFactory = ({
return deletedRelay;
};
const $healthcheckNotify = async () => {
const oneHourAgo = new Date();
oneHourAgo.setHours(oneHourAgo.getHours() - 1);
const unhealthyRelays = await relayDAL.find({
isHeartbeatStale: true
});
if (unhealthyRelays.length === 0) return;
logger.warn(
{ relayIds: unhealthyRelays.map((g) => g.id) },
"Found relays with last heartbeat over an hour ago. Sending notifications."
);
await Promise.all(unhealthyRelays.map((r) => relayDAL.updateById(r.id, { healthAlertedAt: new Date() })));
const relaysByOrg = unhealthyRelays.reduce<Record<string, TRelays[]>>((acc, r) => {
const key = r.orgId ?? "instance";
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(r);
return acc;
}, {});
for await (const [orgId, relays] of Object.entries(relaysByOrg)) {
try {
if (orgId === "instance") {
const superAdmins = await userDAL.find({
superAdmin: true
});
const recipients = superAdmins.map((admin) => admin.email).filter((v): v is string => !!v);
if (recipients.length > 0) {
const relayNames = relays.map((r) => `"${r.name}"`).join(", ");
await smtpService.sendMail({
recipients,
subjectLine: "Relay Health Alert",
substitutions: {
type: "relay",
names: relayNames
},
template: SmtpTemplates.HealthAlert
});
}
} else {
const admins = await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin);
if (admins.length === 0) {
// eslint-disable-next-line no-continue
continue;
}
const relayNames = relays.map((r) => `"${r.name}"`).join(", ");
const body = `The following relay(s) in your organization may be offline as they haven't reported a heartbeat in over an hour: ${relayNames}. Please check their status.`;
await notificationService.createUserNotifications(
admins.map((admin) => ({
userId: admin.user.id,
orgId,
type: NotificationType.RELAY_HEALTH_ALERT,
title: "Relay Health Alert",
body,
link: "/organization/networking"
}))
);
await smtpService.sendMail({
recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v),
subjectLine: "Relay Health Alert",
substitutions: {
type: "relay",
names: relayNames
},
template: SmtpTemplates.HealthAlert
});
}
} catch (error) {
logger.error(error, `Failed to send relay health notifications for organization [orgId=${orgId}]`);
}
}
};
const initializeHealthcheckNotify = async () => {
logger.info("Setting up background notification process for relay health-checks");
await $healthcheckNotify();
// run every 5 minutes
const job = new CronJob("*/5 * * * *", $healthcheckNotify);
job.start();
return job;
};
return {
registerRelay,
getCredentialsForGateway,
getCredentialsForClient,
getRelays,
deleteRelay,
heartbeat
heartbeat,
initializeHealthcheckNotify
};
};