From 12b59fc2c0b7006098ac545f695a0e47910366cc Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 7 Oct 2025 21:17:25 -0400 Subject: [PATCH 1/6] feat(relays): healthcheck --- .../20251008003912_relay-heartbeat.ts | 19 +++++ backend/src/db/schemas/relays.ts | 3 +- backend/src/ee/routes/v1/relay-router.ts | 71 +++++++++++++++++ .../src/ee/services/relay/relay-service.ts | 78 ++++++++++++++++++- backend/src/lib/gateway-v2/gateway-v2.ts | 2 +- .../SecretSyncReviewFields.tsx | 5 +- frontend/src/hooks/api/relays/types.ts | 1 + .../components/GatewayTab/GatewayTab.tsx | 2 +- .../components/RelayTab/RelayTab.tsx | 16 ++++ 9 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 backend/src/db/migrations/20251008003912_relay-heartbeat.ts diff --git a/backend/src/db/migrations/20251008003912_relay-heartbeat.ts b/backend/src/db/migrations/20251008003912_relay-heartbeat.ts new file mode 100644 index 000000000..f19c540cb --- /dev/null +++ b/backend/src/db/migrations/20251008003912_relay-heartbeat.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Relay, "heartbeat"))) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.datetime("heartbeat"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Relay, "heartbeat")) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.dropColumn("heartbeat"); + }); + } +} diff --git a/backend/src/db/schemas/relays.ts b/backend/src/db/schemas/relays.ts index 4bb615e96..476e537c8 100644 --- a/backend/src/db/schemas/relays.ts +++ b/backend/src/db/schemas/relays.ts @@ -14,7 +14,8 @@ export const RelaysSchema = z.object({ orgId: z.string().uuid().nullable().optional(), identityId: z.string().uuid().nullable().optional(), name: z.string(), - host: z.string() + host: z.string(), + heartbeat: z.date().nullable().optional() }); export type TRelays = z.infer; diff --git a/backend/src/ee/routes/v1/relay-router.ts b/backend/src/ee/routes/v1/relay-router.ts index f3d006b10..5db5c0323 100644 --- a/backend/src/ee/routes/v1/relay-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -146,4 +146,75 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { }); } }); + + server.route({ + method: "POST", + url: "/heartbeat-instance-relay", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: slugSchema({ min: 1, max: 32, field: "name" }) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: (req, _, next) => { + const authHeader = req.headers.authorization; + + if (appCfg.RELAY_AUTH_SECRET && authHeader) { + const expectedHeader = `Bearer ${appCfg.RELAY_AUTH_SECRET}`; + if ( + authHeader.length === expectedHeader.length && + crypto.nativeCrypto.timingSafeEqual(Buffer.from(authHeader), Buffer.from(expectedHeader)) + ) { + return next(); + } + } + + throw new UnauthorizedError({ + message: "Invalid relay auth secret" + }); + }, + handler: async (req) => { + await server.services.relay.heartbeat({ + name: req.body.name + }); + + return { message: "Successfully triggered heartbeat" }; + } + }); + + server.route({ + method: "POST", + url: "/heartbeat-org-relay", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: slugSchema({ min: 1, max: 32, field: "name" }) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + await server.services.relay.heartbeat({ + name: req.body.name, + identityId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { message: "Successfully triggered heartbeat" }; + } + }); }; diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 696bd1f4f..43e7cd1de 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -6,7 +6,8 @@ import * as x509 from "@peculiar/x509"; import { TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { createRelayConnection } from "@app/lib/gateway-v2/gateway-v2"; 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"; @@ -1056,6 +1057,78 @@ export const relayServiceFactory = ({ }); }; + const heartbeat = async ({ + name, + identityId, + actorAuthMethod, + orgId + }: { + name: string; + identityId?: string; + actorAuthMethod?: ActorAuthMethod; + orgId?: string; + }) => { + const relay = await relayDAL.findOne({ + name, + orgId: orgId ?? null + }); + + if (!relay) { + throw new NotFoundError({ message: `Relay with name ${name} not found.` }); + } + + let clientOrgId: string; + let clientOrgName: string; + + if (relay.orgId) { + if (!identityId || !orgId || relay.orgId !== orgId) { + throw new ForbiddenRequestError({ + message: "You do not have permission to perform this action on this relay." + }); + } + + const { permission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityId, + orgId, + actorAuthMethod!, + orgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionRelayActions.CreateRelays, + OrgPermissionSubjects.Relay + ); + clientOrgId = orgId; + clientOrgName = orgId; + } else { + clientOrgId = "00000000-0000-0000-0000-000000000000"; + clientOrgName = "heartbeat"; + } + + const relayClientCredentials = await getCredentialsForClient({ + relayId: relay.id, + orgId: clientOrgId, + orgName: clientOrgName, + gatewayId: "00000000-0000-0000-0000-000000000000", + gatewayName: "heartbeat", + duration: 60 * 1000 // 1 minute + }); + + try { + await createRelayConnection({ + relayHost: relayClientCredentials.relayHost, + clientCertificate: relayClientCredentials.clientCertificate, + clientPrivateKey: relayClientCredentials.clientPrivateKey, + serverCertificateChain: relayClientCredentials.serverCertificateChain + }); + + await relayDAL.updateById(relay.id, { heartbeat: new Date() }); + } catch (err) { + const error = err as Error; + throw new BadRequestError({ message: `Relay ${name} is not reachable: ${error.message}` }); + } + }; + const getRelays = async ({ actorId, actor, @@ -1125,6 +1198,7 @@ export const relayServiceFactory = ({ getCredentialsForGateway, getCredentialsForClient, getRelays, - deleteRelay + deleteRelay, + heartbeat }; }; diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index e6e873f11..5ae0e5b1d 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -18,7 +18,7 @@ interface IGatewayRelayServer { getRelayError: () => string; } -const createRelayConnection = async ({ +export const createRelayConnection = async ({ relayHost, clientCertificate, clientPrivateKey, diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 84801b000..0969e446d 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -199,7 +199,10 @@ export const SecretSyncReviewFields = () => {

{duplicateProjectId && (

- Duplicate found in project ID: {duplicateProjectId} + Duplicate found in project ID:{" "} + + {duplicateProjectId} +

)} diff --git a/frontend/src/hooks/api/relays/types.ts b/frontend/src/hooks/api/relays/types.ts index 621fd52db..1d40cb9f0 100644 --- a/frontend/src/hooks/api/relays/types.ts +++ b/frontend/src/hooks/api/relays/types.ts @@ -6,6 +6,7 @@ export type TRelay = { identityId: string | null; name: string; host: string; + heartbeat: string; }; export type TDeleteRelayDTO = { diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx index 0e3069aee..152222b29 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx @@ -48,7 +48,7 @@ import { useDeleteGatewayV2ById } from "@app/hooks/api/gateways-v2"; import { EditGatewayDetailsModal } from "./components/EditGatewayDetailsModal"; -const GatewayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => { +export const GatewayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => { const heartbeatDate = heartbeat ? new Date(heartbeat) : null; const now = new Date(); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx index 4eda269d5..51accf6b6 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx @@ -5,6 +5,7 @@ import { faCopy, faDoorClosed, faEllipsisV, + faInfoCircle, faMagnifyingGlass, faSearch, faTrash @@ -41,6 +42,8 @@ import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { useDeleteRelayById, useGetRelays } from "@app/hooks/api/relays"; +import { GatewayHealthStatus } from "../GatewayTab/GatewayTab"; + export const RelayTab = withPermission( () => { const [search, setSearch] = useState(""); @@ -106,6 +109,16 @@ export const RelayTab = withPermission( Name Host Created + + Health Check + + + + @@ -129,6 +142,9 @@ export const RelayTab = withPermission( {el.host} {formatRelative(new Date(el.createdAt), new Date())} + + + From c6696d4fdec6cdbbe7e38abc80404c3889138907 Mon Sep 17 00:00:00 2001 From: x032205 Date: Wed, 8 Oct 2025 23:46:47 -0400 Subject: [PATCH 2/6] alerting for gateways and relays --- ...251008220303_relay-gateway-health-alarm.ts | 29 +++++ backend/src/db/schemas/gateways-v2.ts | 3 +- backend/src/db/schemas/relays.ts | 3 +- .../ee/services/gateway-v2/gateway-v2-dal.ts | 24 +++- .../services/gateway-v2/gateway-v2-service.ts | 91 +++++++++++++- backend/src/ee/services/relay/relay-dal.ts | 42 ++++++- .../src/ee/services/relay/relay-service.ts | 117 +++++++++++++++++- backend/src/server/routes/index.ts | 21 +++- .../notification/notification-types.ts | 4 +- .../smtp/emails/HealthAlertTemplate.tsx | 41 ++++++ backend/src/services/smtp/emails/index.ts | 1 + backend/src/services/smtp/smtp-service.ts | 7 +- 12 files changed, 365 insertions(+), 18 deletions(-) create mode 100644 backend/src/db/migrations/20251008220303_relay-gateway-health-alarm.ts create mode 100644 backend/src/services/smtp/emails/HealthAlertTemplate.tsx diff --git a/backend/src/db/migrations/20251008220303_relay-gateway-health-alarm.ts b/backend/src/db/migrations/20251008220303_relay-gateway-health-alarm.ts new file mode 100644 index 000000000..2abc1cf69 --- /dev/null +++ b/backend/src/db/migrations/20251008220303_relay-gateway-health-alarm.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Relay, "healthAlertedAt"))) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.datetime("healthAlertedAt"); + }); + } + if (!(await knex.schema.hasColumn(TableName.GatewayV2, "healthAlertedAt"))) { + await knex.schema.alterTable(TableName.GatewayV2, (t) => { + t.datetime("healthAlertedAt"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.GatewayV2, "healthAlertedAt")) { + await knex.schema.alterTable(TableName.GatewayV2, (t) => { + t.dropColumn("healthAlertedAt"); + }); + } + if (await knex.schema.hasColumn(TableName.Relay, "healthAlertedAt")) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.dropColumn("healthAlertedAt"); + }); + } +} diff --git a/backend/src/db/schemas/gateways-v2.ts b/backend/src/db/schemas/gateways-v2.ts index 1362793f6..4a48e3ce5 100644 --- a/backend/src/db/schemas/gateways-v2.ts +++ b/backend/src/db/schemas/gateways-v2.ts @@ -18,7 +18,8 @@ export const GatewaysV2Schema = z.object({ relayId: z.string().uuid().nullable().optional(), name: z.string(), heartbeat: z.date().nullable().optional(), - encryptedPamSessionKey: zodBuffer.nullable().optional() + encryptedPamSessionKey: zodBuffer.nullable().optional(), + healthAlertedAt: z.date().nullable().optional() }); export type TGatewaysV2 = z.infer; diff --git a/backend/src/db/schemas/relays.ts b/backend/src/db/schemas/relays.ts index 476e537c8..82a5fa830 100644 --- a/backend/src/db/schemas/relays.ts +++ b/backend/src/db/schemas/relays.ts @@ -15,7 +15,8 @@ export const RelaysSchema = z.object({ identityId: z.string().uuid().nullable().optional(), name: z.string(), host: z.string(), - heartbeat: z.date().nullable().optional() + heartbeat: z.date().nullable().optional(), + healthAlertedAt: z.date().nullable().optional() }); export type TRelays = z.infer; diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts index da9d3c1ef..36feb3809 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts @@ -10,11 +10,16 @@ export type TGatewayV2DALFactory = ReturnType; export const gatewayV2DalFactory = (db: TDbClient) => { const orm = ormify(db, TableName.GatewayV2); - const find = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + const find = async ( + filter: TFindFilter & { isHeartbeatStale?: boolean }, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { 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) { diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index 4bf6b1aef..128f9a651 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -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; + notificationService: Pick; + smtpService: Pick; }; export type TGatewayV2ServiceFactory = ReturnType; @@ -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>((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 }; }; diff --git a/backend/src/ee/services/relay/relay-dal.ts b/backend/src/ee/services/relay/relay-dal.ts index 9107e0807..cef5e643a 100644 --- a/backend/src/ee/services/relay/relay-dal.ts +++ b/backend/src/ee/services/relay/relay-dal.ts @@ -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; export const relayDalFactory = (db: TDbClient) => { const orm = ormify(db, TableName.Relay); - return orm; + const find = async ( + filter: TFindFilter & { isHeartbeatStale?: boolean }, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { + 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 }; }; diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 43e7cd1de..661a3d304 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -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; + notificationService: Pick; + smtpService: Pick; + userDAL: Pick; }) => { 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>((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 }; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 406daa63d..d16ee4034 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1121,7 +1121,11 @@ export const registerRoutes = async ( relayDAL, kmsService, licenseService, - permissionService + permissionService, + orgDAL, + notificationService, + smtpService, + userDAL }); const gatewayV2Service = gatewayV2ServiceFactory({ @@ -1131,7 +1135,10 @@ export const registerRoutes = async ( orgGatewayConfigV2DAL, gatewayV2DAL, relayDAL, - permissionService + permissionService, + orgDAL, + notificationService, + smtpService }); const secretSyncQueue = secretSyncQueueFactory({ @@ -2330,6 +2337,16 @@ export const registerRoutes = async ( cronJobs.push(configSyncJob); } + const gatewayHealthcheckNotifyJob = await gatewayV2Service.initializeHealthcheckNotify(); + if (gatewayHealthcheckNotifyJob) { + cronJobs.push(gatewayHealthcheckNotifyJob); + } + + const relayHealthcheckNotifyJob = await relayService.initializeHealthcheckNotify(); + if (relayHealthcheckNotifyJob) { + cronJobs.push(relayHealthcheckNotifyJob); + } + const oauthConfigSyncJob = await initializeOauthConfigSync(); if (oauthConfigSyncJob) { cronJobs.push(oauthConfigSyncJob); diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts index a3657c680..84cf35a50 100644 --- a/backend/src/services/notification/notification-types.ts +++ b/backend/src/services/notification/notification-types.ts @@ -15,7 +15,9 @@ export enum NotificationType { DIRECT_PROJECT_ACCESS_ISSUED_TO_ADMIN = "direct-project-access-issued-to-admin", PROJECT_ACCESS_REQUEST = "project-access-request", PROJECT_INVITATION = "project-invitation", - SECRET_SYNC_FAILED = "secret-sync-failed" + SECRET_SYNC_FAILED = "secret-sync-failed", + GATEWAY_HEALTH_ALERT = "gateway-health-alert", + RELAY_HEALTH_ALERT = "relay-health-alert" } export interface TCreateUserNotificationDTO { diff --git a/backend/src/services/smtp/emails/HealthAlertTemplate.tsx b/backend/src/services/smtp/emails/HealthAlertTemplate.tsx new file mode 100644 index 000000000..9b9a1f591 --- /dev/null +++ b/backend/src/services/smtp/emails/HealthAlertTemplate.tsx @@ -0,0 +1,41 @@ +import { Heading, Section, Text } from "@react-email/components"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; + +interface HealthAlertTemplateProps extends Omit { + type: "gateway" | "relay"; + names: string; +} + +export const HealthAlertTemplate = ({ siteUrl, names, type }: HealthAlertTemplateProps) => { + return ( + + + {type === "gateway" ? "Gateway" : "Relay"} Health Alert + +
+ + The following {type}(s) in your organization may be offline as they haven't reported a + heartbeat in over an hour: {names}. + + + If your issue persists, you can contact the Infisical team at{" "} + support@infisical.com. + +
+
+ ); +}; + +export default HealthAlertTemplate; + +HealthAlertTemplate.PreviewProps = { + type: "gateway", + names: '"gateway1", "gateway2"', + siteUrl: "https://infisical.com" +} as HealthAlertTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index d7204085c..06ac31ab6 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -6,6 +6,7 @@ export * from "./EmailVerificationTemplate"; export * from "./ExternalImportFailedTemplate"; export * from "./ExternalImportStartedTemplate"; export * from "./ExternalImportSucceededTemplate"; +export * from "./HealthAlertTemplate"; export * from "./IntegrationSyncFailedTemplate"; export * from "./NewDeviceLoginTemplate"; export * from "./OAuthPasswordResetTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 63645cf67..652f56567 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -15,6 +15,7 @@ import { ExternalImportFailedTemplate, ExternalImportStartedTemplate, ExternalImportSucceededTemplate, + HealthAlertTemplate, IntegrationSyncFailedTemplate, NewDeviceLoginTemplate, OAuthPasswordResetTemplate, @@ -85,7 +86,8 @@ export enum SmtpTemplates { ServiceTokenExpired = "serviceTokenExpired", SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected", - AccountDeletionConfirmation = "accountDeletionConfirmation" + AccountDeletionConfirmation = "accountDeletionConfirmation", + HealthAlert = "healthAlert" } export enum SmtpHost { @@ -131,7 +133,8 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate, [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate, - [SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate + [SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate, + [SmtpTemplates.HealthAlert]: HealthAlertTemplate }; export const smtpServiceFactory = (cfg: TSmtpConfig) => { From e752391dbfe53aacd3132bd399efd1c654f2ef2e Mon Sep 17 00:00:00 2001 From: x032205 Date: Wed, 8 Oct 2025 23:58:38 -0400 Subject: [PATCH 3/6] greptile review fixes --- backend/src/ee/routes/v1/relay-router.ts | 26 +++++++++++++------ .../ee/services/gateway-v2/gateway-v2-dal.ts | 3 +-- .../services/gateway-v2/gateway-v2-service.ts | 4 +-- backend/src/ee/services/relay/relay-dal.ts | 3 +-- .../src/ee/services/relay/relay-service.ts | 6 ++--- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/backend/src/ee/routes/v1/relay-router.ts b/backend/src/ee/routes/v1/relay-router.ts index 5db5c0323..766025c21 100644 --- a/backend/src/ee/routes/v1/relay-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -166,14 +166,24 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { onRequest: (req, _, next) => { const authHeader = req.headers.authorization; - if (appCfg.RELAY_AUTH_SECRET && authHeader) { - const expectedHeader = `Bearer ${appCfg.RELAY_AUTH_SECRET}`; - if ( - authHeader.length === expectedHeader.length && - crypto.nativeCrypto.timingSafeEqual(Buffer.from(authHeader), Buffer.from(expectedHeader)) - ) { - return next(); - } + if (!appCfg.RELAY_AUTH_SECRET) { + throw new UnauthorizedError({ + message: "Relay authentication not configured" + }); + } + + if (!authHeader) { + throw new UnauthorizedError({ + message: "Missing authorization header" + }); + } + + const expectedHeader = `Bearer ${appCfg.RELAY_AUTH_SECRET}`; + if ( + authHeader.length === expectedHeader.length && + crypto.nativeCrypto.timingSafeEqual(Buffer.from(authHeader), Buffer.from(expectedHeader)) + ) { + return next(); } throw new UnauthorizedError({ diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts index 36feb3809..4bb4f58ba 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts @@ -30,8 +30,7 @@ export const gatewayV2DalFactory = (db: TDbClient) => { .select(db.ref("name").withSchema(TableName.Identity).as("identityName")); if (isHeartbeatStale) { - const oneHourAgo = new Date(); - oneHourAgo.setHours(oneHourAgo.getHours() - 1); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); void query.where(`${TableName.GatewayV2}.heartbeat`, "<", oneHourAgo); void query.where((v) => { void v diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index 128f9a651..78a62bcb8 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -902,8 +902,6 @@ export const gatewayV2ServiceFactory = ({ "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>((acc, gw) => { if (!acc[gw.orgId]) { acc[gw.orgId] = []; @@ -944,6 +942,8 @@ export const gatewayV2ServiceFactory = ({ }, template: SmtpTemplates.HealthAlert }); + + await Promise.all(gateways.map((gw) => gatewayV2DAL.updateById(gw.id, { healthAlertedAt: new Date() }))); } catch (error) { logger.error(error, `Failed to send gateway health notifications for organization [orgId=${orgId}]`); } diff --git a/backend/src/ee/services/relay/relay-dal.ts b/backend/src/ee/services/relay/relay-dal.ts index cef5e643a..687b2d0e8 100644 --- a/backend/src/ee/services/relay/relay-dal.ts +++ b/backend/src/ee/services/relay/relay-dal.ts @@ -20,8 +20,7 @@ export const relayDalFactory = (db: TDbClient) => { .where(buildFindFilter(regularFilter, TableName.Relay)); if (isHeartbeatStale) { - const oneHourAgo = new Date(); - oneHourAgo.setHours(oneHourAgo.getHours() - 1); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); void query.where(`${TableName.Relay}.heartbeat`, "<", oneHourAgo); void query.where((v) => { void v diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 661a3d304..6b56908f5 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -1209,8 +1209,6 @@ export const relayServiceFactory = ({ }; const $healthcheckNotify = async () => { - const oneHourAgo = new Date(); - oneHourAgo.setHours(oneHourAgo.getHours() - 1); const unhealthyRelays = await relayDAL.find({ isHeartbeatStale: true }); @@ -1222,8 +1220,6 @@ export const relayServiceFactory = ({ "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>((acc, r) => { const key = r.orgId ?? "instance"; if (!acc[key]) { @@ -1285,6 +1281,8 @@ export const relayServiceFactory = ({ template: SmtpTemplates.HealthAlert }); } + + await Promise.all(relays.map((r) => relayDAL.updateById(r.id, { healthAlertedAt: new Date() }))); } catch (error) { logger.error(error, `Failed to send relay health notifications for organization [orgId=${orgId}]`); } From 2f01d096c6eba6c61d64ddf425ccffbc802f3ba1 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 10 Oct 2025 14:37:52 -0400 Subject: [PATCH 4/6] review fixes --- .../ee/services/gateway-v2/gateway-v2-dal.ts | 1 + .../services/gateway-v2/gateway-v2-service.ts | 9 ++------ backend/src/ee/services/relay/relay-dal.ts | 1 + .../src/ee/services/relay/relay-service.ts | 12 +++-------- .../smtp/emails/HealthAlertTemplate.tsx | 12 ++++++++--- .../components/GatewayTab/GatewayTab.tsx | 2 +- .../components/RelayTab/RelayTab.tsx | 21 +++++++++++++++++-- 7 files changed, 36 insertions(+), 22 deletions(-) diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts index 4bb4f58ba..e19dbe394 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts @@ -31,6 +31,7 @@ export const gatewayV2DalFactory = (db: TDbClient) => { if (isHeartbeatStale) { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + void query.whereNotNull(`${TableName.GatewayV2}.heartbeat`); void query.where(`${TableName.GatewayV2}.heartbeat`, "<", oneHourAgo); void query.where((v) => { void v diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index 78a62bcb8..dce5bc299 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -9,6 +9,7 @@ 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 { groupBy } from "@app/lib/fn"; import { GatewayProxyProtocol } from "@app/lib/gateway/types"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { logger } from "@app/lib/logger"; @@ -902,13 +903,7 @@ export const gatewayV2ServiceFactory = ({ "Found gateways with last heartbeat over an hour ago. Sending notifications." ); - const gatewaysByOrg = unhealthyGateways.reduce>((acc, gw) => { - if (!acc[gw.orgId]) { - acc[gw.orgId] = []; - } - acc[gw.orgId].push(gw); - return acc; - }, {}); + const gatewaysByOrg = groupBy(unhealthyGateways, (gw) => gw.orgId); for await (const [orgId, gateways] of Object.entries(gatewaysByOrg)) { try { diff --git a/backend/src/ee/services/relay/relay-dal.ts b/backend/src/ee/services/relay/relay-dal.ts index 687b2d0e8..8e7eac8de 100644 --- a/backend/src/ee/services/relay/relay-dal.ts +++ b/backend/src/ee/services/relay/relay-dal.ts @@ -21,6 +21,7 @@ export const relayDalFactory = (db: TDbClient) => { if (isHeartbeatStale) { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + void query.whereNotNull(`${TableName.Relay}.heartbeat`); void query.where(`${TableName.Relay}.heartbeat`, "<", oneHourAgo); void query.where((v) => { void v diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 6b56908f5..41fe91bfc 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -8,6 +8,7 @@ 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 { groupBy } from "@app/lib/fn"; import { createRelayConnection } from "@app/lib/gateway-v2/gateway-v2"; import { logger } from "@app/lib/logger"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; @@ -1220,14 +1221,7 @@ export const relayServiceFactory = ({ "Found relays with last heartbeat over an hour ago. Sending notifications." ); - const relaysByOrg = unhealthyRelays.reduce>((acc, r) => { - const key = r.orgId ?? "instance"; - if (!acc[key]) { - acc[key] = []; - } - acc[key].push(r); - return acc; - }, {}); + const relaysByOrg = groupBy(unhealthyRelays, (r) => r.orgId ?? "instance"); for await (const [orgId, relays] of Object.entries(relaysByOrg)) { try { @@ -1244,7 +1238,7 @@ export const relayServiceFactory = ({ recipients, subjectLine: "Relay Health Alert", substitutions: { - type: "relay", + type: "instance-relay", names: relayNames }, template: SmtpTemplates.HealthAlert diff --git a/backend/src/services/smtp/emails/HealthAlertTemplate.tsx b/backend/src/services/smtp/emails/HealthAlertTemplate.tsx index 9b9a1f591..cc90ae3cb 100644 --- a/backend/src/services/smtp/emails/HealthAlertTemplate.tsx +++ b/backend/src/services/smtp/emails/HealthAlertTemplate.tsx @@ -4,7 +4,7 @@ import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; import { BaseLink } from "./BaseLink"; interface HealthAlertTemplateProps extends Omit { - type: "gateway" | "relay"; + type: "gateway" | "relay" | "instance-relay"; names: string; } @@ -24,8 +24,14 @@ export const HealthAlertTemplate = ({ siteUrl, names, type }: HealthAlertTemplat heartbeat in over an hour: {names}. - If your issue persists, you can contact the Infisical team at{" "} - support@infisical.com. + {type === "instance-relay" && ( + <> + If your issue persists, you can contact the Infisical team at{" "} + support@infisical.com. + + )} + {type === "relay" && <>Please contact your relay administrators.} + {type === "gateway" && <>Please contact your gateway administrators.} diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx index 152222b29..0e3069aee 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/GatewayTab.tsx @@ -48,7 +48,7 @@ import { useDeleteGatewayV2ById } from "@app/hooks/api/gateways-v2"; import { EditGatewayDetailsModal } from "./components/EditGatewayDetailsModal"; -export const GatewayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => { +const GatewayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => { const heartbeatDate = heartbeat ? new Date(heartbeat) : null; const now = new Date(); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx index 51accf6b6..24992a58e 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx @@ -42,7 +42,24 @@ import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { useDeleteRelayById, useGetRelays } from "@app/hooks/api/relays"; -import { GatewayHealthStatus } from "../GatewayTab/GatewayTab"; +const RelayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => { + const heartbeatDate = heartbeat ? new Date(heartbeat) : null; + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + + const isHealthy = heartbeatDate && heartbeatDate >= oneHourAgo; + const tooltipContent = heartbeatDate + ? `Last heartbeat: ${heartbeatDate.toLocaleString()}` + : "No heartbeat data available"; + + return ( + + + {isHealthy ? "Healthy" : "Unreachable"} + + + ); +}; export const RelayTab = withPermission( () => { @@ -143,7 +160,7 @@ export const RelayTab = withPermission( {el.host} {formatRelative(new Date(el.createdAt), new Date())} - + From 425a883662c3d16dbb913ebeea3a3c3f219dcc22 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 10 Oct 2025 14:50:16 -0400 Subject: [PATCH 5/6] small review fixes --- backend/src/ee/services/gateway-v2/gateway-v2-dal.ts | 1 - backend/src/services/smtp/emails/HealthAlertTemplate.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts index 36dd45b17..1896192cd 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts @@ -26,7 +26,6 @@ export const gatewayV2DalFactory = (db: TDbClient) => { if (isHeartbeatStale) { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); - void query.whereNotNull(`${TableName.GatewayV2}.heartbeat`); void query.where(`${TableName.GatewayV2}.heartbeat`, "<", oneHourAgo); void query.where((v) => { void v diff --git a/backend/src/services/smtp/emails/HealthAlertTemplate.tsx b/backend/src/services/smtp/emails/HealthAlertTemplate.tsx index cc90ae3cb..46b919f11 100644 --- a/backend/src/services/smtp/emails/HealthAlertTemplate.tsx +++ b/backend/src/services/smtp/emails/HealthAlertTemplate.tsx @@ -26,7 +26,7 @@ export const HealthAlertTemplate = ({ siteUrl, names, type }: HealthAlertTemplat {type === "instance-relay" && ( <> - If your issue persists, you can contact the Infisical team at{" "} + If the issue persists, you can contact the Infisical team at{" "} support@infisical.com. )} From bfbd1d1f914e3d8a3b9417e9fd7ec51c3ec153aa Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 10 Oct 2025 14:57:09 -0400 Subject: [PATCH 6/6] show relays as healthy if heartbeat is null --- .../NetworkingPage/components/RelayTab/RelayTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx index 46356a312..e525b765d 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx @@ -47,7 +47,7 @@ const RelayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => { const now = new Date(); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); - const isHealthy = heartbeatDate && heartbeatDate >= oneHourAgo; + const isHealthy = !heartbeatDate || heartbeatDate >= oneHourAgo; const tooltipContent = heartbeatDate ? `Last heartbeat: ${heartbeatDate.toLocaleString()}` : "No heartbeat data available";