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())} |
+
+
+ |
|