mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(relays): healthcheck
This commit is contained in:
19
backend/src/db/migrations/20251008003912_relay-heartbeat.ts
Normal file
19
backend/src/db/migrations/20251008003912_relay-heartbeat.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ interface IGatewayRelayServer {
|
||||
getRelayError: () => string;
|
||||
}
|
||||
|
||||
const createRelayConnection = async ({
|
||||
export const createRelayConnection = async ({
|
||||
relayHost,
|
||||
clientCertificate,
|
||||
clientPrivateKey,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ export type TRelay = {
|
||||
identityId: string | null;
|
||||
name: string;
|
||||
host: string;
|
||||
heartbeat: string;
|
||||
};
|
||||
|
||||
export type TDeleteRelayDTO = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user