feat(relays): healthcheck

This commit is contained in:
x032205
2025-10-07 21:17:25 -04:00
parent c1833b5cbd
commit 12b59fc2c0
9 changed files with 191 additions and 6 deletions

View File

@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
if (await knex.schema.hasColumn(TableName.Relay, "heartbeat")) {
await knex.schema.alterTable(TableName.Relay, (t) => {
t.dropColumn("heartbeat");
});
}
}

View File

@@ -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<typeof RelaysSchema>;

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ interface IGatewayRelayServer {
getRelayError: () => string;
}
const createRelayConnection = async ({
export const createRelayConnection = async ({
relayHost,
clientCertificate,
clientPrivateKey,

View File

@@ -199,7 +199,10 @@ export const SecretSyncReviewFields = () => {
</p>
{duplicateProjectId && (
<p className="mt-1 text-xs text-yellow-200">
Duplicate found in project ID: <code className="rounded bg-yellow-800/50 px-1 py-0.5">{duplicateProjectId}</code>
Duplicate found in project ID:{" "}
<code className="rounded bg-yellow-800/50 px-1 py-0.5">
{duplicateProjectId}
</code>
</p>
)}
</div>

View File

@@ -6,6 +6,7 @@ export type TRelay = {
identityId: string | null;
name: string;
host: string;
heartbeat: string;
};
export type TDeleteRelayDTO = {

View File

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

View File

@@ -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(
<Th className="w-1/3">Name</Th>
<Th>Host</Th>
<Th>Created</Th>
<Th>
Health Check
<Tooltip
asChild={false}
className="normal-case"
content="The last known healthcheck. Triggers every 1 hour."
>
<FontAwesomeIcon icon={faInfoCircle} className="ml-2" />
</Tooltip>
</Th>
<Th className="w-5" />
</Tr>
</THead>
@@ -129,6 +142,9 @@ export const RelayTab = withPermission(
</Td>
<Td>{el.host}</Td>
<Td>{formatRelative(new Date(el.createdAt), new Date())}</Td>
<Td>
<GatewayHealthStatus heartbeat={el.heartbeat} />
</Td>
<Td className="w-5">
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>