From f1b6cd9974cc1f33c7ec50303545c85ffd21ae14 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 17 Jul 2025 23:46:16 -0300 Subject: [PATCH 01/46] Add card declined alert --- frontend/src/hooks/api/subscriptions/types.ts | 1 + .../components/NavBar/Navbar.tsx | 65 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 87a02231f..0893d3bd1 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -53,4 +53,5 @@ export type SubscriptionPlan = { secretScanning: boolean; enterpriseSecretSyncs: boolean; enterpriseAppConnections: boolean; + cardDeclined?: boolean; }; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index a46578726..8af8a24da 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -8,6 +8,7 @@ import { faCaretDown, faCheck, faEnvelope, + faExclamationTriangle, faInfo, faInfoCircle, faSignOut, @@ -109,6 +110,9 @@ export const Navbar = () => { const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const [showAdminsModal, setShowAdminsModal] = useState(false); + const [showCardDeclinedModal, setShowCardDeclinedModal] = useState( + subscription?.cardDeclined || false + ); const { data: orgs } = useGetOrganizations(); const navigate = useNavigate(); @@ -195,8 +199,23 @@ export const Navbar = () => {
{currentOrg?.name}
-
- {getPlan(subscription)} +
+
+ {getPlan(subscription)} +
+ {subscription.cardDeclined && ( + +
+ +
+
+ )}
@@ -394,6 +413,48 @@ export const Navbar = () => { + + + + Your payment method has been declined + + } + > +
+
+
+

+ Your payment method was declined and your subscription may be at risk. Please + update your payment information to continue using premium features. +

+
+
+
+ + + + +
+
+
+
+
+
From 8666f328e2f2b78b08222a3bf2e5b51c3a38b398 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Wed, 30 Jul 2025 11:58:35 -0300 Subject: [PATCH 02/46] Add declined payment reason and force refresh on billing page load --- backend/src/ee/routes/v1/license-router.ts | 9 +++++++- .../ee/services/license/license-service.ts | 13 ++++++++++- .../src/ee/services/license/license-types.ts | 1 + .../SubscriptionContext.tsx | 4 ++-- .../src/hooks/api/subscriptions/queries.tsx | 4 ++-- frontend/src/hooks/api/subscriptions/types.ts | 1 + .../components/NavBar/Navbar.tsx | 22 ++++++++++++------- .../BillingCloudTab/PreviewSection.tsx | 12 +++++++++- 8 files changed, 51 insertions(+), 15 deletions(-) diff --git a/backend/src/ee/routes/v1/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index 0a59fa7b5..17923975d 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -43,6 +43,12 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ organizationId: z.string().trim() }), + querystring: z.object({ + refreshCache: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), response: { 200: z.object({ plan: z.any() }) } @@ -54,7 +60,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - orgId: req.params.organizationId + orgId: req.params.organizationId, + refreshCache: req.query.refreshCache }); return { plan }; } diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 7e784d9ad..bb208ab2e 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -295,8 +295,19 @@ export const licenseServiceFactory = ({ return data; }; - const getOrgPlan = async ({ orgId, actor, actorId, actorOrgId, actorAuthMethod, projectId }: TOrgPlanDTO) => { + const getOrgPlan = async ({ + orgId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + refreshCache + }: TOrgPlanDTO) => { await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (refreshCache) { + await refreshPlan(orgId); + } const plan = await getPlan(orgId, projectId); return plan; }; diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index a3412f574..ea94f6172 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -84,6 +84,7 @@ export type TOrgPlansTableDTO = { export type TOrgPlanDTO = { projectId?: string; + refreshCache?: boolean; } & TOrgPermission; export type TStartOrgTrialDTO = { diff --git a/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx b/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx index de52e5f18..95da6bb01 100644 --- a/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx +++ b/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx @@ -3,7 +3,7 @@ import { useRouteContext } from "@tanstack/react-router"; import { fetchOrgSubscription, subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries"; -export const useSubscription = () => { +export const useSubscription = (refreshCache?: boolean) => { const organizationId = useRouteContext({ from: "/_authenticate/_inject-org-details", select: (el) => el.organizationId @@ -11,7 +11,7 @@ export const useSubscription = () => { const { data: subscription } = useSuspenseQuery({ queryKey: subscriptionQueryKeys.getOrgSubsription(organizationId), - queryFn: () => fetchOrgSubscription(organizationId), + queryFn: () => fetchOrgSubscription(organizationId, refreshCache), staleTime: Infinity }); diff --git a/frontend/src/hooks/api/subscriptions/queries.tsx b/frontend/src/hooks/api/subscriptions/queries.tsx index 99b1f4486..f545565eb 100644 --- a/frontend/src/hooks/api/subscriptions/queries.tsx +++ b/frontend/src/hooks/api/subscriptions/queries.tsx @@ -10,9 +10,9 @@ export const subscriptionQueryKeys = { getOrgSubsription: (orgID: string) => ["plan", { orgID }] as const }; -export const fetchOrgSubscription = async (orgID: string) => { +export const fetchOrgSubscription = async (orgID: string, refreshCache: boolean = false) => { const { data } = await apiRequest.get<{ plan: SubscriptionPlan }>( - `/api/v1/organizations/${orgID}/plan` + `/api/v1/organizations/${orgID}/plan${refreshCache ? "?refreshCache=true" : ""}` ); return data.plan; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 0893d3bd1..6042df67d 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -54,4 +54,5 @@ export type SubscriptionPlan = { enterpriseSecretSyncs: boolean; enterpriseAppConnections: boolean; cardDeclined?: boolean; + cardDeclinedReason?: string; }; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 8af8a24da..96da887b1 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; import { faCircleQuestion, faUserCircle } from "@fortawesome/free-regular-svg-icons"; import { @@ -110,9 +110,14 @@ export const Navbar = () => { const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const [showAdminsModal, setShowAdminsModal] = useState(false); - const [showCardDeclinedModal, setShowCardDeclinedModal] = useState( - subscription?.cardDeclined || false - ); + const [showCardDeclinedModal, setShowCardDeclinedModal] = useState(false); + + useEffect(() => { + if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) { + sessionStorage.setItem("paymentFailed", "true"); + setShowCardDeclinedModal(true); + } + }, [subscription]); const { data: orgs } = useGetOrganizations(); const navigate = useNavigate(); @@ -205,7 +210,7 @@ export const Navbar = () => {
{subscription.cardDeclined && (
@@ -418,7 +423,7 @@ export const Navbar = () => { title={
- Your payment method has been declined + Your payment could not be processed.
} > @@ -426,8 +431,9 @@ export const Navbar = () => {

- Your payment method was declined and your subscription may be at risk. Please - update your payment information to continue using premium features. + We were unable to process your last payment + {subscription.cardDeclinedReason ? `: ${subscription.cardDeclinedReason}` : ""}. + Please update your payment information to continue using premium features.

diff --git a/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx b/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx index eaa90450e..6ab0429ee 100644 --- a/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx +++ b/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx @@ -1,5 +1,7 @@ +import { useEffect } from "react"; import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQueryClient } from "@tanstack/react-query"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; @@ -15,13 +17,15 @@ import { useGetOrgPlanBillingInfo, useGetOrgTrialUrl } from "@app/hooks/api"; +import { subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries"; import { usePopUp } from "@app/hooks/usePopUp"; import { ManagePlansModal } from "./ManagePlansModal"; export const PreviewSection = () => { const { currentOrg } = useOrganization(); - const { subscription } = useSubscription(); + const { subscription } = useSubscription(true); + const queryClient = useQueryClient(); const { data, isPending } = useGetOrgPlanBillingInfo(currentOrg?.id ?? ""); const getOrgTrialUrl = useGetOrgTrialUrl(); const createCustomerPortalSession = useCreateCustomerPortalSession(); @@ -37,6 +41,12 @@ export const PreviewSection = () => { return formattedTotal; }; + useEffect(() => { + queryClient.invalidateQueries({ + queryKey: subscriptionQueryKeys.getOrgSubsription(currentOrg?.id ?? "") + }); + }, []); + const formatDate = (date: number) => { const createdDate = new Date(date * 1000); const day: number = createdDate.getDate(); From cc34b92d56c8e59bd5ecf37aca42179bafac7e23 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 26 Aug 2025 03:02:34 +0800 Subject: [PATCH 03/46] feat: pki and ssh setup for instance proxy --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 16 + ...1627_add-gateway-v2-pki-and-ssh-configs.ts | 78 +++ backend/src/db/schemas/index.ts | 2 + .../src/db/schemas/instance-proxy-config.ts | 38 ++ backend/src/db/schemas/models.ts | 6 +- backend/src/db/schemas/org-proxy-config.ts | 31 ++ backend/src/ee/routes/v1/index.ts | 2 + backend/src/ee/routes/v1/proxy-router.ts | 24 + .../proxy/instance-proxy-config-dal.ts | 11 + .../ee/services/proxy/org-proxy-config-dal.ts | 11 + .../src/ee/services/proxy/proxy-service.ts | 464 ++++++++++++++++++ backend/src/keystore/keystore.ts | 3 +- backend/src/server/routes/index.ts | 15 +- .../services/certificate/certificate-fns.ts | 3 + 15 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts create mode 100644 backend/src/db/schemas/instance-proxy-config.ts create mode 100644 backend/src/db/schemas/org-proxy-config.ts create mode 100644 backend/src/ee/routes/v1/proxy-router.ts create mode 100644 backend/src/ee/services/proxy/instance-proxy-config-dal.ts create mode 100644 backend/src/ee/services/proxy/org-proxy-config-dal.ts create mode 100644 backend/src/ee/services/proxy/proxy-service.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c25d8d4d1..977970f14 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -31,6 +31,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; +import { TProxyServiceFactory } from "@app/ee/services/proxy/proxy-service"; import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-types"; import { TScimServiceFactory } from "@app/ee/services/scim/scim-types"; @@ -303,6 +304,7 @@ declare module "fastify" { bus: TEventBusService; sse: TServerSentEventsService; identityAuthTemplate: TIdentityAuthTemplateServiceFactory; + proxy: TProxyServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f645cb8f2..9fdc94aca 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -179,6 +179,9 @@ import { TIncidentContacts, TIncidentContactsInsert, TIncidentContactsUpdate, + TInstanceProxyConfig, + TInstanceProxyConfigInsert, + TInstanceProxyConfigUpdate, TIntegrationAuths, TIntegrationAuthsInsert, TIntegrationAuthsUpdate, @@ -233,6 +236,9 @@ import { TOrgMemberships, TOrgMembershipsInsert, TOrgMembershipsUpdate, + TOrgProxyConfig, + TOrgProxyConfigInsert, + TOrgProxyConfigUpdate, TOrgRoles, TOrgRolesInsert, TOrgRolesUpdate, @@ -1254,5 +1260,15 @@ declare module "knex/types/tables" { TRemindersRecipientsInsert, TRemindersRecipientsUpdate >; + [TableName.InstanceProxyConfig]: KnexOriginal.CompositeTableType< + TInstanceProxyConfig, + TInstanceProxyConfigInsert, + TInstanceProxyConfigUpdate + >; + [TableName.OrgProxyConfig]: KnexOriginal.CompositeTableType< + TOrgProxyConfig, + TOrgProxyConfigInsert, + TOrgProxyConfigUpdate + >; } } diff --git a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts new file mode 100644 index 000000000..c242d0f58 --- /dev/null +++ b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts @@ -0,0 +1,78 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.InstanceProxyConfig))) { + await knex.schema.createTable(TableName.InstanceProxyConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + + // Root CA for proxy PKI + t.binary("encryptedRootProxyPkiCaPrivateKey").notNullable(); + t.binary("encryptedRootProxyPkiCaCertificate").notNullable(); + + // Instance CA for proxy PKI + t.binary("encryptedInstanceProxyPkiCaPrivateKey").notNullable(); + t.binary("encryptedInstanceProxyPkiCaCertificate").notNullable(); + t.binary("encryptedInstanceProxyPkiCaCertificateChain").notNullable(); + + // Instance client/server intermediates for proxy PKI + t.binary("encryptedInstanceProxyPkiClientCaPrivateKey").notNullable(); + t.binary("encryptedInstanceProxyPkiClientCaCertificate").notNullable(); + t.binary("encryptedInstanceProxyPkiClientCaCertificateChain").notNullable(); + t.binary("encryptedInstanceProxyPkiServerCaPrivateKey").notNullable(); + t.binary("encryptedInstanceProxyPkiServerCaCertificate").notNullable(); + t.binary("encryptedInstanceProxyPkiServerCaCertificateChain").notNullable(); + + // Org Parent CAs for proxy + t.binary("encryptedOrgProxyPkiCaPrivateKey").notNullable(); + t.binary("encryptedOrgProxyPkiCaCertificate").notNullable(); + t.binary("encryptedOrgProxyPkiCaCertificateChain").notNullable(); + + // Instance SSH CAs for proxy + t.binary("encryptedInstanceProxySshClientCaPrivateKey").notNullable(); + t.binary("encryptedInstanceProxySshClientCaPublicKey").notNullable(); + t.binary("encryptedInstanceProxySshServerCaPrivateKey").notNullable(); + t.binary("encryptedInstanceProxySshServerCaPublicKey").notNullable(); + }); + + await createOnUpdateTrigger(knex, TableName.InstanceProxyConfig); + } + + // Org-level proxy configuration (one-to-one with organization) + if (!(await knex.schema.hasTable(TableName.OrgProxyConfig))) { + await knex.schema.createTable(TableName.OrgProxyConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + + t.uuid("orgId").notNullable().unique(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + // Org-scoped proxy PKI (client + server) + t.binary("encryptedProxyPkiClientCaPrivateKey").notNullable(); + t.binary("encryptedProxyPkiClientCaCertificate").notNullable(); + t.binary("encryptedProxyPkiClientCaCertificateChain").notNullable(); + t.binary("encryptedProxyPkiServerCaPrivateKey").notNullable(); + t.binary("encryptedProxyPkiServerCaCertificate").notNullable(); + t.binary("encryptedProxyPkiServerCaCertificateChain").notNullable(); + + // Org-scoped proxy SSH (client + server) + t.binary("encryptedProxySshClientCaPrivateKey").notNullable(); + t.binary("encryptedProxySshClientCaPublicKey").notNullable(); + t.binary("encryptedProxySshServerCaPrivateKey").notNullable(); + t.binary("encryptedProxySshServerCaPublicKey").notNullable(); + }); + + await createOnUpdateTrigger(knex, TableName.OrgProxyConfig); + } +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.OrgProxyConfig); + await knex.schema.dropTableIfExists(TableName.OrgProxyConfig); + + await dropOnUpdateTrigger(knex, TableName.InstanceProxyConfig); + await knex.schema.dropTableIfExists(TableName.InstanceProxyConfig); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 1642c3555..01c066035 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -57,6 +57,7 @@ export * from "./identity-token-auths"; export * from "./identity-ua-client-secrets"; export * from "./identity-universal-auths"; export * from "./incident-contacts"; +export * from "./instance-proxy-config"; export * from "./integration-auths"; export * from "./integrations"; export * from "./internal-certificate-authorities"; @@ -76,6 +77,7 @@ export * from "./oidc-configs"; export * from "./org-bots"; export * from "./org-gateway-config"; export * from "./org-memberships"; +export * from "./org-proxy-config"; export * from "./org-roles"; export * from "./organizations"; export * from "./pki-alerts"; diff --git a/backend/src/db/schemas/instance-proxy-config.ts b/backend/src/db/schemas/instance-proxy-config.ts new file mode 100644 index 000000000..369ae381a --- /dev/null +++ b/backend/src/db/schemas/instance-proxy-config.ts @@ -0,0 +1,38 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const InstanceProxyConfigSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + encryptedRootProxyPkiCaPrivateKey: zodBuffer, + encryptedRootProxyPkiCaCertificate: zodBuffer, + encryptedInstanceProxyPkiCaPrivateKey: zodBuffer, + encryptedInstanceProxyPkiCaCertificate: zodBuffer, + encryptedInstanceProxyPkiCaCertificateChain: zodBuffer, + encryptedInstanceProxyPkiClientCaPrivateKey: zodBuffer, + encryptedInstanceProxyPkiClientCaCertificate: zodBuffer, + encryptedInstanceProxyPkiClientCaCertificateChain: zodBuffer, + encryptedInstanceProxyPkiServerCaPrivateKey: zodBuffer, + encryptedInstanceProxyPkiServerCaCertificate: zodBuffer, + encryptedInstanceProxyPkiServerCaCertificateChain: zodBuffer, + encryptedOrgProxyPkiCaPrivateKey: zodBuffer, + encryptedOrgProxyPkiCaCertificate: zodBuffer, + encryptedOrgProxyPkiCaCertificateChain: zodBuffer, + encryptedInstanceProxySshClientCaPrivateKey: zodBuffer, + encryptedInstanceProxySshClientCaPublicKey: zodBuffer, + encryptedInstanceProxySshServerCaPrivateKey: zodBuffer, + encryptedInstanceProxySshServerCaPublicKey: zodBuffer +}); + +export type TInstanceProxyConfig = z.infer; +export type TInstanceProxyConfigInsert = Omit, TImmutableDBKeys>; +export type TInstanceProxyConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 855934b28..dd1526011 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -178,7 +178,11 @@ export enum TableName { SecretScanningConfig = "secret_scanning_configs", // reminders Reminder = "reminders", - ReminderRecipient = "reminders_recipients" + ReminderRecipient = "reminders_recipients", + + // gateway v2 + InstanceProxyConfig = "instance_proxy_config", + OrgProxyConfig = "org_proxy_config" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/db/schemas/org-proxy-config.ts b/backend/src/db/schemas/org-proxy-config.ts new file mode 100644 index 000000000..8b854ffc2 --- /dev/null +++ b/backend/src/db/schemas/org-proxy-config.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const OrgProxyConfigSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid(), + encryptedProxyPkiClientCaPrivateKey: zodBuffer, + encryptedProxyPkiClientCaCertificate: zodBuffer, + encryptedProxyPkiClientCaCertificateChain: zodBuffer, + encryptedProxyPkiServerCaPrivateKey: zodBuffer, + encryptedProxyPkiServerCaCertificate: zodBuffer, + encryptedProxyPkiServerCaCertificateChain: zodBuffer, + encryptedProxySshClientCaPrivateKey: zodBuffer, + encryptedProxySshClientCaPublicKey: zodBuffer, + encryptedProxySshServerCaPrivateKey: zodBuffer, + encryptedProxySshServerCaPublicKey: zodBuffer +}); + +export type TOrgProxyConfig = z.infer; +export type TOrgProxyConfigInsert = Omit, TImmutableDBKeys>; +export type TOrgProxyConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index ab9503f58..d1232e5e8 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -23,6 +23,7 @@ import { registerOrgRoleRouter } from "./org-role-router"; import { registerPITRouter } from "./pit-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; +import { registerProxyRouter } from "./proxy-router"; import { registerRateLimitRouter } from "./rate-limit-router"; import { registerSamlRouter } from "./saml-router"; import { registerScimRouter } from "./scim-router"; @@ -79,6 +80,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { ); await server.register(registerGatewayRouter, { prefix: "/gateways" }); + await server.register(registerProxyRouter, { prefix: "/proxies" }); await server.register(registerGithubOrgSyncRouter, { prefix: "/github-org-sync-config" }); await server.register( diff --git a/backend/src/ee/routes/v1/proxy-router.ts b/backend/src/ee/routes/v1/proxy-router.ts new file mode 100644 index 000000000..d2c580708 --- /dev/null +++ b/backend/src/ee/routes/v1/proxy-router.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { writeLimit } from "@app/server/config/rateLimiter"; + +export const registerProxyRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + ip: z.string() + }), + response: { + 200: z.any() + } + }, + handler: async (req) => { + return server.services.proxy.registerProxy(req.body); + } + }); +}; diff --git a/backend/src/ee/services/proxy/instance-proxy-config-dal.ts b/backend/src/ee/services/proxy/instance-proxy-config-dal.ts new file mode 100644 index 000000000..4a128daf3 --- /dev/null +++ b/backend/src/ee/services/proxy/instance-proxy-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TInstanceProxyConfigDALFactory = ReturnType; + +export const instanceProxyConfigDalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.InstanceProxyConfig); + + return orm; +}; diff --git a/backend/src/ee/services/proxy/org-proxy-config-dal.ts b/backend/src/ee/services/proxy/org-proxy-config-dal.ts new file mode 100644 index 000000000..f15dd823b --- /dev/null +++ b/backend/src/ee/services/proxy/org-proxy-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgProxyConfigDALFactory = ReturnType; + +export const orgProxyConfigDalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.OrgProxyConfig); + + return orm; +}; diff --git a/backend/src/ee/services/proxy/proxy-service.ts b/backend/src/ee/services/proxy/proxy-service.ts new file mode 100644 index 000000000..e3e5fb921 --- /dev/null +++ b/backend/src/ee/services/proxy/proxy-service.ts @@ -0,0 +1,464 @@ +import * as x509 from "@peculiar/x509"; + +import { PgSqlLock } from "@app/keystore/keystore"; +import { crypto } from "@app/lib/crypto"; +import { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns"; +import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + createSerialNumber, + keyAlgorithmToAlgCfg +} from "@app/services/certificate-authority/certificate-authority-fns"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns"; +import { SshCertType } from "../ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types"; +import { TInstanceProxyConfigDALFactory } from "./instance-proxy-config-dal"; +import { TOrgProxyConfigDALFactory } from "./org-proxy-config-dal"; + +export type TProxyServiceFactory = ReturnType; + +const INSTANCE_PROXY_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; + +export const proxyServiceFactory = ({ + instanceProxyConfigDAL, + orgProxyConfigDAL, + kmsService +}: { + instanceProxyConfigDAL: TInstanceProxyConfigDALFactory; + orgProxyConfigDAL: TOrgProxyConfigDALFactory; + kmsService: TKmsServiceFactory; +}) => { + const $getInstanceCAs = async () => { + const instanceConfig = await instanceProxyConfigDAL.transaction(async (tx) => { + const existingInstanceProxyConfig = await instanceProxyConfigDAL.findById(INSTANCE_PROXY_CONFIG_UUID); + if (existingInstanceProxyConfig) return existingInstanceProxyConfig; + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.InstanceProxyConfigInit()]); + + const rootCaKeyAlgorithm = CertKeyAlgorithm.RSA_2048; + const alg = keyAlgorithmToAlgCfg(rootCaKeyAlgorithm); + const rootCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + // generate root CA + const rootCaSerialNumber = createSerialNumber(); + const rootCaSkObj = crypto.nativeCrypto.KeyObject.from(rootCaKeys.privateKey); + const rootCaIssuedAt = new Date(); + const rootCaExpiration = new Date(new Date().setFullYear(2045)); + const rootCaCert = await x509.X509CertificateGenerator.createSelfSigned({ + name: `O=Infisical,CN=Infisical Instance Root Proxy CA`, + serialNumber: rootCaSerialNumber, + notBefore: rootCaIssuedAt, + notAfter: rootCaExpiration, + signingAlgorithm: alg, + keys: rootCaKeys, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(rootCaKeys.publicKey) + ] + }); + + // generate org proxy CA + const orgProxyCaSerialNumber = createSerialNumber(); + const orgProxyCaIssuedAt = new Date(); + const orgProxyCaExpiration = new Date(new Date().setFullYear(2045)); + const orgProxyCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const orgProxyCaSkObj = crypto.nativeCrypto.KeyObject.from(orgProxyCaKeys.privateKey); + const orgProxyCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: orgProxyCaSerialNumber, + subject: `O=Infisical,CN=Infisical Organization Proxy CA`, + issuer: rootCaCert.subject, + notBefore: orgProxyCaIssuedAt, + notAfter: orgProxyCaExpiration, + signingKey: rootCaKeys.privateKey, + publicKey: orgProxyCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(orgProxyCaKeys.publicKey) + ] + }); + const orgProxyCaChain = constructPemChainFromCerts([rootCaCert]); + + // generate instance proxy CA + const instanceProxyCaSerialNumber = createSerialNumber(); + const instanceProxyCaIssuedAt = new Date(); + const instanceProxyCaExpiration = new Date(new Date().setFullYear(2045)); + const instanceProxyCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const instanceProxyCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceProxyCaKeys.privateKey); + const instanceProxyCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: instanceProxyCaSerialNumber, + subject: `O=Infisical,CN=Infisical Instance Proxy CA`, + issuer: rootCaCert.subject, + notBefore: instanceProxyCaIssuedAt, + notAfter: instanceProxyCaExpiration, + signingKey: rootCaKeys.privateKey, + publicKey: instanceProxyCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(instanceProxyCaKeys.publicKey) + ] + }); + const instanceProxyCaChain = constructPemChainFromCerts([rootCaCert]); + + // generate instance proxy client CA + const instanceProxyClientCaSerialNumber = createSerialNumber(); + const instanceProxyClientCaIssuedAt = new Date(); + const instanceProxyClientCaExpiration = new Date(new Date().setFullYear(2045)); + const instanceProxyClientCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const instanceProxyClientCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceProxyClientCaKeys.privateKey); + const instanceProxyClientCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: instanceProxyClientCaSerialNumber, + subject: `O=Infisical,CN=Infisical Instance Proxy Client CA`, + issuer: instanceProxyCaCert.subject, + notBefore: instanceProxyClientCaIssuedAt, + notAfter: instanceProxyClientCaExpiration, + signingKey: instanceProxyCaKeys.privateKey, + publicKey: instanceProxyClientCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(instanceProxyCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(instanceProxyClientCaKeys.publicKey) + ] + }); + const instanceProxyClientCaChain = constructPemChainFromCerts([instanceProxyCaCert, rootCaCert]); + + // generate instance proxy server CA + const instanceProxyServerCaSerialNumber = createSerialNumber(); + const instanceProxyServerCaIssuedAt = new Date(); + const instanceProxyServerCaExpiration = new Date(new Date().setFullYear(2045)); + const instanceProxyServerCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const instanceProxyServerCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceProxyServerCaKeys.privateKey); + const instanceProxyServerCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: instanceProxyServerCaSerialNumber, + subject: `O=Infisical,CN=Infisical Instance Proxy Server CA`, + issuer: instanceProxyCaCert.subject, + notBefore: instanceProxyServerCaIssuedAt, + notAfter: instanceProxyServerCaExpiration, + signingKey: instanceProxyCaKeys.privateKey, + publicKey: instanceProxyServerCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(instanceProxyCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(instanceProxyServerCaKeys.publicKey) + ] + }); + const instanceProxyServerCaChain = constructPemChainFromCerts([instanceProxyCaCert, rootCaCert]); + + const instanceSshServerCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); + const instanceSshClientCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); + + const encryptWithRoot = kmsService.encryptWithRootKey(); + + // root proxy CA + const encryptedRootProxyPkiCaPrivateKey = encryptWithRoot( + Buffer.from( + rootCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedRootProxyPkiCaCertificate = encryptWithRoot(Buffer.from(rootCaCert.rawData)); + + // org proxy CA + const encryptedOrgProxyPkiCaPrivateKey = encryptWithRoot( + Buffer.from( + orgProxyCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedOrgProxyPkiCaCertificate = encryptWithRoot(Buffer.from(orgProxyCaCert.rawData)); + const encryptedOrgProxyPkiCaCertificateChain = encryptWithRoot(Buffer.from(orgProxyCaChain)); + + // instance proxy CA + const encryptedInstanceProxyPkiCaPrivateKey = encryptWithRoot( + Buffer.from( + instanceProxyCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedInstanceProxyPkiCaCertificate = encryptWithRoot(Buffer.from(instanceProxyCaCert.rawData)); + const encryptedInstanceProxyPkiCaCertificateChain = encryptWithRoot(Buffer.from(instanceProxyCaChain)); + + // instance proxy client CA + const encryptedInstanceProxyPkiClientCaPrivateKey = encryptWithRoot( + Buffer.from( + instanceProxyClientCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedInstanceProxyPkiClientCaCertificate = encryptWithRoot( + Buffer.from(instanceProxyClientCaCert.rawData) + ); + const encryptedInstanceProxyPkiClientCaCertificateChain = encryptWithRoot( + Buffer.from(instanceProxyClientCaChain) + ); + + // instance proxy server CA + const encryptedInstanceProxyPkiServerCaPrivateKey = encryptWithRoot( + Buffer.from( + instanceProxyServerCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedInstanceProxyPkiServerCaCertificate = encryptWithRoot( + Buffer.from(instanceProxyServerCaCert.rawData) + ); + const encryptedInstanceProxyPkiServerCaCertificateChain = encryptWithRoot( + Buffer.from(instanceProxyServerCaChain) + ); + + const encryptedInstanceProxySshClientCaPublicKey = encryptWithRoot( + Buffer.from(instanceSshClientCaKeyPair.publicKey) + ); + const encryptedInstanceProxySshClientCaPrivateKey = encryptWithRoot( + Buffer.from(instanceSshClientCaKeyPair.privateKey) + ); + + const encryptedInstanceProxySshServerCaPublicKey = encryptWithRoot( + Buffer.from(instanceSshServerCaKeyPair.publicKey) + ); + const encryptedInstanceProxySshServerCaPrivateKey = encryptWithRoot( + Buffer.from(instanceSshServerCaKeyPair.privateKey) + ); + + return instanceProxyConfigDAL.create({ + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + id: INSTANCE_PROXY_CONFIG_UUID, + encryptedRootProxyPkiCaPrivateKey, + encryptedRootProxyPkiCaCertificate, + encryptedInstanceProxyPkiCaPrivateKey, + encryptedInstanceProxyPkiCaCertificate, + encryptedInstanceProxyPkiCaCertificateChain, + encryptedInstanceProxyPkiClientCaPrivateKey, + encryptedInstanceProxyPkiClientCaCertificate, + encryptedInstanceProxyPkiClientCaCertificateChain, + encryptedInstanceProxyPkiServerCaPrivateKey, + encryptedInstanceProxyPkiServerCaCertificate, + encryptedInstanceProxyPkiServerCaCertificateChain, + encryptedOrgProxyPkiCaPrivateKey, + encryptedOrgProxyPkiCaCertificate, + encryptedOrgProxyPkiCaCertificateChain, + encryptedInstanceProxySshClientCaPublicKey, + encryptedInstanceProxySshClientCaPrivateKey, + encryptedInstanceProxySshServerCaPublicKey, + encryptedInstanceProxySshServerCaPrivateKey + }); + }); + + // decrypt the instance config + const decryptWithRoot = kmsService.decryptWithRootKey(); + + // decrypt root proxy CA + const rootProxyPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedRootProxyPkiCaPrivateKey); + const rootProxyPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedRootProxyPkiCaCertificate); + + // decrypt org proxy CA + const orgProxyPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedOrgProxyPkiCaPrivateKey); + const orgProxyPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedOrgProxyPkiCaCertificate); + const orgProxyPkiCaCertificateChain = decryptWithRoot(instanceConfig.encryptedOrgProxyPkiCaCertificateChain); + + // decrypt instance proxy CA + const instanceProxyPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedInstanceProxyPkiCaPrivateKey); + const instanceProxyPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedInstanceProxyPkiCaCertificate); + const instanceProxyPkiCaCertificateChain = decryptWithRoot( + instanceConfig.encryptedInstanceProxyPkiCaCertificateChain + ); + + // decrypt instance proxy client CA + const instanceProxyPkiClientCaPrivateKey = decryptWithRoot( + instanceConfig.encryptedInstanceProxyPkiClientCaPrivateKey + ); + const instanceProxyPkiClientCaCertificate = decryptWithRoot( + instanceConfig.encryptedInstanceProxyPkiClientCaCertificate + ); + const instanceProxyPkiClientCaCertificateChain = decryptWithRoot( + instanceConfig.encryptedInstanceProxyPkiClientCaCertificateChain + ); + + // decrypt instance proxy server CA + const instanceProxyPkiServerCaPrivateKey = decryptWithRoot( + instanceConfig.encryptedInstanceProxyPkiServerCaPrivateKey + ); + const instanceProxyPkiServerCaCertificate = decryptWithRoot( + instanceConfig.encryptedInstanceProxyPkiServerCaCertificate + ); + const instanceProxyPkiServerCaCertificateChain = decryptWithRoot( + instanceConfig.encryptedInstanceProxyPkiServerCaCertificateChain + ); + + // decrypt SSH keys + const instanceProxySshClientCaPublicKey = decryptWithRoot( + instanceConfig.encryptedInstanceProxySshClientCaPublicKey + ); + const instanceProxySshClientCaPrivateKey = decryptWithRoot( + instanceConfig.encryptedInstanceProxySshClientCaPrivateKey + ); + const instanceProxySshServerCaPublicKey = decryptWithRoot( + instanceConfig.encryptedInstanceProxySshServerCaPublicKey + ); + const instanceProxySshServerCaPrivateKey = decryptWithRoot( + instanceConfig.encryptedInstanceProxySshServerCaPrivateKey + ); + + return { + rootProxyPkiCaPrivateKey, + rootProxyPkiCaCertificate, + orgProxyPkiCaPrivateKey, + orgProxyPkiCaCertificate, + orgProxyPkiCaCertificateChain, + instanceProxyPkiCaPrivateKey, + instanceProxyPkiCaCertificate, + instanceProxyPkiCaCertificateChain, + instanceProxyPkiClientCaPrivateKey, + instanceProxyPkiClientCaCertificate, + instanceProxyPkiClientCaCertificateChain, + instanceProxyPkiServerCaPrivateKey, + instanceProxyPkiServerCaCertificate, + instanceProxyPkiServerCaCertificateChain, + instanceProxySshClientCaPublicKey, + instanceProxySshClientCaPrivateKey, + instanceProxySshServerCaPublicKey, + instanceProxySshServerCaPrivateKey + }; + }; + + const registerProxy = async ({ ip }: { ip: string }) => { + // initialize instance CAs if not yet initialized + const instanceCAs = await $getInstanceCAs(); + + // TODO: check if identity used already has an existing proxy. If the same IP, return the existing proxy. If not, create a new proxy and overwrite + + // generate proxy server PKI certificate + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const proxyServerCaCert = new x509.X509Certificate(instanceCAs.instanceProxyPkiServerCaCertificate); + const rootProxyCaCert = new x509.X509Certificate(instanceCAs.rootProxyPkiCaCertificate); + const proxyServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: instanceCAs.instanceProxyPkiServerCaPrivateKey, + format: "der", + type: "pkcs8" + }); + const proxyServerCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + proxyServerCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const proxyServerKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const proxyServerCertIssuedAt = new Date(); + const proxyServerCertExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1)); + const proxyServerCertPrivateKey = crypto.nativeCrypto.KeyObject.from(proxyServerKeys.privateKey); + + const proxyServerCertExtensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(proxyServerCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(proxyServerKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), + // san + new x509.SubjectAlternativeNameExtension([{ type: "ip", value: ip }], false) + ]; + + const proxyServerSerialNumber = createSerialNumber(); + const proxyServerCertificate = await x509.X509CertificateGenerator.create({ + serialNumber: proxyServerSerialNumber, + subject: `CN=${ip},O=Infisical,OU=Proxy`, + issuer: proxyServerCaCert.subject, + notBefore: proxyServerCertIssuedAt, + notAfter: proxyServerCertExpireAt, + signingKey: proxyServerCaPrivateKey, + publicKey: proxyServerKeys.publicKey, + signingAlgorithm: alg, + extensions: proxyServerCertExtensions + }); + + // generate proxy server SSH certificate + const keyAlgorithm = SshCertKeyAlgorithm.RSA_2048; + const { publicKey: proxyServerSshPublicKey, privateKey: proxyServerSshPrivateKey } = + await createSshKeyPair(keyAlgorithm); + + const proxyServerSshCert = await createSshCert({ + caPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey.toString("utf8"), + clientPublicKey: proxyServerSshPublicKey, + keyId: "proxy-server", + principals: [ip], + certType: SshCertType.HOST, + requestedTtl: "30d" + }); + + return { + pki: { + serverCertificate: proxyServerCertificate.toString("pem"), + serverCertificateChain: prependCertToPemChain( + proxyServerCaCert, + instanceCAs.instanceProxyPkiServerCaCertificateChain.toString("utf8") + ), + serverPrivateKey: proxyServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + clientCA: rootProxyCaCert.toString("pem") + }, + ssh: { + serverCertificate: proxyServerSshCert.signedPublicKey, + serverPrivateKey: proxyServerSshPrivateKey, + clientCAPublicKey: instanceCAs.instanceProxySshClientCaPublicKey.toString("utf8") + } + }; + }; + + return { + registerProxy + }; +}; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 26aff767e..c4583b574 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -13,7 +13,8 @@ export const PgSqlLock = { SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`), CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`), CreateFolder: (envId: string, projectId: string) => pgAdvisoryLockHashText(`create-folder:${envId}-${projectId}`), - SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`) + SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`), + InstanceProxyConfigInit: () => pgAdvisoryLockHashText("instance-proxy-config-init") } as const; // all the key prefixes used must be set here to avoid conflict diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 6dd7d190d..ce2e5dac6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -70,6 +70,9 @@ import { projectTemplateDALFactory } from "@app/ee/services/project-template/pro import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; +import { instanceProxyConfigDalFactory } from "@app/ee/services/proxy/instance-proxy-config-dal"; +import { orgProxyConfigDalFactory } from "@app/ee/services/proxy/org-proxy-config-dal"; +import { proxyServiceFactory } from "@app/ee/services/proxy/proxy-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; import { rateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; @@ -939,6 +942,9 @@ export const registerRoutes = async ( const pkiSubscriberDAL = pkiSubscriberDALFactory(db); const pkiTemplatesDAL = pkiTemplatesDALFactory(db); + const instanceProxyConfigDAL = instanceProxyConfigDalFactory(db); + const orgProxyConfigDAL = orgProxyConfigDalFactory(db); + const certificateService = certificateServiceFactory({ certificateDAL, certificateBodyDAL, @@ -1960,6 +1966,12 @@ export const registerRoutes = async ( appConnectionDAL }); + const proxyService = proxyServiceFactory({ + instanceProxyConfigDAL, + orgProxyConfigDAL, + kmsService + }); + // setup the communication with license key server await licenseService.init(); @@ -2091,7 +2103,8 @@ export const registerRoutes = async ( secretScanningV2: secretScanningV2Service, reminder: reminderService, bus: eventBusService, - sse: sseService + sse: sseService, + proxy: proxyService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index eee220ce9..b2bc4df41 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -52,6 +52,9 @@ export const constructPemChainFromCerts = (certificates: x509.X509Certificate[]) .join("\n") .trim(); +export const prependCertToPemChain = (cert: x509.X509Certificate, pemChain: string) => + `${cert.toString("pem")}\n${pemChain}`; + export const splitPemChain = (pemText: string) => { const re2Pattern = new RE2("-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----", "g"); From ae62c5938239eb679b6e2b8c3decf064d808250d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 27 Aug 2025 04:36:17 +0800 Subject: [PATCH 04/46] feat: add gateway registration and org-proxy initialization --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 12 + ...1627_add-gateway-v2-pki-and-ssh-configs.ts | 43 ++ backend/src/db/schemas/index.ts | 2 + backend/src/db/schemas/models.ts | 4 +- .../src/db/schemas/org-gateway-config-v2.ts | 29 ++ backend/src/db/schemas/proxies.ts | 22 + backend/src/ee/routes/v1/proxy-router.ts | 51 +- backend/src/ee/routes/v2/gateway-router.ts | 29 ++ backend/src/ee/routes/v2/index.ts | 3 + .../services/gateway-v2/gateway-v2-service.ts | 274 +++++++++++ .../gateway-v2/org-gateway-config-v2-dal.ts | 11 + backend/src/ee/services/proxy/proxy-dal.ts | 11 + backend/src/ee/services/proxy/proxy-fns.ts | 3 + .../src/ee/services/proxy/proxy-service.ts | 442 +++++++++++++++++- backend/src/keystore/keystore.ts | 4 +- backend/src/lib/config/env.ts | 2 + .../server/plugins/auth/inject-identity.ts | 4 + backend/src/server/routes/index.ts | 16 +- 19 files changed, 944 insertions(+), 20 deletions(-) create mode 100644 backend/src/db/schemas/org-gateway-config-v2.ts create mode 100644 backend/src/db/schemas/proxies.ts create mode 100644 backend/src/ee/routes/v2/gateway-router.ts create mode 100644 backend/src/ee/services/gateway-v2/gateway-v2-service.ts create mode 100644 backend/src/ee/services/gateway-v2/org-gateway-config-v2-dal.ts create mode 100644 backend/src/ee/services/proxy/proxy-dal.ts create mode 100644 backend/src/ee/services/proxy/proxy-fns.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 977970f14..2b997eb46 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -16,6 +16,7 @@ import { TEventBusService } from "@app/ee/services/event/event-bus-service"; import { TServerSentEventsService } from "@app/ee/services/event/event-sse-service"; import { TExternalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; import { TIdentityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template"; @@ -305,6 +306,7 @@ declare module "fastify" { sse: TServerSentEventsService; identityAuthTemplate: TIdentityAuthTemplateServiceFactory; proxy: TProxyServiceFactory; + gatewayV2: TGatewayV2ServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 9fdc94aca..f2b768eb6 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -233,6 +233,9 @@ import { TOrgGatewayConfig, TOrgGatewayConfigInsert, TOrgGatewayConfigUpdate, + TOrgGatewayConfigV2, + TOrgGatewayConfigV2Insert, + TOrgGatewayConfigV2Update, TOrgMemberships, TOrgMembershipsInsert, TOrgMembershipsUpdate, @@ -293,6 +296,9 @@ import { TProjectUserMembershipRoles, TProjectUserMembershipRolesInsert, TProjectUserMembershipRolesUpdate, + TProxies, + TProxiesInsert, + TProxiesUpdate, TRateLimit, TRateLimitInsert, TRateLimitUpdate, @@ -1270,5 +1276,11 @@ declare module "knex/types/tables" { TOrgProxyConfigInsert, TOrgProxyConfigUpdate >; + [TableName.OrgGatewayConfigV2]: KnexOriginal.CompositeTableType< + TOrgGatewayConfigV2, + TOrgGatewayConfigV2Insert, + TOrgGatewayConfigV2Update + >; + [TableName.Proxy]: KnexOriginal.CompositeTableType; } } diff --git a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts index c242d0f58..68aa15374 100644 --- a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts +++ b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts @@ -67,6 +67,43 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.OrgProxyConfig); } + + if (!(await knex.schema.hasTable(TableName.OrgGatewayConfigV2))) { + await knex.schema.createTable(TableName.OrgGatewayConfigV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable().unique(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.binary("encryptedRootGatewayCaPrivateKey").notNullable(); + t.binary("encryptedRootGatewayCaCertificate").notNullable(); + t.binary("encryptedGatewayServerCaPrivateKey").notNullable(); + t.binary("encryptedGatewayServerCaCertificate").notNullable(); + t.binary("encryptedGatewayServerCaCertificateChain").notNullable(); + t.binary("encryptedGatewayClientCaPrivateKey").notNullable(); + t.binary("encryptedGatewayClientCaCertificate").notNullable(); + t.binary("encryptedGatewayClientCaCertificateChain").notNullable(); + }); + + await createOnUpdateTrigger(knex, TableName.OrgGatewayConfigV2); + } + + if (!(await knex.schema.hasTable(TableName.Proxy))) { + await knex.schema.createTable(TableName.Proxy, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.uuid("identityId"); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + + t.string("name").notNullable().unique(); + t.string("ip").notNullable(); + }); + + await createOnUpdateTrigger(knex, TableName.Proxy); + } } export async function down(knex: Knex): Promise { @@ -75,4 +112,10 @@ export async function down(knex: Knex): Promise { await dropOnUpdateTrigger(knex, TableName.InstanceProxyConfig); await knex.schema.dropTableIfExists(TableName.InstanceProxyConfig); + + await dropOnUpdateTrigger(knex, TableName.OrgGatewayConfigV2); + await knex.schema.dropTableIfExists(TableName.OrgGatewayConfigV2); + + await dropOnUpdateTrigger(knex, TableName.Proxy); + await knex.schema.dropTableIfExists(TableName.Proxy); } diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 01c066035..03813ee49 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -76,6 +76,7 @@ export * from "./models"; export * from "./oidc-configs"; export * from "./org-bots"; export * from "./org-gateway-config"; +export * from "./org-gateway-config-v2"; export * from "./org-memberships"; export * from "./org-proxy-config"; export * from "./org-roles"; @@ -164,3 +165,4 @@ export * from "./user-group-membership"; export * from "./users"; export * from "./webhooks"; export * from "./workflow-integrations"; +export * from "./proxies"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index dd1526011..99681b986 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -182,7 +182,9 @@ export enum TableName { // gateway v2 InstanceProxyConfig = "instance_proxy_config", - OrgProxyConfig = "org_proxy_config" + OrgProxyConfig = "org_proxy_config", + OrgGatewayConfigV2 = "org_gateway_config_v2", + Proxy = "proxies" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/db/schemas/org-gateway-config-v2.ts b/backend/src/db/schemas/org-gateway-config-v2.ts new file mode 100644 index 000000000..fab9a3182 --- /dev/null +++ b/backend/src/db/schemas/org-gateway-config-v2.ts @@ -0,0 +1,29 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const OrgGatewayConfigV2Schema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + encryptedRootGatewayCaPrivateKey: zodBuffer, + encryptedRootGatewayCaCertificate: zodBuffer, + encryptedGatewayServerCaPrivateKey: zodBuffer, + encryptedGatewayServerCaCertificate: zodBuffer, + encryptedGatewayServerCaCertificateChain: zodBuffer, + encryptedGatewayClientCaPrivateKey: zodBuffer, + encryptedGatewayClientCaCertificate: zodBuffer, + encryptedGatewayClientCaCertificateChain: zodBuffer +}); + +export type TOrgGatewayConfigV2 = z.infer; +export type TOrgGatewayConfigV2Insert = Omit, TImmutableDBKeys>; +export type TOrgGatewayConfigV2Update = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/proxies.ts b/backend/src/db/schemas/proxies.ts new file mode 100644 index 000000000..508c4d25e --- /dev/null +++ b/backend/src/db/schemas/proxies.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProxiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid().nullable().optional(), + identityId: z.string().uuid().nullable().optional(), + name: z.string(), + ip: z.string() +}); + +export type TProxies = z.infer; +export type TProxiesInsert = Omit, TImmutableDBKeys>; +export type TProxiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/proxy-router.ts b/backend/src/ee/routes/v1/proxy-router.ts index d2c580708..561fe7780 100644 --- a/backend/src/ee/routes/v1/proxy-router.ts +++ b/backend/src/ee/routes/v1/proxy-router.ts @@ -1,24 +1,69 @@ import { z } from "zod"; +import { getConfig } from "@app/lib/config/env"; +import { UnauthorizedError } from "@app/lib/errors"; import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; export const registerProxyRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + server.route({ method: "POST", - url: "/", + url: "/register-instance-proxy", config: { rateLimit: writeLimit }, schema: { body: z.object({ - ip: z.string() + ip: z.string(), + name: z.string() }), response: { 200: z.any() } }, + onRequest: (req, _, next) => { + const authHeader = req.headers.authorization; + + if (appCfg.PROXY_AUTH_SECRET && authHeader === `Bearer ${appCfg.PROXY_AUTH_SECRET}`) { + return next(); + } + + throw new UnauthorizedError({ + message: "Invalid proxy auth secret" + }); + }, handler: async (req) => { - return server.services.proxy.registerProxy(req.body); + return server.services.proxy.registerProxy({ + ...req.body + }); + } + }); + + server.route({ + method: "POST", + url: "/register-org-proxy", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + ip: z.string(), + name: z.string() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.proxy.registerProxy({ + ...req.body, + identityId: req.permission.id, + orgId: req.permission.orgId + }); } }); }; diff --git a/backend/src/ee/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts new file mode 100644 index 000000000..31130b206 --- /dev/null +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -0,0 +1,29 @@ +import z from "zod"; + +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerGatewayV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + body: z.object({ + proxyName: z.string() + }), + response: { + 200: z.any() + } + }, + handler: async (req) => { + const gateway = await server.services.gatewayV2.registerGateway({ + orgId: req.permission.orgId, + proxyName: req.body.proxyName, + actorId: req.permission.id + }); + + return gateway; + } + }); +}; diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index e364f4949..b7ee038a1 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -9,6 +9,7 @@ import { import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerProjectRoleRouter } from "./project-role-router"; +import { registerGatewayV2Router } from "./gateway-router"; export const registerV2EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization @@ -23,6 +24,8 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { prefix: "/identity-project-additional-privilege" }); + await server.register(registerGatewayV2Router, { prefix: "/gateways" }); + await server.register( async (secretRotationV2Router) => { // register generic secret rotation endpoints diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts new file mode 100644 index 000000000..e55acbffa --- /dev/null +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -0,0 +1,274 @@ +import * as x509 from "@peculiar/x509"; + +import { PgSqlLock } from "@app/keystore/keystore"; +import { crypto } from "@app/lib/crypto"; +import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns"; +import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + createSerialNumber, + keyAlgorithmToAlgCfg +} 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 { TProxyServiceFactory } from "../proxy/proxy-service"; +import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal"; + +type TGatewayV2ServiceFactoryDep = { + orgGatewayConfigV2DAL: Pick; + kmsService: TKmsServiceFactory; + proxyService: TProxyServiceFactory; +}; + +export type TGatewayV2ServiceFactory = ReturnType; + +export const gatewayV2ServiceFactory = ({ + orgGatewayConfigV2DAL, + kmsService, + proxyService +}: TGatewayV2ServiceFactoryDep) => { + const $getOrgCAs = async (orgId: string) => { + const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const orgCAs = await orgGatewayConfigV2DAL.transaction(async (tx) => { + const orgGatewayConfigV2 = await orgGatewayConfigV2DAL.findOne({ orgId }); + if (orgGatewayConfigV2) return orgGatewayConfigV2; + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgGatewayV2Init(orgId)]); + + // generate root CA + const rootCaKeyAlgorithm = CertKeyAlgorithm.RSA_2048; + const alg = keyAlgorithmToAlgCfg(rootCaKeyAlgorithm); + const rootCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const rootCaSerialNumber = createSerialNumber(); + const rootCaSkObj = crypto.nativeCrypto.KeyObject.from(rootCaKeys.privateKey); + const rootCaIssuedAt = new Date(); + const rootCaExpiration = new Date(new Date().setFullYear(2045)); + + const rootCaCert = await x509.X509CertificateGenerator.createSelfSigned({ + name: `O=${orgId},CN=Infisical Gateway Root CA`, + serialNumber: rootCaSerialNumber, + notBefore: rootCaIssuedAt, + notAfter: rootCaExpiration, + signingAlgorithm: alg, + keys: rootCaKeys, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(rootCaKeys.publicKey) + ] + }); + + // generate server CA + const serverCaSerialNumber = createSerialNumber(); + const serverCaIssuedAt = new Date(); + const serverCaExpiration = new Date(new Date().setFullYear(2045)); + const serverCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const serverCaSkObj = crypto.nativeCrypto.KeyObject.from(serverCaKeys.privateKey); + const serverCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: serverCaSerialNumber, + subject: `O=${orgId},CN=Infisical Gateway Server CA`, + issuer: rootCaCert.subject, + notBefore: serverCaIssuedAt, + notAfter: serverCaExpiration, + signingKey: rootCaKeys.privateKey, + publicKey: serverCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(serverCaKeys.publicKey) + ] + }); + + // generate client CA + const clientCaSerialNumber = createSerialNumber(); + const clientCaIssuedAt = new Date(); + const clientCaExpiration = new Date(new Date().setFullYear(2045)); + const clientCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const clientCaSkObj = crypto.nativeCrypto.KeyObject.from(clientCaKeys.privateKey); + const clientCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: clientCaSerialNumber, + subject: `O=${orgId},CN=Infisical Gateway Client CA`, + issuer: rootCaCert.subject, + notBefore: clientCaIssuedAt, + notAfter: clientCaExpiration, + signingKey: rootCaKeys.privateKey, + publicKey: clientCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(clientCaKeys.publicKey) + ] + }); + + const encryptedRootGatewayCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from( + rootCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + }).cipherTextBlob; + const encryptedRootGatewayCaCertificate = orgKmsEncryptor({ + plainText: Buffer.from(rootCaCert.rawData) + }).cipherTextBlob; + + const encryptedGatewayServerCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from(serverCaSkObj.export({ type: "pkcs8", format: "der" })) + }).cipherTextBlob; + const encryptedGatewayServerCaCertificate = orgKmsEncryptor({ + plainText: Buffer.from(serverCaCert.rawData) + }).cipherTextBlob; + const encryptedGatewayServerCaCertificateChain = orgKmsEncryptor({ + plainText: Buffer.from(constructPemChainFromCerts([rootCaCert])) + }).cipherTextBlob; + + const encryptedGatewayClientCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from(clientCaSkObj.export({ type: "pkcs8", format: "der" })) + }).cipherTextBlob; + const encryptedGatewayClientCaCertificate = orgKmsEncryptor({ + plainText: Buffer.from(clientCaCert.rawData) + }).cipherTextBlob; + const encryptedGatewayClientCaCertificateChain = orgKmsEncryptor({ + plainText: Buffer.from(constructPemChainFromCerts([rootCaCert])) + }).cipherTextBlob; + + return orgGatewayConfigV2DAL.create({ + orgId, + encryptedRootGatewayCaPrivateKey, + encryptedRootGatewayCaCertificate, + encryptedGatewayServerCaPrivateKey, + encryptedGatewayServerCaCertificate, + encryptedGatewayServerCaCertificateChain, + encryptedGatewayClientCaPrivateKey, + encryptedGatewayClientCaCertificate, + encryptedGatewayClientCaCertificateChain + }); + }); + + const rootGatewayCaPrivateKey = orgKmsDecryptor({ cipherTextBlob: orgCAs.encryptedRootGatewayCaPrivateKey }); + const rootGatewayCaCertificate = orgKmsDecryptor({ cipherTextBlob: orgCAs.encryptedRootGatewayCaCertificate }); + + const gatewayServerCaPrivateKey = orgKmsDecryptor({ cipherTextBlob: orgCAs.encryptedGatewayServerCaPrivateKey }); + const gatewayServerCaCertificate = orgKmsDecryptor({ cipherTextBlob: orgCAs.encryptedGatewayServerCaCertificate }); + const gatewayServerCaCertificateChain = orgKmsDecryptor({ + cipherTextBlob: orgCAs.encryptedGatewayServerCaCertificateChain + }); + + const gatewayClientCaPrivateKey = orgKmsDecryptor({ cipherTextBlob: orgCAs.encryptedGatewayClientCaPrivateKey }); + const gatewayClientCaCertificate = orgKmsDecryptor({ + cipherTextBlob: orgCAs.encryptedGatewayClientCaCertificate + }); + const gatewayClientCaCertificateChain = orgKmsDecryptor({ + cipherTextBlob: orgCAs.encryptedGatewayClientCaCertificateChain + }); + + return { + rootGatewayCaPrivateKey, + rootGatewayCaCertificate, + gatewayServerCaPrivateKey, + gatewayServerCaCertificate, + gatewayServerCaCertificateChain, + gatewayClientCaPrivateKey, + gatewayClientCaCertificate, + gatewayClientCaCertificateChain + }; + }; + + const registerGateway = async ({ orgId, proxyName }: { orgId: string; actorId: string; proxyName: string }) => { + const orgCAs = await $getOrgCAs(orgId); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const gatewayServerCaCert = new x509.X509Certificate(orgCAs.gatewayServerCaCertificate); + const rootGatewayCaCert = new x509.X509Certificate(orgCAs.rootGatewayCaCertificate); + + const gatewayServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: orgCAs.gatewayServerCaPrivateKey, + format: "der", + type: "pkcs8" + }); + const gatewayServerCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + gatewayServerCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const gatewayServerKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const gatewayServerCertIssuedAt = new Date(); + const gatewayServerCertExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1)); + const gatewayServerCertPrivateKey = crypto.nativeCrypto.KeyObject.from(gatewayServerKeys.privateKey); + + const gatewayServerCertExtensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(gatewayServerCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(gatewayServerKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true) + ]; + + const gatewayServerSerialNumber = createSerialNumber(); + const gatewayServerCertificate = await x509.X509CertificateGenerator.create({ + serialNumber: gatewayServerSerialNumber, + subject: `O=${orgId},CN=Gateway`, + issuer: gatewayServerCaCert.subject, + notBefore: gatewayServerCertIssuedAt, + notAfter: gatewayServerCertExpireAt, + signingKey: gatewayServerCaPrivateKey, + publicKey: gatewayServerKeys.publicKey, + signingAlgorithm: alg, + extensions: gatewayServerCertExtensions + }); + + const proxyCredentials = await proxyService.generateSshCredentialsForGateway({ + proxyName, + orgId + }); + + return { + pki: { + serverCertificate: gatewayServerCertificate.toString("pem"), + serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert]), + serverPrivateKey: gatewayServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + clientCA: rootGatewayCaCert.toString("pem") + }, + ssh: { + clientCertificate: proxyCredentials.clientSshCert, + clientPrivateKey: proxyCredentials.clientSshPrivateKey, + serverCAPublicKey: proxyCredentials.serverCAPublicKey + } + }; + }; + + return { + registerGateway + }; +}; diff --git a/backend/src/ee/services/gateway-v2/org-gateway-config-v2-dal.ts b/backend/src/ee/services/gateway-v2/org-gateway-config-v2-dal.ts new file mode 100644 index 000000000..8f16d798a --- /dev/null +++ b/backend/src/ee/services/gateway-v2/org-gateway-config-v2-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgGatewayConfigV2DALFactory = ReturnType; + +export const orgGatewayConfigV2DalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.OrgGatewayConfigV2); + + return orm; +}; diff --git a/backend/src/ee/services/proxy/proxy-dal.ts b/backend/src/ee/services/proxy/proxy-dal.ts new file mode 100644 index 000000000..a1570f2a8 --- /dev/null +++ b/backend/src/ee/services/proxy/proxy-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProxyDALFactory = ReturnType; + +export const proxyDalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Proxy); + + return orm; +}; diff --git a/backend/src/ee/services/proxy/proxy-fns.ts b/backend/src/ee/services/proxy/proxy-fns.ts new file mode 100644 index 000000000..588a7b2ba --- /dev/null +++ b/backend/src/ee/services/proxy/proxy-fns.ts @@ -0,0 +1,3 @@ +export const isInstanceProxy = (proxyName: string) => { + return proxyName.startsWith("infisical-"); +}; diff --git a/backend/src/ee/services/proxy/proxy-service.ts b/backend/src/ee/services/proxy/proxy-service.ts index e3e5fb921..65ec9069e 100644 --- a/backend/src/ee/services/proxy/proxy-service.ts +++ b/backend/src/ee/services/proxy/proxy-service.ts @@ -1,7 +1,9 @@ import * as x509 from "@peculiar/x509"; +import { TProxies } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { @@ -9,12 +11,15 @@ import { keyAlgorithmToAlgCfg } 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 { createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns"; import { SshCertType } from "../ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types"; import { TInstanceProxyConfigDALFactory } from "./instance-proxy-config-dal"; import { TOrgProxyConfigDALFactory } from "./org-proxy-config-dal"; +import { TProxyDALFactory } from "./proxy-dal"; +import { isInstanceProxy } from "./proxy-fns"; export type TProxyServiceFactory = ReturnType; @@ -23,10 +28,12 @@ const INSTANCE_PROXY_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; export const proxyServiceFactory = ({ instanceProxyConfigDAL, orgProxyConfigDAL, + proxyDAL, kmsService }: { instanceProxyConfigDAL: TInstanceProxyConfigDALFactory; orgProxyConfigDAL: TOrgProxyConfigDALFactory; + proxyDAL: TProxyDALFactory; kmsService: TKmsServiceFactory; }) => { const $getInstanceCAs = async () => { @@ -36,8 +43,7 @@ export const proxyServiceFactory = ({ await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.InstanceProxyConfigInit()]); - const rootCaKeyAlgorithm = CertKeyAlgorithm.RSA_2048; - const alg = keyAlgorithmToAlgCfg(rootCaKeyAlgorithm); + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const rootCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); // generate root CA @@ -370,21 +376,303 @@ export const proxyServiceFactory = ({ }; }; - const registerProxy = async ({ ip }: { ip: string }) => { - // initialize instance CAs if not yet initialized + const $getOrgCAs = async (orgId: string) => { const instanceCAs = await $getInstanceCAs(); + const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); - // TODO: check if identity used already has an existing proxy. If the same IP, return the existing proxy. If not, create a new proxy and overwrite + const orgProxyConfig = await orgProxyConfigDAL.transaction(async (tx) => { + const existingOrgProxyConfig = await orgProxyConfigDAL.findOne( + { + orgId + }, + tx + ); - // generate proxy server PKI certificate + if (existingOrgProxyConfig) { + return existingOrgProxyConfig; + } + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgProxyConfigInit(orgId)]); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const orgProxyCaCert = new x509.X509Certificate(instanceCAs.orgProxyPkiCaCertificate); + const rootProxyCaCert = new x509.X509Certificate(instanceCAs.rootProxyPkiCaCertificate); + const orgProxyCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: instanceCAs.orgProxyPkiCaPrivateKey, + format: "der", + type: "pkcs8" + }); + const orgProxyClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + orgProxyCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + // generate org proxy client CA + const orgProxyClientCaSerialNumber = createSerialNumber(); + const orgProxyClientCaIssuedAt = new Date(); + const orgProxyClientCaExpiration = new Date(new Date().setFullYear(2045)); + const orgProxyClientCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const orgProxyClientCaSkObj = crypto.nativeCrypto.KeyObject.from(orgProxyClientCaKeys.privateKey); + const orgProxyClientCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: orgProxyClientCaSerialNumber, + subject: `O=${orgId},CN=Infisical Org Proxy Client CA`, + issuer: orgProxyCaCert.subject, + notBefore: orgProxyClientCaIssuedAt, + notAfter: orgProxyClientCaExpiration, + signingKey: orgProxyClientCaPrivateKey, + publicKey: orgProxyClientCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(orgProxyCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(orgProxyClientCaKeys.publicKey) + ] + }); + const orgProxyClientCaChain = constructPemChainFromCerts([orgProxyCaCert, rootProxyCaCert]); + + // generate org SSH CA + const orgSshServerCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); + const orgSshClientCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); + + // generate org proxy server CA + const orgProxyServerCaSerialNumber = createSerialNumber(); + const orgProxyServerCaIssuedAt = new Date(); + const orgProxyServerCaExpiration = new Date(new Date().setFullYear(2045)); + const orgProxyServerCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const orgProxyServerCaSkObj = crypto.nativeCrypto.KeyObject.from(orgProxyServerCaKeys.privateKey); + const orgProxyServerCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: orgProxyServerCaSerialNumber, + subject: `O=${orgId},CN=Infisical Org Proxy Server CA`, + issuer: orgProxyCaCert.subject, + notBefore: orgProxyServerCaIssuedAt, + notAfter: orgProxyServerCaExpiration, + signingKey: orgProxyClientCaPrivateKey, + publicKey: orgProxyServerCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(orgProxyCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(orgProxyServerCaKeys.publicKey) + ] + }); + const orgProxyServerCaChain = constructPemChainFromCerts([orgProxyCaCert, rootProxyCaCert]); + + const encryptedProxyPkiClientCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from( + orgProxyClientCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + }).cipherTextBlob; + const encryptedProxyPkiClientCaCertificate = orgKmsEncryptor({ + plainText: Buffer.from(orgProxyClientCaCert.rawData) + }).cipherTextBlob; + + const encryptedProxyPkiClientCaCertificateChain = orgKmsEncryptor({ + plainText: Buffer.from(orgProxyClientCaChain) + }).cipherTextBlob; + + const encryptedProxyPkiServerCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from( + orgProxyServerCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + }).cipherTextBlob; + const encryptedProxyPkiServerCaCertificate = orgKmsEncryptor({ + plainText: Buffer.from(orgProxyServerCaCert.rawData) + }).cipherTextBlob; + const encryptedProxyPkiServerCaCertificateChain = orgKmsEncryptor({ + plainText: Buffer.from(orgProxyServerCaChain) + }).cipherTextBlob; + + const encryptedProxySshClientCaPublicKey = orgKmsEncryptor({ + plainText: Buffer.from(orgSshClientCaKeyPair.publicKey) + }).cipherTextBlob; + const encryptedProxySshClientCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from(orgSshClientCaKeyPair.privateKey) + }).cipherTextBlob; + + const encryptedProxySshServerCaPublicKey = orgKmsEncryptor({ + plainText: Buffer.from(orgSshServerCaKeyPair.publicKey) + }).cipherTextBlob; + const encryptedProxySshServerCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from(orgSshServerCaKeyPair.privateKey) + }).cipherTextBlob; + + return orgProxyConfigDAL.create({ + orgId, + encryptedProxyPkiClientCaPrivateKey, + encryptedProxyPkiClientCaCertificate, + encryptedProxyPkiClientCaCertificateChain, + encryptedProxyPkiServerCaPrivateKey, + encryptedProxyPkiServerCaCertificate, + encryptedProxyPkiServerCaCertificateChain, + encryptedProxySshClientCaPublicKey, + encryptedProxySshClientCaPrivateKey, + encryptedProxySshServerCaPublicKey, + encryptedProxySshServerCaPrivateKey + }); + }); + + const proxyPkiClientCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxyPkiClientCaPrivateKey + }); + const proxyPkiClientCaCertificate = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxyPkiClientCaCertificate + }); + const proxyPkiClientCaCertificateChain = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxyPkiClientCaCertificateChain + }); + + const proxyPkiServerCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxyPkiServerCaPrivateKey + }); + const proxyPkiServerCaCertificate = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxyPkiServerCaCertificate + }); + const proxyPkiServerCaCertificateChain = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxyPkiServerCaCertificateChain + }); + + const proxySshClientCaPublicKey = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxySshClientCaPublicKey + }); + const proxySshClientCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxySshClientCaPrivateKey + }); + + const proxySshServerCaPublicKey = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxySshServerCaPublicKey + }); + const proxySshServerCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgProxyConfig.encryptedProxySshServerCaPrivateKey + }); + + return { + proxyPkiClientCaPrivateKey, + proxyPkiClientCaCertificate, + proxyPkiClientCaCertificateChain, + proxyPkiServerCaPrivateKey, + proxyPkiServerCaCertificate, + proxyPkiServerCaCertificateChain, + proxySshClientCaPublicKey, + proxySshClientCaPrivateKey, + proxySshServerCaPublicKey, + proxySshServerCaPrivateKey + }; + }; + + const generateSshCredentialsForGateway = async ({ proxyName, orgId }: { proxyName: string; orgId: string }) => { + let proxy: TProxies | null; + if (isInstanceProxy(proxyName)) { + proxy = await proxyDAL.findOne({ + name: proxyName + }); + } else { + proxy = await proxyDAL.findOne({ + orgId, + name: proxyName + }); + } + + if (!proxy) { + throw new NotFoundError({ + message: "Proxy not found" + }); + } + + const keyAlgorithm = SshCertKeyAlgorithm.RSA_2048; + const { publicKey: proxyClientSshPublicKey, privateKey: proxyClientSshPrivateKey } = + await createSshKeyPair(keyAlgorithm); + + if (isInstanceProxy(proxyName)) { + const instanceCAs = await $getInstanceCAs(); + const proxyClientSshCert = await createSshCert({ + caPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey.toString("utf8"), + clientPublicKey: proxyClientSshPublicKey, + keyId: `proxy-client-${proxy.id}`, + principals: [orgId], + certType: SshCertType.USER, + requestedTtl: "30d" + }); + + return { + clientSshCert: proxyClientSshCert.signedPublicKey, + clientSshPrivateKey: proxyClientSshPrivateKey, + serverCAPublicKey: instanceCAs.instanceProxySshServerCaPublicKey.toString("utf8") + }; + } + + const orgCAs = await $getOrgCAs(orgId); + const proxyClientSshCert = await createSshCert({ + caPrivateKey: orgCAs.proxySshServerCaPrivateKey.toString("utf8"), + clientPublicKey: proxyClientSshPublicKey, + keyId: `proxy-client-${proxy.id}`, + principals: [orgId], + certType: SshCertType.USER, + requestedTtl: "30d" + }); + + return { + clientSshCert: proxyClientSshCert.signedPublicKey, + clientSshPrivateKey: proxyClientSshPrivateKey, + serverCAPublicKey: orgCAs.proxySshServerCaPublicKey.toString("utf8") + }; + }; + + const $generateProxyCredentials = async ({ + ip, + orgId, + rootProxyPkiCaCertificate, + proxyPkiServerCaCertificate, + proxyPkiServerCaPrivateKey, + proxySshServerCaPrivateKey, + proxyPkiServerCaCertificateChain, + proxySshClientCaPublicKey + }: { + ip: string; + rootProxyPkiCaCertificate: Buffer; + proxyPkiServerCaCertificate: Buffer; + proxyPkiServerCaPrivateKey: Buffer; + proxySshServerCaPrivateKey: Buffer; + proxyPkiServerCaCertificateChain: Buffer; + proxySshClientCaPublicKey: Buffer; + orgId?: string; + }) => { const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); - const proxyServerCaCert = new x509.X509Certificate(instanceCAs.instanceProxyPkiServerCaCertificate); - const rootProxyCaCert = new x509.X509Certificate(instanceCAs.rootProxyPkiCaCertificate); + const proxyServerCaCert = new x509.X509Certificate(proxyPkiServerCaCertificate); + const rootProxyCaCert = new x509.X509Certificate(rootProxyPkiCaCertificate); const proxyServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ - key: instanceCAs.instanceProxyPkiServerCaPrivateKey, + key: proxyPkiServerCaPrivateKey, format: "der", type: "pkcs8" }); + const proxyServerCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( "pkcs8", proxyServerCaSkObj.export({ format: "der", type: "pkcs8" }), @@ -416,7 +704,7 @@ export const proxyServiceFactory = ({ const proxyServerSerialNumber = createSerialNumber(); const proxyServerCertificate = await x509.X509CertificateGenerator.create({ serialNumber: proxyServerSerialNumber, - subject: `CN=${ip},O=Infisical,OU=Proxy`, + subject: `CN=${ip},O=${orgId ?? "Infisical"},OU=Proxy`, issuer: proxyServerCaCert.subject, notBefore: proxyServerCertIssuedAt, notAfter: proxyServerCertExpireAt, @@ -432,7 +720,7 @@ export const proxyServiceFactory = ({ await createSshKeyPair(keyAlgorithm); const proxyServerSshCert = await createSshCert({ - caPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey.toString("utf8"), + caPrivateKey: proxySshServerCaPrivateKey.toString("utf8"), clientPublicKey: proxyServerSshPublicKey, keyId: "proxy-server", principals: [ip], @@ -445,7 +733,7 @@ export const proxyServiceFactory = ({ serverCertificate: proxyServerCertificate.toString("pem"), serverCertificateChain: prependCertToPemChain( proxyServerCaCert, - instanceCAs.instanceProxyPkiServerCaCertificateChain.toString("utf8") + proxyPkiServerCaCertificateChain.toString("utf8") ), serverPrivateKey: proxyServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), clientCA: rootProxyCaCert.toString("pem") @@ -453,12 +741,138 @@ export const proxyServiceFactory = ({ ssh: { serverCertificate: proxyServerSshCert.signedPublicKey, serverPrivateKey: proxyServerSshPrivateKey, - clientCAPublicKey: instanceCAs.instanceProxySshClientCaPublicKey.toString("utf8") + clientCAPublicKey: proxySshClientCaPublicKey.toString("utf8") } }; }; + const registerProxy = async ({ + ip, + name, + identityId, + orgId + }: { + ip: string; + name: string; + identityId?: string; + orgId?: string; + }) => { + let proxy: TProxies; + const isOrgProxy = identityId && orgId; + + if (isOrgProxy) { + // organization proxy + if (isInstanceProxy(name)) { + throw new BadRequestError({ + message: "Org proxy name cannot start with 'infisical-'. This is reserved for internal use." + }); + } + + proxy = await proxyDAL.transaction(async (tx) => { + const existingProxy = await proxyDAL.findOne( + { + identityId, + orgId + }, + tx + ); + + if (existingProxy && (existingProxy.ip !== ip || existingProxy.name !== name)) { + throw new BadRequestError({ + message: "Org proxy with this machine identity already exists." + }); + } + + if (!existingProxy) { + return proxyDAL.create( + { + ip, + name, + identityId, + orgId + }, + tx + ); + } + + return existingProxy; + }); + } else { + // instance proxy + if (!name.startsWith("infisical-")) { + throw new BadRequestError({ + message: "Instance proxy name must start with 'infisical-'." + }); + } + + proxy = await proxyDAL.transaction(async (tx) => { + const existingProxy = await proxyDAL.findOne( + { + name + }, + tx + ); + + if (existingProxy && existingProxy.ip !== ip) { + throw new BadRequestError({ + message: "Instance proxy with this name already exists" + }); + } + + if (!existingProxy) { + return proxyDAL.create( + { + ip, + name + }, + tx + ); + } + + return existingProxy; + }); + } + + if (isInstanceProxy(name)) { + const instanceCAs = await $getInstanceCAs(); + return $generateProxyCredentials({ + ip, + rootProxyPkiCaCertificate: instanceCAs.rootProxyPkiCaCertificate, + + proxyPkiServerCaCertificate: instanceCAs.instanceProxyPkiServerCaCertificate, + proxyPkiServerCaPrivateKey: instanceCAs.instanceProxyPkiServerCaPrivateKey, + proxyPkiServerCaCertificateChain: instanceCAs.instanceProxyPkiServerCaCertificateChain, + + proxySshServerCaPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey, + proxySshClientCaPublicKey: instanceCAs.instanceProxySshClientCaPublicKey + }); + } + + if (proxy.orgId) { + const orgCAs = await $getOrgCAs(proxy.orgId); + const instanceCAs = await $getInstanceCAs(); + + return $generateProxyCredentials({ + ip, + orgId: proxy.orgId, + rootProxyPkiCaCertificate: instanceCAs.rootProxyPkiCaCertificate, + + proxyPkiServerCaCertificate: orgCAs.proxyPkiServerCaCertificate, + proxyPkiServerCaPrivateKey: orgCAs.proxyPkiServerCaPrivateKey, + proxyPkiServerCaCertificateChain: orgCAs.proxyPkiServerCaCertificateChain, + + proxySshServerCaPrivateKey: orgCAs.proxySshServerCaPrivateKey, + proxySshClientCaPublicKey: orgCAs.proxySshClientCaPublicKey + }); + } + + throw new BadRequestError({ + message: "Unhandled proxy type" + }); + }; + return { - registerProxy + registerProxy, + generateSshCredentialsForGateway }; }; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index c4583b574..d7b097cbc 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -14,7 +14,9 @@ export const PgSqlLock = { CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`), CreateFolder: (envId: string, projectId: string) => pgAdvisoryLockHashText(`create-folder:${envId}-${projectId}`), SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`), - InstanceProxyConfigInit: () => pgAdvisoryLockHashText("instance-proxy-config-init") + InstanceProxyConfigInit: () => pgAdvisoryLockHashText("instance-proxy-config-init"), + OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`), + OrgProxyConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-proxy-config-init:${orgId}`) } as const; // all the key prefixes used must be set here to avoid conflict diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 586a69655..6e926b21e 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -233,6 +233,8 @@ const envSchema = z GATEWAY_RELAY_REALM: zpStr(z.string().optional()), GATEWAY_RELAY_AUTH_SECRET: zpStr(z.string().optional()), + PROXY_AUTH_SECRET: zpStr(z.string().optional()), + DYNAMIC_SECRET_ALLOW_INTERNAL_IP: zodStrBool.default("false"), DYNAMIC_SECRET_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()).default( process.env.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 97c62b545..0d0926d35 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -121,6 +121,10 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { return; } + if (req.url.includes("/api/v1/proxies/register-instance-proxy")) { + return; + } + const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); if (!authMode) return; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ce2e5dac6..2952b062d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -147,6 +147,8 @@ import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; import { certificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { gatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; +import { orgGatewayConfigV2DalFactory } from "@app/ee/services/gateway-v2/org-gateway-config-v2-dal"; import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; @@ -317,6 +319,7 @@ import { registerV1Routes } from "./v1"; import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; +import { proxyDalFactory } from "@app/ee/services/proxy/proxy-dal"; const histogram = monitorEventLoopDelay({ resolution: 20 }); histogram.enable(); @@ -944,6 +947,9 @@ export const registerRoutes = async ( const instanceProxyConfigDAL = instanceProxyConfigDalFactory(db); const orgProxyConfigDAL = orgProxyConfigDalFactory(db); + const proxyDAL = proxyDalFactory(db); + + const orgGatewayConfigV2DAL = orgGatewayConfigV2DalFactory(db); const certificateService = certificateServiceFactory({ certificateDAL, @@ -1969,9 +1975,16 @@ export const registerRoutes = async ( const proxyService = proxyServiceFactory({ instanceProxyConfigDAL, orgProxyConfigDAL, + proxyDAL, kmsService }); + const gatewayV2Service = gatewayV2ServiceFactory({ + kmsService, + proxyService, + orgGatewayConfigV2DAL + }); + // setup the communication with license key server await licenseService.init(); @@ -2104,7 +2117,8 @@ export const registerRoutes = async ( reminder: reminderService, bus: eventBusService, sse: sseService, - proxy: proxyService + proxy: proxyService, + gatewayV2: gatewayV2Service }); const cronJobs: CronJob[] = []; From 2fb13463bcb7a651c0489cb9048feb0fbc8f8728 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 28 Aug 2025 18:21:51 +0800 Subject: [PATCH 05/46] misc: add schema for gateway --- ...1627_add-gateway-v2-pki-and-ssh-configs.ts | 23 +++++++++++++++++++ backend/src/db/schemas/models.ts | 3 ++- .../services/gateway-v2/gateway-v2-service.ts | 7 +++++- .../src/ee/services/proxy/proxy-service.ts | 10 ++++---- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts index 68aa15374..e35948ef1 100644 --- a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts +++ b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts @@ -104,6 +104,26 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.Proxy); } + + if (!(await knex.schema.hasTable(TableName.GatewayV2))) { + await knex.schema.createTable(TableName.GatewayV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.uuid("identityId").unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + + t.uuid("proxyId"); + t.foreign("proxyId").references("id").inTable(TableName.Proxy).onDelete("CASCADE"); + + t.string("name").notNullable().unique(); + }); + + await createOnUpdateTrigger(knex, TableName.GatewayV2); + } } export async function down(knex: Knex): Promise { @@ -118,4 +138,7 @@ export async function down(knex: Knex): Promise { await dropOnUpdateTrigger(knex, TableName.Proxy); await knex.schema.dropTableIfExists(TableName.Proxy); + + await dropOnUpdateTrigger(knex, TableName.GatewayV2); + await knex.schema.dropTableIfExists(TableName.GatewayV2); } diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 99681b986..87ea9f8e5 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -184,7 +184,8 @@ export enum TableName { InstanceProxyConfig = "instance_proxy_config", OrgProxyConfig = "org_proxy_config", OrgGatewayConfigV2 = "org_gateway_config_v2", - Proxy = "proxies" + Proxy = "proxies", + GatewayV2 = "gateways_v2" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; 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 e55acbffa..ec7617c93 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -200,6 +200,9 @@ export const gatewayV2ServiceFactory = ({ const registerGateway = async ({ orgId, proxyName }: { orgId: string; actorId: string; proxyName: string }) => { const orgCAs = await $getOrgCAs(orgId); + // TODO: Save gateway to DB and set Gateway ID as principal in SSH certificate + // only throw error if proxy is different from existing DB record + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const gatewayServerCaCert = new x509.X509Certificate(orgCAs.gatewayServerCaCertificate); const rootGatewayCaCert = new x509.X509Certificate(orgCAs.rootGatewayCaCertificate); @@ -248,12 +251,14 @@ export const gatewayV2ServiceFactory = ({ extensions: gatewayServerCertExtensions }); - const proxyCredentials = await proxyService.generateSshCredentialsForGateway({ + const proxyCredentials = await proxyService.getCredentialsForGateway({ proxyName, orgId }); return { + // TODO: return gateway ID + proxyIp: proxyCredentials.proxyIp, pki: { serverCertificate: gatewayServerCertificate.toString("pem"), serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert]), diff --git a/backend/src/ee/services/proxy/proxy-service.ts b/backend/src/ee/services/proxy/proxy-service.ts index 65ec9069e..9f6c45fe4 100644 --- a/backend/src/ee/services/proxy/proxy-service.ts +++ b/backend/src/ee/services/proxy/proxy-service.ts @@ -587,7 +587,7 @@ export const proxyServiceFactory = ({ }; }; - const generateSshCredentialsForGateway = async ({ proxyName, orgId }: { proxyName: string; orgId: string }) => { + const getCredentialsForGateway = async ({ proxyName, orgId }: { proxyName: string; orgId: string }) => { let proxy: TProxies | null; if (isInstanceProxy(proxyName)) { proxy = await proxyDAL.findOne({ @@ -616,12 +616,13 @@ export const proxyServiceFactory = ({ caPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey.toString("utf8"), clientPublicKey: proxyClientSshPublicKey, keyId: `proxy-client-${proxy.id}`, - principals: [orgId], + principals: ["gateway ID"], // TODO: set gateway ID as principal in SSH certificate certType: SshCertType.USER, requestedTtl: "30d" }); return { + proxyIp: proxy.ip, clientSshCert: proxyClientSshCert.signedPublicKey, clientSshPrivateKey: proxyClientSshPrivateKey, serverCAPublicKey: instanceCAs.instanceProxySshServerCaPublicKey.toString("utf8") @@ -639,6 +640,7 @@ export const proxyServiceFactory = ({ }); return { + proxyIp: proxy.ip, clientSshCert: proxyClientSshCert.signedPublicKey, clientSshPrivateKey: proxyClientSshPrivateKey, serverCAPublicKey: orgCAs.proxySshServerCaPublicKey.toString("utf8") @@ -723,7 +725,7 @@ export const proxyServiceFactory = ({ caPrivateKey: proxySshServerCaPrivateKey.toString("utf8"), clientPublicKey: proxyServerSshPublicKey, keyId: "proxy-server", - principals: [ip], + principals: [`${ip}:2222`], certType: SshCertType.HOST, requestedTtl: "30d" }); @@ -873,6 +875,6 @@ export const proxyServiceFactory = ({ return { registerProxy, - generateSshCredentialsForGateway + getCredentialsForGateway }; }; From 81dfcb5de17d52a8bdbd29f8fcb583a3df0227d8 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sat, 30 Aug 2025 05:51:07 +0800 Subject: [PATCH 06/46] feat: gateway registration + start of gateway v2 integration --- backend/src/@types/knex.d.ts | 4 + ...1627_add-gateway-v2-pki-and-ssh-configs.ts | 12 +- backend/src/db/schemas/gateways-v2.ts | 22 ++ backend/src/db/schemas/index.ts | 3 +- backend/src/ee/routes/v2/gateway-router.ts | 26 +- .../dynamic-secret/dynamic-secret-service.ts | 10 +- .../dynamic-secret/providers/index.ts | 7 +- .../dynamic-secret/providers/kubernetes.ts | 28 +- .../ee/services/gateway-v2/gateway-v2-dal.ts | 11 + .../services/gateway-v2/gateway-v2-service.ts | 196 ++++++++++- .../src/ee/services/proxy/proxy-service.ts | 310 +++++++++++++----- backend/src/server/routes/index.ts | 43 ++- frontend/src/hooks/api/gateways-v2/index.tsx | 1 + .../src/hooks/api/gateways-v2/queries.tsx | 18 + frontend/src/hooks/api/gateways-v2/types.ts | 11 + frontend/src/hooks/api/gateways/queries.tsx | 5 +- 16 files changed, 577 insertions(+), 130 deletions(-) create mode 100644 backend/src/db/schemas/gateways-v2.ts create mode 100644 backend/src/ee/services/gateway-v2/gateway-v2-dal.ts create mode 100644 frontend/src/hooks/api/gateways-v2/index.tsx create mode 100644 frontend/src/hooks/api/gateways-v2/queries.tsx create mode 100644 frontend/src/hooks/api/gateways-v2/types.ts diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f2b768eb6..da75c8d94 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -101,6 +101,9 @@ import { TGateways, TGatewaysInsert, TGatewaysUpdate, + TGatewaysV2, + TGatewaysV2Insert, + TGatewaysV2Update, TGitAppInstallSessions, TGitAppInstallSessionsInsert, TGitAppInstallSessionsUpdate, @@ -1282,5 +1285,6 @@ declare module "knex/types/tables" { TOrgGatewayConfigV2Update >; [TableName.Proxy]: KnexOriginal.CompositeTableType; + [TableName.GatewayV2]: KnexOriginal.CompositeTableType; } } diff --git a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts index e35948ef1..c21d739b8 100644 --- a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts +++ b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts @@ -110,14 +110,14 @@ export async function up(knex: Knex): Promise { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.timestamps(true, true, true); - t.uuid("orgId"); + t.uuid("orgId").notNullable(); t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - t.uuid("identityId").unique(); + t.uuid("identityId").notNullable().unique(); t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); t.uuid("proxyId"); - t.foreign("proxyId").references("id").inTable(TableName.Proxy).onDelete("CASCADE"); + t.foreign("proxyId").references("id").inTable(TableName.Proxy).onDelete("SET NULL"); t.string("name").notNullable().unique(); }); @@ -136,9 +136,9 @@ export async function down(knex: Knex): Promise { await dropOnUpdateTrigger(knex, TableName.OrgGatewayConfigV2); await knex.schema.dropTableIfExists(TableName.OrgGatewayConfigV2); - await dropOnUpdateTrigger(knex, TableName.Proxy); - await knex.schema.dropTableIfExists(TableName.Proxy); - await dropOnUpdateTrigger(knex, TableName.GatewayV2); await knex.schema.dropTableIfExists(TableName.GatewayV2); + + await dropOnUpdateTrigger(knex, TableName.Proxy); + await knex.schema.dropTableIfExists(TableName.Proxy); } diff --git a/backend/src/db/schemas/gateways-v2.ts b/backend/src/db/schemas/gateways-v2.ts new file mode 100644 index 000000000..722b39361 --- /dev/null +++ b/backend/src/db/schemas/gateways-v2.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GatewaysV2Schema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid(), + identityId: z.string().uuid(), + proxyId: z.string().uuid().nullable().optional(), + name: z.string() +}); + +export type TGatewaysV2 = z.infer; +export type TGatewaysV2Insert = Omit, TImmutableDBKeys>; +export type TGatewaysV2Update = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 03813ee49..5311265b5 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -31,6 +31,7 @@ export * from "./folder-commits"; export * from "./folder-tree-checkpoint-resources"; export * from "./folder-tree-checkpoints"; export * from "./gateways"; +export * from "./gateways-v2"; export * from "./git-app-install-sessions"; export * from "./git-app-org"; export * from "./github-org-sync-configs"; @@ -98,6 +99,7 @@ export * from "./project-templates"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; +export * from "./proxies"; export * from "./rate-limit"; export * from "./resource-metadata"; export * from "./saml-configs"; @@ -165,4 +167,3 @@ export * from "./user-group-membership"; export * from "./users"; export * from "./webhooks"; export * from "./workflow-integrations"; -export * from "./proxies"; diff --git a/backend/src/ee/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts index 31130b206..64b794fad 100644 --- a/backend/src/ee/routes/v2/gateway-router.ts +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -7,23 +7,43 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/", - onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { body: z.object({ - proxyName: z.string() + proxyName: z.string(), + name: z.string() }), response: { 200: z.any() } }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const gateway = await server.services.gatewayV2.registerGateway({ orgId: req.permission.orgId, proxyName: req.body.proxyName, - actorId: req.permission.id + actorId: req.permission.id, + name: req.body.name }); return gateway; } }); + + server.route({ + method: "GET", + url: "/", + schema: { + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const gateways = await server.services.gatewayV2.listGateways({ + orgPermission: req.permission + }); + + return gateways; + } + }); }; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 73dcbe6e3..fe8c98d95 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -19,6 +19,7 @@ import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-fold import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal"; import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue"; import { TGatewayDALFactory } from "../gateway/gateway-dal"; +import { TGatewayV2DALFactory } from "../gateway-v2/gateway-v2-dal"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; import { DynamicSecretStatus, TDynamicSecretServiceFactory } from "./dynamic-secret-types"; @@ -39,6 +40,7 @@ type TDynamicSecretServiceFactoryDep = { permissionService: Pick; kmsService: Pick; gatewayDAL: Pick; + gatewayV2DAL: Pick; resourceMetadataDAL: Pick; }; @@ -53,6 +55,7 @@ export const dynamicSecretServiceFactory = ({ projectDAL, kmsService, gatewayDAL, + gatewayV2DAL, resourceMetadataDAL }: TDynamicSecretServiceFactoryDep): TDynamicSecretServiceFactory => { const create: TDynamicSecretServiceFactory["create"] = async ({ @@ -118,8 +121,9 @@ export const dynamicSecretServiceFactory = ({ const gatewayId = inputs.gatewayId as string; const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actorOrgId }); + const [gatewayv2] = await gatewayV2DAL.find({ id: gatewayId, orgId: actorOrgId }); - if (!gateway) { + if (!gateway && !gatewayv2) { throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found` }); @@ -128,7 +132,7 @@ export const dynamicSecretServiceFactory = ({ const { permission: orgPermission } = await permissionService.getOrgPermission( actor, actorId, - gateway.orgId, + gateway?.orgId ?? gatewayv2?.orgId, actorAuthMethod, actorOrgId ); @@ -138,7 +142,7 @@ export const dynamicSecretServiceFactory = ({ OrgPermissionSubjects.Gateway ); - selectedGatewayId = gateway.id; + selectedGatewayId = gateway?.id ?? gatewayv2?.id; } const isConnected = await selectedProvider.validateConnection(provider.inputs, { projectId }); diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 184b9fc89..7907a10df 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,6 +1,7 @@ import { SnowflakeProvider } from "@app/ee/services/dynamic-secret/providers/snowflake"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; @@ -24,10 +25,12 @@ import { VerticaProvider } from "./vertica"; type TBuildDynamicSecretProviderDTO = { gatewayService: Pick; + gatewayV2Service: Pick; }; export const buildDynamicSecretProviders = ({ - gatewayService + gatewayService, + gatewayV2Service }: TBuildDynamicSecretProviderDTO): Record => ({ [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider({ gatewayService }), [DynamicSecretProviders.Cassandra]: CassandraProvider(), @@ -44,7 +47,7 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), [DynamicSecretProviders.Totp]: TotpProvider(), [DynamicSecretProviders.SapAse]: SapAseProvider(), - [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), + [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService, gatewayV2Service }), [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }), [DynamicSecretProviders.GcpIam]: GcpIamProvider(), [DynamicSecretProviders.Github]: GithubProvider(), diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index 3d69c3282..82add3738 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -5,12 +5,14 @@ import https from "https"; import { BadRequestError } from "@app/lib/errors"; import { sanitizeString } from "@app/lib/fn"; import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; import { TDynamicSecretKubernetesLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; import { DynamicSecretKubernetesSchema, KubernetesAuthMethod, @@ -26,6 +28,7 @@ const GATEWAY_AUTH_DEFAULT_URL = "https://kubernetes.default.svc.cluster.local"; type TKubernetesProviderDTO = { gatewayService: Pick; + gatewayV2Service: Pick; }; const generateUsername = (usernameTemplate?: string | null) => { @@ -38,7 +41,10 @@ const generateUsername = (usernameTemplate?: string | null) => { }); }; -export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => { +export const KubernetesProvider = ({ + gatewayService, + gatewayV2Service +}: TKubernetesProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs); if (!providerInputs.gatewayId && providerInputs.url) { @@ -58,6 +64,26 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): }, gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise ): Promise => { + const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId(inputs.gatewayId); + if (gatewayV2ConnectionDetails) { + const callbackResult = await withGatewayV2Proxy( + async (port) => { + return gatewayCallback( + inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost", + port, + inputs.httpsAgent + ); + }, + { + proxyIp: gatewayV2ConnectionDetails.proxyIp, + gateway: gatewayV2ConnectionDetails.gateway, + proxy: gatewayV2ConnectionDetails.proxy + } + ); + + return callbackResult; + } + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts new file mode 100644 index 000000000..763858de0 --- /dev/null +++ b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TGatewayV2DALFactory = ReturnType; + +export const gatewayV2DalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.GatewayV2); + + return orm; +}; 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 ec7617c93..f2f78f480 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -1,7 +1,11 @@ import * as x509 from "@peculiar/x509"; +import { TProxies } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { @@ -11,13 +15,18 @@ import { import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProxyDALFactory } from "../proxy/proxy-dal"; +import { isInstanceProxy } from "../proxy/proxy-fns"; import { TProxyServiceFactory } from "../proxy/proxy-service"; +import { TGatewayV2DALFactory } from "./gateway-v2-dal"; import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal"; type TGatewayV2ServiceFactoryDep = { orgGatewayConfigV2DAL: Pick; kmsService: TKmsServiceFactory; proxyService: TProxyServiceFactory; + gatewayV2DAL: TGatewayV2DALFactory; + proxyDAL: TProxyDALFactory; }; export type TGatewayV2ServiceFactory = ReturnType; @@ -25,7 +34,9 @@ export type TGatewayV2ServiceFactory = ReturnType { const $getOrgCAs = async (orgId: string) => { const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -197,11 +208,179 @@ export const gatewayV2ServiceFactory = ({ }; }; - const registerGateway = async ({ orgId, proxyName }: { orgId: string; actorId: string; proxyName: string }) => { + const listGateways = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { + // const { permission } = await permissionService.getOrgPermission( + // orgPermission.type, + // orgPermission.id, + // orgPermission.orgId, + // orgPermission.authMethod, + // orgPermission.orgId + // ); + // ForbiddenError.from(permission).throwUnlessCan( + // OrgPermissionGatewayActions.ListGateways, + // OrgPermissionSubjects.Gateway + // ); + + const orgGatewayConfig = await orgGatewayConfigV2DAL.findOne({ orgId: orgPermission.orgId }); + if (!orgGatewayConfig) return []; + + const gateways = await gatewayV2DAL.find({ + orgId: orgPermission.orgId + }); + + return gateways; + }; + + const getPlatformConnectionDetailsByGatewayId = async (gatewayId: string) => { + const gateway = await gatewayV2DAL.findById(gatewayId); + if (!gateway) { + return; + } + + const orgGatewayConfig = await orgGatewayConfigV2DAL.findOne({ orgId: gateway.orgId }); + if (!orgGatewayConfig) { + throw new NotFoundError({ message: `Gateway Config for org ${gateway.orgId} not found.` }); + } + + if (!gateway.proxyId) { + throw new BadRequestError({ + message: "Gateway is not associated with a proxy" + }); + } + + // const orgLicensePlan = await licenseService.getPlan(orgGatewayConfig.orgId); + // if (!orgLicensePlan.gateway) { + // throw new BadRequestError({ + // message: "Please upgrade your instance to Infisical's Enterprise plan to use gateways." + // }); + // } + + const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgGatewayConfig.orgId + }); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + + const rootGatewayCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedRootGatewayCaCertificate + }) + ); + + const gatewayClientCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaCertificate + }) + ); + + const gatewayClientCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaPrivateKey + }); + + const gatewayClientCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: gatewayClientCaPrivateKey, + format: "der", + type: "pkcs8" + }); + + const importedGatewayClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + gatewayClientCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const clientCertIssuedAt = new Date(); + const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000); + const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const clientCertSerialNumber = createSerialNumber(); + + const clientCert = await x509.X509CertificateGenerator.create({ + serialNumber: clientCertSerialNumber, + subject: `O=${orgGatewayConfig.orgId},OU=gateway-client,CN=${ActorType.PLATFORM}:${gatewayId}`, + issuer: gatewayClientCaCert.subject, + notAfter: clientCertExpiration, + notBefore: clientCertIssuedAt, + signingKey: importedGatewayClientCaPrivateKey, + publicKey: clientKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(gatewayClientCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | + x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] | + x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true) + ] + }); + const gatewayClientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); + + const proxyCredentials = await proxyService.getCredentialsForClient({ + proxyId: gateway.proxyId, + orgId: gateway.orgId, + gatewayId, + actor: ActorType.PLATFORM + }); + + return { + proxyIp: proxyCredentials.proxyIp, + gateway: { + clientCertificate: clientCert.toString("pem"), + clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + clientCertificateChain: constructPemChainFromCerts([gatewayClientCaCert, rootGatewayCaCert]), + serverCA: rootGatewayCaCert.toString("pem") + }, + proxy: { + clientCertificate: proxyCredentials.clientCertificate, + clientPrivateKey: proxyCredentials.clientPrivateKey, + serverCertificateChain: proxyCredentials.serverCertificateChain + } + }; + }; + + const registerGateway = async ({ + orgId, + actorId, + proxyName, + name + }: { + orgId: string; + actorId: string; + proxyName: string; + name: string; + }) => { const orgCAs = await $getOrgCAs(orgId); - // TODO: Save gateway to DB and set Gateway ID as principal in SSH certificate - // only throw error if proxy is different from existing DB record + let proxy: TProxies; + if (isInstanceProxy(proxyName)) { + proxy = await proxyDAL.findOne({ name: proxyName }); + } else { + proxy = await proxyDAL.findOne({ orgId, name: proxyName }); + } + + if (!proxy) { + throw new Error("Proxy not found"); + } + + const [gateway] = await gatewayV2DAL.upsert( + [ + { + orgId, + name, + identityId: actorId, + proxyId: proxy.id + } + ], + ["identityId"] + ); const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const gatewayServerCaCert = new x509.X509Certificate(orgCAs.gatewayServerCaCertificate); @@ -253,11 +432,12 @@ export const gatewayV2ServiceFactory = ({ const proxyCredentials = await proxyService.getCredentialsForGateway({ proxyName, - orgId + orgId, + gatewayId: gateway.id }); return { - // TODO: return gateway ID + gatewayId: gateway.id, proxyIp: proxyCredentials.proxyIp, pki: { serverCertificate: gatewayServerCertificate.toString("pem"), @@ -274,6 +454,8 @@ export const gatewayV2ServiceFactory = ({ }; return { - registerGateway + listGateways, + registerGateway, + getPlatformConnectionDetailsByGatewayId }; }; diff --git a/backend/src/ee/services/proxy/proxy-service.ts b/backend/src/ee/services/proxy/proxy-service.ts index 9f6c45fe4..2576ff1fe 100644 --- a/backend/src/ee/services/proxy/proxy-service.ts +++ b/backend/src/ee/services/proxy/proxy-service.ts @@ -4,6 +4,7 @@ import { TProxies } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { 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"; import { @@ -89,7 +90,7 @@ export const proxyServiceFactory = ({ x509.KeyUsageFlags.keyEncipherment, true ), - new x509.BasicConstraintsExtension(true, 0, true), + new x509.BasicConstraintsExtension(true, 2, true), await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), await x509.SubjectKeyIdentifierExtension.create(orgProxyCaKeys.publicKey) ] @@ -120,7 +121,7 @@ export const proxyServiceFactory = ({ x509.KeyUsageFlags.keyEncipherment, true ), - new x509.BasicConstraintsExtension(true, 0, true), + new x509.BasicConstraintsExtension(true, 1, true), await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), await x509.SubjectKeyIdentifierExtension.create(instanceProxyCaKeys.publicKey) ] @@ -587,88 +588,28 @@ export const proxyServiceFactory = ({ }; }; - const getCredentialsForGateway = async ({ proxyName, orgId }: { proxyName: string; orgId: string }) => { - let proxy: TProxies | null; - if (isInstanceProxy(proxyName)) { - proxy = await proxyDAL.findOne({ - name: proxyName - }); - } else { - proxy = await proxyDAL.findOne({ - orgId, - name: proxyName - }); - } - - if (!proxy) { - throw new NotFoundError({ - message: "Proxy not found" - }); - } - - const keyAlgorithm = SshCertKeyAlgorithm.RSA_2048; - const { publicKey: proxyClientSshPublicKey, privateKey: proxyClientSshPrivateKey } = - await createSshKeyPair(keyAlgorithm); - - if (isInstanceProxy(proxyName)) { - const instanceCAs = await $getInstanceCAs(); - const proxyClientSshCert = await createSshCert({ - caPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey.toString("utf8"), - clientPublicKey: proxyClientSshPublicKey, - keyId: `proxy-client-${proxy.id}`, - principals: ["gateway ID"], // TODO: set gateway ID as principal in SSH certificate - certType: SshCertType.USER, - requestedTtl: "30d" - }); - - return { - proxyIp: proxy.ip, - clientSshCert: proxyClientSshCert.signedPublicKey, - clientSshPrivateKey: proxyClientSshPrivateKey, - serverCAPublicKey: instanceCAs.instanceProxySshServerCaPublicKey.toString("utf8") - }; - } - - const orgCAs = await $getOrgCAs(orgId); - const proxyClientSshCert = await createSshCert({ - caPrivateKey: orgCAs.proxySshServerCaPrivateKey.toString("utf8"), - clientPublicKey: proxyClientSshPublicKey, - keyId: `proxy-client-${proxy.id}`, - principals: [orgId], - certType: SshCertType.USER, - requestedTtl: "30d" - }); - - return { - proxyIp: proxy.ip, - clientSshCert: proxyClientSshCert.signedPublicKey, - clientSshPrivateKey: proxyClientSshPrivateKey, - serverCAPublicKey: orgCAs.proxySshServerCaPublicKey.toString("utf8") - }; - }; - - const $generateProxyCredentials = async ({ + const $generateProxyServerCredentials = async ({ ip, orgId, - rootProxyPkiCaCertificate, proxyPkiServerCaCertificate, proxyPkiServerCaPrivateKey, - proxySshServerCaPrivateKey, - proxyPkiServerCaCertificateChain, - proxySshClientCaPublicKey + proxyPkiClientCaCertificate, + proxyPkiClientCaCertificateChain, + proxySshClientCaPublicKey, + proxySshServerCaPrivateKey }: { ip: string; - rootProxyPkiCaCertificate: Buffer; proxyPkiServerCaCertificate: Buffer; proxyPkiServerCaPrivateKey: Buffer; + proxyPkiClientCaCertificateChain: Buffer; + proxyPkiClientCaCertificate: Buffer; proxySshServerCaPrivateKey: Buffer; - proxyPkiServerCaCertificateChain: Buffer; proxySshClientCaPublicKey: Buffer; orgId?: string; }) => { const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const proxyServerCaCert = new x509.X509Certificate(proxyPkiServerCaCertificate); - const rootProxyCaCert = new x509.X509Certificate(rootProxyPkiCaCertificate); + const proxyClientCaCert = new x509.X509Certificate(proxyPkiClientCaCertificate); const proxyServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ key: proxyPkiServerCaPrivateKey, format: "der", @@ -733,12 +674,11 @@ export const proxyServiceFactory = ({ return { pki: { serverCertificate: proxyServerCertificate.toString("pem"), - serverCertificateChain: prependCertToPemChain( - proxyServerCaCert, - proxyPkiServerCaCertificateChain.toString("utf8") - ), serverPrivateKey: proxyServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), - clientCA: rootProxyCaCert.toString("pem") + clientCertificateChain: prependCertToPemChain( + proxyClientCaCert, + proxyPkiClientCaCertificateChain.toString("utf8") + ) }, ssh: { serverCertificate: proxyServerSshCert.signedPublicKey, @@ -748,6 +688,205 @@ export const proxyServiceFactory = ({ }; }; + const $generateProxyClientCredentials = async ({ + actor, + gatewayId, + orgId, + proxyPkiClientCaCertificate, + proxyPkiClientCaPrivateKey, + proxyPkiServerCaCertificate, + proxyPkiServerCaCertificateChain + }: { + actor: ActorType; + gatewayId: string; + orgId: string; + proxyPkiClientCaCertificate: Buffer; + proxyPkiClientCaPrivateKey: Buffer; + proxyPkiServerCaCertificate: Buffer; + proxyPkiServerCaCertificateChain: Buffer; + }) => { + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const proxyClientCaCert = new x509.X509Certificate(proxyPkiClientCaCertificate); + const proxyServerCaCert = new x509.X509Certificate(proxyPkiServerCaCertificate); + const proxyClientCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: proxyPkiClientCaPrivateKey, + format: "der", + type: "pkcs8" + }); + + const importedProxyClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + proxyClientCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const clientCertIssuedAt = new Date(); + const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000); + const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); + const clientCertSerialNumber = createSerialNumber(); + + const clientCert = await x509.X509CertificateGenerator.create({ + serialNumber: clientCertSerialNumber, + subject: `O=${orgId},OU=proxy-client,CN=${actor}:${gatewayId}`, + issuer: proxyClientCaCert.subject, + notAfter: clientCertExpiration, + notBefore: clientCertIssuedAt, + signingKey: importedProxyClientCaPrivateKey, + publicKey: clientKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(proxyClientCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | + x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] | + x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true) + ] + }); + + return { + clientCertificate: clientCert.toString("pem"), + clientPrivateKey: clientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + serverCertificateChain: prependCertToPemChain( + proxyServerCaCert, + proxyPkiServerCaCertificateChain.toString("utf8") + ) + }; + }; + + const getCredentialsForGateway = async ({ + proxyName, + orgId, + gatewayId + }: { + proxyName: string; + orgId: string; + gatewayId: string; + }) => { + let proxy: TProxies | null; + if (isInstanceProxy(proxyName)) { + proxy = await proxyDAL.findOne({ + name: proxyName + }); + } else { + proxy = await proxyDAL.findOne({ + orgId, + name: proxyName + }); + } + + if (!proxy) { + throw new NotFoundError({ + message: "Proxy not found" + }); + } + + const keyAlgorithm = SshCertKeyAlgorithm.RSA_2048; + const { publicKey: proxyClientSshPublicKey, privateKey: proxyClientSshPrivateKey } = + await createSshKeyPair(keyAlgorithm); + + if (isInstanceProxy(proxyName)) { + const instanceCAs = await $getInstanceCAs(); + const proxyClientSshCert = await createSshCert({ + caPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey.toString("utf8"), + clientPublicKey: proxyClientSshPublicKey, + keyId: `proxy-client-${proxy.id}`, + principals: [gatewayId], + certType: SshCertType.USER, + requestedTtl: "30d" + }); + + return { + proxyIp: proxy.ip, + clientSshCert: proxyClientSshCert.signedPublicKey, + clientSshPrivateKey: proxyClientSshPrivateKey, + serverCAPublicKey: instanceCAs.instanceProxySshServerCaPublicKey.toString("utf8") + }; + } + + const orgCAs = await $getOrgCAs(orgId); + const proxyClientSshCert = await createSshCert({ + caPrivateKey: orgCAs.proxySshServerCaPrivateKey.toString("utf8"), + clientPublicKey: proxyClientSshPublicKey, + keyId: `proxy-client-${proxy.id}`, + principals: [gatewayId], + certType: SshCertType.USER, + requestedTtl: "30d" + }); + + return { + proxyIp: proxy.ip, + clientSshCert: proxyClientSshCert.signedPublicKey, + clientSshPrivateKey: proxyClientSshPrivateKey, + serverCAPublicKey: orgCAs.proxySshServerCaPublicKey.toString("utf8") + }; + }; + + const getCredentialsForClient = async ({ + proxyId, + orgId, + gatewayId, + actor + }: { + proxyId: string; + orgId: string; + gatewayId: string; + actor: ActorType; + }) => { + const proxy = await proxyDAL.findOne({ + id: proxyId + }); + + if (!proxy) { + throw new NotFoundError({ + message: "Proxy not found" + }); + } + + if (isInstanceProxy(proxy.name)) { + const instanceCAs = await $getInstanceCAs(); + const proxyCertificateCredentials = await $generateProxyClientCredentials({ + actor, + gatewayId, + orgId, + proxyPkiClientCaCertificate: instanceCAs.instanceProxyPkiClientCaCertificate, + proxyPkiClientCaPrivateKey: instanceCAs.instanceProxyPkiClientCaPrivateKey, + proxyPkiServerCaCertificate: instanceCAs.instanceProxyPkiServerCaCertificate, + proxyPkiServerCaCertificateChain: instanceCAs.instanceProxyPkiServerCaCertificateChain + }); + + return { + ...proxyCertificateCredentials, + proxyIp: proxy.ip + }; + } + + const orgCAs = await $getOrgCAs(orgId); + const proxyCertificateCredentials = await $generateProxyClientCredentials({ + actor, + gatewayId, + orgId, + proxyPkiClientCaCertificate: orgCAs.proxyPkiClientCaCertificate, + proxyPkiClientCaPrivateKey: orgCAs.proxyPkiClientCaPrivateKey, + proxyPkiServerCaCertificate: orgCAs.proxyPkiServerCaCertificate, + proxyPkiServerCaCertificateChain: orgCAs.proxyPkiServerCaCertificateChain + }); + + return { + ...proxyCertificateCredentials, + proxyIp: proxy.ip + }; + }; + const registerProxy = async ({ ip, name, @@ -837,14 +976,12 @@ export const proxyServiceFactory = ({ if (isInstanceProxy(name)) { const instanceCAs = await $getInstanceCAs(); - return $generateProxyCredentials({ + return $generateProxyServerCredentials({ ip, - rootProxyPkiCaCertificate: instanceCAs.rootProxyPkiCaCertificate, - proxyPkiServerCaCertificate: instanceCAs.instanceProxyPkiServerCaCertificate, proxyPkiServerCaPrivateKey: instanceCAs.instanceProxyPkiServerCaPrivateKey, - proxyPkiServerCaCertificateChain: instanceCAs.instanceProxyPkiServerCaCertificateChain, - + proxyPkiClientCaCertificate: instanceCAs.instanceProxyPkiClientCaCertificate, + proxyPkiClientCaCertificateChain: instanceCAs.instanceProxyPkiClientCaCertificateChain, proxySshServerCaPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey, proxySshClientCaPublicKey: instanceCAs.instanceProxySshClientCaPublicKey }); @@ -852,17 +989,13 @@ export const proxyServiceFactory = ({ if (proxy.orgId) { const orgCAs = await $getOrgCAs(proxy.orgId); - const instanceCAs = await $getInstanceCAs(); - - return $generateProxyCredentials({ + return $generateProxyServerCredentials({ ip, orgId: proxy.orgId, - rootProxyPkiCaCertificate: instanceCAs.rootProxyPkiCaCertificate, - proxyPkiServerCaCertificate: orgCAs.proxyPkiServerCaCertificate, proxyPkiServerCaPrivateKey: orgCAs.proxyPkiServerCaPrivateKey, - proxyPkiServerCaCertificateChain: orgCAs.proxyPkiServerCaCertificateChain, - + proxyPkiClientCaCertificate: orgCAs.proxyPkiClientCaCertificate, + proxyPkiClientCaCertificateChain: orgCAs.proxyPkiClientCaCertificateChain, proxySshServerCaPrivateKey: orgCAs.proxySshServerCaPrivateKey, proxySshClientCaPublicKey: orgCAs.proxySshClientCaPublicKey }); @@ -875,6 +1008,7 @@ export const proxyServiceFactory = ({ return { registerProxy, - getCredentialsForGateway + getCredentialsForGateway, + getCredentialsForClient }; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2952b062d..bf8e1d364 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -38,6 +38,9 @@ import { externalKmsServiceFactory } from "@app/ee/services/external-kms/externa import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal"; +import { gatewayV2DalFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal"; +import { gatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; +import { orgGatewayConfigV2DalFactory } from "@app/ee/services/gateway-v2/org-gateway-config-v2-dal"; import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github-org-sync-dal"; import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { groupDALFactory } from "@app/ee/services/group/group-dal"; @@ -72,6 +75,7 @@ import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/proje import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { instanceProxyConfigDalFactory } from "@app/ee/services/proxy/instance-proxy-config-dal"; import { orgProxyConfigDalFactory } from "@app/ee/services/proxy/org-proxy-config-dal"; +import { proxyDalFactory } from "@app/ee/services/proxy/proxy-dal"; import { proxyServiceFactory } from "@app/ee/services/proxy/proxy-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; import { rateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; @@ -147,8 +151,6 @@ import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; import { certificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; -import { gatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; -import { orgGatewayConfigV2DalFactory } from "@app/ee/services/gateway-v2/org-gateway-config-v2-dal"; import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; @@ -319,7 +321,6 @@ import { registerV1Routes } from "./v1"; import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; -import { proxyDalFactory } from "@app/ee/services/proxy/proxy-dal"; const histogram = monitorEventLoopDelay({ resolution: 20 }); histogram.enable(); @@ -948,6 +949,7 @@ export const registerRoutes = async ( const instanceProxyConfigDAL = instanceProxyConfigDalFactory(db); const orgProxyConfigDAL = orgProxyConfigDalFactory(db); const proxyDAL = proxyDalFactory(db); + const gatewayV2DAL = gatewayV2DalFactory(db); const orgGatewayConfigV2DAL = orgGatewayConfigV2DalFactory(db); @@ -1626,9 +1628,26 @@ export const registerRoutes = async ( identityAuthTemplateDAL }); - const dynamicSecretProviders = buildDynamicSecretProviders({ - gatewayService + const proxyService = proxyServiceFactory({ + instanceProxyConfigDAL, + orgProxyConfigDAL, + proxyDAL, + kmsService }); + + const gatewayV2Service = gatewayV2ServiceFactory({ + kmsService, + proxyService, + orgGatewayConfigV2DAL, + gatewayV2DAL, + proxyDAL + }); + + const dynamicSecretProviders = buildDynamicSecretProviders({ + gatewayService, + gatewayV2Service + }); + const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, dynamicSecretLeaseDAL, @@ -1648,6 +1667,7 @@ export const registerRoutes = async ( licenseService, kmsService, gatewayDAL, + gatewayV2DAL, resourceMetadataDAL }); @@ -1972,19 +1992,6 @@ export const registerRoutes = async ( appConnectionDAL }); - const proxyService = proxyServiceFactory({ - instanceProxyConfigDAL, - orgProxyConfigDAL, - proxyDAL, - kmsService - }); - - const gatewayV2Service = gatewayV2ServiceFactory({ - kmsService, - proxyService, - orgGatewayConfigV2DAL - }); - // setup the communication with license key server await licenseService.init(); diff --git a/frontend/src/hooks/api/gateways-v2/index.tsx b/frontend/src/hooks/api/gateways-v2/index.tsx new file mode 100644 index 000000000..c4a4e685c --- /dev/null +++ b/frontend/src/hooks/api/gateways-v2/index.tsx @@ -0,0 +1 @@ +export { gatewaysV2QueryKeys } from "./queries"; diff --git a/frontend/src/hooks/api/gateways-v2/queries.tsx b/frontend/src/hooks/api/gateways-v2/queries.tsx new file mode 100644 index 000000000..8c3184778 --- /dev/null +++ b/frontend/src/hooks/api/gateways-v2/queries.tsx @@ -0,0 +1,18 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TGatewayV2 } from "./types"; + +export const gatewaysV2QueryKeys = { + allKey: () => ["gateways-v2"], + listKey: () => [...gatewaysV2QueryKeys.allKey(), "list"], + list: () => + queryOptions({ + queryKey: gatewaysV2QueryKeys.listKey(), + queryFn: async () => { + const { data } = await apiRequest.get<{ gateways: TGatewayV2[] }>("/api/v2/gateways"); + return data.gateways; + } + }) +}; diff --git a/frontend/src/hooks/api/gateways-v2/types.ts b/frontend/src/hooks/api/gateways-v2/types.ts new file mode 100644 index 000000000..40a0bf5b4 --- /dev/null +++ b/frontend/src/hooks/api/gateways-v2/types.ts @@ -0,0 +1,11 @@ +export type TGatewayV2 = { + id: string; + identityId: string; + name: string; + createdAt: string; + updatedAt: string; + identity: { + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/gateways/queries.tsx b/frontend/src/hooks/api/gateways/queries.tsx index bb05b17a4..64cb18c79 100644 --- a/frontend/src/hooks/api/gateways/queries.tsx +++ b/frontend/src/hooks/api/gateways/queries.tsx @@ -2,6 +2,7 @@ import { queryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { TGatewayV2 } from "../gateways-v2/types"; import { TGateway } from "./types"; export const gatewaysQueryKeys = { @@ -12,7 +13,9 @@ export const gatewaysQueryKeys = { queryKey: gatewaysQueryKeys.listKey(), queryFn: async () => { const { data } = await apiRequest.get<{ gateways: TGateway[] }>("/api/v1/gateways"); - return data.gateways; + const { data: dataV2 } = await apiRequest.get("/api/v2/gateways"); + + return [...data.gateways, ...dataV2]; } }) }; From 879361fd7a96256945393d75015bcb5ea1982a95 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 2 Sep 2025 03:46:57 +0800 Subject: [PATCH 07/46] feat: half-way done through integrating with platform --- ...0250901091637_add-gateway-v2-id-columns.ts | 33 +++ backend/src/db/schemas/dynamic-secrets.ts | 3 +- .../db/schemas/identity-kubernetes-auths.ts | 3 +- .../dynamic-secret/dynamic-secret-service.ts | 24 +- .../dynamic-secret/providers/index.ts | 2 +- .../dynamic-secret/providers/kubernetes.ts | 52 +++- .../dynamic-secret/providers/sql-database.ts | 28 +- .../gateway-v2/gateway-v2-constants.ts | 2 + .../services/gateway-v2/gateway-v2-service.ts | 71 ++++- .../src/ee/services/proxy/proxy-service.ts | 42 ++- .../secret-rotation-v2-service.ts | 14 +- .../secret-rotation-v2-types.ts | 4 +- .../sql-credentials-rotation-fns.ts | 3 +- backend/src/lib/gateway-v2/gateway-v2.ts | 278 ++++++++++++++++++ backend/src/server/routes/index.ts | 41 +-- .../app-connection/app-connection-fns.ts | 6 +- .../app-connection/app-connection-service.ts | 23 +- .../app-connection/app-connection-types.ts | 7 +- .../shared/sql/sql-connection-fns.ts | 51 +++- .../identity-kubernetes-auth-service.ts | 107 +++++-- 20 files changed, 683 insertions(+), 111 deletions(-) create mode 100644 backend/src/db/migrations/20250901091637_add-gateway-v2-id-columns.ts create mode 100644 backend/src/ee/services/gateway-v2/gateway-v2-constants.ts create mode 100644 backend/src/lib/gateway-v2/gateway-v2.ts diff --git a/backend/src/db/migrations/20250901091637_add-gateway-v2-id-columns.ts b/backend/src/db/migrations/20250901091637_add-gateway-v2-id-columns.ts new file mode 100644 index 000000000..cb46b1261 --- /dev/null +++ b/backend/src/db/migrations/20250901091637_add-gateway-v2-id-columns.ts @@ -0,0 +1,33 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayV2Id"))) { + await knex.schema.alterTable(TableName.DynamicSecret, (table) => { + table.uuid("gatewayV2Id"); + table.foreign("gatewayV2Id").references("id").inTable(TableName.GatewayV2).onDelete("SET NULL"); + }); + } + + if (!(await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayV2Id"))) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.uuid("gatewayV2Id"); + table.foreign("gatewayV2Id").references("id").inTable(TableName.GatewayV2).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.DynamicSecret, "gatewayV2Id")) { + await knex.schema.alterTable(TableName.DynamicSecret, (table) => { + table.dropColumn("gatewayV2Id"); + }); + } + + if (await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "gatewayV2Id")) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.dropColumn("gatewayV2Id"); + }); + } +} diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts index 637d0c632..526239f1c 100644 --- a/backend/src/db/schemas/dynamic-secrets.ts +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -29,7 +29,8 @@ export const DynamicSecretsSchema = z.object({ encryptedInput: zodBuffer, projectGatewayId: z.string().uuid().nullable().optional(), gatewayId: z.string().uuid().nullable().optional(), - usernameTemplate: z.string().nullable().optional() + usernameTemplate: z.string().nullable().optional(), + gatewayV2Id: z.string().uuid().nullable().optional() }); export type TDynamicSecrets = z.infer; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts index deb78bf8a..4789ef365 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -32,7 +32,8 @@ export const IdentityKubernetesAuthsSchema = z.object({ encryptedKubernetesCaCertificate: zodBuffer.nullable().optional(), gatewayId: z.string().uuid().nullable().optional(), accessTokenPeriod: z.coerce.number().default(0), - tokenReviewMode: z.string().default("api") + tokenReviewMode: z.string().default("api"), + gatewayV2Id: z.string().uuid().nullable().optional() }); export type TIdentityKubernetesAuths = z.infer; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index fe8c98d95..279134804 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -73,6 +73,7 @@ export const dynamicSecretServiceFactory = ({ metadata, usernameTemplate }) => { + let isGatewayV1 = true; const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -129,6 +130,10 @@ export const dynamicSecretServiceFactory = ({ }); } + if (!gateway) { + isGatewayV1 = false; + } + const { permission: orgPermission } = await permissionService.getOrgPermission( actor, actorId, @@ -163,7 +168,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, folderId: folder.id, name, - gatewayId: selectedGatewayId, + gatewayId: isGatewayV1 ? selectedGatewayId : undefined, + gatewayV2Id: isGatewayV1 ? undefined : selectedGatewayId, usernameTemplate }, tx @@ -274,20 +280,27 @@ export const dynamicSecretServiceFactory = ({ const updatedInput = await selectedProvider.validateProviderInputs(newInput, { projectId }); let selectedGatewayId: string | null = null; + let isGatewayV1 = true; if (updatedInput && typeof updatedInput === "object" && "gatewayId" in updatedInput && updatedInput?.gatewayId) { const gatewayId = updatedInput.gatewayId as string; const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actorOrgId }); - if (!gateway) { + const [gatewayv2] = await gatewayV2DAL.find({ id: gatewayId, orgId: actorOrgId }); + + if (!gateway && !gatewayv2) { throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found` }); } + if (!gateway) { + isGatewayV1 = false; + } + const { permission: orgPermission } = await permissionService.getOrgPermission( actor, actorId, - gateway.orgId, + actorOrgId, actorAuthMethod, actorOrgId ); @@ -297,7 +310,7 @@ export const dynamicSecretServiceFactory = ({ OrgPermissionSubjects.Gateway ); - selectedGatewayId = gateway.id; + selectedGatewayId = gateway?.id ?? gatewayv2?.id; } const isConnected = await selectedProvider.validateConnection(newInput, { projectId }); @@ -313,7 +326,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, name: newName ?? name, status: null, - gatewayId: selectedGatewayId, + gatewayId: isGatewayV1 ? selectedGatewayId : null, + gatewayV2Id: isGatewayV1 ? null : selectedGatewayId, usernameTemplate }, tx diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 7907a10df..3ec0f795e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -32,7 +32,7 @@ export const buildDynamicSecretProviders = ({ gatewayService, gatewayV2Service }: TBuildDynamicSecretProviderDTO): Record => ({ - [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider({ gatewayService }), + [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider({ gatewayService, gatewayV2Service }), [DynamicSecretProviders.Cassandra]: CassandraProvider(), [DynamicSecretProviders.AwsIam]: AwsIamProvider(), [DynamicSecretProviders.Redis]: RedisDatabaseProvider(), diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index 82add3738..e60b11576 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -64,7 +64,11 @@ export const KubernetesProvider = ({ }, gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise ): Promise => { - const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId(inputs.gatewayId); + const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId: inputs.gatewayId, + targetHost: inputs.targetHost, + targetPort: inputs.targetPort + }); if (gatewayV2ConnectionDetails) { const callbackResult = await withGatewayV2Proxy( async (port) => { @@ -77,7 +81,9 @@ export const KubernetesProvider = ({ { proxyIp: gatewayV2ConnectionDetails.proxyIp, gateway: gatewayV2ConnectionDetails.gateway, - proxy: gatewayV2ConnectionDetails.proxy + proxy: gatewayV2ConnectionDetails.proxy, + protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, + httpsAgent: inputs.httpsAgent } ); @@ -379,8 +385,18 @@ export const KubernetesProvider = ({ return true; } catch (error) { let errorMessage = error instanceof Error ? error.message : "Unknown error"; - if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) { - errorMessage = (error.response?.data as { message: string }).message; + if (axios.isAxiosError(error)) { + if (error.response) { + let { message } = error?.response?.data as unknown as { message?: string }; + + if (!message && typeof error.response.data === "string") { + message = error.response.data; + } + + if (message) { + errorMessage = message; + } + } } const sanitizedErrorMessage = sanitizeString({ @@ -629,8 +645,18 @@ export const KubernetesProvider = ({ }; } catch (error) { let errorMessage = error instanceof Error ? error.message : "Unknown error"; - if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) { - errorMessage = (error.response?.data as { message: string }).message; + if (axios.isAxiosError(error)) { + if (error.response) { + let { message } = error?.response?.data as unknown as { message?: string }; + + if (!message && typeof error.response.data === "string") { + message = error.response.data; + } + + if (message) { + errorMessage = message; + } + } } const sanitizedErrorMessage = sanitizeString({ @@ -766,8 +792,18 @@ export const KubernetesProvider = ({ } } catch (error) { let errorMessage = error instanceof Error ? error.message : "Unknown error"; - if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) { - errorMessage = (error.response?.data as { message: string }).message; + if (axios.isAxiosError(error)) { + if (error.response) { + let { message } = error?.response?.data as unknown as { message?: string }; + + if (!message && typeof error.response.data === "string") { + message = error.response.data; + } + + if (message) { + errorMessage = message; + } + } } const sanitizedErrorMessage = sanitizeString({ diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index c8d036ce3..331a0cb25 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -6,10 +6,12 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { sanitizeString } from "@app/lib/fn"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models"; import { compileUsernameTemplate } from "./templateUtils"; @@ -128,9 +130,13 @@ const generateUsername = (provider: SqlProviders, usernameTemplate?: string | nu type TSqlDatabaseProviderDTO = { gatewayService: Pick; + gatewayV2Service: Pick; }; -export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO): TDynamicProviderFns => { +export const SqlDatabaseProvider = ({ + gatewayService, + gatewayV2Service +}: TSqlDatabaseProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); @@ -183,6 +189,26 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) providerInputs: z.infer, gatewayCallback: (host: string, port: number) => Promise ) => { + const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId: providerInputs.gatewayId as string, + targetHost: providerInputs.host, + targetPort: providerInputs.port + }); + + if (gatewayV2ConnectionDetails) { + return withGatewayV2Proxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + proxyIp: gatewayV2ConnectionDetails.proxyIp, + gateway: gatewayV2ConnectionDetails.gateway, + proxy: gatewayV2ConnectionDetails.proxy, + protocol: GatewayProxyProtocol.Tcp + } + ); + } + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); await withGatewayProxy( diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts b/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts new file mode 100644 index 000000000..e67d4e890 --- /dev/null +++ b/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts @@ -0,0 +1,2 @@ +export const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1"; +export const GATEWAY_ACTOR_OID = "1.3.6.1.4.1.12345.100.2"; 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 f2f78f480..0d62927d0 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -15,14 +15,17 @@ import { import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TProxyDALFactory } from "../proxy/proxy-dal"; import { isInstanceProxy } from "../proxy/proxy-fns"; import { TProxyServiceFactory } from "../proxy/proxy-service"; +import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID } from "./gateway-v2-constants"; import { TGatewayV2DALFactory } from "./gateway-v2-dal"; import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal"; type TGatewayV2ServiceFactoryDep = { orgGatewayConfigV2DAL: Pick; + licenseService: Pick; kmsService: TKmsServiceFactory; proxyService: TProxyServiceFactory; gatewayV2DAL: TGatewayV2DALFactory; @@ -33,6 +36,7 @@ export type TGatewayV2ServiceFactory = ReturnType { + const getPlatformConnectionDetailsByGatewayId = async ({ + gatewayId, + targetHost, + targetPort + }: { + gatewayId: string; + targetHost: string; + targetPort: number; + }) => { const gateway = await gatewayV2DAL.findById(gatewayId); if (!gateway) { return; @@ -248,12 +260,12 @@ export const gatewayV2ServiceFactory = ({ }); } - // const orgLicensePlan = await licenseService.getPlan(orgGatewayConfig.orgId); - // if (!orgLicensePlan.gateway) { - // throw new BadRequestError({ - // message: "Please upgrade your instance to Infisical's Enterprise plan to use gateways." - // }); - // } + const orgLicensePlan = await licenseService.getPlan(orgGatewayConfig.orgId); + if (!orgLicensePlan.gateway) { + throw new BadRequestError({ + message: "Please upgrade your instance to Infisical's Enterprise plan to use gateways." + }); + } const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -274,6 +286,12 @@ export const gatewayV2ServiceFactory = ({ }) ); + const gatewayServerCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayServerCaCertificate + }) + ); + const gatewayClientCaPrivateKey = orgKmsDecryptor({ cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaPrivateKey }); @@ -297,6 +315,23 @@ export const gatewayV2ServiceFactory = ({ const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const clientCertSerialNumber = createSerialNumber(); + const routingInfo = { + targetHost, + targetPort + }; + + const routingExtension = new x509.Extension( + GATEWAY_ROUTING_INFO_OID, + false, + Buffer.from(JSON.stringify(routingInfo)) + ); + + const actorExtension = new x509.Extension( + GATEWAY_ACTOR_OID, + false, + Buffer.from(JSON.stringify({ type: ActorType.PLATFORM })) + ); + const clientCert = await x509.X509CertificateGenerator.create({ serialNumber: clientCertSerialNumber, subject: `O=${orgGatewayConfig.orgId},OU=gateway-client,CN=${ActorType.PLATFORM}:${gatewayId}`, @@ -318,16 +353,18 @@ export const gatewayV2ServiceFactory = ({ x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], true ), - new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true) + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true), + routingExtension, + actorExtension ] }); + const gatewayClientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); const proxyCredentials = await proxyService.getCredentialsForClient({ proxyId: gateway.proxyId, orgId: gateway.orgId, - gatewayId, - actor: ActorType.PLATFORM + gatewayId }); return { @@ -335,8 +372,7 @@ export const gatewayV2ServiceFactory = ({ gateway: { clientCertificate: clientCert.toString("pem"), clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), - clientCertificateChain: constructPemChainFromCerts([gatewayClientCaCert, rootGatewayCaCert]), - serverCA: rootGatewayCaCert.toString("pem") + serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert]) }, proxy: { clientCertificate: proxyCredentials.clientCertificate, @@ -385,6 +421,7 @@ export const gatewayV2ServiceFactory = ({ const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const gatewayServerCaCert = new x509.X509Certificate(orgCAs.gatewayServerCaCertificate); const rootGatewayCaCert = new x509.X509Certificate(orgCAs.rootGatewayCaCertificate); + const gatewayClientCaCert = new x509.X509Certificate(orgCAs.gatewayClientCaCertificate); const gatewayServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ key: orgCAs.gatewayServerCaPrivateKey, @@ -414,7 +451,12 @@ export const gatewayV2ServiceFactory = ({ x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT], true ), - new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true) + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), + new x509.SubjectAlternativeNameExtension([ + { type: "dns", value: "localhost" }, + { type: "ip", value: "127.0.0.1" }, + { type: "ip", value: "::1" } + ]) ]; const gatewayServerSerialNumber = createSerialNumber(); @@ -441,9 +483,8 @@ export const gatewayV2ServiceFactory = ({ proxyIp: proxyCredentials.proxyIp, pki: { serverCertificate: gatewayServerCertificate.toString("pem"), - serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert]), serverPrivateKey: gatewayServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), - clientCA: rootGatewayCaCert.toString("pem") + clientCertificateChain: constructPemChainFromCerts([gatewayClientCaCert, rootGatewayCaCert]) }, ssh: { clientCertificate: proxyCredentials.clientSshCert, diff --git a/backend/src/ee/services/proxy/proxy-service.ts b/backend/src/ee/services/proxy/proxy-service.ts index 2576ff1fe..96ecf3574 100644 --- a/backend/src/ee/services/proxy/proxy-service.ts +++ b/backend/src/ee/services/proxy/proxy-service.ts @@ -4,7 +4,6 @@ import { TProxies } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { 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"; import { @@ -689,7 +688,6 @@ export const proxyServiceFactory = ({ }; const $generateProxyClientCredentials = async ({ - actor, gatewayId, orgId, proxyPkiClientCaCertificate, @@ -697,7 +695,6 @@ export const proxyServiceFactory = ({ proxyPkiServerCaCertificate, proxyPkiServerCaCertificateChain }: { - actor: ActorType; gatewayId: string; orgId: string; proxyPkiClientCaCertificate: Buffer; @@ -728,29 +725,32 @@ export const proxyServiceFactory = ({ const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); const clientCertSerialNumber = createSerialNumber(); + // Build standard extensions + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(proxyClientCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | + x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] | + x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true) + ]; + const clientCert = await x509.X509CertificateGenerator.create({ serialNumber: clientCertSerialNumber, - subject: `O=${orgId},OU=proxy-client,CN=${actor}:${gatewayId}`, + subject: `O=${orgId},OU=proxy-client,CN=${gatewayId}`, issuer: proxyClientCaCert.subject, notAfter: clientCertExpiration, notBefore: clientCertIssuedAt, signingKey: importedProxyClientCaPrivateKey, publicKey: clientKeys.publicKey, signingAlgorithm: alg, - extensions: [ - new x509.BasicConstraintsExtension(false), - await x509.AuthorityKeyIdentifierExtension.create(proxyClientCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey), - new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | - x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] | - x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], - true - ), - new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true) - ] + extensions }); return { @@ -834,13 +834,11 @@ export const proxyServiceFactory = ({ const getCredentialsForClient = async ({ proxyId, orgId, - gatewayId, - actor + gatewayId }: { proxyId: string; orgId: string; gatewayId: string; - actor: ActorType; }) => { const proxy = await proxyDAL.findOne({ id: proxyId @@ -855,7 +853,6 @@ export const proxyServiceFactory = ({ if (isInstanceProxy(proxy.name)) { const instanceCAs = await $getInstanceCAs(); const proxyCertificateCredentials = await $generateProxyClientCredentials({ - actor, gatewayId, orgId, proxyPkiClientCaCertificate: instanceCAs.instanceProxyPkiClientCaCertificate, @@ -872,7 +869,6 @@ export const proxyServiceFactory = ({ const orgCAs = await $getOrgCAs(orgId); const proxyCertificateCredentials = await $generateProxyClientCredentials({ - actor, gatewayId, orgId, proxyPkiClientCaCertificate: orgCAs.proxyPkiClientCaCertificate, diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 65f60972f..97a0f5700 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -82,6 +82,7 @@ import { import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { awsIamUserSecretRotationFactory } from "./aws-iam-user-secret/aws-iam-user-secret-rotation-fns"; import { oktaClientSecretRotationFactory } from "./okta-client-secret/okta-client-secret-rotation-fns"; import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; @@ -110,6 +111,7 @@ export type TSecretRotationV2ServiceFactoryDep = { appConnectionDAL: Pick; folderCommitService: Pick; gatewayService: Pick; + gatewayV2Service: Pick; }; export type TSecretRotationV2ServiceFactory = ReturnType; @@ -153,7 +155,8 @@ export const secretRotationV2ServiceFactory = ({ queueService, folderCommitService, appConnectionDAL, - gatewayService + gatewayService, + gatewayV2Service }: TSecretRotationV2ServiceFactoryDep) => { const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => { const appCfg = getConfig(); @@ -467,7 +470,8 @@ export const secretRotationV2ServiceFactory = ({ } as TSecretRotationV2WithConnection, appConnectionDAL, kmsService, - gatewayService + gatewayService, + gatewayV2Service ); // even though we have a db constraint we want to check before any rotation of credentials is attempted @@ -831,7 +835,8 @@ export const secretRotationV2ServiceFactory = ({ } as TSecretRotationV2WithConnection, appConnectionDAL, kmsService, - gatewayService + gatewayService, + gatewayV2Service ); const generatedCredentials = await decryptSecretRotationCredentials({ @@ -915,7 +920,8 @@ export const secretRotationV2ServiceFactory = ({ } as TSecretRotationV2WithConnection, appConnectionDAL, kmsService, - gatewayService + gatewayService, + gatewayV2Service ); const updatedRotation = await rotationFactory.rotateCredentials( diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts index ab348f172..2af2ddc7b 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -6,6 +6,7 @@ import { TAppConnectionDALFactory } from "@app/services/app-connection/app-conne import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TAuth0ClientSecretRotation, TAuth0ClientSecretRotationGeneratedCredentials, @@ -253,7 +254,8 @@ export type TRotationFactory< secretRotation: T, appConnectionDAL: Pick, kmsService: Pick, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { issueCredentials: TRotationFactoryIssueCredentials; revokeCredentials: TRotationFactoryRevokeCredentials; diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts index 1da1db376..6673baab1 100644 --- a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts @@ -41,7 +41,7 @@ const ORACLE_PASSWORD_REQUIREMENTS = { export const sqlCredentialsRotationFactory: TRotationFactory< TSqlCredentialsRotationWithConnection, TSqlCredentialsRotationGeneratedCredentials -> = (secretRotation, _appConnectionDAL, _kmsService, gatewayService) => { +> = (secretRotation, _appConnectionDAL, _kmsService, gatewayService, gatewayV2Service) => { const { connection, parameters: { username1, username2 }, @@ -67,6 +67,7 @@ export const sqlCredentialsRotationFactory: TRotationFactory< credentials: finalCredentials }, gatewayService, + gatewayV2Service, (client) => operation(client) ); }; diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts new file mode 100644 index 000000000..a46fdd58c --- /dev/null +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -0,0 +1,278 @@ +import net from "node:net"; +import tls from "node:tls"; + +import https from "https"; + +import { splitPemChain } from "@app/services/certificate/certificate-fns"; + +import { BadRequestError } from "../errors"; +import { GatewayProxyProtocol } from "../gateway/types"; +import { logger } from "../logger"; + +/* +TODOs: +- Add heartbeat tracking to gateway connection +*/ + +interface IGatewayProxyServer { + server: net.Server; + port: number; + cleanup: () => Promise; + getProxyError: () => string; +} + +const createProxyConnection = async ({ + proxyIp, + clientCertificate, + clientPrivateKey, + serverCertificateChain +}: { + proxyIp: string; + clientCertificate: string; + clientPrivateKey: string; + serverCertificateChain: string; +}): Promise => { + const [host, portStr] = proxyIp.split(":"); + const port = parseInt(portStr, 10) || 443; + + const serverCAs = splitPemChain(serverCertificateChain); + const tlsOptions: tls.ConnectionOptions = { + host, + port, + cert: clientCertificate, + key: clientPrivateKey, + ca: serverCAs, + minVersion: "TLSv1.2", + rejectUnauthorized: true + }; + + return new Promise((resolve, reject) => { + try { + const socket = tls.connect(tlsOptions, () => { + logger.info("Proxy TLS connection established successfully"); + resolve(socket); + }); + + socket.on("error", (err: Error) => { + reject(new Error(`TLS connection error: ${err.message}`)); + }); + + socket.on("close", (hadError: boolean) => { + logger.error(`TLS connection closed${hadError ? " with error" : ""}`); + }); + + socket.on("timeout", () => { + logger.error(`TLS connection timeout after 30 seconds`); + socket.destroy(); + reject(new Error("TLS connection timeout")); + }); + + socket.setTimeout(30000); + } catch (error: unknown) { + reject(new Error(`Failed to create TLS connection: ${error instanceof Error ? error.message : String(error)}`)); + } + }); +}; + +const createGatewayConnection = async ( + proxyConn: net.Socket, + gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string } +): Promise => { + const tlsOptions: tls.ConnectionOptions = { + socket: proxyConn, + cert: gateway.clientCertificate, + key: gateway.clientPrivateKey, + ca: splitPemChain(gateway.serverCertificateChain), + minVersion: "TLSv1.2", + maxVersion: "TLSv1.3", + rejectUnauthorized: true + }; + + return new Promise((resolve, reject) => { + try { + const gatewaySocket = tls.connect(tlsOptions, () => { + if (!gatewaySocket.authorized) { + const error = gatewaySocket.authorizationError; + gatewaySocket.destroy(); + reject(new Error(`Gateway TLS authorization failed: ${error?.message}`)); + return; + } + + logger.info("Gateway mTLS connection established successfully"); + resolve(gatewaySocket); + }); + + gatewaySocket.on("error", (err: Error) => { + reject(new Error(`Failed to establish gateway mTLS: ${err.message}`)); + }); + + gatewaySocket.setTimeout(30000); + gatewaySocket.on("timeout", () => { + gatewaySocket.destroy(); + reject(new Error("Gateway connection timeout")); + }); + } catch (error: unknown) { + reject( + new Error(`Failed to create gateway TLS connection: ${error instanceof Error ? error.message : String(error)}`) + ); + } + }); +}; + +const setupProxyServer = async ({ + protocol, + proxyIp, + gateway, + proxy, + httpsAgent +}: { + protocol: GatewayProxyProtocol; + proxyIp: string; + gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; + proxy: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; + httpsAgent?: https.Agent; +}): Promise => { + const proxyErrorMsg: string[] = []; + + return new Promise((resolve, reject) => { + const server = net.createServer(); + + server.on("connection", (clientConn) => { + void (async () => { + try { + clientConn.setKeepAlive(true, 30000); + clientConn.setNoDelay(true); + + // Stage 1: Connect to proxy relay with TLS + const proxyConn = await createProxyConnection({ + proxyIp, + clientCertificate: proxy.clientCertificate, + clientPrivateKey: proxy.clientPrivateKey, + serverCertificateChain: proxy.serverCertificateChain + }); + + // Stage 2: Establish mTLS connection to gateway through the proxy + const gatewayConn = await createGatewayConnection(proxyConn, gateway); + + let command = ""; + + // Send protocol data to gateway + if (protocol === GatewayProxyProtocol.Http) { + command += "FORWARD-HTTP"; + // extract ca certificate from httpsAgent if present + if (httpsAgent) { + const agentOptions = httpsAgent.options; + if (agentOptions && agentOptions.ca) { + const caCert = Array.isArray(agentOptions.ca) ? agentOptions.ca.join("\n") : agentOptions.ca; + const caB64 = Buffer.from(caCert as string).toString("base64"); + command += ` ca=${caB64}`; + + const rejectUnauthorized = agentOptions.rejectUnauthorized !== false; + command += ` verify=${rejectUnauthorized}`; + } + } + + command += "\n"; + } else if (protocol === GatewayProxyProtocol.Tcp) { + command += `FORWARD-TCP\n`; + } else { + throw new BadRequestError({ + message: `Invalid protocol: ${protocol as string}` + }); + } + + gatewayConn.write(Buffer.from(command)); + + // Bidirectional data forwarding + clientConn.pipe(gatewayConn); + gatewayConn.pipe(clientConn); + + // Handle connection closure + clientConn.on("close", () => { + proxyConn.destroy(); + gatewayConn.destroy(); + }); + + proxyConn.on("close", () => { + clientConn.destroy(); + gatewayConn.destroy(); + }); + + gatewayConn.on("close", () => { + clientConn.destroy(); + proxyConn.destroy(); + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + proxyErrorMsg.push(errorMsg); + clientConn.destroy(); + } + })(); + }); + + server.on("error", (err) => { + reject(err); + }); + + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to get server port")); + return; + } + + console.log(`Gateway proxy started on port ${address.port}`); + resolve({ + server, + port: address.port, + cleanup: async () => { + try { + server.close(); + } catch (err) { + console.debug("Error closing server:", err); + } + }, + getProxyError: () => proxyErrorMsg.join(",") + }); + }); + }); +}; + +export const withGatewayV2Proxy = async ( + callback: (port: number) => Promise, + options: { + protocol: GatewayProxyProtocol; + proxyIp: string; + gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; + proxy: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; + httpsAgent?: https.Agent; + } +): Promise => { + const { protocol, proxyIp, gateway, proxy, httpsAgent } = options; + + const { port, cleanup, getProxyError } = await setupProxyServer({ + protocol, + proxyIp, + gateway, + proxy, + httpsAgent + }); + + try { + // Execute the callback with the allocated port + return await callback(port); + } catch (err) { + const proxyErrorMessage = getProxyError(); + if (proxyErrorMessage) { + logger.error("Proxy error:", proxyErrorMessage); + } + logger.error("Gateway error:", err instanceof Error ? err.message : String(err)); + + const errorMessage = proxyErrorMessage || (err instanceof Error ? err.message : String(err)); + throw new Error(errorMessage); + } finally { + // Ensure cleanup happens regardless of success or failure + await cleanup(); + } +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index bf8e1d364..2bded6dbc 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1463,6 +1463,22 @@ export const registerRoutes = async ( smtpService }); + const proxyService = proxyServiceFactory({ + instanceProxyConfigDAL, + orgProxyConfigDAL, + proxyDAL, + kmsService + }); + + const gatewayV2Service = gatewayV2ServiceFactory({ + kmsService, + licenseService, + proxyService, + orgGatewayConfigV2DAL, + gatewayV2DAL, + proxyDAL + }); + const identityService = identityServiceFactory({ permissionService, identityDAL, @@ -1517,6 +1533,7 @@ export const registerRoutes = async ( permissionService, licenseService }); + const identityUaService = identityUaServiceFactory({ identityOrgMembershipDAL, permissionService, @@ -1533,6 +1550,8 @@ export const registerRoutes = async ( permissionService, licenseService, gatewayService, + gatewayV2Service, + gatewayV2DAL, gatewayDAL, kmsService }); @@ -1628,21 +1647,6 @@ export const registerRoutes = async ( identityAuthTemplateDAL }); - const proxyService = proxyServiceFactory({ - instanceProxyConfigDAL, - orgProxyConfigDAL, - proxyDAL, - kmsService - }); - - const gatewayV2Service = gatewayV2ServiceFactory({ - kmsService, - proxyService, - orgGatewayConfigV2DAL, - gatewayV2DAL, - proxyDAL - }); - const dynamicSecretProviders = buildDynamicSecretProviders({ gatewayService, gatewayV2Service @@ -1791,7 +1795,9 @@ export const registerRoutes = async ( kmsService, licenseService, gatewayService, - gatewayDAL + gatewayV2Service, + gatewayDAL, + gatewayV2DAL }); const secretSyncService = secretSyncServiceFactory({ @@ -1890,7 +1896,8 @@ export const registerRoutes = async ( secretQueueService, queueService, appConnectionDAL, - gatewayService + gatewayService, + gatewayV2Service }); const certificateAuthorityService = certificateAuthorityServiceFactory({ diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 9ffc358b6..647fc74e5 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -6,6 +6,7 @@ import { } from "@app/ee/services/app-connections/oci"; import { getOracleDBConnectionListItem, OracleDBConnectionMethod } from "@app/ee/services/app-connections/oracledb"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; @@ -213,7 +214,8 @@ export const decryptAppConnectionCredentials = async ({ export const validateAppConnectionCredentials = async ( appConnection: TAppConnectionConfig, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ): Promise => { const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator, @@ -257,7 +259,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator }; - return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService); + return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService, gatewayV2Service); }; export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index a9556b6cd..73563da79 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -5,6 +5,8 @@ import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-c import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb"; import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2DALFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionAppConnectionActions, @@ -109,7 +111,9 @@ export type TAppConnectionServiceFactoryDep = { kmsService: Pick; licenseService: Pick; gatewayService: Pick; + gatewayV2Service: Pick; gatewayDAL: Pick; + gatewayV2DAL: Pick; }; export type TAppConnectionServiceFactory = ReturnType; @@ -160,7 +164,9 @@ export const appConnectionServiceFactory = ({ kmsService, licenseService, gatewayService, - gatewayDAL + gatewayV2Service, + gatewayDAL, + gatewayV2DAL }: TAppConnectionServiceFactoryDep) => { const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { const { permission } = await permissionService.getOrgPermission( @@ -264,7 +270,8 @@ export const appConnectionServiceFactory = ({ ); const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actor.orgId }); - if (!gateway) { + const [gatewayV2] = await gatewayV2DAL.find({ id: gatewayId, orgId: actor.orgId }); + if (!gateway && !gatewayV2) { throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found for org` }); @@ -286,7 +293,8 @@ export const appConnectionServiceFactory = ({ orgId: actor.orgId, gatewayId } as TAppConnectionConfig, - gatewayService + gatewayService, + gatewayV2Service ); try { @@ -319,7 +327,8 @@ export const appConnectionServiceFactory = ({ gatewayId } as TAppConnectionConfig, (platformCredentials) => createConnection(platformCredentials), - gatewayService + gatewayService, + gatewayV2Service ); } else { connection = await createConnection(validatedCredentials); @@ -415,7 +424,8 @@ export const appConnectionServiceFactory = ({ method, gatewayId } as TAppConnectionConfig, - gatewayService + gatewayService, + gatewayV2Service ); if (!updatedCredentials) @@ -456,7 +466,8 @@ export const appConnectionServiceFactory = ({ gatewayId } as TAppConnectionConfig, (platformCredentials) => updateConnection(platformCredentials), - gatewayService + gatewayService, + gatewayV2Service ); } else { updatedConnection = await updateConnection(updatedCredentials); diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 55511138c..526996520 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -10,6 +10,7 @@ import { TValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -401,13 +402,15 @@ export type TListAwsConnectionIamUsers = { export type TAppConnectionCredentialsValidator = ( appConnection: TAppConnectionConfig, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => Promise; export type TAppConnectionTransitionCredentialsToPlatform = ( appConnection: TAppConnectionConfig, callback: (credentials: TAppConnection["credentials"]) => Promise, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => Promise; export type TAppConnectionBaseConfig = { diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index b9425d7be..636d59de5 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -2,12 +2,14 @@ import knex, { Knex } from "knex"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TSqlCredentialsRotationGeneratedCredentials, TSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { TAppConnectionRaw, TSqlConnection } from "@app/services/app-connection/app-connection-types"; @@ -104,12 +106,49 @@ export const getSqlConnectionClient = async (appConnection: Pick( config: TSqlConnectionConfig, gatewayService: Pick, + gatewayV2Service: Pick, operation: (client: Knex) => Promise ): Promise => { const { credentials, app, gatewayId } = config; - if (gatewayId && gatewayService) { + if (gatewayId && gatewayService && gatewayV2Service) { const [targetHost] = await verifyHostInputValidity(credentials.host, true); + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort: credentials.port + }); + + if (platformConnectionDetails) { + return withGatewayV2Proxy( + async (proxyPort) => { + const client = knex({ + client: SQL_CONNECTION_CLIENT_MAP[app], + connection: { + database: credentials.database, + port: proxyPort, + host: "localhost", + user: credentials.username, + password: credentials.password, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ...getConnectionConfig({ app, credentials }) + } + }); + try { + return await operation(client); + } finally { + await client.destroy(); + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + proxyIp: platformConnectionDetails.proxyIp, + gateway: platformConnectionDetails.gateway, + proxy: platformConnectionDetails.proxy + } + ); + } + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); @@ -161,10 +200,11 @@ export const executeWithPotentialGateway = async ( export const validateSqlConnectionCredentials = async ( config: TSqlConnectionConfig, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { try { - await executeWithPotentialGateway(config, gatewayService, async (client) => { + await executeWithPotentialGateway(config, gatewayService, gatewayV2Service, async (client) => { await client.raw(config.app === AppConnection.OracleDB ? `SELECT 1 FROM DUAL` : `Select 1`); }); return config.credentials; @@ -191,14 +231,15 @@ export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record< export const transferSqlConnectionCredentialsToPlatform = async ( config: TSqlConnectionConfig, callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { const { credentials, app } = config; const newPassword = alphaNumericNanoId(32); try { - return await executeWithPotentialGateway(config, gatewayService, (client) => { + return await executeWithPotentialGateway(config, gatewayService, gatewayV2Service, (client) => { return client.transaction(async (tx) => { await tx.raw( ...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword }) diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 9584b122a..6b0955dc8 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -6,6 +6,8 @@ import RE2 from "re2"; import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2DALFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionGatewayActions, @@ -21,6 +23,7 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; @@ -54,11 +57,15 @@ type TIdentityKubernetesAuthServiceFactoryDep = { licenseService: Pick; kmsService: Pick; gatewayService: TGatewayServiceFactory; + gatewayV2Service: TGatewayV2ServiceFactory; gatewayDAL: Pick; + gatewayV2DAL: Pick; }; export type TIdentityKubernetesAuthServiceFactory = ReturnType; +const GATEWAY_AUTH_DEFAULT_HOST = "https://kubernetes.default.svc.cluster.local"; + export const identityKubernetesAuthServiceFactory = ({ identityKubernetesAuthDAL, identityOrgMembershipDAL, @@ -66,7 +73,9 @@ export const identityKubernetesAuthServiceFactory = ({ permissionService, licenseService, gatewayService, + gatewayV2Service, gatewayDAL, + gatewayV2DAL, kmsService }: TIdentityKubernetesAuthServiceFactoryDep) => { const $gatewayProxyWrapper = async ( @@ -79,6 +88,42 @@ export const identityKubernetesAuthServiceFactory = ({ }, gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise ): Promise => { + const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId: inputs.gatewayId, + targetHost: inputs.targetHost ?? GATEWAY_AUTH_DEFAULT_HOST, + targetPort: inputs.targetPort ?? 443 + }); + + if (gatewayV2ConnectionDetails) { + let httpsAgent: https.Agent | undefined; + if (!inputs.reviewTokenThroughGateway) { + httpsAgent = new https.Agent({ + ca: inputs.caCert, + rejectUnauthorized: Boolean(inputs.caCert) + }); + } + + const callbackResult = await withGatewayV2Proxy( + async (port) => { + const res = await gatewayCallback( + inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost", + port, + httpsAgent + ); + return res; + }, + { + protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, + proxyIp: gatewayV2ConnectionDetails.proxyIp, + gateway: gatewayV2ConnectionDetails.gateway, + proxy: gatewayV2ConnectionDetails.proxy, + httpsAgent + } + ); + + return callbackResult; + } + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); @@ -277,7 +322,7 @@ export const identityKubernetesAuthServiceFactory = ({ let data: TCreateTokenReviewResponse | undefined; if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { - if (!identityKubernetesAuth.gatewayId) { + if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) { throw new BadRequestError({ message: "Gateway ID is required when token review mode is set to Gateway" }); @@ -285,7 +330,7 @@ export const identityKubernetesAuthServiceFactory = ({ data = await $gatewayProxyWrapper( { - gatewayId: identityKubernetesAuth.gatewayId, + gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, reviewTokenThroughGateway: true }, tokenReviewCallbackThroughGateway @@ -304,17 +349,18 @@ export const identityKubernetesAuthServiceFactory = ({ const [k8sHost, k8sPort] = kubernetesHost.split(":"); - data = identityKubernetesAuth.gatewayId - ? await $gatewayProxyWrapper( - { - gatewayId: identityKubernetesAuth.gatewayId, - targetHost: k8sHost, - targetPort: k8sPort ? Number(k8sPort) : 443, - reviewTokenThroughGateway: false - }, - tokenReviewCallbackRaw - ) - : await tokenReviewCallbackRaw(); + data = + identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id + ? await $gatewayProxyWrapper( + { + gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, + targetHost: k8sHost, + targetPort: k8sPort ? Number(k8sPort) : 443, + reviewTokenThroughGateway: false + }, + tokenReviewCallbackRaw + ) + : await tokenReviewCallbackRaw(); } else { throw new BadRequestError({ message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` @@ -490,14 +536,20 @@ export const identityKubernetesAuthServiceFactory = ({ return extractIPDetails(accessTokenTrustedIp.ipAddress); }); + let isGatewayV1 = true; if (gatewayId) { const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); - if (!gateway) { + const [gatewayV2] = await gatewayV2DAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); + if (!gateway && !gatewayV2) { throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found` }); } + if (!gateway) { + isGatewayV1 = false; + } + const { permission: orgPermission } = await permissionService.getOrgPermission( actor, actorId, @@ -528,7 +580,8 @@ export const identityKubernetesAuthServiceFactory = ({ accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, - gatewayId, + gatewayId: isGatewayV1 ? gatewayId : null, + gatewayV2Id: isGatewayV1 ? null : gatewayId, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), encryptedKubernetesTokenReviewerJwt: tokenReviewerJwt ? encryptor({ plainText: Buffer.from(tokenReviewerJwt) }).cipherTextBlob @@ -608,14 +661,21 @@ export const identityKubernetesAuthServiceFactory = ({ return extractIPDetails(accessTokenTrustedIp.ipAddress); }); + let isGatewayV1 = true; if (gatewayId) { const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); - if (!gateway) { + const [gatewayV2] = await gatewayV2DAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); + + if (!gateway && !gatewayV2) { throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found` }); } + if (!gateway) { + isGatewayV1 = false; + } + const { permission: orgPermission } = await permissionService.getOrgPermission( actor, actorId, @@ -629,13 +689,18 @@ export const identityKubernetesAuthServiceFactory = ({ ); } + const shouldUpdateGatewayId = Boolean(gatewayId); + const gatewayIdValue = isGatewayV1 ? gatewayId : null; + const gatewayV2IdValue = isGatewayV1 ? null : gatewayId; + const updateQuery: TIdentityKubernetesAuthsUpdate = { kubernetesHost, tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, - gatewayId, + gatewayId: shouldUpdateGatewayId ? gatewayIdValue : undefined, + gatewayV2Id: shouldUpdateGatewayId ? gatewayV2IdValue : undefined, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, @@ -730,7 +795,13 @@ export const identityKubernetesAuthServiceFactory = ({ }).toString(); } - return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; + return { + ...identityKubernetesAuth, + caCert, + tokenReviewerJwt, + orgId: identityMembershipOrg.orgId, + gatewayId: identityKubernetesAuth.gatewayId ?? identityKubernetesAuth.gatewayV2Id + }; }; const revokeIdentityKubernetesAuth = async ({ From d5dcaf0d591cbf45b41bb89a0acf59dc0d8f2a5b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 2 Sep 2025 21:51:29 +0800 Subject: [PATCH 08/46] feat: added heartbeat and fixed up gateway page --- ...1627_add-gateway-v2-pki-and-ssh-configs.ts | 2 + backend/src/db/schemas/gateways-v2.ts | 3 +- backend/src/ee/routes/v2/gateway-router.ts | 48 ++++++++ .../ee/services/gateway-v2/gateway-v2-dal.ts | 38 +++++- .../services/gateway-v2/gateway-v2-service.ts | 112 +++++++++++++++++- backend/src/lib/gateway-v2/gateway-v2.ts | 14 +-- backend/src/lib/gateway/types.ts | 3 +- frontend/src/hooks/api/gateways-v2/index.tsx | 2 +- .../src/hooks/api/gateways-v2/mutations.tsx | 17 +++ .../src/hooks/api/gateways-v2/queries.tsx | 18 --- frontend/src/hooks/api/gateways-v2/types.ts | 1 + frontend/src/hooks/api/gateways/queries.tsx | 11 +- .../GatewayListPage/GatewayListPage.tsx | 66 ++++++----- 13 files changed, 269 insertions(+), 66 deletions(-) create mode 100644 frontend/src/hooks/api/gateways-v2/mutations.tsx delete mode 100644 frontend/src/hooks/api/gateways-v2/queries.tsx diff --git a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts index c21d739b8..179d7aa2d 100644 --- a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts +++ b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts @@ -120,6 +120,8 @@ export async function up(knex: Knex): Promise { t.foreign("proxyId").references("id").inTable(TableName.Proxy).onDelete("SET NULL"); t.string("name").notNullable().unique(); + + t.dateTime("heartbeat"); }); await createOnUpdateTrigger(knex, TableName.GatewayV2); diff --git a/backend/src/db/schemas/gateways-v2.ts b/backend/src/db/schemas/gateways-v2.ts index 722b39361..c3226aa61 100644 --- a/backend/src/db/schemas/gateways-v2.ts +++ b/backend/src/db/schemas/gateways-v2.ts @@ -14,7 +14,8 @@ export const GatewaysV2Schema = z.object({ orgId: z.string().uuid(), identityId: z.string().uuid(), proxyId: z.string().uuid().nullable().optional(), - name: z.string() + name: z.string(), + heartbeat: z.date().nullable().optional() }); export type TGatewaysV2 = z.infer; diff --git a/backend/src/ee/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts index 64b794fad..e7171aff8 100644 --- a/backend/src/ee/routes/v2/gateway-router.ts +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -1,5 +1,6 @@ import z from "zod"; +import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -29,6 +30,29 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/heartbeat", + config: { + rateLimit: writeLimit + }, + schema: { + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + await server.services.gatewayV2.heartbeat({ + orgPermission: req.permission + }); + + return { message: "Successfully triggered heartbeat" }; + } + }); + server.route({ method: "GET", url: "/", @@ -46,4 +70,28 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { return gateways; } }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.any() + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]), + handler: async (req) => { + const gateway = await server.services.gatewayV2.deleteGatewayById({ + orgPermission: req.permission, + id: req.params.id + }); + return { gateway }; + } + }); }; 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 763858de0..6154d3357 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts @@ -1,11 +1,43 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { GatewaysV2Schema, TableName, TGatewaysV2 } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; export type TGatewayV2DALFactory = ReturnType; export const gatewayV2DalFactory = (db: TDbClient) => { const orm = ormify(db, TableName.GatewayV2); - return orm; + const find = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + try { + const query = (tx || db)(TableName.GatewayV2) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(filter, TableName.GatewayV2)) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.GatewayV2}.identityId`) + .join( + TableName.IdentityOrgMembership, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.GatewayV2}.identityId` + ) + .select(selectAllTableCols(TableName.GatewayV2)) + .select(db.ref("name").withSchema(TableName.Identity).as("identityName")); + + 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.map((el) => ({ + ...GatewaysV2Schema.parse(el), + identity: { id: el.identityId, name: el.identityName } + })); + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.GatewayV2}: Find` }); + } + }; + + return { ...orm, find }; }; 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 0d62927d0..f6d56b7e8 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -1,9 +1,13 @@ +import net from "node:net"; + import * as x509 from "@peculiar/x509"; import { TProxies } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway/types"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns"; @@ -494,9 +498,115 @@ export const gatewayV2ServiceFactory = ({ }; }; + const heartbeat = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { + const gateway = await gatewayV2DAL.findOne({ + orgId: orgPermission.orgId, + identityId: orgPermission.id + }); + + if (!gateway) { + throw new NotFoundError({ message: `Gateway for identity ${orgPermission.id} not found.` }); + } + + const gatewayV2ConnectionDetails = await getPlatformConnectionDetailsByGatewayId({ + gatewayId: gateway.id, + targetHost: "health-check", + targetPort: 443 + }); + + if (!gatewayV2ConnectionDetails) { + throw new NotFoundError({ message: `Gateway connection details for gateway ${gateway.id} not found.` }); + } + + const isGatewayReachable = await withGatewayV2Proxy( + async (port) => { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + let responseReceived = false; + let isResolved = false; + + // Set socket timeout + socket.setTimeout(10000); + + const cleanup = () => { + if (!socket.destroyed) { + socket.destroy(); + } + }; + + socket.on("data", (data: Buffer) => { + const response = data.toString().trim(); + if (response === "PONG" && !isResolved) { + isResolved = true; + responseReceived = true; + cleanup(); + resolve(true); + } + }); + + socket.on("error", (err: Error) => { + if (!isResolved) { + isResolved = true; + cleanup(); + reject(new Error(`TCP connection error: ${err.message}`)); + } + }); + + socket.on("timeout", () => { + if (!isResolved) { + isResolved = true; + cleanup(); + reject(new Error("TCP connection timeout")); + } + }); + + socket.on("close", () => { + if (!isResolved && !responseReceived) { + isResolved = true; + cleanup(); + reject(new Error("Connection closed without receiving PONG")); + } + }); + + socket.connect(port, "localhost"); + }); + }, + { + protocol: GatewayProxyProtocol.Ping, + proxyIp: gatewayV2ConnectionDetails.proxyIp, + gateway: gatewayV2ConnectionDetails.gateway, + proxy: gatewayV2ConnectionDetails.proxy + } + ); + + if (!isGatewayReachable) { + throw new BadRequestError({ message: `Gateway ${gateway.id} is not reachable` }); + } + + await gatewayV2DAL.updateById(gateway.id, { heartbeat: new Date() }); + }; + + const deleteGatewayById = async ({ orgPermission, id }: { orgPermission: OrgServiceActor; id: string }) => { + // const { permission } = await permissionService.getOrgPermission( + // orgPermission.type, + // orgPermission.id, + // orgPermission.orgId, + // orgPermission.authMethod, + // orgPermission.orgId + // ); + // ForbiddenError.from(permission).throwUnlessCan( + // OrgPermissionGatewayActions.DeleteGateways, + // OrgPermissionSubjects.Gateway + // ); + + return gatewayV2DAL.deleteById(id); + }; + return { listGateways, registerGateway, - getPlatformConnectionDetailsByGatewayId + getPlatformConnectionDetailsByGatewayId, + deleteGatewayById, + heartbeat }; }; diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index a46fdd58c..beb76e582 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -9,11 +9,6 @@ import { BadRequestError } from "../errors"; import { GatewayProxyProtocol } from "../gateway/types"; import { logger } from "../logger"; -/* -TODOs: -- Add heartbeat tracking to gateway connection -*/ - interface IGatewayProxyServer { server: net.Server; port: number; @@ -58,7 +53,9 @@ const createProxyConnection = async ({ }); socket.on("close", (hadError: boolean) => { - logger.error(`TLS connection closed${hadError ? " with error" : ""}`); + if (hadError) { + logger.error("TLS connection closed with error"); + } }); socket.on("timeout", () => { @@ -175,6 +172,8 @@ const setupProxyServer = async ({ command += "\n"; } else if (protocol === GatewayProxyProtocol.Tcp) { command += `FORWARD-TCP\n`; + } else if (protocol === GatewayProxyProtocol.Ping) { + command += `PING\n`; } else { throw new BadRequestError({ message: `Invalid protocol: ${protocol as string}` @@ -222,7 +221,6 @@ const setupProxyServer = async ({ return; } - console.log(`Gateway proxy started on port ${address.port}`); resolve({ server, port: address.port, @@ -230,7 +228,7 @@ const setupProxyServer = async ({ try { server.close(); } catch (err) { - console.debug("Error closing server:", err); + logger.debug("Error closing server:", err instanceof Error ? err.message : String(err)); } }, getProxyError: () => proxyErrorMsg.join(",") diff --git a/backend/src/lib/gateway/types.ts b/backend/src/lib/gateway/types.ts index 8552fbf54..e9b8b7114 100644 --- a/backend/src/lib/gateway/types.ts +++ b/backend/src/lib/gateway/types.ts @@ -6,7 +6,8 @@ export type TGatewayTlsOptions = { ca: string; cert: string; key: string }; export enum GatewayProxyProtocol { Http = "http", - Tcp = "tcp" + Tcp = "tcp", + Ping = "ping" } export enum GatewayHttpProxyActions { diff --git a/frontend/src/hooks/api/gateways-v2/index.tsx b/frontend/src/hooks/api/gateways-v2/index.tsx index c4a4e685c..f8dd99d03 100644 --- a/frontend/src/hooks/api/gateways-v2/index.tsx +++ b/frontend/src/hooks/api/gateways-v2/index.tsx @@ -1 +1 @@ -export { gatewaysV2QueryKeys } from "./queries"; +export * from "./mutations"; diff --git a/frontend/src/hooks/api/gateways-v2/mutations.tsx b/frontend/src/hooks/api/gateways-v2/mutations.tsx new file mode 100644 index 000000000..c3bf8bd1b --- /dev/null +++ b/frontend/src/hooks/api/gateways-v2/mutations.tsx @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { gatewaysQueryKeys } from "../gateways/queries"; + +export const useDeleteGatewayV2ById = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => { + return apiRequest.delete(`/api/v2/gateways/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries(gatewaysQueryKeys.list()); + } + }); +}; diff --git a/frontend/src/hooks/api/gateways-v2/queries.tsx b/frontend/src/hooks/api/gateways-v2/queries.tsx deleted file mode 100644 index 8c3184778..000000000 --- a/frontend/src/hooks/api/gateways-v2/queries.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; - -import { apiRequest } from "@app/config/request"; - -import { TGatewayV2 } from "./types"; - -export const gatewaysV2QueryKeys = { - allKey: () => ["gateways-v2"], - listKey: () => [...gatewaysV2QueryKeys.allKey(), "list"], - list: () => - queryOptions({ - queryKey: gatewaysV2QueryKeys.listKey(), - queryFn: async () => { - const { data } = await apiRequest.get<{ gateways: TGatewayV2[] }>("/api/v2/gateways"); - return data.gateways; - } - }) -}; diff --git a/frontend/src/hooks/api/gateways-v2/types.ts b/frontend/src/hooks/api/gateways-v2/types.ts index 40a0bf5b4..69bc21702 100644 --- a/frontend/src/hooks/api/gateways-v2/types.ts +++ b/frontend/src/hooks/api/gateways-v2/types.ts @@ -4,6 +4,7 @@ export type TGatewayV2 = { name: string; createdAt: string; updatedAt: string; + heartbeat: string; identity: { name: string; id: string; diff --git a/frontend/src/hooks/api/gateways/queries.tsx b/frontend/src/hooks/api/gateways/queries.tsx index 64cb18c79..43d3aae87 100644 --- a/frontend/src/hooks/api/gateways/queries.tsx +++ b/frontend/src/hooks/api/gateways/queries.tsx @@ -15,7 +15,16 @@ export const gatewaysQueryKeys = { const { data } = await apiRequest.get<{ gateways: TGateway[] }>("/api/v1/gateways"); const { data: dataV2 } = await apiRequest.get("/api/v2/gateways"); - return [...data.gateways, ...dataV2]; + return [ + ...data.gateways.map((g) => ({ + ...g, + isV1: true + })), + ...dataV2.map((g) => ({ + ...g, + isV1: false + })) + ]; } }) }; diff --git a/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx b/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx index 1552f8548..4d21d93b3 100644 --- a/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx +++ b/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx @@ -14,7 +14,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQuery } from "@tanstack/react-query"; -import { format, formatRelative } from "date-fns"; +import { formatRelative } from "date-fns"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; @@ -48,13 +48,14 @@ import { import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { gatewaysQueryKeys, useDeleteGatewayById } from "@app/hooks/api/gateways"; +import { useDeleteGatewayV2ById } from "@app/hooks/api/gateways-v2"; import { EditGatewayDetailsModal } from "./components/EditGatewayDetailsModal"; export const GatewayListPage = withPermission( () => { const [search, setSearch] = useState(""); - const { data: gateways, isPending: isGatewayLoading } = useQuery(gatewaysQueryKeys.list()); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "deleteGateway", @@ -62,16 +63,20 @@ export const GatewayListPage = withPermission( ] as const); const deleteGatewayById = useDeleteGatewayById(); + const deleteGatewayV2ById = useDeleteGatewayV2ById(); const handleDeleteGateway = async () => { - await deleteGatewayById.mutateAsync((popUp.deleteGateway.data as { id: string }).id, { - onSuccess: () => { - handlePopUpToggle("deleteGateway"); - createNotification({ - type: "success", - text: "Successfully delete gateway" - }); - } + const data = popUp.deleteGateway.data as { id: string; isV1: boolean }; + if (data.isV1) { + await deleteGatewayById.mutateAsync(data.id); + } else { + await deleteGatewayV2ById.mutateAsync(data.id); + } + + handlePopUpToggle("deleteGateway"); + createNotification({ + type: "success", + text: "Successfully deleted gateway" }); }; @@ -127,7 +132,6 @@ export const GatewayListPage = withPermission( Name - Cert Issued At Identity Health Check @@ -143,13 +147,12 @@ export const GatewayListPage = withPermission( - {isGatewayLoading && ( + {isGatewaysLoading && ( )} {filteredGateway?.map((el) => ( {el.name} - {format(new Date(el.issuedAt), "yyyy-MM-dd hh:mm:ss aaa")} {el.identity.name} {el.heartbeat @@ -176,20 +179,22 @@ export const GatewayListPage = withPermission( > Copy ID - - {(isAllowed: boolean) => ( - } - onClick={() => handlePopUpOpen("editDetails", el)} - > - Edit Details - - )} - + {el.isV1 && ( + + {(isAllowed: boolean) => ( + } + onClick={() => handlePopUpOpen("editDetails", el)} + > + Edit Details + + )} + + )} - {!isGatewayLoading && !filteredGateway?.length && ( + {!isGatewaysLoading && !filteredGateway?.length && ( ); }, - { - action: OrgPermissionAppConnectionActions.Read, - subject: OrgPermissionSubjects.AppConnections - } + { action: OrgGatewayPermissionActions.ListGateways, subject: OrgPermissionSubjects.Gateway } ); From 9a9cdf140ae3c21a2c06cf5551cbc9d2472d64fb Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Sep 2025 18:45:26 +0800 Subject: [PATCH 09/46] feat: integrated with github app connection to gateway and some adjustments --- backend/src/ee/routes/v1/proxy-router.ts | 19 ++- backend/src/ee/routes/v2/gateway-router.ts | 37 +++++- .../services/gateway-v2/gateway-v2-service.ts | 93 +++++++++---- .../src/ee/services/proxy/proxy-service.ts | 8 +- backend/src/server/routes/index.ts | 36 +++--- .../app-connection/app-connection-service.ts | 2 +- .../github/github-connection-fns.ts | 122 ++++++++++++++---- .../github/github-connection-service.ts | 10 +- .../secret-sync/github/github-sync-fns.ts | 32 +++-- .../services/secret-sync/secret-sync-fns.ts | 10 +- .../services/secret-sync/secret-sync-queue.ts | 14 +- 11 files changed, 277 insertions(+), 106 deletions(-) diff --git a/backend/src/ee/routes/v1/proxy-router.ts b/backend/src/ee/routes/v1/proxy-router.ts index 561fe7780..e837eb624 100644 --- a/backend/src/ee/routes/v1/proxy-router.ts +++ b/backend/src/ee/routes/v1/proxy-router.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; -import { UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -21,7 +21,18 @@ export const registerProxyRouter = async (server: FastifyZodProvider) => { name: z.string() }), response: { - 200: z.any() + 200: z.object({ + pki: z.object({ + serverCertificate: z.string(), + serverPrivateKey: z.string(), + clientCertificateChain: z.string() + }), + ssh: z.object({ + serverCertificate: z.string(), + serverPrivateKey: z.string(), + clientCAPublicKey: z.string() + }) + }) } }, onRequest: (req, _, next) => { @@ -59,6 +70,10 @@ export const registerProxyRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + throw new BadRequestError({ + message: "Org proxy registration is not yet supported" + }); + return server.services.proxy.registerProxy({ ...req.body, identityId: req.permission.id, diff --git a/backend/src/ee/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts index e7171aff8..114672a23 100644 --- a/backend/src/ee/routes/v2/gateway-router.ts +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -1,9 +1,19 @@ import z from "zod"; +import { GatewaysV2Schema } from "@app/db/schemas"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const SanitizedGatewayV2Schema = GatewaysV2Schema.pick({ + id: true, + identityId: true, + name: true, + createdAt: true, + updatedAt: true, + heartbeat: true +}); + export const registerGatewayV2Router = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -14,7 +24,20 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { name: z.string() }), response: { - 200: z.any() + 200: z.object({ + gatewayId: z.string(), + proxyIp: z.string(), + pki: z.object({ + serverCertificate: z.string(), + serverPrivateKey: z.string(), + clientCertificateChain: z.string() + }), + ssh: z.object({ + clientCertificate: z.string(), + clientPrivateKey: z.string(), + serverCAPublicKey: z.string() + }) + }) } }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), @@ -23,6 +46,7 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { orgId: req.permission.orgId, proxyName: req.body.proxyName, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, name: req.body.name }); @@ -58,7 +82,12 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { url: "/", schema: { response: { - 200: z.any() + 200: SanitizedGatewayV2Schema.extend({ + identity: z.object({ + name: z.string(), + id: z.string() + }) + }).array() } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), @@ -82,7 +111,7 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { id: z.string() }), response: { - 200: z.any() + 200: SanitizedGatewayV2Schema } }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN, AuthMode.JWT]), @@ -91,7 +120,7 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { orgPermission: req.permission, id: req.params.id }); - return { gateway }; + return gateway; } }); }; 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 f6d56b7e8..e8aede3b5 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -1,5 +1,6 @@ import net from "node:net"; +import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { TProxies } from "@app/db/schemas"; @@ -9,7 +10,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { GatewayProxyProtocol } from "@app/lib/gateway/types"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { OrgServiceActor } from "@app/lib/types"; -import { ActorType } from "@app/services/auth/auth-type"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { @@ -20,6 +21,8 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TProxyDALFactory } from "../proxy/proxy-dal"; import { isInstanceProxy } from "../proxy/proxy-fns"; import { TProxyServiceFactory } from "../proxy/proxy-service"; @@ -34,6 +37,7 @@ type TGatewayV2ServiceFactoryDep = { proxyService: TProxyServiceFactory; gatewayV2DAL: TGatewayV2DALFactory; proxyDAL: TProxyDALFactory; + permissionService: TPermissionServiceFactory; }; export type TGatewayV2ServiceFactory = ReturnType; @@ -44,8 +48,32 @@ export const gatewayV2ServiceFactory = ({ kmsService, proxyService, gatewayV2DAL, - proxyDAL + proxyDAL, + permissionService }: TGatewayV2ServiceFactoryDep) => { + const $validateIdentityAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => { + const orgLicensePlan = await licenseService.getPlan(orgId); + if (!orgLicensePlan.gateway) { + throw new BadRequestError({ + message: + "Gateway operation failed due to organization plan restrictions. Please upgrade your instance to Infisical's Enterprise plan." + }); + } + + const { permission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + actorId, + orgId, + actorAuthMethod, + orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + }; + const $getOrgCAs = async (orgId: string) => { const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -217,20 +245,18 @@ export const gatewayV2ServiceFactory = ({ }; const listGateways = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { - // const { permission } = await permissionService.getOrgPermission( - // orgPermission.type, - // orgPermission.id, - // orgPermission.orgId, - // orgPermission.authMethod, - // orgPermission.orgId - // ); - // ForbiddenError.from(permission).throwUnlessCan( - // OrgPermissionGatewayActions.ListGateways, - // OrgPermissionSubjects.Gateway - // ); + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); - const orgGatewayConfig = await orgGatewayConfigV2DAL.findOne({ orgId: orgPermission.orgId }); - if (!orgGatewayConfig) return []; + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.ListGateways, + OrgPermissionSubjects.Gateway + ); const gateways = await gatewayV2DAL.find({ orgId: orgPermission.orgId @@ -389,14 +415,17 @@ export const gatewayV2ServiceFactory = ({ const registerGateway = async ({ orgId, actorId, + actorAuthMethod, proxyName, name }: { orgId: string; actorId: string; + actorAuthMethod: ActorAuthMethod; proxyName: string; name: string; }) => { + await $validateIdentityAccessToGateway(orgId, actorId, actorAuthMethod); const orgCAs = await $getOrgCAs(orgId); let proxy: TProxies; @@ -407,7 +436,7 @@ export const gatewayV2ServiceFactory = ({ } if (!proxy) { - throw new Error("Proxy not found"); + throw new NotFoundError({ message: `Proxy ${proxyName} not found` }); } const [gateway] = await gatewayV2DAL.upsert( @@ -499,6 +528,8 @@ export const gatewayV2ServiceFactory = ({ }; const heartbeat = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { + await $validateIdentityAccessToGateway(orgPermission.orgId, orgPermission.id, orgPermission.authMethod); + const gateway = await gatewayV2DAL.findOne({ orgId: orgPermission.orgId, identityId: orgPermission.id @@ -587,19 +618,25 @@ export const gatewayV2ServiceFactory = ({ }; const deleteGatewayById = async ({ orgPermission, id }: { orgPermission: OrgServiceActor; id: string }) => { - // const { permission } = await permissionService.getOrgPermission( - // orgPermission.type, - // orgPermission.id, - // orgPermission.orgId, - // orgPermission.authMethod, - // orgPermission.orgId - // ); - // ForbiddenError.from(permission).throwUnlessCan( - // OrgPermissionGatewayActions.DeleteGateways, - // OrgPermissionSubjects.Gateway - // ); + const gateway = await gatewayV2DAL.findOne({ id, orgId: orgPermission.orgId }); + if (!gateway) { + throw new NotFoundError({ message: `Gateway ${id} not found` }); + } - return gatewayV2DAL.deleteById(id); + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + gateway.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.DeleteGateways, + OrgPermissionSubjects.Gateway + ); + + return gatewayV2DAL.deleteById(gateway.id); }; return { diff --git a/backend/src/ee/services/proxy/proxy-service.ts b/backend/src/ee/services/proxy/proxy-service.ts index 96ecf3574..37b9ab7e3 100644 --- a/backend/src/ee/services/proxy/proxy-service.ts +++ b/backend/src/ee/services/proxy/proxy-service.ts @@ -799,7 +799,7 @@ export const proxyServiceFactory = ({ const proxyClientSshCert = await createSshCert({ caPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey.toString("utf8"), clientPublicKey: proxyClientSshPublicKey, - keyId: `proxy-client-${proxy.id}`, + keyId: `client-${proxyName}`, principals: [gatewayId], certType: SshCertType.USER, requestedTtl: "30d" @@ -898,7 +898,6 @@ export const proxyServiceFactory = ({ const isOrgProxy = identityId && orgId; if (isOrgProxy) { - // organization proxy if (isInstanceProxy(name)) { throw new BadRequestError({ message: "Org proxy name cannot start with 'infisical-'. This is reserved for internal use." @@ -935,8 +934,7 @@ export const proxyServiceFactory = ({ return existingProxy; }); } else { - // instance proxy - if (!name.startsWith("infisical-")) { + if (!isInstanceProxy(name)) { throw new BadRequestError({ message: "Instance proxy name must start with 'infisical-'." }); @@ -952,7 +950,7 @@ export const proxyServiceFactory = ({ if (existingProxy && existingProxy.ip !== ip) { throw new BadRequestError({ - message: "Instance proxy with this name already exists" + message: "Instance proxy with this name already exists with a different IP address" }); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2bded6dbc..b38cb5e13 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1071,6 +1071,23 @@ export const registerRoutes = async ( keyStore }); + const proxyService = proxyServiceFactory({ + instanceProxyConfigDAL, + orgProxyConfigDAL, + proxyDAL, + kmsService + }); + + const gatewayV2Service = gatewayV2ServiceFactory({ + kmsService, + licenseService, + proxyService, + orgGatewayConfigV2DAL, + gatewayV2DAL, + proxyDAL, + permissionService + }); + const secretSyncQueue = secretSyncQueueFactory({ queueService, secretSyncDAL, @@ -1095,7 +1112,8 @@ export const registerRoutes = async ( resourceMetadataDAL, appConnectionDAL, licenseService, - gatewayService + gatewayService, + gatewayV2Service }); const secretQueueService = secretQueueFactory({ @@ -1463,22 +1481,6 @@ export const registerRoutes = async ( smtpService }); - const proxyService = proxyServiceFactory({ - instanceProxyConfigDAL, - orgProxyConfigDAL, - proxyDAL, - kmsService - }); - - const gatewayV2Service = gatewayV2ServiceFactory({ - kmsService, - licenseService, - proxyService, - orgGatewayConfigV2DAL, - gatewayV2DAL, - proxyDAL - }); - const identityService = identityServiceFactory({ permissionService, identityDAL, diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 73563da79..a40d0f5bd 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -597,7 +597,7 @@ export const appConnectionServiceFactory = ({ deleteAppConnection, connectAppConnectionById, listAvailableAppConnectionsForUser, - github: githubConnectionService(connectAppConnectionById, gatewayService), + github: githubConnectionService(connectAppConnectionById, gatewayService, gatewayV2Service), githubRadar: githubRadarConnectionService(connectAppConnectionById), gcp: gcpConnectionService(connectAppConnectionById), databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index a71036d82..ef7a9cfd6 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -6,10 +6,12 @@ import RE2 from "re2"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { getConfig } from "@app/lib/config/env"; import { request as httpRequest } from "@app/lib/config/request"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; @@ -50,6 +52,7 @@ export const getGitHubInstanceApiUrl = async (config: { export const requestWithGitHubGateway = async ( appConnection: { gatewayId?: string | null }, gatewayService: Pick, + gatewayV2Service: Pick, requestConfig: AxiosRequestConfig ): Promise> => { const { gatewayId } = appConnection; @@ -64,6 +67,52 @@ export const requestWithGitHubGateway = async ( await blockLocalAndPrivateIpAddresses(url.toString()); const [targetHost] = await verifyHostInputValidity(url.host, true); + const gatewayConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort: 443 + }); + + if (gatewayConnectionDetails) { + return withGatewayV2Proxy( + async (proxyPort) => { + const httpsAgent = new https.Agent({ + servername: targetHost + }); + + url.protocol = "https:"; + url.host = `localhost:${proxyPort}`; + + const finalRequestConfig: AxiosRequestConfig = { + ...requestConfig, + url: url.toString(), + httpsAgent, + headers: { + ...requestConfig.headers, + Host: targetHost + } + }; + + try { + return await httpRequest.request(finalRequestConfig); + } catch (error) { + const axiosError = error as AxiosError; + logger.error( + { message: axiosError.message, data: axiosError.response?.data }, + "Error during GitHub gateway request:" + ); + throw error; + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + proxyIp: gatewayConnectionDetails.proxyIp, + gateway: gatewayConnectionDetails.gateway, + proxy: gatewayConnectionDetails.proxy + } + ); + } + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); @@ -168,6 +217,7 @@ function extractNextPageUrl(linkHeader: string | undefined): string | null { export const makePaginatedGitHubRequest = async ( appConnection: TGitHubConnection, gatewayService: Pick, + gatewayV2Service: Pick, path: string, dataMapper?: (data: R) => T[] ): Promise => { @@ -184,15 +234,20 @@ export const makePaginatedGitHubRequest = async ( const maxIterations = 1000; // Make initial request to get link header - const firstResponse: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { - url: initialUrlObj.toString(), - method: "GET", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28" + const firstResponse: AxiosResponse = await requestWithGitHubGateway( + appConnection, + gatewayService, + gatewayV2Service, + { + url: initialUrlObj.toString(), + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } } - }); + ); const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]); results = results.concat(firstPageItems); @@ -212,7 +267,7 @@ export const makePaginatedGitHubRequest = async ( pageUrlObj.searchParams.set("page", pageNum.toString()); pageRequests.push( - requestWithGitHubGateway(appConnection, gatewayService, { + requestWithGitHubGateway(appConnection, gatewayService, gatewayV2Service, { url: pageUrlObj.toString(), method: "GET", headers: { @@ -236,15 +291,20 @@ export const makePaginatedGitHubRequest = async ( while (url && i < maxIterations) { // eslint-disable-next-line no-await-in-loop - const response: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { - url, - method: "GET", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28" + const response: AxiosResponse = await requestWithGitHubGateway( + appConnection, + gatewayService, + gatewayV2Service, + { + url, + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } } - }); + ); const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); results = results.concat(items); @@ -283,30 +343,39 @@ type GitHubEnvironment = { export const getGitHubRepositories = async ( appConnection: TGitHubConnection, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { if (appConnection.method === GitHubConnectionMethod.App) { return makePaginatedGitHubRequest( appConnection, gatewayService, + gatewayV2Service, "/installation/repositories", (data) => data.repositories ); } - const repos = await makePaginatedGitHubRequest(appConnection, gatewayService, "/user/repos"); + const repos = await makePaginatedGitHubRequest( + appConnection, + gatewayService, + gatewayV2Service, + "/user/repos" + ); + return repos.filter((repo) => repo.permissions?.admin); }; export const getGitHubOrganizations = async ( appConnection: TGitHubConnection, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { if (appConnection.method === GitHubConnectionMethod.App) { const installationRepositories = await makePaginatedGitHubRequest< GitHubRepository, { repositories: GitHubRepository[] } - >(appConnection, gatewayService, "/installation/repositories", (data) => data.repositories); + >(appConnection, gatewayService, gatewayV2Service, "/installation/repositories", (data) => data.repositories); const organizationMap: Record = {}; installationRepositories.forEach((repo) => { @@ -318,12 +387,13 @@ export const getGitHubOrganizations = async ( return Object.values(organizationMap); } - return makePaginatedGitHubRequest(appConnection, gatewayService, "/user/orgs"); + return makePaginatedGitHubRequest(appConnection, gatewayService, gatewayV2Service, "/user/orgs"); }; export const getGitHubEnvironments = async ( appConnection: TGitHubConnection, gatewayService: Pick, + gatewayV2Service: Pick, owner: string, repo: string ) => { @@ -331,6 +401,7 @@ export const getGitHubEnvironments = async ( return await makePaginatedGitHubRequest( appConnection, gatewayService, + gatewayV2Service, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/environments`, (data) => data.environments ); @@ -358,7 +429,8 @@ export function isGithubErrorResponse(data: GithubTokenRespData): data is Github export const validateGitHubConnectionCredentials = async ( config: TGitHubConnectionConfig, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { const { credentials, method } = config; const { @@ -394,7 +466,7 @@ export const validateGitHubConnectionCredentials = async ( const host = credentials.host || "github.com"; try { - tokenResp = await requestWithGitHubGateway(config, gatewayService, { + tokenResp = await requestWithGitHubGateway(config, gatewayService, gatewayV2Service, { url: `https://${host}/login/oauth/access_token`, method: "POST", data: { @@ -446,7 +518,7 @@ export const validateGitHubConnectionCredentials = async ( id: number; }; }[]; - }>(config, gatewayService, { + }>(config, gatewayService, gatewayV2Service, { url: `https://${await getGitHubInstanceApiUrl(config)}/user/installations`, headers: { Accept: "application/json", diff --git a/backend/src/services/app-connection/github/github-connection-service.ts b/backend/src/services/app-connection/github/github-connection-service.ts index f1198ddfa..8292d94e0 100644 --- a/backend/src/services/app-connection/github/github-connection-service.ts +++ b/backend/src/services/app-connection/github/github-connection-service.ts @@ -1,4 +1,5 @@ import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { @@ -22,12 +23,13 @@ type TListGitHubEnvironmentsDTO = { export const githubConnectionService = ( getAppConnection: TGetAppConnectionFunc, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { const listRepositories = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const repositories = await getGitHubRepositories(appConnection, gatewayService); + const repositories = await getGitHubRepositories(appConnection, gatewayService, gatewayV2Service); return repositories; }; @@ -35,7 +37,7 @@ export const githubConnectionService = ( const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const organizations = await getGitHubOrganizations(appConnection, gatewayService); + const organizations = await getGitHubOrganizations(appConnection, gatewayService, gatewayV2Service); return organizations; }; @@ -46,7 +48,7 @@ export const githubConnectionService = ( ) => { const appConnection = await getAppConnection(AppConnection.GitHub, connectionId, actor); - const environments = await getGitHubEnvironments(appConnection, gatewayService, owner, repo); + const environments = await getGitHubEnvironments(appConnection, gatewayService, gatewayV2Service, owner, repo); return environments; }; diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index e2cf8f6e8..e41763474 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -1,6 +1,7 @@ import sodium from "libsodium-wrappers"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { getGitHubAppAuthToken, getGitHubInstanceApiUrl, @@ -20,7 +21,8 @@ import { TGitHubPublicKey, TGitHubSecret, TGitHubSecretPayload, TGitHubSyncWithC const getEncryptedSecrets = async ( secretSync: TGitHubSyncWithCredentials, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { const { destinationConfig, connection } = secretSync; @@ -44,6 +46,7 @@ const getEncryptedSecrets = async ( return makePaginatedGitHubRequest( connection, gatewayService, + gatewayV2Service, path, (data) => data.secrets ); @@ -52,6 +55,7 @@ const getEncryptedSecrets = async ( const getPublicKey = async ( secretSync: TGitHubSyncWithCredentials, gatewayService: Pick, + gatewayV2Service: Pick, token: string ) => { const { destinationConfig, connection } = secretSync; @@ -73,7 +77,7 @@ const getPublicKey = async ( } } - const response = await requestWithGitHubGateway(connection, gatewayService, { + const response = await requestWithGitHubGateway(connection, gatewayService, gatewayV2Service, { url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, method: "GET", headers: { @@ -89,6 +93,7 @@ const getPublicKey = async ( const deleteSecret = async ( secretSync: TGitHubSyncWithCredentials, gatewayService: Pick, + gatewayV2Service: Pick, token: string, encryptedSecret: TGitHubSecret ) => { @@ -111,7 +116,7 @@ const deleteSecret = async ( } } - await requestWithGitHubGateway(connection, gatewayService, { + await requestWithGitHubGateway(connection, gatewayService, gatewayV2Service, { url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, method: "DELETE", headers: { @@ -125,6 +130,7 @@ const deleteSecret = async ( const putSecret = async ( secretSync: TGitHubSyncWithCredentials, gatewayService: Pick, + gatewayV2Service: Pick, token: string, payload: TGitHubSecretPayload ) => { @@ -157,7 +163,7 @@ const putSecret = async ( } } - await requestWithGitHubGateway(connection, gatewayService, { + await requestWithGitHubGateway(connection, gatewayService, gatewayV2Service, { url: `https://${await getGitHubInstanceApiUrl(connection)}${path}`, method: "PUT", headers: { @@ -173,7 +179,8 @@ export const GithubSyncFns = { syncSecrets: async ( secretSync: TGitHubSyncWithCredentials, ogSecretMap: TSecretMap, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { const secretMap = Object.fromEntries(Object.entries(ogSecretMap).map(([i, v]) => [i.toUpperCase(), v])); @@ -209,8 +216,8 @@ export const GithubSyncFns = { ? connection.credentials.accessToken : await getGitHubAppAuthToken(connection); - const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); - const publicKey = await getPublicKey(secretSync, gatewayService, token); + const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); + const publicKey = await getPublicKey(secretSync, gatewayService, gatewayV2Service, token); await sodium.ready; for await (const key of Object.keys(secretMap)) { @@ -225,7 +232,7 @@ export const GithubSyncFns = { const encryptedSecretValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL); try { - await putSecret(secretSync, gatewayService, token, { + await putSecret(secretSync, gatewayService, gatewayV2Service, token, { secret_name: key, encrypted_value: encryptedSecretValue, key_id: publicKey.key_id @@ -246,7 +253,7 @@ export const GithubSyncFns = { continue; if (!(encryptedSecret.name in secretMap)) { - await deleteSecret(secretSync, gatewayService, token, encryptedSecret); + await deleteSecret(secretSync, gatewayService, gatewayV2Service, token, encryptedSecret); } } }, @@ -256,7 +263,8 @@ export const GithubSyncFns = { removeSecrets: async ( secretSync: TGitHubSyncWithCredentials, ogSecretMap: TSecretMap, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { const secretMap = Object.fromEntries(Object.entries(ogSecretMap).map(([i, v]) => [i.toUpperCase(), v])); @@ -266,11 +274,11 @@ export const GithubSyncFns = { ? connection.credentials.accessToken : await getGitHubAppAuthToken(connection); - const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); + const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); for await (const encryptedSecret of encryptedSecrets) { if (encryptedSecret.name in secretMap) { - await deleteSecret(secretSync, gatewayService, token, encryptedSecret); + await deleteSecret(secretSync, gatewayService, gatewayV2Service, token, encryptedSecret); } } } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 3ff7cefbc..bbaa4577a 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -2,6 +2,7 @@ import { AxiosError } from "axios"; import handlebars from "handlebars"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault"; import { BadRequestError } from "@app/lib/errors"; @@ -101,6 +102,7 @@ type TSyncSecretDeps = { appConnectionDAL: Pick; kmsService: Pick; gatewayService: Pick; + gatewayV2Service: Pick; }; // Add schema to secret keys @@ -195,7 +197,7 @@ export const SecretSyncFns = { syncSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, - { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService, gatewayV2Service }: TSyncSecretDeps ): Promise => { const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); @@ -205,7 +207,7 @@ export const SecretSyncFns = { case SecretSync.AWSSecretsManager: return AwsSecretsManagerSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.GitHub: - return GithubSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService); + return GithubSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService, gatewayV2Service); case SecretSync.GCPSecretManager: return GcpSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.AzureKeyVault: @@ -404,7 +406,7 @@ export const SecretSyncFns = { removeSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, - { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService, gatewayV2Service }: TSyncSecretDeps ): Promise => { const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); @@ -414,7 +416,7 @@ export const SecretSyncFns = { case SecretSync.AWSSecretsManager: return AwsSecretsManagerSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.GitHub: - return GithubSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService); + return GithubSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService, gatewayV2Service); case SecretSync.GCPSecretManager: return GcpSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.AzureKeyVault: diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 7bef7d8c7..9b6ea6b0b 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -5,6 +5,7 @@ import { Job } from "bullmq"; import { ProjectMembershipRole, SecretType } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; @@ -98,6 +99,7 @@ type TSecretSyncQueueFactoryDep = { folderCommitService: Pick; licenseService: Pick; gatewayService: Pick; + gatewayV2Service: Pick; }; type SecretSyncActionJob = Job< @@ -141,7 +143,8 @@ export const secretSyncQueueFactory = ({ resourceMetadataDAL, folderCommitService, licenseService, - gatewayService + gatewayService, + gatewayV2Service }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -357,7 +360,8 @@ export const secretSyncQueueFactory = ({ const importedSecrets = await SecretSyncFns.getSecrets(secretSync, { appConnectionDAL, kmsService, - gatewayService + gatewayService, + gatewayV2Service }); if (!Object.keys(importedSecrets).length) return {}; @@ -486,7 +490,8 @@ export const secretSyncQueueFactory = ({ await SecretSyncFns.syncSecrets(secretSyncWithCredentials, secretMap, { appConnectionDAL, kmsService, - gatewayService + gatewayService, + gatewayV2Service }); isSynced = true; @@ -736,7 +741,8 @@ export const secretSyncQueueFactory = ({ { appConnectionDAL, kmsService, - gatewayService + gatewayService, + gatewayV2Service } ); From ec39f84719598e86f59e64393472345eb4b68e8a Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Sep 2025 21:18:34 +0800 Subject: [PATCH 10/46] misc: addressed greptile comments 1 --- backend/src/ee/routes/v1/proxy-router.ts | 13 ++++++++++++- backend/src/ee/services/proxy/proxy-fns.ts | 4 +++- backend/src/ee/services/proxy/proxy-service.ts | 6 +++--- backend/src/lib/gateway-v2/gateway-v2.ts | 8 +++++--- backend/src/server/plugins/auth/inject-identity.ts | 3 ++- .../hc-vault/hc-vault-connection-fns.ts | 5 ++++- frontend/src/hooks/api/gateways/queries.tsx | 6 ++++-- 7 files changed, 33 insertions(+), 12 deletions(-) diff --git a/backend/src/ee/routes/v1/proxy-router.ts b/backend/src/ee/routes/v1/proxy-router.ts index e837eb624..d7742baa7 100644 --- a/backend/src/ee/routes/v1/proxy-router.ts +++ b/backend/src/ee/routes/v1/proxy-router.ts @@ -65,7 +65,18 @@ export const registerProxyRouter = async (server: FastifyZodProvider) => { name: z.string() }), response: { - 200: z.any() + 200: z.object({ + pki: z.object({ + serverCertificate: z.string(), + serverPrivateKey: z.string(), + clientCertificateChain: z.string() + }), + ssh: z.object({ + serverCertificate: z.string(), + serverPrivateKey: z.string(), + clientCAPublicKey: z.string() + }) + }) } }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), diff --git a/backend/src/ee/services/proxy/proxy-fns.ts b/backend/src/ee/services/proxy/proxy-fns.ts index 588a7b2ba..58ad60832 100644 --- a/backend/src/ee/services/proxy/proxy-fns.ts +++ b/backend/src/ee/services/proxy/proxy-fns.ts @@ -1,3 +1,5 @@ +export const INSTANCE_PROXY_PREFIX = "infisical-"; + export const isInstanceProxy = (proxyName: string) => { - return proxyName.startsWith("infisical-"); + return proxyName.startsWith(INSTANCE_PROXY_PREFIX); }; diff --git a/backend/src/ee/services/proxy/proxy-service.ts b/backend/src/ee/services/proxy/proxy-service.ts index 37b9ab7e3..ae6ae3383 100644 --- a/backend/src/ee/services/proxy/proxy-service.ts +++ b/backend/src/ee/services/proxy/proxy-service.ts @@ -405,7 +405,7 @@ export const proxyServiceFactory = ({ format: "der", type: "pkcs8" }); - const orgProxyClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + const orgProxyCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( "pkcs8", orgProxyCaSkObj.export({ format: "der", type: "pkcs8" }), alg, @@ -425,7 +425,7 @@ export const proxyServiceFactory = ({ issuer: orgProxyCaCert.subject, notBefore: orgProxyClientCaIssuedAt, notAfter: orgProxyClientCaExpiration, - signingKey: orgProxyClientCaPrivateKey, + signingKey: orgProxyCaPrivateKey, publicKey: orgProxyClientCaKeys.publicKey, signingAlgorithm: alg, extensions: [ @@ -460,7 +460,7 @@ export const proxyServiceFactory = ({ issuer: orgProxyCaCert.subject, notBefore: orgProxyServerCaIssuedAt, notAfter: orgProxyServerCaExpiration, - signingKey: orgProxyClientCaPrivateKey, + signingKey: orgProxyCaPrivateKey, publicKey: orgProxyServerCaKeys.publicKey, signingAlgorithm: alg, extensions: [ diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index beb76e582..c29cfe272 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -3,6 +3,7 @@ import tls from "node:tls"; import https from "https"; +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { splitPemChain } from "@app/services/certificate/certificate-fns"; import { BadRequestError } from "../errors"; @@ -27,12 +28,13 @@ const createProxyConnection = async ({ clientPrivateKey: string; serverCertificateChain: string; }): Promise => { - const [host, portStr] = proxyIp.split(":"); - const port = parseInt(portStr, 10) || 443; + const [targetHost] = await verifyHostInputValidity(proxyIp); + const [, portStr] = proxyIp.split(":"); + const port = parseInt(portStr, 10) || 8443; const serverCAs = splitPemChain(serverCertificateChain); const tlsOptions: tls.ConnectionOptions = { - host, + host: targetHost, port, cert: clientCertificate, key: clientPrivateKey, diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 0d0926d35..0126a4129 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -121,7 +121,8 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { return; } - if (req.url.includes("/api/v1/proxies/register-instance-proxy")) { + // Authentication is handled on a route-level + if (req.url === "/api/v1/proxies/register-instance-proxy") { return; } diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 46a59bcec..3a79e2f8e 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -3,6 +3,7 @@ import https from "https"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; @@ -144,7 +145,9 @@ export const getHCVaultAccessToken = async ( export const validateHCVaultConnectionCredentials = async ( connection: THCVaultConnection, - gatewayService: Pick + gatewayService: Pick, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _gatewayV2Service: Pick ) => { const instanceUrl = await getHCVaultInstanceUrl(connection); diff --git a/frontend/src/hooks/api/gateways/queries.tsx b/frontend/src/hooks/api/gateways/queries.tsx index 43d3aae87..ef4dafb75 100644 --- a/frontend/src/hooks/api/gateways/queries.tsx +++ b/frontend/src/hooks/api/gateways/queries.tsx @@ -12,8 +12,10 @@ export const gatewaysQueryKeys = { queryOptions({ queryKey: gatewaysQueryKeys.listKey(), queryFn: async () => { - const { data } = await apiRequest.get<{ gateways: TGateway[] }>("/api/v1/gateways"); - const { data: dataV2 } = await apiRequest.get("/api/v2/gateways"); + const [{ data }, { data: dataV2 }] = await Promise.all([ + apiRequest.get<{ gateways: TGateway[] }>("/api/v1/gateways"), + apiRequest.get("/api/v2/gateways") + ]); return [ ...data.gateways.map((g) => ({ From e00df2c65976032e599f9f47ed388873bb6b604d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Sep 2025 21:30:48 +0800 Subject: [PATCH 11/46] mics: added missing github app connection usage --- .../services/app-connection/github/github-connection-fns.ts | 6 ++++-- backend/src/services/secret-sync/github/github-sync-fns.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index e59367ed3..f55fb0eb2 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -164,7 +164,8 @@ export const requestWithGitHubGateway = async ( export const getGitHubAppAuthToken = async ( appConnection: TGitHubConnection, - gatewayService: Pick + gatewayService: Pick, + gatewayV2Service: Pick ) => { const appCfg = getConfig(); const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; @@ -200,6 +201,7 @@ export const getGitHubAppAuthToken = async ( const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>( appConnection, gatewayService, + gatewayV2Service, { url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`, method: "POST", @@ -249,7 +251,7 @@ export const makePaginatedGitHubRequest = async ( const token = method === GitHubConnectionMethod.OAuth ? credentials.accessToken - : await getGitHubAppAuthToken(appConnection, gatewayService); + : await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service); const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; const initialUrlObj = new URL(baseUrl); diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 913881381..4b174ca2a 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -214,7 +214,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection, gatewayService); + : await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); const publicKey = await getPublicKey(secretSync, gatewayService, gatewayV2Service, token); @@ -272,7 +272,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection, gatewayService); + : await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); From 54cd93a5d2767099fdcdea3485e739d56491dd6c Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Sep 2025 22:36:19 +0800 Subject: [PATCH 12/46] misc: added proper error message handling --- backend/src/lib/gateway-v2/gateway-v2.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index c29cfe272..105b53c1f 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -1,6 +1,7 @@ import net from "node:net"; import tls from "node:tls"; +import axios from "axios"; import https from "https"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; @@ -269,7 +270,11 @@ export const withGatewayV2Proxy = async ( } logger.error("Gateway error:", err instanceof Error ? err.message : String(err)); - const errorMessage = proxyErrorMessage || (err instanceof Error ? err.message : String(err)); + let errorMessage = proxyErrorMessage || (err instanceof Error ? err.message : String(err)); + if (axios.isAxiosError(err) && (err.response?.data as { message?: string })?.message) { + errorMessage = (err.response?.data as { message: string }).message; + } + throw new Error(errorMessage); } finally { // Ensure cleanup happens regardless of success or failure From a22903b9715c1303bb70d694a885e7da64602949 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 3 Sep 2025 23:26:13 +0800 Subject: [PATCH 13/46] misc: improved error being thrown --- backend/src/lib/gateway-v2/gateway-v2.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index 105b53c1f..b8623095f 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -269,13 +269,12 @@ export const withGatewayV2Proxy = async ( logger.error("Proxy error:", proxyErrorMessage); } logger.error("Gateway error:", err instanceof Error ? err.message : String(err)); - let errorMessage = proxyErrorMessage || (err instanceof Error ? err.message : String(err)); if (axios.isAxiosError(err) && (err.response?.data as { message?: string })?.message) { errorMessage = (err.response?.data as { message: string }).message; } - throw new Error(errorMessage); + throw new BadRequestError({ message: errorMessage }); } finally { // Ensure cleanup happens regardless of success or failure await cleanup(); From d994cb0bf88c288d6ea9c1d7f94f3a8478ad3509 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 4 Sep 2025 01:02:42 +0800 Subject: [PATCH 14/46] misc: updated helm chart for gateway v2 --- helm-charts/infisical-gateway/Chart.yaml | 4 ++-- helm-charts/infisical-gateway/templates/deployment.yaml | 1 + helm-charts/infisical-gateway/values.yaml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/helm-charts/infisical-gateway/Chart.yaml b/helm-charts/infisical-gateway/Chart.yaml index 17c0a3785..2dc9ef796 100644 --- a/helm-charts/infisical-gateway/Chart.yaml +++ b/helm-charts/infisical-gateway/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.0.5 +version: 1.0.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.0.5" +appVersion: "1.0.0" diff --git a/helm-charts/infisical-gateway/templates/deployment.yaml b/helm-charts/infisical-gateway/templates/deployment.yaml index a6fac0e7c..f55684f3b 100644 --- a/helm-charts/infisical-gateway/templates/deployment.yaml +++ b/helm-charts/infisical-gateway/templates/deployment.yaml @@ -38,6 +38,7 @@ spec: image: "infisical/cli:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} args: + - network - gateway envFrom: - secretRef: diff --git a/helm-charts/infisical-gateway/values.yaml b/helm-charts/infisical-gateway/values.yaml index 2e293d1e1..67874055f 100644 --- a/helm-charts/infisical-gateway/values.yaml +++ b/helm-charts/infisical-gateway/values.yaml @@ -1,6 +1,6 @@ image: pullPolicy: IfNotPresent - tag: "0.41.84" + tag: "0.42.0" secret: # The secret that contains the environment variables to be used by the gateway, such as INFISICAL_API_URL and TOKEN From 0a9f51f62fb9351b0a1c778277653a0431b0d7a9 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 4 Sep 2025 01:09:48 +0800 Subject: [PATCH 15/46] doc: cli docs for gateway v2 --- docs/cli/commands/gateway.mdx | 90 ++++--- docs/cli/commands/network.mdx | 441 ++++++++++++++++++++++++++++++++++ docs/docs.json | 34 ++- 3 files changed, 525 insertions(+), 40 deletions(-) create mode 100644 docs/cli/commands/network.mdx diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index a12493c58..0168c7a42 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -3,6 +3,22 @@ title: "infisical gateway" description: "Run the Infisical gateway or manage its systemd service" --- + +**New Gateway Architecture Available** + +A completely redesigned gateway system is now available under the `infisical network` command with a fundamentally different architecture: + +- **TCP-based SSH tunnels** instead of UDP/TURN protocol +- **Eliminates firewall complexity** - no UDP configuration needed +- **Enhanced security** with certificate-based authentication +- **Flexible deployment options** - instance-wide or organization-specific proxies + +**Learn more:** See [`infisical network`](/cli/commands/network) for the new gateway architecture. + +**Migration:** The current `infisical gateway` command will continue to work but **will be deprecated in a future release**. Migration to `infisical network gateway` requires **complete reconfiguration** - you cannot simply switch commands as this is an entirely different gateway infrastructure. We strongly recommend planning migration to `infisical network gateway` for all deployments. + + + ```bash @@ -25,13 +41,13 @@ Run the Infisical gateway in the foreground or manage its systemd service instal Run the Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection. - ```bash - infisical gateway --domain= --auth-method= - ``` +```bash +infisical gateway --domain= --auth-method= +``` - ### Authentication +### Authentication - The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. +The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. @@ -121,7 +137,6 @@ Run the Infisical gateway in the foreground or manage its systemd service instal infisical gateway --auth-method=gcp-id-token --machine-identity-id= ``` - The GCP IAM method is used to authenticate with Infisical with a GCP service account key. @@ -163,7 +178,6 @@ Run the Infisical gateway in the foreground or manage its systemd service instal infisical gateway --auth-method=aws-iam --machine-identity-id= ``` - The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. @@ -185,6 +199,7 @@ Run the Infisical gateway in the foreground or manage its systemd service instal ```bash infisical gateway --auth-method=oidc-auth --machine-identity-id= --jwt= ``` + @@ -208,6 +223,7 @@ Run the Infisical gateway in the foreground or manage its systemd service instal ```bash infisical gateway --auth-method=jwt-auth --jwt= --machine-identity-id= ``` + You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. @@ -227,7 +243,7 @@ Run the Infisical gateway in the foreground or manage its systemd service instal - ### Other Flags +### Other Flags Domain of your self-hosted Infisical instance. @@ -236,22 +252,24 @@ Run the Infisical gateway in the foreground or manage its systemd service instal # Example infisical gateway --domain=https://app.your-domain.com ``` + Install and enable the gateway as a systemd service. This command must be run with sudo on Linux. - ```bash - sudo infisical gateway install --token= --domain= - ``` +```bash +sudo infisical gateway install --token= --domain= +``` - ### Requirements - - Must be run on Linux - - Must be run with root/sudo privileges - - Requires systemd +### Requirements - ### Flags +- Must be run on Linux +- Must be run with root/sudo privileges +- Requires systemd + +### Flags The machine identity access token to authenticate with Infisical. @@ -262,6 +280,7 @@ Run the Infisical gateway in the foreground or manage its systemd service instal ``` You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the install command. + @@ -271,24 +290,29 @@ Run the Infisical gateway in the foreground or manage its systemd service instal # Example sudo infisical gateway install --domain=https://app.your-domain.com ``` + - ### Service Details - The systemd service is installed with secure defaults: - - Service file: `/etc/systemd/system/infisical-gateway.service` - - Config file: `/etc/infisical/gateway.conf` - - Runs with restricted privileges: - - InaccessibleDirectories=/home - - PrivateTmp=yes - - Resource limits configured for stability - - Automatically restarts on failure - - Enabled to start on boot +### Service Details + +The systemd service is installed with secure defaults: + +- Service file: `/etc/systemd/system/infisical-gateway.service` +- Config file: `/etc/infisical/gateway.conf` +- Runs with restricted privileges: + - InaccessibleDirectories=/home + - PrivateTmp=yes + - Resource limits configured for stability +- Automatically restarts on failure +- Enabled to start on boot + +After installation, manage the service with standard systemd commands: + +```bash +sudo systemctl start infisical-gateway # Start the service +sudo systemctl stop infisical-gateway # Stop the service +sudo systemctl status infisical-gateway # Check service status +sudo systemctl disable infisical-gateway # Disable auto-start on boot +``` - After installation, manage the service with standard systemd commands: - ```bash - sudo systemctl start infisical-gateway # Start the service - sudo systemctl stop infisical-gateway # Stop the service - sudo systemctl status infisical-gateway # Check service status - sudo systemctl disable infisical-gateway # Disable auto-start on boot - ``` diff --git a/docs/cli/commands/network.mdx b/docs/cli/commands/network.mdx new file mode 100644 index 000000000..4e4cdbfe3 --- /dev/null +++ b/docs/cli/commands/network.mdx @@ -0,0 +1,441 @@ +--- +title: "infisical network" +description: "Network-related commands for Infisical including gateway and proxy components" +--- + + + + ```bash + infisical network gateway --token= + ``` + + + ```bash + sudo infisical network gateway install --token= --domain= --name= --proxy-name= + ``` + + + +## Description + +Network-related commands for Infisical that provide secure access to private resources through a three-tier proxy system: + +- **Gateway**: Lightweight agent deployed within your VPCs to provide access to private resources +- **Proxy**: Identity-aware relay infrastructure that routes encrypted traffic (can be instance-wide or organization-specific) + +The gateway system uses SSH reverse tunnels over TCP, eliminating firewall complexity and providing excellent performance for enterprise environments. + +## Subcommands & flags + + + Run the Infisical gateway component within your VPC. The gateway establishes an SSH reverse tunnel to the specified proxy server and provides secure access to private resources. + +```bash +infisical network gateway --proxy-name= --name= --auth-method= +``` + +The gateway component: + +- Establishes outbound SSH reverse tunnels to proxy servers (no inbound firewall rules needed) +- Authenticates using SSH certificates issued by Infisical +- Automatically reconnects if the connection is lost +- Provides access to private resources within your network + +### Authentication + +The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. + + + + The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. + + + + + Your machine identity client ID. + + + Your machine identity client secret. + + + The authentication method to use. Must be `universal-auth` when using Universal Auth. + + + + + ```bash + infisical network gateway --auth-method=universal-auth --client-id= --client-secret= --proxy-name= --name= + ``` + + + + The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. + + + + + Your machine identity ID. + + + Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + The authentication method to use. Must be `kubernetes` when using Native Kubernetes. + + + + + + + ```bash + infisical network gateway --auth-method=kubernetes --machine-identity-id= --proxy-name= --name= + ``` + + + + The Native Azure method is used to authenticate with Infisical when running in an Azure environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `azure` when using Native Azure. + + + + + + + ```bash + infisical network gateway --auth-method=azure --machine-identity-id= --proxy-name= --name= + ``` + + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. + + + + + + + ```bash + infisical network gateway --auth-method=gcp-id-token --machine-identity-id= --proxy-name= --name= + ``` + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + + Your machine identity ID. + + + Path to your GCP service account key file _(Must be in JSON format!)_ + + + The authentication method to use. Must be `gcp-iam` when using GCP IAM. + + + + + ```bash + infisical network gateway --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --proxy-name= --name= + ``` + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `aws-iam` when using Native AWS IAM. + + + + + ```bash + infisical network gateway --auth-method=aws-iam --machine-identity-id= --proxy-name= --name= + ``` + + + + The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. + + + + + Your machine identity ID. + + + The OIDC JWT from the identity provider. + + + The authentication method to use. Must be `oidc-auth` when using OIDC Auth. + + + + + ```bash + infisical network gateway --auth-method=oidc-auth --machine-identity-id= --jwt= --proxy-name= --name= + ``` + + + + + The JWT Auth method is used to authenticate with Infisical via a JWT token. + + + + + The JWT token to use for authentication. + + + Your machine identity ID. + + + The authentication method to use. Must be `jwt-auth` when using JWT Auth. + + + + + + ```bash + infisical network gateway --auth-method=jwt-auth --jwt= --machine-identity-id= --proxy-name= --name= + ``` + + + + You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. + + + + + The machine identity access token to use for authentication. + + + + + ```bash + infisical network gateway --token= --proxy-name= --name= + ``` + + + + +### Other Flags + + + The name of the proxy that this gateway should connect to. The proxy must be running and registered before starting the gateway. + + ```bash + # Example + infisical network gateway --proxy-name=my-proxy --name=my-gateway --token= + ``` + + **Note:** If using organization proxies or self-hosted instance proxies, you must first start a proxy server using `infisical network proxy` before connecting gateways to it. For Infisical Cloud users using instance proxies, the proxy infrastructure is already running and managed by Infisical. + + + + + The name of the gateway instance. + + ```bash + # Example + infisical network gateway --name=my-gateway --proxy-name=my-proxy --token= + ``` + + + + + Domain of your self-hosted Infisical instance. + + ```bash + # Example + infisical network gateway --domain=https://app.your-domain.com --proxy-name= --name= + ``` + + + + + + Install and enable the gateway as a systemd service. This command must be run with sudo on Linux. + +```bash +sudo infisical network gateway install --token= --domain= --name= --proxy-name= +``` + +### Requirements + +- Must be run on Linux +- Must be run with root/sudo privileges +- Requires systemd + +### Flags + + + The machine identity access token to authenticate with Infisical. + + ```bash + # Example + sudo infisical network gateway install --token= --name= --proxy-name= + ``` + + You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the install command. + + + + + Domain of your self-hosted Infisical instance. + + ```bash + # Example + sudo infisical network gateway install --domain=https://app.your-domain.com --name= --proxy-name= + ``` + + + + + The name of the gateway instance. + + ```bash + # Example + sudo infisical network gateway install --name=my-gateway --token= --proxy-name= + ``` + + + + + The name of the proxy that this gateway should connect to. + + ```bash + # Example + sudo infisical network gateway install --proxy-name=my-proxy --token= --name= + ``` + + + +### Service Details + +The systemd service is installed with secure defaults: + +- Service file: `/etc/systemd/system/infisical-gateway.service` +- Config file: `/etc/infisical/gateway.conf` +- Runs with restricted privileges: + - InaccessibleDirectories=/home + - PrivateTmp=yes + - Resource limits configured for stability +- Automatically restarts on failure +- Enabled to start on boot +- Maintains persistent SSH reverse tunnel connections to the specified proxy +- Handles certificate rotation and connection recovery automatically + +After installation, manage the service with standard systemd commands: + +```bash +sudo systemctl start infisical-gateway # Start the service +sudo systemctl stop infisical-gateway # Stop the service +sudo systemctl status infisical-gateway # Check service status +sudo systemctl disable infisical-gateway # Disable auto-start on boot +``` + + + + + Run the Infisical proxy component. The proxy handles network traffic routing and can operate in different modes. + +```bash +infisical network proxy --type= --ip= --name= --auth-method= +``` + +### Flags + + + The type of proxy to run. Must be either 'instance' or 'org'. + + - **`instance`**: Shared proxy server that can be used by all organizations on your Infisical instance. Set up by the instance administrator. Uses `INFISICAL_PROXY_AUTH_SECRET` environment variable for authentication, which must be configured by the instance admin. + - **`org`**: Dedicated proxy server that individual organizations deploy and manage in their own infrastructure. Provides enhanced security, custom geographic placement, and compliance benefits. Uses standard Infisical authentication methods. + + ```bash + # Organization proxy (customer-deployed) + infisical network proxy --type=org --ip=192.168.1.100 --name=my-org-proxy + + # Instance proxy (configured by instance admin) + INFISICAL_PROXY_AUTH_SECRET= infisical network proxy --type=instance --ip=10.0.1.50 --name=shared-proxy + ``` + + + + + The public IP address of the instance where the proxy is deployed. This must be a static public IP that gateways can reach. + + ```bash + # Example + infisical network proxy --ip=203.0.113.100 --type=org --name=my-proxy + ``` + + + + + The name of the proxy. + + ```bash + # Example + infisical network proxy --name=my-proxy --type=org --ip=192.168.1.100 + ``` + + + +### Authentication + +**Organization Proxies (`--type=org`):** +Deploy your own proxy server in your infrastructure for enhanced security and reduced latency. Supports all standard Infisical authentication methods documented above in the gateway section. + +**Instance Proxies (`--type=instance`):** +Shared proxy servers that serve all organizations on your Infisical instance. For Infisical Cloud, these are already running and ready to use. For self-hosted deployments, they're set up by the instance administrator. Authentication is handled via the `INFISICAL_PROXY_AUTH_SECRET` environment variable. + +```bash +# Organization proxy with Universal Auth (customer-deployed) +infisical network proxy --type=org --ip=192.168.1.100 --name=my-org-proxy --auth-method=universal-auth --client-id= --client-secret= + +# Instance proxy (configured by instance admin) +INFISICAL_PROXY_AUTH_SECRET= infisical network proxy --type=instance --ip=10.0.1.50 --name=shared-proxy +``` + +### Deployment Considerations + +**When to use Instance Proxies (`--type=instance`):** + +- You want to get started quickly without setting up your own proxy infrastructure +- You're using Infisical Cloud and want to leverage the existing proxy infrastructure +- You're on a self-hosted instance where the admin has already set up shared proxies +- You don't need custom geographic placement of proxy servers +- You don't have specific compliance requirements that require dedicated infrastructure +- You want to minimize operational overhead by using shared infrastructure + +**When to use Organization Proxies (`--type=org`):** + +- You need lower latency by deploying proxy servers closer to your resources +- You have security requirements that mandate running infrastructure in your own environment +- You have compliance requirements such as data sovereignty or air-gapped environments +- You need custom network policies or specific networking configurations +- You have high-scale performance requirements that shared infrastructure can't meet +- You want full control over your proxy infrastructure and its configuration + + diff --git a/docs/docs.json b/docs/docs.json index 08121fa08..2f1bb225d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -346,7 +346,10 @@ }, { "group": "Architecture", - "pages": ["internals/architecture/components", "internals/architecture/cloud"] + "pages": [ + "internals/architecture/components", + "internals/architecture/cloud" + ] }, "internals/security", "internals/service-tokens" @@ -564,7 +567,10 @@ "integrations/cloud/gcp-secret-manager", { "group": "Cloudflare", - "pages": ["integrations/cloud/cloudflare-pages", "integrations/cloud/cloudflare-workers"] + "pages": [ + "integrations/cloud/cloudflare-pages", + "integrations/cloud/cloudflare-workers" + ] }, "integrations/cloud/terraform-cloud", "integrations/cloud/databricks", @@ -659,7 +665,9 @@ "documentation/platform/secret-scanning/overview", { "group": "Concepts", - "pages": ["documentation/platform/secret-scanning/concepts/secret-scanning"] + "pages": [ + "documentation/platform/secret-scanning/concepts/secret-scanning" + ] } ] }, @@ -709,13 +717,18 @@ "documentation/platform/ssh/overview", { "group": "Concepts", - "pages": ["documentation/platform/ssh/concepts/ssh-certificates"] + "pages": [ + "documentation/platform/ssh/concepts/ssh-certificates" + ] } ] }, { "group": "Platform Reference", - "pages": ["documentation/platform/ssh/usage", "documentation/platform/ssh/host-groups"] + "pages": [ + "documentation/platform/ssh/usage", + "documentation/platform/ssh/host-groups" + ] } ] }, @@ -757,12 +770,17 @@ "cli/commands/export", "cli/commands/token", "cli/commands/service-token", + "cli/commands/network", "cli/commands/vault", "cli/commands/user", "cli/commands/reset", { "group": "infisical scan", - "pages": ["cli/commands/scan", "cli/commands/scan-git-changes", "cli/commands/scan-install"] + "pages": [ + "cli/commands/scan", + "cli/commands/scan-git-changes", + "cli/commands/scan-install" + ] } ] }, @@ -1096,7 +1114,9 @@ "pages": [ { "group": "Kubernetes", - "pages": ["api-reference/endpoints/dynamic-secrets/kubernetes/create-lease"] + "pages": [ + "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" + ] }, "api-reference/endpoints/dynamic-secrets/create", "api-reference/endpoints/dynamic-secrets/update", From 4d22030bb89b92b024d48b4dacf10fb5e63ae639 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 4 Sep 2025 03:37:31 +0800 Subject: [PATCH 16/46] doc: added platform dcs --- docs/cli/commands/network.mdx | 2 +- docs/docs.json | 10 +- .../gateways-deprecated/gateway-security.mdx | 91 +++++ .../images/gateway-highlevel-diagram.png | Bin .../gateways-deprecated/networking.mdx | 168 +++++++++ .../platform/gateways-deprecated/overview.mdx | 352 ++++++++++++++++++ .../platform/gateways/gateway-security.mdx | 142 ++++--- .../platform/gateways/networking.mdx | 183 ++++----- .../platform/gateways/overview.mdx | 138 +++++-- .../gateways/gateway-highlevel-diagram.png | Bin 0 -> 113242 bytes 10 files changed, 916 insertions(+), 170 deletions(-) create mode 100644 docs/documentation/platform/gateways-deprecated/gateway-security.mdx rename docs/documentation/platform/{gateways => gateways-deprecated}/images/gateway-highlevel-diagram.png (100%) create mode 100644 docs/documentation/platform/gateways-deprecated/networking.mdx create mode 100644 docs/documentation/platform/gateways-deprecated/overview.mdx create mode 100644 docs/images/platform/gateways/gateway-highlevel-diagram.png diff --git a/docs/cli/commands/network.mdx b/docs/cli/commands/network.mdx index 4e4cdbfe3..0ed19a224 100644 --- a/docs/cli/commands/network.mdx +++ b/docs/cli/commands/network.mdx @@ -18,7 +18,7 @@ description: "Network-related commands for Infisical including gateway and proxy ## Description -Network-related commands for Infisical that provide secure access to private resources through a three-tier proxy system: +Network-related commands for Infisical that provide secure access to private resources: - **Gateway**: Lightweight agent deployed within your VPCs to provide access to private resources - **Proxy**: Identity-aware relay infrastructure that routes encrypted traffic (can be instance-wide or organization-specific) diff --git a/docs/docs.json b/docs/docs.json index 2f1bb225d..dc73fdb48 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -174,7 +174,15 @@ "pages": [ "documentation/platform/gateways/overview", "documentation/platform/gateways/gateway-security", - "documentation/platform/gateways/networking" + "documentation/platform/gateways/networking", + { + "group": "Gateway (Deprecated)", + "pages": [ + "documentation/platform/gateways-deprecated/overview", + "documentation/platform/gateways-deprecated/gateway-security", + "documentation/platform/gateways-deprecated/networking" + ] + } ] } ] diff --git a/docs/documentation/platform/gateways-deprecated/gateway-security.mdx b/docs/documentation/platform/gateways-deprecated/gateway-security.mdx new file mode 100644 index 000000000..93a7f662f --- /dev/null +++ b/docs/documentation/platform/gateways-deprecated/gateway-security.mdx @@ -0,0 +1,91 @@ +--- +title: "Gateway Security Architecture" +sidebarTitle: "Architecture" +description: "Understand the security model and tenant isolation of Infisical's Gateway" +--- + +# Gateway Security Architecture + +The Infisical Gateway enables Infisical Cloud to securely interact with private resources using mutual TLS authentication and private PKI (Public Key Infrastructure) system to ensure secure, isolated communication between multiple tenants. +This document explains the internal security architecture and how tenant isolation is maintained. + +## Security Model Overview + +### Private PKI System +Each organization (tenant) in Infisical has its own private PKI system consisting of: + +1. **Root CA**: The ultimate trust anchor for the organization +2. **Intermediate CAs**: + - Client CA: Issues certificates for cloud components + - Gateway CA: Issues certificates for gateway instances + +This hierarchical structure ensures complete isolation between organizations as each has its own independent certificate chain. + +### Certificate Hierarchy +``` +Root CA (Organization Specific) +├── Client CA +│ └── Client Certificates (Cloud Components) +└── Gateway CA + └── Gateway Certificates (Gateway Instances) +``` + +## Communication Security + +### 1. Gateway Registration +When a gateway is first deployed: + +1. Establishes initial connection using machine identity token +2. Allocates a relay address for communication +3. Exchanges certificates through a secure handshake: + - Gateway receives a unique certificate signed by organization's Gateway CA along with certificate chain for verification + +### 2. Mutual TLS Authentication +All communication between gateway and cloud uses mutual TLS (mTLS): + +- **Gateway Authentication**: + - Presents certificate signed by organization's Gateway CA + - Certificate contains unique identifiers (Organization ID, Gateway ID) + - Cloud validates complete certificate chain + +- **Cloud Authentication**: + - Presents certificate signed by organization's Client CA + - Certificate includes required organizational unit ("gateway-client") + - Gateway validates certificate chain back to organization's root CA + +### 3. Relay Communication +The relay system provides secure tunneling: + +1. **Connection Establishment**: + - Uses QUIC protocol over UDP for efficient, secure communication + - Provides built-in encryption, congestion control, and multiplexing + - Enables faster connection establishment and reduced latency + - Each organization's traffic is isolated using separate relay sessions + +2. **Traffic Isolation**: + - Each gateway gets unique relay credentials + - Traffic is end-to-end encrypted using QUIC's TLS 1.3 + - Organization's private keys never leave their environment + +## Tenant Isolation + +### Certificate-Based Isolation +- Each organization has unique root CA and intermediate CAs +- Certificates contain organization-specific identifiers +- Cross-tenant communication is cryptographically impossible + +### Gateway-Project Mapping +- Gateways are explicitly mapped to specific projects +- Access controls enforce organization boundaries +- Project-level permissions determine resource accessibility + +### Resource Access Control +1. **Project Verification**: + - Gateway verifies project membership + - Validates organization ownership + - Enforces project-level permissions + +2. **Resource Restrictions**: + - Gateways only accept connections to approved resources + - Each connection requires explicit project authorization + - Resources remain private to their assigned organization diff --git a/docs/documentation/platform/gateways/images/gateway-highlevel-diagram.png b/docs/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png similarity index 100% rename from docs/documentation/platform/gateways/images/gateway-highlevel-diagram.png rename to docs/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png diff --git a/docs/documentation/platform/gateways-deprecated/networking.mdx b/docs/documentation/platform/gateways-deprecated/networking.mdx new file mode 100644 index 000000000..6acdc1993 --- /dev/null +++ b/docs/documentation/platform/gateways-deprecated/networking.mdx @@ -0,0 +1,168 @@ +--- +title: "Networking" +description: "Network configuration and firewall requirements for Infisical Gateway" +--- + +The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. +This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. + +## Network Architecture + +The gateway uses a relay-based architecture to establish secure connections: + +1. **Gateway** connects outbound to **Relay Servers** using UDP/QUIC protocol +2. **Relay Servers** facilitate secure communication between Gateway and Infisical Cloud +3. All traffic is end-to-end encrypted using mutual TLS over QUIC + +## Required Network Connectivity + +### Outbound Connections (Required) + +The gateway requires the following outbound connectivity: + +| Protocol | Destination | Ports | Purpose | +|----------|-------------|-------|---------| +| UDP | Relay Servers | 49152-65535 | Allocated relay communication (TLS) | +| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and relay allocation | + +### Relay Server IP Addresses + +Your firewall must allow outbound connectivity to the following Infisical relay servers on dynamically allocated ports. + + + + ``` + 54.235.197.91:49152-65535 + 18.215.196.229:49152-65535 + 3.222.120.233:49152-65535 + 34.196.115.157:49152-65535 + ``` + + + ``` + 3.125.237.40:49152-65535 + 52.28.157.98:49152-65535 + 3.125.176.90:49152-65535 + ``` + + + Please contact your Infisical account manager for dedicated relay server IP addresses. + + + + + These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. + + +## Protocol Details + +### QUIC over UDP + +The gateway uses QUIC (Quick UDP Internet Connections) for primary communication: + +- **Port 5349**: STUN/TURN over TLS (secure relay communication) +- **Built-in features**: Connection migration, multiplexing, reduced latency +- **Encryption**: TLS 1.3 with certificate pinning + +## Understanding Firewall Behavior with UDP + +Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. +When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. +Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. + + +### Connection Tracking + +Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: +- Automatically handles return responses +- Reduces firewall rule complexity +- Avoids the need for manual IP whitelisting + +In the event that your firewall does not support connection tracking, you will need to whitelist the relay IPs to explicitly define return traffic manually. + +## Common Network Scenarios + +### Corporate Firewalls + +For corporate environments with strict egress filtering: + +1. **Whitelist relay IP addresses** (listed above) +2. **Allow UDP port 5349** outbound +3. **Configure connection tracking** for UDP return traffic +4. **Allow ephemeral port range** 49152-65535 for return traffic if connection tracking is disabled + +### Cloud Environments (AWS/GCP/Azure) + +Configure security groups to allow: +- **Outbound UDP** to relay IPs on port 5349 +- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 +- **Inbound UDP** on ephemeral ports (if not using stateful rules) + +## Frequently Asked Questions + + +The gateway is designed to handle network interruptions gracefully: + +- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers every 5 seconds if the connection is lost +- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention +- **Multiple relay servers**: If one relay server is unavailable, the gateway can connect to alternative relay servers +- **Persistent sessions**: Existing connections are maintained where possible during brief network interruptions +- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity + +No manual intervention is typically required during network interruptions. + + + +QUIC (Quick UDP Internet Connections) provides several advantages over traditional TCP for gateway communication: + +- **Faster connection establishment**: QUIC combines transport and security handshakes, reducing connection setup time +- **Built-in encryption**: TLS 1.3 is integrated into the protocol, ensuring all traffic is encrypted by default +- **Connection migration**: QUIC connections can survive IP address changes (useful for NAT rebinding) +- **Reduced head-of-line blocking**: Multiple data streams can be multiplexed without blocking each other +- **Better performance over unreliable networks**: Advanced congestion control and packet loss recovery +- **Lower latency**: Optimized for real-time communication between gateway and cloud services + +While TCP is stateful and easier for firewalls to track, QUIC's performance benefits outweigh the additional firewall configuration requirements. + + + +No inbound ports need to be opened. The gateway only makes outbound connections: + +- **Outbound UDP** to relay servers on ports 49152-65535 +- **Outbound HTTPS** to Infisical API endpoints +- **Return responses** are handled by connection tracking or explicit IP whitelisting + +This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. + + + +If your firewall has strict UDP restrictions: + +1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses +2. **Use explicit IP whitelisting** if connection tracking is disabled +3. **Consider network policy exceptions** for the gateway host +4. **Monitor firewall logs** to identify which specific rules are blocking traffic + +The gateway requires UDP connectivity to function - TCP-only configurations are not supported. + + + +The gateway connects to **one relay server at a time**: + +- **Single active connection**: Only one relay connection is established per gateway instance +- **Automatic failover**: If the current relay becomes unavailable, the gateway will connect to an alternative relay +- **Load distribution**: Different gateway instances may connect to different relay servers for load balancing +- **No manual selection**: The Infisical API automatically assigns the optimal relay server based on availability and proximity + +You should whitelist all relay IP addresses to ensure proper failover functionality. + + +No, relay servers cannot decrypt any traffic passing through them: + +- **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning +- **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys +- **No data storage**: Relay servers do not store any traffic or network-identifiable information +- **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation + +The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. + \ No newline at end of file diff --git a/docs/documentation/platform/gateways-deprecated/overview.mdx b/docs/documentation/platform/gateways-deprecated/overview.mdx new file mode 100644 index 000000000..f81809f7b --- /dev/null +++ b/docs/documentation/platform/gateways-deprecated/overview.mdx @@ -0,0 +1,352 @@ +--- +title: "Gateway" +sidebarTitle: "Overview" +description: "How to access private network resources from Infisical" +--- + +![Alt text](/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png) + +The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. +This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. +Common use cases include generating dynamic credentials or rotating credentials for private databases. + + + Gateway is a paid feature available under the Enterprise Tier for Infisical + Cloud users. Self-hosted Infisical users can contact + [sales@infisical.com](mailto:sales@infisical.com) to purchase an enterprise + license. + + +## How It Works + +The Gateway serves as a secure intermediary that facilitates direct communication between the Infisical server and your private network. +It’s a lightweight daemon packaged within the Infisical CLI, making it easy to deploy and manage. Once set up, the Gateway establishes a connection with a relay server, ensuring that all communication between Infisical and your Gateway is fully end-to-end encrypted. +This setup guarantees that only the platform and your Gateway can decrypt the transmitted information, keeping communication with your resources secure, private and isolated. + +## Deployment + +The Infisical Gateway is seamlessly integrated into the Infisical CLI under the `gateway` command, making it simple to deploy and manage. +You can install the Gateway in all the same ways you install the Infisical CLI—whether via npm, Docker, or a binary. +For detailed installation instructions, refer to the Infisical [CLI Installation instructions](/cli/overview). + +To function, the Gateway must authenticate with Infisical. This requires a machine identity configured with the appropriate permissions to create and manage a Gateway. +Once authenticated, the Gateway establishes a secure connection with Infisical to allow your private resources to be reachable. + +### Get started + + + + 1. Navigate to **Organization Access Control** in your Infisical dashboard. + 2. Create a dedicated machine identity for your Gateway. + 3. **Best Practice:** Assign a unique identity to each Gateway for better security and management. + ![Create Gateway Identity](../../../images/platform/gateways/create-identity-for-gateway.png) + + + + You'll need to choose an authentication method to initiate communication with Infisical. View the available machine identity authentication methods [here](/documentation/platform/identities/machine-identities). + + + + Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production: + + + + For production deployments on Linux, install the Gateway as a systemd service: + ```bash + sudo infisical gateway install --token --domain + sudo systemctl start infisical-gateway + ``` + This will install and start the Gateway as a secure systemd service that: + - Runs with restricted privileges: + - Runs as root user (required for secure token management) + - Restricted access to home directories + - Private temporary directory + - Automatically restarts on failure + - Starts on system boot + - Manages token and domain configuration securely in `/etc/infisical/gateway.conf` + + + The install command requires: + - Linux operating system + - Root/sudo privileges + - Systemd + + + + + + The Gateway can be installed via [Helm](https://helm.sh/). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. + + For production deployments on Kubernetes, install the Gateway using the Infisical Helm chart: + + ### Install the latest Helm Chart repository + ```bash + helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' + ``` + + ### Update the Helm Chart repository + ```bash + helm repo update + ``` + + ### Create a Kubernetes Secret containing gateway environment variables + + The gateway supports all identity authentication methods through the use of environment variables. + The environment variables must be set in the `infisical-gateway-environment` Kubernetes secret. + + + #### Supported authentication methods + + + + The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. + + + + + Your machine identity client ID. + + + Your machine identity client secret. + + + The authentication method to use. Must be `universal-auth` when using Universal Auth. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=universal-auth --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= + ``` + + + + The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. + + + + + Your machine identity ID. + + + Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + The authentication method to use. Must be `kubernetes` when using Native Kubernetes. + + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=kubernetes --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + + The Native Azure method is used to authenticate with Infisical when running in an Azure environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `azure` when using Native Azure. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=azure --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-id-token --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + + Your machine identity ID. + + + Path to your GCP service account key file _(Must be in JSON format!)_ + + + The authentication method to use. Must be `gcp-iam` when using GCP IAM. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH= + ``` + + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `aws-iam` when using Native AWS IAM. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=aws-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + + The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. + + + + + Your machine identity ID. + + + The OIDC JWT from the identity provider. + + + The authentication method to use. Must be `oidc-auth` when using OIDC Auth. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=oidc-auth --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_JWT= + ``` + + + + The JWT Auth method is used to authenticate with Infisical via a JWT token. + + + + + The JWT token to use for authentication. + + + Your machine identity ID. + + + The authentication method to use. Must be `jwt-auth` when using JWT Auth. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=jwt-auth --from-literal=INFISICAL_JWT= --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. + + + + + The machine identity access token to use for authentication. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_TOKEN= + ``` + + + + + #### Other environment variables + + + + The API URL to use for the gateway. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. + + + + + ### Install the Infisical Gateway Helm Chart + ```bash + helm install infisical-gateway infisical-helm-charts/infisical-gateway + ``` + + ### Check the gateway logs + After installing the gateway, you can check the logs to ensure it's running as expected. + + ```bash + kubectl logs deployment/infisical-gateway + ``` + + You should see the following output which indicates the gateway is running as expected. + ```bash + $ kubectl logs deployment/infisical-gateway + INF Provided relay port 5349. Using TLS + INF Connected with relay + INF 10.0.101.112:56735 + INF Starting relay connection health check + INF Gateway started successfully + INF New connection from: 10.0.1.8:34051 + INF Gateway is reachable by Infisical + ``` + + + + + For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: + ```bash + infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) + ``` + + Alternatively, if you already have the token, use it directly with the `--token` flag: + ```bash + infisical gateway --token + ``` + + Or set it as an environment variable: + ```bash + export INFISICAL_TOKEN= + infisical gateway + ``` + + + + For detailed information about the gateway command and its options, see the [gateway command documentation](/cli/commands/gateway). + + + Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical. + + + + + + To confirm your Gateway is working, check the deployment status by looking for the message **"Gateway started successfully"** in the Gateway logs. This indicates the Gateway is running properly. Next, verify its registration by opening your Infisical dashboard, navigating to **Organization Access Control**, and selecting the **Gateways** tab. Your newly deployed Gateway should appear in the list. + ![Gateway List](../../../images/platform/gateways/gateway-list.png) + + diff --git a/docs/documentation/platform/gateways/gateway-security.mdx b/docs/documentation/platform/gateways/gateway-security.mdx index 93a7f662f..668d69c3a 100644 --- a/docs/documentation/platform/gateways/gateway-security.mdx +++ b/docs/documentation/platform/gateways/gateway-security.mdx @@ -6,86 +6,124 @@ description: "Understand the security model and tenant isolation of Infisical's # Gateway Security Architecture -The Infisical Gateway enables Infisical Cloud to securely interact with private resources using mutual TLS authentication and private PKI (Public Key Infrastructure) system to ensure secure, isolated communication between multiple tenants. +The Infisical Gateway enables secure access to private resources using SSH reverse tunnels, certificate-based authentication, and a comprehensive PKI (Public Key Infrastructure) system. The architecture provides end-to-end encryption and complete tenant isolation through multiple certificate authorities. This document explains the internal security architecture and how tenant isolation is maintained. ## Security Model Overview -### Private PKI System -Each organization (tenant) in Infisical has its own private PKI system consisting of: +### Certificate Architecture -1. **Root CA**: The ultimate trust anchor for the organization -2. **Intermediate CAs**: - - Client CA: Issues certificates for cloud components - - Gateway CA: Issues certificates for gateway instances +The gateway system uses multiple certificate authorities depending on deployment configuration: -This hierarchical structure ensures complete isolation between organizations as each has its own independent certificate chain. +**For Organizations Using Infisical-Managed Proxies:** + +- **Instance proxy SSH Client CA & Server CA** - Gateway ↔ Infisical Proxy Server authentication +- **Instance proxy PKI Client CA & Server CA** - Platform ↔ Infisical Proxy Server authentication +- **Organization Gateway Client CA & Server CA** - Platform ↔ Gateway authentication + +**For Organizations Using Customer-Deployed Proxies:** + +- **Organization proxy SSH Client CA & Server CA** - Gateway ↔ Customer Proxy Server authentication +- **Organization proxy PKI Client CA & Server CA** - Platform ↔ Customer Proxy Server authentication +- **Organization Gateway Client CA & Server CA** - Platform ↔ Gateway authentication ### Certificate Hierarchy + ``` -Root CA (Organization Specific) -├── Client CA -│ └── Client Certificates (Cloud Components) -└── Gateway CA - └── Gateway Certificates (Gateway Instances) +Instance Level (Shared Proxies): +├── Instance Proxy SSH CA (Gateway ↔ Proxy) +├── Instance Proxy PKI CA (Platform ↔ Proxy) + +Organization Level: +├── Organization Proxy SSH CA (Gateway ↔ Org Proxy) +├── Organization Proxy PKI CA (Platform ↔ Org Proxy) +└── Organization Gateway CA (Platform ↔ Gateway) ``` ## Communication Security ### 1. Gateway Registration + When a gateway is first deployed: -1. Establishes initial connection using machine identity token -2. Allocates a relay address for communication -3. Exchanges certificates through a secure handshake: - - Gateway receives a unique certificate signed by organization's Gateway CA along with certificate chain for verification +1. Authenticates with Infisical using machine identity token +2. Receives SSH certificates for proxy server authentication +3. Establishes SSH reverse tunnel to assigned proxy server +4. Certificate issuance varies by proxy configuration: + - **Infisical-managed proxy**: Receives Instance proxy SSH client certificate + Instance proxy SSH Server CA + - **Customer-deployed proxy**: Receives Organization proxy SSH client certificate + Organization proxy SSH Server CA -### 2. Mutual TLS Authentication -All communication between gateway and cloud uses mutual TLS (mTLS): +### 2. SSH Tunnel Authentication + +Gateway ↔ Proxy Server communication uses SSH certificate authentication: - **Gateway Authentication**: - - Presents certificate signed by organization's Gateway CA - - Certificate contains unique identifiers (Organization ID, Gateway ID) - - Cloud validates complete certificate chain -- **Cloud Authentication**: - - Presents certificate signed by organization's Client CA - - Certificate includes required organizational unit ("gateway-client") - - Gateway validates certificate chain back to organization's root CA + - Presents SSH client certificate (Instance or Organization proxy SSH Client CA) + - Certificate contains gateway identification and permissions + - Proxy server validates certificate against appropriate SSH Client CA -### 3. Relay Communication -The relay system provides secure tunneling: +- **Proxy Server Authentication**: + - Presents SSH server certificate (Instance or Organization proxy SSH Server CA) + - Gateway validates certificate against appropriate SSH Server CA + - Ensures gateway connects to legitimate proxy infrastructure -1. **Connection Establishment**: - - Uses QUIC protocol over UDP for efficient, secure communication - - Provides built-in encryption, congestion control, and multiplexing - - Enables faster connection establishment and reduced latency - - Each organization's traffic is isolated using separate relay sessions +### 3. Application Traffic Security -2. **Traffic Isolation**: - - Each gateway gets unique relay credentials - - Traffic is end-to-end encrypted using QUIC's TLS 1.3 +End-to-end encryption for application data: + +1. **mTLS Layer**: + + - Infisical platform establishes mTLS connections directly with gateways + - Uses Organization Gateway certificates for authentication + - Application traffic is encrypted end-to-end between platform and gateway + +2. **SSH Tunnel Layer**: + + - mTLS-encrypted application traffic travels through SSH reverse tunnels + - Creates double encryption: mTLS payload within SSH tunnel + - Proxy servers cannot decrypt either encryption layer + +3. **Traffic Isolation**: + - Each gateway maintains separate SSH tunnels - Organization's private keys never leave their environment + - Complete cryptographic isolation between organizations ## Tenant Isolation -### Certificate-Based Isolation -- Each organization has unique root CA and intermediate CAs -- Certificates contain organization-specific identifiers -- Cross-tenant communication is cryptographically impossible +### Multi-Layer Certificate Isolation -### Gateway-Project Mapping -- Gateways are explicitly mapped to specific projects -- Access controls enforce organization boundaries -- Project-level permissions determine resource accessibility +The architecture provides tenant isolation through multiple certificate authority layers: + +- **Instance-level CAs**: Shared proxy infrastructure uses instance-level certificates +- **Organization-level CAs**: Each organization has unique certificate authorities +- **Proxy deployment flexibility**: Organizations can choose shared or dedicated proxy infrastructure +- **Cryptographic separation**: Cross-tenant communication is cryptographically impossible + +### Authentication Flows by Deployment Type + +**Infisical-Managed Proxy Deployments:** + +- Gateway authenticates with proxy using Instance proxy SSH certificates +- Platform authenticates with proxy using Instance proxy PKI certificates +- Platform authenticates with gateway using Organization Gateway certificates + +**Customer-Deployed Proxy Deployments:** + +- Gateway authenticates with proxy using Organization proxy SSH certificates +- Platform authenticates with proxy using Organization proxy PKI certificates +- Platform authenticates with gateway using Organization Gateway certificates ### Resource Access Control -1. **Project Verification**: - - Gateway verifies project membership - - Validates organization ownership - - Enforces project-level permissions -2. **Resource Restrictions**: - - Gateways only accept connections to approved resources - - Each connection requires explicit project authorization - - Resources remain private to their assigned organization +1. **Certificate Validation**: + + - All connections require valid certificates from appropriate CAs + - Embedded certificate details control access permissions + - Ephemeral certificate validation ensures time-bound access + +2. **Network Isolation**: + + - Each organization's traffic flows through isolated certificate-authenticated channels + - Proxy servers route traffic based on certificate validation without content access + - Gateway validates all incoming connections against Organization Gateway Client CA diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx index 6acdc1993..c7ab95fd9 100644 --- a/docs/documentation/platform/gateways/networking.mdx +++ b/docs/documentation/platform/gateways/networking.mdx @@ -3,16 +3,17 @@ title: "Networking" description: "Network configuration and firewall requirements for Infisical Gateway" --- -The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. +The Infisical Gateway requires outbound network connectivity to establish secure SSH reverse tunnels with proxy servers. This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. ## Network Architecture -The gateway uses a relay-based architecture to establish secure connections: +The gateway uses SSH reverse tunnels to establish secure connections with end-to-end encryption: -1. **Gateway** connects outbound to **Relay Servers** using UDP/QUIC protocol -2. **Relay Servers** facilitate secure communication between Gateway and Infisical Cloud -3. All traffic is end-to-end encrypted using mutual TLS over QUIC +1. **Gateway** connects outbound to **Proxy Servers** using SSH over TCP +2. **Infisical platform** establishes mTLS connections with gateways for application traffic +3. **Proxy Servers** route the doubly-encrypted traffic (mTLS payload within SSH tunnels) between the platform and gateways +4. **Double encryption** ensures proxy servers cannot access application data - only the platform and gateway can decrypt traffic ## Required Network Connectivity @@ -20,65 +21,69 @@ The gateway uses a relay-based architecture to establish secure connections: The gateway requires the following outbound connectivity: -| Protocol | Destination | Ports | Purpose | -|----------|-------------|-------|---------| -| UDP | Relay Servers | 49152-65535 | Allocated relay communication (TLS) | -| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and relay allocation | +| Protocol | Destination | Ports | Purpose | +| -------- | ------------------------------------ | ----- | ------------------------------------------ | +| TCP | Proxy Servers | 2222 | SSH reverse tunnel establishment | +| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and certificate requests | -### Relay Server IP Addresses +### Proxy Server Connectivity -Your firewall must allow outbound connectivity to the following Infisical relay servers on dynamically allocated ports. +**For Instance Proxies (Infisical Cloud):** Your firewall must allow outbound connectivity to Infisical-managed proxy servers. + +**For Organization Proxies:** Your firewall must allow outbound connectivity to your own proxy server IP addresses. + +**For Self-hosted Instance Proxies:** Your firewall must allow outbound connectivity to proxy servers configured by your instance administrator. - - ``` - 54.235.197.91:49152-65535 - 18.215.196.229:49152-65535 - 3.222.120.233:49152-65535 - 34.196.115.157:49152-65535 - ``` + + Infisical provides multiple managed proxy servers with static IP addresses. + You can whitelist these IPs ahead of time based on which proxy server you + choose to connect to. **Firewall requirements:** Allow outbound TCP + connections to the desired proxy server IP on port 2222. - - ``` - 3.125.237.40:49152-65535 - 52.28.157.98:49152-65535 - 3.125.176.90:49152-65535 - ``` + + You control the proxy server IP addresses when deploying your own + organization proxies. **Firewall requirements:** Allow outbound TCP + connections to your proxy server IP on port 2222. For example, if your proxy + is at `203.0.113.100`, allow TCP to `203.0.113.100:2222`. - - Please contact your Infisical account manager for dedicated relay server IP addresses. + + Contact your instance administrator for the proxy server IP addresses + configured for your deployment. **Firewall requirements:** Allow outbound + TCP connections to instance proxy servers on port 2222. - - These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. - - ## Protocol Details -### QUIC over UDP +### SSH over TCP -The gateway uses QUIC (Quick UDP Internet Connections) for primary communication: +The gateway uses SSH reverse tunnels for primary communication: -- **Port 5349**: STUN/TURN over TLS (secure relay communication) -- **Built-in features**: Connection migration, multiplexing, reduced latency -- **Encryption**: TLS 1.3 with certificate pinning +- **Port 2222**: SSH connection to proxy servers +- **Built-in features**: Automatic reconnection, certificate-based authentication, encrypted tunneling +- **Encryption**: SSH with certificate-based authentication and key exchange -## Understanding Firewall Behavior with UDP +## Firewall Configuration for SSH -Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. -When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. -Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. +The gateway uses standard SSH over TCP, making firewall configuration straightforward. +### TCP Connection Handling -### Connection Tracking +SSH connections over TCP are stateful and handled seamlessly by all modern firewalls: -Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: -- Automatically handles return responses -- Reduces firewall rule complexity -- Avoids the need for manual IP whitelisting +- **Established connections** are automatically tracked +- **Return traffic** is allowed for established outbound connections +- **No special configuration** needed for connection tracking +- **Standard SSH protocol** that enterprise firewalls handle well -In the event that your firewall does not support connection tracking, you will need to whitelist the relay IPs to explicitly define return traffic manually. +### Simplified Firewall Rules + +Since SSH uses TCP, you only need simple outbound rules: + +1. **Allow outbound TCP** to proxy servers on port 2222 +2. **Allow outbound HTTPS** to Infisical API endpoints on port 443 +3. **No inbound rules required** - all connections are outbound only ## Common Network Scenarios @@ -86,83 +91,87 @@ In the event that your firewall does not support connection tracking, you will n For corporate environments with strict egress filtering: -1. **Whitelist relay IP addresses** (listed above) -2. **Allow UDP port 5349** outbound -3. **Configure connection tracking** for UDP return traffic -4. **Allow ephemeral port range** 49152-65535 for return traffic if connection tracking is disabled +1. **Allow outbound TCP** to proxy servers on port 2222 +2. **Allow outbound HTTPS** to the Infisical API server on port 443 +3. **No inbound rules required** - all connections are outbound only +4. **Standard TCP rules** - simple and straightforward configuration ### Cloud Environments (AWS/GCP/Azure) Configure security groups to allow: -- **Outbound UDP** to relay IPs on port 5349 + +- **Outbound TCP** to proxy servers on port 2222 - **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 -- **Inbound UDP** on ephemeral ports (if not using stateful rules) +- **No inbound rules required** - SSH reverse tunnels are outbound only ## Frequently Asked Questions The gateway is designed to handle network interruptions gracefully: -- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers every 5 seconds if the connection is lost +- **Automatic reconnection**: The gateway will automatically attempt to reconnect to proxy servers if the SSH connection is lost - **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention -- **Multiple relay servers**: If one relay server is unavailable, the gateway can connect to alternative relay servers -- **Persistent sessions**: Existing connections are maintained where possible during brief network interruptions +- **Persistent SSH tunnels**: SSH connections are automatically re-established when connectivity is restored +- **Certificate rotation**: The gateway handles certificate renewal automatically during reconnection - **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity No manual intervention is typically required during network interruptions. + - -QUIC (Quick UDP Internet Connections) provides several advantages over traditional TCP for gateway communication: + +SSH over TCP provides several advantages for enterprise gateway communication: -- **Faster connection establishment**: QUIC combines transport and security handshakes, reducing connection setup time -- **Built-in encryption**: TLS 1.3 is integrated into the protocol, ensuring all traffic is encrypted by default -- **Connection migration**: QUIC connections can survive IP address changes (useful for NAT rebinding) -- **Reduced head-of-line blocking**: Multiple data streams can be multiplexed without blocking each other -- **Better performance over unreliable networks**: Advanced congestion control and packet loss recovery -- **Lower latency**: Optimized for real-time communication between gateway and cloud services +- **Firewall-friendly**: TCP is stateful and handled seamlessly by all enterprise firewalls +- **Standard protocol**: SSH is a well-established protocol that network teams are familiar with +- **Certificate-based security**: Uses SSH certificates for strong authentication without shared secrets +- **Automatic tunneling**: SSH reverse tunnels handle all the complexity of secure communication +- **Enterprise compatibility**: Works reliably across all enterprise network configurations + +TCP's reliability and firewall compatibility make it ideal for enterprise environments where network policies are strictly managed. -While TCP is stateful and easier for firewalls to track, QUIC's performance benefits outweigh the additional firewall configuration requirements. No inbound ports need to be opened. The gateway only makes outbound connections: -- **Outbound UDP** to relay servers on ports 49152-65535 -- **Outbound HTTPS** to Infisical API endpoints -- **Return responses** are handled by connection tracking or explicit IP whitelisting +- **Outbound SSH** to proxy servers on port 2222 +- **Outbound HTTPS** to Infisical API endpoints on port 443 +- **SSH reverse tunnels** handle all communication - no return traffic configuration needed This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. + - -If your firewall has strict UDP restrictions: + +If your firewall has strict outbound restrictions: -1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses -2. **Use explicit IP whitelisting** if connection tracking is disabled -3. **Consider network policy exceptions** for the gateway host +1. **Work with your network team** to allow outbound TCP connections on port 2222 to proxy servers +2. **Allow standard SSH traffic** - most enterprises already have SSH policies in place +3. **Consider network policy exceptions** for the gateway host if needed 4. **Monitor firewall logs** to identify which specific rules are blocking traffic -The gateway requires UDP connectivity to function - TCP-only configurations are not supported. - -The gateway connects to **one relay server at a time**: + +The gateway connects to **one proxy server**: -- **Single active connection**: Only one relay connection is established per gateway instance -- **Automatic failover**: If the current relay becomes unavailable, the gateway will connect to an alternative relay -- **Load distribution**: Different gateway instances may connect to different relay servers for load balancing -- **No manual selection**: The Infisical API automatically assigns the optimal relay server based on availability and proximity +- **Single SSH connection**: Each gateway establishes one SSH reverse tunnel to its assigned proxy server +- **Named proxy assignment**: Gateways connect to the specific proxy server specified by `--proxy-name` +- **Automatic reconnection**: If the proxy connection is lost, the gateway automatically reconnects to the same proxy +- **Certificate-based authentication**: Each connection uses SSH certificates issued by Infisical for secure authentication -You should whitelist all relay IP addresses to ensure proper failover functionality. - -No, relay servers cannot decrypt any traffic passing through them: + +No, proxy servers cannot decrypt any traffic passing through them due to end-to-end encryption: -- **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning -- **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys -- **No data storage**: Relay servers do not store any traffic or network-identifiable information -- **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation +- **Client-to-Gateway mTLS**: Clients establish mTLS connections directly with gateways, encrypting all application traffic +- **SSH tunnel encryption**: The mTLS-encrypted traffic is then transmitted through SSH reverse tunnels to proxy servers +- **Double encryption**: Traffic is encrypted twice - once by client mTLS and again by SSH tunnels +- **Proxy acts as a relay**: The proxy server only routes the doubly-encrypted traffic without access to either encryption layer +- **No data storage**: Proxy servers do not store any traffic or sensitive information +- **Certificate isolation**: Each connection uses unique certificates, ensuring complete tenant isolation -The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. - \ No newline at end of file +The proxy infrastructure is designed as a secure routing mechanism where only the client and gateway can decrypt the actual application traffic. + + diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index 127e544b7..58701c41b 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -4,33 +4,53 @@ sidebarTitle: "Overview" description: "How to access private network resources from Infisical" --- -![Alt text](/documentation/platform/gateways/images/gateway-highlevel-diagram.png) +![Architecture Overview](../../../images/platform/gateways/gateway-highlevel-diagram.png) + +The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. + +**Architecture Components:** + +- **Gateway**: Lightweight agent deployed within your VPCs that provides access to private resources +- **Proxy**: Identity-aware relay infrastructure that routes encrypted traffic (instance-wide or organization-specific) -The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. -This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. Common use cases include generating dynamic credentials or rotating credentials for private databases. - **Note:** Gateway is a paid feature. - **Infisical Cloud users:** Gateway is - available under the **Enterprise Tier**. - **Self-Hosted Infisical:** Please - contact [sales@infisical.com](mailto:sales@infisical.com) to purchase an - enterprise license. + Gateway is a paid feature available under the Enterprise Tier for Infisical + Cloud users. Self-hosted Infisical users can contact + [sales@infisical.com](mailto:sales@infisical.com) to purchase an enterprise + license. ## How It Works -The Gateway serves as a secure intermediary that facilitates direct communication between the Infisical server and your private network. -It’s a lightweight daemon packaged within the Infisical CLI, making it easy to deploy and manage. Once set up, the Gateway establishes a connection with a relay server, ensuring that all communication between Infisical and your Gateway is fully end-to-end encrypted. -This setup guarantees that only the platform and your Gateway can decrypt the transmitted information, keeping communication with your resources secure, private and isolated. +The Gateway system uses SSH reverse tunnels for secure, firewall-friendly connectivity: + +1. **Gateway Registration**: The gateway establishes an outbound SSH reverse tunnel to a proxy server using SSH certificates issued by Infisical +2. **Proxy Routing**: The proxy server acts as an identity-aware relay that routes encrypted traffic between the Infisical platform and gateways +3. **Resource Access**: The Infisical platform connects to your private resources through the established gateway connections + +**Key Benefits:** + +- **No inbound firewall rules needed** - all connections are outbound from your network +- **Firewall-friendly** - uses standard SSH over TCP +- **Certificate-based authentication** provides enhanced security +- **Automatic reconnection** if connections are lost ## Deployment -The Infisical Gateway is seamlessly integrated into the Infisical CLI under the `gateway` command, making it simple to deploy and manage. +The Infisical Gateway is integrated into the Infisical CLI under the `network gateway` command, making it simple to deploy and manage. You can install the Gateway in all the same ways you install the Infisical CLI—whether via npm, Docker, or a binary. For detailed installation instructions, refer to the Infisical [CLI Installation instructions](/cli/overview). -To function, the Gateway must authenticate with Infisical. This requires a machine identity configured with the appropriate permissions to create and manage a Gateway. -Once authenticated, the Gateway establishes a secure connection with Infisical to allow your private resources to be reachable. +**Prerequisites:** + +1. **Proxy Server**: Before deploying gateways, you need a running proxy server: + - **Infisical Cloud**: Instance proxies are already available - no setup needed + - **Self-hosted**: Instance admin must set up shared instance proxies, or organizations can deploy their own +2. **Machine Identity**: Configure a machine identity with appropriate permissions to create and manage gateways + +Once authenticated, the Gateway establishes an SSH reverse tunnel to the specified proxy server, allowing secure access to your private resources. ### Get started @@ -46,6 +66,36 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t You'll need to choose an authentication method to initiate communication with Infisical. View the available machine identity authentication methods [here](/documentation/platform/identities/machine-identities). + + You have two options for proxy infrastructure: + + + + **Infisical Cloud:** Instance proxies are already running and available - **no setup required**. You can immediately proceed to deploy gateways using these shared proxies. + + **Self-hosted:** If your instance admin has set up shared instance proxies, you can use them directly. If not, the instance admin can set them up: + ```bash + # Instance admin sets up shared proxy (one-time setup) + export INFISICAL_PROXY_AUTH_SECRET= + infisical network proxy --type=instance --ip= --name= + ``` + + + **Available for all users:** Deploy your own dedicated proxy infrastructure for enhanced control: + ```bash + # Deploy organization-specific proxy + infisical network proxy --type=org --ip= --name= --auth-method=universal-auth --client-id= --client-secret= + ``` + + **When to choose this:** + - You need lower latency (deploy closer to your resources) + - Enhanced security requirements + - Compliance needs (data sovereignty, air-gapped environments) + - Custom network policies + + + + Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production: @@ -53,7 +103,7 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t For production deployments on Linux, install the Gateway as a systemd service: ```bash - sudo infisical gateway install --token --domain + sudo infisical network gateway install --token --domain --name --proxy-name sudo systemctl start infisical-gateway ``` This will install and start the Gateway as a secure systemd service that: @@ -81,7 +131,7 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t ### Install the latest Helm Chart repository ```bash - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' + helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' ``` ### Update the Helm Chart repository @@ -116,7 +166,12 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=universal-auth --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= + kubectl create secret generic infisical-gateway-environment \ + --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ + --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ + --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ + --from-literal=INFISICAL_PROXY_NAME= \ + --from-literal=INFISICAL_GATEWAY_NAME= ``` @@ -283,6 +338,29 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t + #### Required environment variables + + In addition to the authentication method above, you **must** include these required variables: + + + + The name of the proxy server that this gateway should connect to. + + + The name of this gateway instance. + + + + **Complete example with required variables:** + ```bash + kubectl create secret generic infisical-gateway-environment \ + --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ + --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ + --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ + --from-literal=INFISICAL_PROXY_NAME= \ + --from-literal=INFISICAL_GATEWAY_NAME= + ``` + #### Other environment variables @@ -306,14 +384,12 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t You should see the following output which indicates the gateway is running as expected. ```bash - $ kubectl logs deployment/infisical-gateway - INF Provided relay port 5349. Using TLS - INF Connected with relay - INF 10.0.101.112:56735 - INF Starting relay connection health check - INF Gateway started successfully - INF New connection from: 10.0.1.8:34051 - INF Gateway is reachable by Infisical + $ kubectl logs deployment/infisical-gateway + INF Starting gateway + INF Starting gateway certificate renewal goroutine + INF Successfully registered gateway and received certificates + INF Connecting to proxy server infisical-start on 152.42.218.156:2222... + INF Proxy connection established for gateway ``` @@ -321,27 +397,31 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: ```bash - infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) + infisical network gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) --proxy-name= --name= ``` Alternatively, if you already have the token, use it directly with the `--token` flag: ```bash - infisical gateway --token + infisical network gateway --token --proxy-name= --name= ``` Or set it as an environment variable: ```bash export INFISICAL_TOKEN= - infisical gateway + infisical network gateway --proxy-name= --name= ``` - For detailed information about the gateway command and its options, see the [gateway command documentation](/cli/commands/gateway). + For detailed information about the network commands and their options, see the [network command documentation](/cli/commands/network). - Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical. + **Requirements:** + - Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical + - The gateway must be able to reach the proxy server (outbound connection only) + - Replace `` with the name of your proxy server and `` with a unique name for this gateway + diff --git a/docs/images/platform/gateways/gateway-highlevel-diagram.png b/docs/images/platform/gateways/gateway-highlevel-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..0555cfadd422942006109b41c81872abab9bb0d3 GIT binary patch literal 113242 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

A) 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 Wwjs-fDIxI5F~ys=WlL|E1pFUUG~`48 literal 0 HcmV?d00001 From 4f7b0eaa0d6551a7a0fbffa6d8bcdc27d2deea26 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 4 Sep 2025 19:16:50 +0800 Subject: [PATCH 17/46] misc: added proper handling for gateway name conflicts --- .../services/gateway-v2/gateway-v2-service.ts | 179 +++++++++--------- 1 file changed, 94 insertions(+), 85 deletions(-) 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 e8aede3b5..8fb53fa20 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -6,7 +6,8 @@ import * as x509 from "@peculiar/x509"; import { TProxies } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +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 { OrgServiceActor } from "@app/lib/types"; @@ -439,92 +440,100 @@ export const gatewayV2ServiceFactory = ({ throw new NotFoundError({ message: `Proxy ${proxyName} not found` }); } - const [gateway] = await gatewayV2DAL.upsert( - [ - { - orgId, - name, - identityId: actorId, - proxyId: proxy.id + try { + const [gateway] = await gatewayV2DAL.upsert( + [ + { + orgId, + name, + identityId: actorId, + proxyId: proxy.id + } + ], + ["identityId"] + ); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const gatewayServerCaCert = new x509.X509Certificate(orgCAs.gatewayServerCaCertificate); + const rootGatewayCaCert = new x509.X509Certificate(orgCAs.rootGatewayCaCertificate); + const gatewayClientCaCert = new x509.X509Certificate(orgCAs.gatewayClientCaCertificate); + + const gatewayServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: orgCAs.gatewayServerCaPrivateKey, + format: "der", + type: "pkcs8" + }); + const gatewayServerCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + gatewayServerCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const gatewayServerKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const gatewayServerCertIssuedAt = new Date(); + const gatewayServerCertExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1)); + const gatewayServerCertPrivateKey = crypto.nativeCrypto.KeyObject.from(gatewayServerKeys.privateKey); + + const gatewayServerCertExtensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(gatewayServerCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(gatewayServerKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), + new x509.SubjectAlternativeNameExtension([ + { type: "dns", value: "localhost" }, + { type: "ip", value: "127.0.0.1" }, + { type: "ip", value: "::1" } + ]) + ]; + + const gatewayServerSerialNumber = createSerialNumber(); + const gatewayServerCertificate = await x509.X509CertificateGenerator.create({ + serialNumber: gatewayServerSerialNumber, + subject: `O=${orgId},CN=Gateway`, + issuer: gatewayServerCaCert.subject, + notBefore: gatewayServerCertIssuedAt, + notAfter: gatewayServerCertExpireAt, + signingKey: gatewayServerCaPrivateKey, + publicKey: gatewayServerKeys.publicKey, + signingAlgorithm: alg, + extensions: gatewayServerCertExtensions + }); + + const proxyCredentials = await proxyService.getCredentialsForGateway({ + proxyName, + orgId, + gatewayId: gateway.id + }); + + return { + gatewayId: gateway.id, + proxyIp: proxyCredentials.proxyIp, + pki: { + serverCertificate: gatewayServerCertificate.toString("pem"), + serverPrivateKey: gatewayServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + clientCertificateChain: constructPemChainFromCerts([gatewayClientCaCert, rootGatewayCaCert]) + }, + ssh: { + clientCertificate: proxyCredentials.clientSshCert, + clientPrivateKey: proxyCredentials.clientSshPrivateKey, + serverCAPublicKey: proxyCredentials.serverCAPublicKey } - ], - ["identityId"] - ); - - const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); - const gatewayServerCaCert = new x509.X509Certificate(orgCAs.gatewayServerCaCertificate); - const rootGatewayCaCert = new x509.X509Certificate(orgCAs.rootGatewayCaCertificate); - const gatewayClientCaCert = new x509.X509Certificate(orgCAs.gatewayClientCaCertificate); - - const gatewayServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ - key: orgCAs.gatewayServerCaPrivateKey, - format: "der", - type: "pkcs8" - }); - const gatewayServerCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( - "pkcs8", - gatewayServerCaSkObj.export({ format: "der", type: "pkcs8" }), - alg, - true, - ["sign"] - ); - - const gatewayServerKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const gatewayServerCertIssuedAt = new Date(); - const gatewayServerCertExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1)); - const gatewayServerCertPrivateKey = crypto.nativeCrypto.KeyObject.from(gatewayServerKeys.privateKey); - - const gatewayServerCertExtensions: x509.Extension[] = [ - new x509.BasicConstraintsExtension(false), - await x509.AuthorityKeyIdentifierExtension.create(gatewayServerCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(gatewayServerKeys.publicKey), - new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT], - true - ), - new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), - new x509.SubjectAlternativeNameExtension([ - { type: "dns", value: "localhost" }, - { type: "ip", value: "127.0.0.1" }, - { type: "ip", value: "::1" } - ]) - ]; - - const gatewayServerSerialNumber = createSerialNumber(); - const gatewayServerCertificate = await x509.X509CertificateGenerator.create({ - serialNumber: gatewayServerSerialNumber, - subject: `O=${orgId},CN=Gateway`, - issuer: gatewayServerCaCert.subject, - notBefore: gatewayServerCertIssuedAt, - notAfter: gatewayServerCertExpireAt, - signingKey: gatewayServerCaPrivateKey, - publicKey: gatewayServerKeys.publicKey, - signingAlgorithm: alg, - extensions: gatewayServerCertExtensions - }); - - const proxyCredentials = await proxyService.getCredentialsForGateway({ - proxyName, - orgId, - gatewayId: gateway.id - }); - - return { - gatewayId: gateway.id, - proxyIp: proxyCredentials.proxyIp, - pki: { - serverCertificate: gatewayServerCertificate.toString("pem"), - serverPrivateKey: gatewayServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), - clientCertificateChain: constructPemChainFromCerts([gatewayClientCaCert, rootGatewayCaCert]) - }, - ssh: { - clientCertificate: proxyCredentials.clientSshCert, - clientPrivateKey: proxyCredentials.clientSshPrivateKey, - serverCAPublicKey: proxyCredentials.serverCAPublicKey + }; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ message: `Gateway with name "${name}" already exists` }); } - }; + + throw err; + } }; const heartbeat = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { From 09d179f30d1f1bbaeda703d7fe3cc61834608ba7 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 5 Sep 2025 23:36:48 +0800 Subject: [PATCH 18/46] misc: addressed comments --- backend/src/ee/routes/v1/proxy-router.ts | 11 +++++++++-- backend/src/ee/routes/v2/gateway-router.ts | 8 +++++++- backend/src/lib/crypto/cryptography/crypto.ts | 3 ++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/src/ee/routes/v1/proxy-router.ts b/backend/src/ee/routes/v1/proxy-router.ts index d7742baa7..3fe225ab2 100644 --- a/backend/src/ee/routes/v1/proxy-router.ts +++ b/backend/src/ee/routes/v1/proxy-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -38,8 +39,14 @@ export const registerProxyRouter = async (server: FastifyZodProvider) => { onRequest: (req, _, next) => { const authHeader = req.headers.authorization; - if (appCfg.PROXY_AUTH_SECRET && authHeader === `Bearer ${appCfg.PROXY_AUTH_SECRET}`) { - return next(); + if (appCfg.PROXY_AUTH_SECRET && authHeader) { + const expectedHeader = `Bearer ${appCfg.PROXY_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/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts index 114672a23..4ab3f5ec2 100644 --- a/backend/src/ee/routes/v2/gateway-router.ts +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -1,7 +1,7 @@ import z from "zod"; import { GatewaysV2Schema } from "@app/db/schemas"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -40,6 +40,9 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { }) } }, + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const gateway = await server.services.gatewayV2.registerGateway({ @@ -90,6 +93,9 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { }).array() } }, + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const gateways = await server.services.gatewayV2.listGateways({ diff --git a/backend/src/lib/crypto/cryptography/crypto.ts b/backend/src/lib/crypto/cryptography/crypto.ts index b8fc45645..b6fd44f41 100644 --- a/backend/src/lib/crypto/cryptography/crypto.ts +++ b/backend/src/lib/crypto/cryptography/crypto.ts @@ -421,7 +421,8 @@ const cryptographyFactory = () => { constants: crypto.constants, X509Certificate: crypto.X509Certificate, KeyObject: crypto.KeyObject, - Hash: crypto.Hash + Hash: crypto.Hash, + timingSafeEqual: crypto.timingSafeEqual } }; }; From c9136a23bf525b113359f8c6f137fecc84146ab1 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Sep 2025 01:30:45 +0800 Subject: [PATCH 19/46] misc: updated proxy terminology to relay --- backend/src/@types/fastify.d.ts | 4 +- backend/src/@types/knex.d.ts | 40 +- ...1627_add-gateway-v2-pki-and-ssh-configs.ts | 106 +- backend/src/db/schemas/gateways-v2.ts | 2 +- backend/src/db/schemas/index.ts | 6 +- .../src/db/schemas/instance-proxy-config.ts | 38 - .../src/db/schemas/instance-relay-config.ts | 38 + backend/src/db/schemas/models.ts | 6 +- backend/src/db/schemas/org-proxy-config.ts | 31 - backend/src/db/schemas/org-relay-config.ts | 31 + .../src/db/schemas/{proxies.ts => relays.ts} | 8 +- backend/src/ee/routes/v1/index.ts | 4 +- .../v1/{proxy-router.ts => relay-router.ts} | 18 +- .../dynamic-secret/providers/kubernetes.ts | 4 +- .../dynamic-secret/providers/sql-database.ts | 4 +- .../services/gateway-v2/gateway-v2-service.ts | 68 +- .../proxy/instance-proxy-config-dal.ts | 11 - .../ee/services/proxy/org-proxy-config-dal.ts | 11 - backend/src/ee/services/proxy/proxy-dal.ts | 11 - backend/src/ee/services/proxy/proxy-fns.ts | 5 - .../src/ee/services/proxy/proxy-service.ts | 1008 ----------------- .../relay/instance-relay-config-dal.ts | 11 + .../ee/services/relay/org-relay-config-dal.ts | 11 + backend/src/ee/services/relay/relay-dal.ts | 11 + backend/src/ee/services/relay/relay-fns.ts | 5 + .../src/ee/services/relay/relay-service.ts | 1008 +++++++++++++++++ backend/src/keystore/keystore.ts | 4 +- backend/src/lib/config/env.ts | 2 +- backend/src/lib/gateway-v2/gateway-v2.ts | 80 +- .../server/plugins/auth/inject-identity.ts | 2 +- backend/src/server/routes/index.ts | 28 +- .../github/github-connection-fns.ts | 4 +- .../shared/sql/sql-connection-fns.ts | 4 +- .../identity-kubernetes-auth-service.ts | 4 +- 34 files changed, 1314 insertions(+), 1314 deletions(-) delete mode 100644 backend/src/db/schemas/instance-proxy-config.ts create mode 100644 backend/src/db/schemas/instance-relay-config.ts delete mode 100644 backend/src/db/schemas/org-proxy-config.ts create mode 100644 backend/src/db/schemas/org-relay-config.ts rename backend/src/db/schemas/{proxies.ts => relays.ts} (63%) rename backend/src/ee/routes/v1/{proxy-router.ts => relay-router.ts} (83%) delete mode 100644 backend/src/ee/services/proxy/instance-proxy-config-dal.ts delete mode 100644 backend/src/ee/services/proxy/org-proxy-config-dal.ts delete mode 100644 backend/src/ee/services/proxy/proxy-dal.ts delete mode 100644 backend/src/ee/services/proxy/proxy-fns.ts delete mode 100644 backend/src/ee/services/proxy/proxy-service.ts create mode 100644 backend/src/ee/services/relay/instance-relay-config-dal.ts create mode 100644 backend/src/ee/services/relay/org-relay-config-dal.ts create mode 100644 backend/src/ee/services/relay/relay-dal.ts create mode 100644 backend/src/ee/services/relay/relay-fns.ts create mode 100644 backend/src/ee/services/relay/relay-service.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 2b997eb46..b0115c926 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -32,8 +32,8 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; -import { TProxyServiceFactory } from "@app/ee/services/proxy/proxy-service"; import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types"; +import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-types"; import { TScimServiceFactory } from "@app/ee/services/scim/scim-types"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -305,7 +305,7 @@ declare module "fastify" { bus: TEventBusService; sse: TServerSentEventsService; identityAuthTemplate: TIdentityAuthTemplateServiceFactory; - proxy: TProxyServiceFactory; + relay: TRelayServiceFactory; gatewayV2: TGatewayV2ServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index da75c8d94..5888ac7a8 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -182,9 +182,9 @@ import { TIncidentContacts, TIncidentContactsInsert, TIncidentContactsUpdate, - TInstanceProxyConfig, - TInstanceProxyConfigInsert, - TInstanceProxyConfigUpdate, + TInstanceRelayConfig, + TInstanceRelayConfigInsert, + TInstanceRelayConfigUpdate, TIntegrationAuths, TIntegrationAuthsInsert, TIntegrationAuthsUpdate, @@ -242,9 +242,9 @@ import { TOrgMemberships, TOrgMembershipsInsert, TOrgMembershipsUpdate, - TOrgProxyConfig, - TOrgProxyConfigInsert, - TOrgProxyConfigUpdate, + TOrgRelayConfig, + TOrgRelayConfigInsert, + TOrgRelayConfigUpdate, TOrgRoles, TOrgRolesInsert, TOrgRolesUpdate, @@ -299,12 +299,12 @@ import { TProjectUserMembershipRoles, TProjectUserMembershipRolesInsert, TProjectUserMembershipRolesUpdate, - TProxies, - TProxiesInsert, - TProxiesUpdate, TRateLimit, TRateLimitInsert, TRateLimitUpdate, + TRelays, + TRelaysInsert, + TRelaysUpdate, TResourceMetadata, TResourceMetadataInsert, TResourceMetadataUpdate, @@ -1269,22 +1269,22 @@ declare module "knex/types/tables" { TRemindersRecipientsInsert, TRemindersRecipientsUpdate >; - [TableName.InstanceProxyConfig]: KnexOriginal.CompositeTableType< - TInstanceProxyConfig, - TInstanceProxyConfigInsert, - TInstanceProxyConfigUpdate - >; - [TableName.OrgProxyConfig]: KnexOriginal.CompositeTableType< - TOrgProxyConfig, - TOrgProxyConfigInsert, - TOrgProxyConfigUpdate - >; [TableName.OrgGatewayConfigV2]: KnexOriginal.CompositeTableType< TOrgGatewayConfigV2, TOrgGatewayConfigV2Insert, TOrgGatewayConfigV2Update >; - [TableName.Proxy]: KnexOriginal.CompositeTableType; [TableName.GatewayV2]: KnexOriginal.CompositeTableType; + [TableName.InstanceRelayConfig]: KnexOriginal.CompositeTableType< + TInstanceRelayConfig, + TInstanceRelayConfigInsert, + TInstanceRelayConfigUpdate + >; + [TableName.OrgRelayConfig]: KnexOriginal.CompositeTableType< + TOrgRelayConfig, + TOrgRelayConfigInsert, + TOrgRelayConfigUpdate + >; + [TableName.Relay]: KnexOriginal.CompositeTableType; } } diff --git a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts index 179d7aa2d..3c825b08d 100644 --- a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts +++ b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts @@ -4,68 +4,68 @@ import { TableName } from "../schemas"; import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; export async function up(knex: Knex): Promise { - if (!(await knex.schema.hasTable(TableName.InstanceProxyConfig))) { - await knex.schema.createTable(TableName.InstanceProxyConfig, (t) => { + if (!(await knex.schema.hasTable(TableName.InstanceRelayConfig))) { + await knex.schema.createTable(TableName.InstanceRelayConfig, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.timestamps(true, true, true); - // Root CA for proxy PKI - t.binary("encryptedRootProxyPkiCaPrivateKey").notNullable(); - t.binary("encryptedRootProxyPkiCaCertificate").notNullable(); + // Root CA for relay PKI + t.binary("encryptedRootRelayPkiCaPrivateKey").notNullable(); + t.binary("encryptedRootRelayPkiCaCertificate").notNullable(); - // Instance CA for proxy PKI - t.binary("encryptedInstanceProxyPkiCaPrivateKey").notNullable(); - t.binary("encryptedInstanceProxyPkiCaCertificate").notNullable(); - t.binary("encryptedInstanceProxyPkiCaCertificateChain").notNullable(); + // Instance CA for relay PKI + t.binary("encryptedInstanceRelayPkiCaPrivateKey").notNullable(); + t.binary("encryptedInstanceRelayPkiCaCertificate").notNullable(); + t.binary("encryptedInstanceRelayPkiCaCertificateChain").notNullable(); - // Instance client/server intermediates for proxy PKI - t.binary("encryptedInstanceProxyPkiClientCaPrivateKey").notNullable(); - t.binary("encryptedInstanceProxyPkiClientCaCertificate").notNullable(); - t.binary("encryptedInstanceProxyPkiClientCaCertificateChain").notNullable(); - t.binary("encryptedInstanceProxyPkiServerCaPrivateKey").notNullable(); - t.binary("encryptedInstanceProxyPkiServerCaCertificate").notNullable(); - t.binary("encryptedInstanceProxyPkiServerCaCertificateChain").notNullable(); + // Instance client/server intermediates for relay PKI + t.binary("encryptedInstanceRelayPkiClientCaPrivateKey").notNullable(); + t.binary("encryptedInstanceRelayPkiClientCaCertificate").notNullable(); + t.binary("encryptedInstanceRelayPkiClientCaCertificateChain").notNullable(); + t.binary("encryptedInstanceRelayPkiServerCaPrivateKey").notNullable(); + t.binary("encryptedInstanceRelayPkiServerCaCertificate").notNullable(); + t.binary("encryptedInstanceRelayPkiServerCaCertificateChain").notNullable(); - // Org Parent CAs for proxy - t.binary("encryptedOrgProxyPkiCaPrivateKey").notNullable(); - t.binary("encryptedOrgProxyPkiCaCertificate").notNullable(); - t.binary("encryptedOrgProxyPkiCaCertificateChain").notNullable(); + // Org Parent CAs for relay + t.binary("encryptedOrgRelayPkiCaPrivateKey").notNullable(); + t.binary("encryptedOrgRelayPkiCaCertificate").notNullable(); + t.binary("encryptedOrgRelayPkiCaCertificateChain").notNullable(); - // Instance SSH CAs for proxy - t.binary("encryptedInstanceProxySshClientCaPrivateKey").notNullable(); - t.binary("encryptedInstanceProxySshClientCaPublicKey").notNullable(); - t.binary("encryptedInstanceProxySshServerCaPrivateKey").notNullable(); - t.binary("encryptedInstanceProxySshServerCaPublicKey").notNullable(); + // Instance SSH CAs for relay + t.binary("encryptedInstanceRelaySshClientCaPrivateKey").notNullable(); + t.binary("encryptedInstanceRelaySshClientCaPublicKey").notNullable(); + t.binary("encryptedInstanceRelaySshServerCaPrivateKey").notNullable(); + t.binary("encryptedInstanceRelaySshServerCaPublicKey").notNullable(); }); - await createOnUpdateTrigger(knex, TableName.InstanceProxyConfig); + await createOnUpdateTrigger(knex, TableName.InstanceRelayConfig); } - // Org-level proxy configuration (one-to-one with organization) - if (!(await knex.schema.hasTable(TableName.OrgProxyConfig))) { - await knex.schema.createTable(TableName.OrgProxyConfig, (t) => { + // Org-level relay configuration (one-to-one with organization) + if (!(await knex.schema.hasTable(TableName.OrgRelayConfig))) { + await knex.schema.createTable(TableName.OrgRelayConfig, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.timestamps(true, true, true); t.uuid("orgId").notNullable().unique(); t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); - // Org-scoped proxy PKI (client + server) - t.binary("encryptedProxyPkiClientCaPrivateKey").notNullable(); - t.binary("encryptedProxyPkiClientCaCertificate").notNullable(); - t.binary("encryptedProxyPkiClientCaCertificateChain").notNullable(); - t.binary("encryptedProxyPkiServerCaPrivateKey").notNullable(); - t.binary("encryptedProxyPkiServerCaCertificate").notNullable(); - t.binary("encryptedProxyPkiServerCaCertificateChain").notNullable(); + // Org-scoped relay PKI (client + server) + t.binary("encryptedRelayPkiClientCaPrivateKey").notNullable(); + t.binary("encryptedRelayPkiClientCaCertificate").notNullable(); + t.binary("encryptedRelayPkiClientCaCertificateChain").notNullable(); + t.binary("encryptedRelayPkiServerCaPrivateKey").notNullable(); + t.binary("encryptedRelayPkiServerCaCertificate").notNullable(); + t.binary("encryptedRelayPkiServerCaCertificateChain").notNullable(); - // Org-scoped proxy SSH (client + server) - t.binary("encryptedProxySshClientCaPrivateKey").notNullable(); - t.binary("encryptedProxySshClientCaPublicKey").notNullable(); - t.binary("encryptedProxySshServerCaPrivateKey").notNullable(); - t.binary("encryptedProxySshServerCaPublicKey").notNullable(); + // Org-scoped relay SSH (client + server) + t.binary("encryptedRelaySshClientCaPrivateKey").notNullable(); + t.binary("encryptedRelaySshClientCaPublicKey").notNullable(); + t.binary("encryptedRelaySshServerCaPrivateKey").notNullable(); + t.binary("encryptedRelaySshServerCaPublicKey").notNullable(); }); - await createOnUpdateTrigger(knex, TableName.OrgProxyConfig); + await createOnUpdateTrigger(knex, TableName.OrgRelayConfig); } if (!(await knex.schema.hasTable(TableName.OrgGatewayConfigV2))) { @@ -87,8 +87,8 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.OrgGatewayConfigV2); } - if (!(await knex.schema.hasTable(TableName.Proxy))) { - await knex.schema.createTable(TableName.Proxy, (t) => { + if (!(await knex.schema.hasTable(TableName.Relay))) { + await knex.schema.createTable(TableName.Relay, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.timestamps(true, true, true); @@ -102,7 +102,7 @@ export async function up(knex: Knex): Promise { t.string("ip").notNullable(); }); - await createOnUpdateTrigger(knex, TableName.Proxy); + await createOnUpdateTrigger(knex, TableName.Relay); } if (!(await knex.schema.hasTable(TableName.GatewayV2))) { @@ -116,8 +116,8 @@ export async function up(knex: Knex): Promise { t.uuid("identityId").notNullable().unique(); t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); - t.uuid("proxyId"); - t.foreign("proxyId").references("id").inTable(TableName.Proxy).onDelete("SET NULL"); + t.uuid("relayId"); + t.foreign("relayId").references("id").inTable(TableName.Relay).onDelete("SET NULL"); t.string("name").notNullable().unique(); @@ -129,11 +129,11 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - await dropOnUpdateTrigger(knex, TableName.OrgProxyConfig); - await knex.schema.dropTableIfExists(TableName.OrgProxyConfig); + await dropOnUpdateTrigger(knex, TableName.OrgRelayConfig); + await knex.schema.dropTableIfExists(TableName.OrgRelayConfig); - await dropOnUpdateTrigger(knex, TableName.InstanceProxyConfig); - await knex.schema.dropTableIfExists(TableName.InstanceProxyConfig); + await dropOnUpdateTrigger(knex, TableName.InstanceRelayConfig); + await knex.schema.dropTableIfExists(TableName.InstanceRelayConfig); await dropOnUpdateTrigger(knex, TableName.OrgGatewayConfigV2); await knex.schema.dropTableIfExists(TableName.OrgGatewayConfigV2); @@ -141,6 +141,6 @@ export async function down(knex: Knex): Promise { await dropOnUpdateTrigger(knex, TableName.GatewayV2); await knex.schema.dropTableIfExists(TableName.GatewayV2); - await dropOnUpdateTrigger(knex, TableName.Proxy); - await knex.schema.dropTableIfExists(TableName.Proxy); + await dropOnUpdateTrigger(knex, TableName.Relay); + await knex.schema.dropTableIfExists(TableName.Relay); } diff --git a/backend/src/db/schemas/gateways-v2.ts b/backend/src/db/schemas/gateways-v2.ts index c3226aa61..6aff8a168 100644 --- a/backend/src/db/schemas/gateways-v2.ts +++ b/backend/src/db/schemas/gateways-v2.ts @@ -13,7 +13,7 @@ export const GatewaysV2Schema = z.object({ updatedAt: z.date(), orgId: z.string().uuid(), identityId: z.string().uuid(), - proxyId: z.string().uuid().nullable().optional(), + relayId: z.string().uuid().nullable().optional(), name: z.string(), heartbeat: z.date().nullable().optional() }); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 5311265b5..e1e0fe7d4 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -58,7 +58,7 @@ export * from "./identity-token-auths"; export * from "./identity-ua-client-secrets"; export * from "./identity-universal-auths"; export * from "./incident-contacts"; -export * from "./instance-proxy-config"; +export * from "./instance-relay-config"; export * from "./integration-auths"; export * from "./integrations"; export * from "./internal-certificate-authorities"; @@ -79,7 +79,7 @@ export * from "./org-bots"; export * from "./org-gateway-config"; export * from "./org-gateway-config-v2"; export * from "./org-memberships"; -export * from "./org-proxy-config"; +export * from "./org-relay-config"; export * from "./org-roles"; export * from "./organizations"; export * from "./pki-alerts"; @@ -99,8 +99,8 @@ export * from "./project-templates"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; -export * from "./proxies"; export * from "./rate-limit"; +export * from "./relays"; export * from "./resource-metadata"; export * from "./saml-configs"; export * from "./scim-tokens"; diff --git a/backend/src/db/schemas/instance-proxy-config.ts b/backend/src/db/schemas/instance-proxy-config.ts deleted file mode 100644 index 369ae381a..000000000 --- a/backend/src/db/schemas/instance-proxy-config.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { zodBuffer } from "@app/lib/zod"; - -import { TImmutableDBKeys } from "./models"; - -export const InstanceProxyConfigSchema = z.object({ - id: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), - encryptedRootProxyPkiCaPrivateKey: zodBuffer, - encryptedRootProxyPkiCaCertificate: zodBuffer, - encryptedInstanceProxyPkiCaPrivateKey: zodBuffer, - encryptedInstanceProxyPkiCaCertificate: zodBuffer, - encryptedInstanceProxyPkiCaCertificateChain: zodBuffer, - encryptedInstanceProxyPkiClientCaPrivateKey: zodBuffer, - encryptedInstanceProxyPkiClientCaCertificate: zodBuffer, - encryptedInstanceProxyPkiClientCaCertificateChain: zodBuffer, - encryptedInstanceProxyPkiServerCaPrivateKey: zodBuffer, - encryptedInstanceProxyPkiServerCaCertificate: zodBuffer, - encryptedInstanceProxyPkiServerCaCertificateChain: zodBuffer, - encryptedOrgProxyPkiCaPrivateKey: zodBuffer, - encryptedOrgProxyPkiCaCertificate: zodBuffer, - encryptedOrgProxyPkiCaCertificateChain: zodBuffer, - encryptedInstanceProxySshClientCaPrivateKey: zodBuffer, - encryptedInstanceProxySshClientCaPublicKey: zodBuffer, - encryptedInstanceProxySshServerCaPrivateKey: zodBuffer, - encryptedInstanceProxySshServerCaPublicKey: zodBuffer -}); - -export type TInstanceProxyConfig = z.infer; -export type TInstanceProxyConfigInsert = Omit, TImmutableDBKeys>; -export type TInstanceProxyConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/instance-relay-config.ts b/backend/src/db/schemas/instance-relay-config.ts new file mode 100644 index 000000000..8b18ef0f5 --- /dev/null +++ b/backend/src/db/schemas/instance-relay-config.ts @@ -0,0 +1,38 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const InstanceRelayConfigSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + encryptedRootRelayPkiCaPrivateKey: zodBuffer, + encryptedRootRelayPkiCaCertificate: zodBuffer, + encryptedInstanceRelayPkiCaPrivateKey: zodBuffer, + encryptedInstanceRelayPkiCaCertificate: zodBuffer, + encryptedInstanceRelayPkiCaCertificateChain: zodBuffer, + encryptedInstanceRelayPkiClientCaPrivateKey: zodBuffer, + encryptedInstanceRelayPkiClientCaCertificate: zodBuffer, + encryptedInstanceRelayPkiClientCaCertificateChain: zodBuffer, + encryptedInstanceRelayPkiServerCaPrivateKey: zodBuffer, + encryptedInstanceRelayPkiServerCaCertificate: zodBuffer, + encryptedInstanceRelayPkiServerCaCertificateChain: zodBuffer, + encryptedOrgRelayPkiCaPrivateKey: zodBuffer, + encryptedOrgRelayPkiCaCertificate: zodBuffer, + encryptedOrgRelayPkiCaCertificateChain: zodBuffer, + encryptedInstanceRelaySshClientCaPrivateKey: zodBuffer, + encryptedInstanceRelaySshClientCaPublicKey: zodBuffer, + encryptedInstanceRelaySshServerCaPrivateKey: zodBuffer, + encryptedInstanceRelaySshServerCaPublicKey: zodBuffer +}); + +export type TInstanceRelayConfig = z.infer; +export type TInstanceRelayConfigInsert = Omit, TImmutableDBKeys>; +export type TInstanceRelayConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 87ea9f8e5..23da64e62 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -181,10 +181,10 @@ export enum TableName { ReminderRecipient = "reminders_recipients", // gateway v2 - InstanceProxyConfig = "instance_proxy_config", - OrgProxyConfig = "org_proxy_config", + InstanceRelayConfig = "instance_relay_config", + OrgRelayConfig = "org_relay_config", OrgGatewayConfigV2 = "org_gateway_config_v2", - Proxy = "proxies", + Relay = "relays", GatewayV2 = "gateways_v2" } diff --git a/backend/src/db/schemas/org-proxy-config.ts b/backend/src/db/schemas/org-proxy-config.ts deleted file mode 100644 index 8b854ffc2..000000000 --- a/backend/src/db/schemas/org-proxy-config.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { zodBuffer } from "@app/lib/zod"; - -import { TImmutableDBKeys } from "./models"; - -export const OrgProxyConfigSchema = z.object({ - id: z.string().uuid(), - createdAt: z.date(), - updatedAt: z.date(), - orgId: z.string().uuid(), - encryptedProxyPkiClientCaPrivateKey: zodBuffer, - encryptedProxyPkiClientCaCertificate: zodBuffer, - encryptedProxyPkiClientCaCertificateChain: zodBuffer, - encryptedProxyPkiServerCaPrivateKey: zodBuffer, - encryptedProxyPkiServerCaCertificate: zodBuffer, - encryptedProxyPkiServerCaCertificateChain: zodBuffer, - encryptedProxySshClientCaPrivateKey: zodBuffer, - encryptedProxySshClientCaPublicKey: zodBuffer, - encryptedProxySshServerCaPrivateKey: zodBuffer, - encryptedProxySshServerCaPublicKey: zodBuffer -}); - -export type TOrgProxyConfig = z.infer; -export type TOrgProxyConfigInsert = Omit, TImmutableDBKeys>; -export type TOrgProxyConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/org-relay-config.ts b/backend/src/db/schemas/org-relay-config.ts new file mode 100644 index 000000000..1752da76a --- /dev/null +++ b/backend/src/db/schemas/org-relay-config.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const OrgRelayConfigSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid(), + encryptedRelayPkiClientCaPrivateKey: zodBuffer, + encryptedRelayPkiClientCaCertificate: zodBuffer, + encryptedRelayPkiClientCaCertificateChain: zodBuffer, + encryptedRelayPkiServerCaPrivateKey: zodBuffer, + encryptedRelayPkiServerCaCertificate: zodBuffer, + encryptedRelayPkiServerCaCertificateChain: zodBuffer, + encryptedRelaySshClientCaPrivateKey: zodBuffer, + encryptedRelaySshClientCaPublicKey: zodBuffer, + encryptedRelaySshServerCaPrivateKey: zodBuffer, + encryptedRelaySshServerCaPublicKey: zodBuffer +}); + +export type TOrgRelayConfig = z.infer; +export type TOrgRelayConfigInsert = Omit, TImmutableDBKeys>; +export type TOrgRelayConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/proxies.ts b/backend/src/db/schemas/relays.ts similarity index 63% rename from backend/src/db/schemas/proxies.ts rename to backend/src/db/schemas/relays.ts index 508c4d25e..d29f2438f 100644 --- a/backend/src/db/schemas/proxies.ts +++ b/backend/src/db/schemas/relays.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { TImmutableDBKeys } from "./models"; -export const ProxiesSchema = z.object({ +export const RelaysSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), @@ -17,6 +17,6 @@ export const ProxiesSchema = z.object({ ip: z.string() }); -export type TProxies = z.infer; -export type TProxiesInsert = Omit, TImmutableDBKeys>; -export type TProxiesUpdate = Partial, TImmutableDBKeys>>; +export type TRelays = z.infer; +export type TRelaysInsert = Omit, TImmutableDBKeys>; +export type TRelaysUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index d1232e5e8..cdaa2f7f4 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -23,8 +23,8 @@ import { registerOrgRoleRouter } from "./org-role-router"; import { registerPITRouter } from "./pit-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; -import { registerProxyRouter } from "./proxy-router"; import { registerRateLimitRouter } from "./rate-limit-router"; +import { registerRelayRouter } from "./relay-router"; import { registerSamlRouter } from "./saml-router"; import { registerScimRouter } from "./scim-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; @@ -80,7 +80,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { ); await server.register(registerGatewayRouter, { prefix: "/gateways" }); - await server.register(registerProxyRouter, { prefix: "/proxies" }); + await server.register(registerRelayRouter, { prefix: "/relays" }); await server.register(registerGithubOrgSyncRouter, { prefix: "/github-org-sync-config" }); await server.register( diff --git a/backend/src/ee/routes/v1/proxy-router.ts b/backend/src/ee/routes/v1/relay-router.ts similarity index 83% rename from backend/src/ee/routes/v1/proxy-router.ts rename to backend/src/ee/routes/v1/relay-router.ts index 3fe225ab2..a04791797 100644 --- a/backend/src/ee/routes/v1/proxy-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -7,12 +7,12 @@ import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -export const registerProxyRouter = async (server: FastifyZodProvider) => { +export const registerRelayRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); server.route({ method: "POST", - url: "/register-instance-proxy", + url: "/register-instance-relay", config: { rateLimit: writeLimit }, @@ -39,8 +39,8 @@ export const registerProxyRouter = async (server: FastifyZodProvider) => { onRequest: (req, _, next) => { const authHeader = req.headers.authorization; - if (appCfg.PROXY_AUTH_SECRET && authHeader) { - const expectedHeader = `Bearer ${appCfg.PROXY_AUTH_SECRET}`; + 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)) @@ -50,11 +50,11 @@ export const registerProxyRouter = async (server: FastifyZodProvider) => { } throw new UnauthorizedError({ - message: "Invalid proxy auth secret" + message: "Invalid relay auth secret" }); }, handler: async (req) => { - return server.services.proxy.registerProxy({ + return server.services.relay.registerRelay({ ...req.body }); } @@ -62,7 +62,7 @@ export const registerProxyRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/register-org-proxy", + url: "/register-org-relay", config: { rateLimit: writeLimit }, @@ -89,10 +89,10 @@ export const registerProxyRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { throw new BadRequestError({ - message: "Org proxy registration is not yet supported" + message: "Org relay registration is not yet supported" }); - return server.services.proxy.registerProxy({ + return server.services.relay.registerRelay({ ...req.body, identityId: req.permission.id, orgId: req.permission.orgId diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index e60b11576..a7b69d882 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -79,9 +79,9 @@ export const KubernetesProvider = ({ ); }, { - proxyIp: gatewayV2ConnectionDetails.proxyIp, + relayIp: gatewayV2ConnectionDetails.relayIp, gateway: gatewayV2ConnectionDetails.gateway, - proxy: gatewayV2ConnectionDetails.proxy, + relay: gatewayV2ConnectionDetails.relay, protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, httpsAgent: inputs.httpsAgent } diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 331a0cb25..59355ab7c 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -201,9 +201,9 @@ export const SqlDatabaseProvider = ({ await gatewayCallback("localhost", port); }, { - proxyIp: gatewayV2ConnectionDetails.proxyIp, + relayIp: gatewayV2ConnectionDetails.relayIp, gateway: gatewayV2ConnectionDetails.gateway, - proxy: gatewayV2ConnectionDetails.proxy, + relay: gatewayV2ConnectionDetails.relay, protocol: GatewayProxyProtocol.Tcp } ); 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 8fb53fa20..6ec379854 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -3,7 +3,7 @@ import net from "node:net"; import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { TProxies } from "@app/db/schemas"; +import { TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { DatabaseErrorCode } from "@app/lib/error-codes"; @@ -24,9 +24,9 @@ import { KmsDataKey } from "@app/services/kms/kms-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { TProxyDALFactory } from "../proxy/proxy-dal"; -import { isInstanceProxy } from "../proxy/proxy-fns"; -import { TProxyServiceFactory } from "../proxy/proxy-service"; +import { TRelayDALFactory } from "../relay/relay-dal"; +import { isInstanceRelay } from "../relay/relay-fns"; +import { TRelayServiceFactory } from "../relay/relay-service"; import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID } from "./gateway-v2-constants"; import { TGatewayV2DALFactory } from "./gateway-v2-dal"; import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal"; @@ -35,9 +35,9 @@ type TGatewayV2ServiceFactoryDep = { orgGatewayConfigV2DAL: Pick; licenseService: Pick; kmsService: TKmsServiceFactory; - proxyService: TProxyServiceFactory; + relayService: TRelayServiceFactory; gatewayV2DAL: TGatewayV2DALFactory; - proxyDAL: TProxyDALFactory; + relayDAL: TRelayDALFactory; permissionService: TPermissionServiceFactory; }; @@ -47,9 +47,9 @@ export const gatewayV2ServiceFactory = ({ orgGatewayConfigV2DAL, licenseService, kmsService, - proxyService, + relayService, gatewayV2DAL, - proxyDAL, + relayDAL, permissionService }: TGatewayV2ServiceFactoryDep) => { const $validateIdentityAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => { @@ -285,9 +285,9 @@ export const gatewayV2ServiceFactory = ({ throw new NotFoundError({ message: `Gateway Config for org ${gateway.orgId} not found.` }); } - if (!gateway.proxyId) { + if (!gateway.relayId) { throw new BadRequestError({ - message: "Gateway is not associated with a proxy" + message: "Gateway is not associated with a relay" }); } @@ -392,23 +392,23 @@ export const gatewayV2ServiceFactory = ({ const gatewayClientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); - const proxyCredentials = await proxyService.getCredentialsForClient({ - proxyId: gateway.proxyId, + const relayCredentials = await relayService.getCredentialsForClient({ + relayId: gateway.relayId, orgId: gateway.orgId, gatewayId }); return { - proxyIp: proxyCredentials.proxyIp, + relayIp: relayCredentials.relayIp, gateway: { clientCertificate: clientCert.toString("pem"), clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert]) }, - proxy: { - clientCertificate: proxyCredentials.clientCertificate, - clientPrivateKey: proxyCredentials.clientPrivateKey, - serverCertificateChain: proxyCredentials.serverCertificateChain + relay: { + clientCertificate: relayCredentials.clientCertificate, + clientPrivateKey: relayCredentials.clientPrivateKey, + serverCertificateChain: relayCredentials.serverCertificateChain } }; }; @@ -417,27 +417,27 @@ export const gatewayV2ServiceFactory = ({ orgId, actorId, actorAuthMethod, - proxyName, + relayName, name }: { orgId: string; actorId: string; actorAuthMethod: ActorAuthMethod; - proxyName: string; + relayName: string; name: string; }) => { await $validateIdentityAccessToGateway(orgId, actorId, actorAuthMethod); const orgCAs = await $getOrgCAs(orgId); - let proxy: TProxies; - if (isInstanceProxy(proxyName)) { - proxy = await proxyDAL.findOne({ name: proxyName }); + let relay: TRelays; + if (isInstanceRelay(relayName)) { + relay = await relayDAL.findOne({ name: relayName }); } else { - proxy = await proxyDAL.findOne({ orgId, name: proxyName }); + relay = await relayDAL.findOne({ orgId, name: relayName }); } - if (!proxy) { - throw new NotFoundError({ message: `Proxy ${proxyName} not found` }); + if (!relay) { + throw new NotFoundError({ message: `Relay ${relayName} not found` }); } try { @@ -447,7 +447,7 @@ export const gatewayV2ServiceFactory = ({ orgId, name, identityId: actorId, - proxyId: proxy.id + relayId: relay.id } ], ["identityId"] @@ -507,24 +507,24 @@ export const gatewayV2ServiceFactory = ({ extensions: gatewayServerCertExtensions }); - const proxyCredentials = await proxyService.getCredentialsForGateway({ - proxyName, + const relayCredentials = await relayService.getCredentialsForGateway({ + relayName, orgId, gatewayId: gateway.id }); return { gatewayId: gateway.id, - proxyIp: proxyCredentials.proxyIp, + relayIp: relayCredentials.relayIp, pki: { serverCertificate: gatewayServerCertificate.toString("pem"), serverPrivateKey: gatewayServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), clientCertificateChain: constructPemChainFromCerts([gatewayClientCaCert, rootGatewayCaCert]) }, ssh: { - clientCertificate: proxyCredentials.clientSshCert, - clientPrivateKey: proxyCredentials.clientSshPrivateKey, - serverCAPublicKey: proxyCredentials.serverCAPublicKey + clientCertificate: relayCredentials.clientSshCert, + clientPrivateKey: relayCredentials.clientSshPrivateKey, + serverCAPublicKey: relayCredentials.serverCAPublicKey } }; } catch (err) { @@ -613,9 +613,9 @@ export const gatewayV2ServiceFactory = ({ }, { protocol: GatewayProxyProtocol.Ping, - proxyIp: gatewayV2ConnectionDetails.proxyIp, + relayIp: gatewayV2ConnectionDetails.relayIp, gateway: gatewayV2ConnectionDetails.gateway, - proxy: gatewayV2ConnectionDetails.proxy + relay: gatewayV2ConnectionDetails.relay } ); diff --git a/backend/src/ee/services/proxy/instance-proxy-config-dal.ts b/backend/src/ee/services/proxy/instance-proxy-config-dal.ts deleted file mode 100644 index 4a128daf3..000000000 --- a/backend/src/ee/services/proxy/instance-proxy-config-dal.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TInstanceProxyConfigDALFactory = ReturnType; - -export const instanceProxyConfigDalFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.InstanceProxyConfig); - - return orm; -}; diff --git a/backend/src/ee/services/proxy/org-proxy-config-dal.ts b/backend/src/ee/services/proxy/org-proxy-config-dal.ts deleted file mode 100644 index f15dd823b..000000000 --- a/backend/src/ee/services/proxy/org-proxy-config-dal.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TOrgProxyConfigDALFactory = ReturnType; - -export const orgProxyConfigDalFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.OrgProxyConfig); - - return orm; -}; diff --git a/backend/src/ee/services/proxy/proxy-dal.ts b/backend/src/ee/services/proxy/proxy-dal.ts deleted file mode 100644 index a1570f2a8..000000000 --- a/backend/src/ee/services/proxy/proxy-dal.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TProxyDALFactory = ReturnType; - -export const proxyDalFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.Proxy); - - return orm; -}; diff --git a/backend/src/ee/services/proxy/proxy-fns.ts b/backend/src/ee/services/proxy/proxy-fns.ts deleted file mode 100644 index 58ad60832..000000000 --- a/backend/src/ee/services/proxy/proxy-fns.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const INSTANCE_PROXY_PREFIX = "infisical-"; - -export const isInstanceProxy = (proxyName: string) => { - return proxyName.startsWith(INSTANCE_PROXY_PREFIX); -}; diff --git a/backend/src/ee/services/proxy/proxy-service.ts b/backend/src/ee/services/proxy/proxy-service.ts deleted file mode 100644 index ae6ae3383..000000000 --- a/backend/src/ee/services/proxy/proxy-service.ts +++ /dev/null @@ -1,1008 +0,0 @@ -import * as x509 from "@peculiar/x509"; - -import { TProxies } from "@app/db/schemas"; -import { PgSqlLock } from "@app/keystore/keystore"; -import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns"; -import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; -import { - createSerialNumber, - keyAlgorithmToAlgCfg -} 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 { createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns"; -import { SshCertType } from "../ssh/ssh-certificate-authority-types"; -import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types"; -import { TInstanceProxyConfigDALFactory } from "./instance-proxy-config-dal"; -import { TOrgProxyConfigDALFactory } from "./org-proxy-config-dal"; -import { TProxyDALFactory } from "./proxy-dal"; -import { isInstanceProxy } from "./proxy-fns"; - -export type TProxyServiceFactory = ReturnType; - -const INSTANCE_PROXY_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; - -export const proxyServiceFactory = ({ - instanceProxyConfigDAL, - orgProxyConfigDAL, - proxyDAL, - kmsService -}: { - instanceProxyConfigDAL: TInstanceProxyConfigDALFactory; - orgProxyConfigDAL: TOrgProxyConfigDALFactory; - proxyDAL: TProxyDALFactory; - kmsService: TKmsServiceFactory; -}) => { - const $getInstanceCAs = async () => { - const instanceConfig = await instanceProxyConfigDAL.transaction(async (tx) => { - const existingInstanceProxyConfig = await instanceProxyConfigDAL.findById(INSTANCE_PROXY_CONFIG_UUID); - if (existingInstanceProxyConfig) return existingInstanceProxyConfig; - - await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.InstanceProxyConfigInit()]); - - const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); - const rootCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - - // generate root CA - const rootCaSerialNumber = createSerialNumber(); - const rootCaSkObj = crypto.nativeCrypto.KeyObject.from(rootCaKeys.privateKey); - const rootCaIssuedAt = new Date(); - const rootCaExpiration = new Date(new Date().setFullYear(2045)); - const rootCaCert = await x509.X509CertificateGenerator.createSelfSigned({ - name: `O=Infisical,CN=Infisical Instance Root Proxy CA`, - serialNumber: rootCaSerialNumber, - notBefore: rootCaIssuedAt, - notAfter: rootCaExpiration, - signingAlgorithm: alg, - keys: rootCaKeys, - extensions: [ - // eslint-disable-next-line no-bitwise - new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), - await x509.SubjectKeyIdentifierExtension.create(rootCaKeys.publicKey) - ] - }); - - // generate org proxy CA - const orgProxyCaSerialNumber = createSerialNumber(); - const orgProxyCaIssuedAt = new Date(); - const orgProxyCaExpiration = new Date(new Date().setFullYear(2045)); - const orgProxyCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const orgProxyCaSkObj = crypto.nativeCrypto.KeyObject.from(orgProxyCaKeys.privateKey); - const orgProxyCaCert = await x509.X509CertificateGenerator.create({ - serialNumber: orgProxyCaSerialNumber, - subject: `O=Infisical,CN=Infisical Organization Proxy CA`, - issuer: rootCaCert.subject, - notBefore: orgProxyCaIssuedAt, - notAfter: orgProxyCaExpiration, - signingKey: rootCaKeys.privateKey, - publicKey: orgProxyCaKeys.publicKey, - signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment, - true - ), - new x509.BasicConstraintsExtension(true, 2, true), - await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(orgProxyCaKeys.publicKey) - ] - }); - const orgProxyCaChain = constructPemChainFromCerts([rootCaCert]); - - // generate instance proxy CA - const instanceProxyCaSerialNumber = createSerialNumber(); - const instanceProxyCaIssuedAt = new Date(); - const instanceProxyCaExpiration = new Date(new Date().setFullYear(2045)); - const instanceProxyCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const instanceProxyCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceProxyCaKeys.privateKey); - const instanceProxyCaCert = await x509.X509CertificateGenerator.create({ - serialNumber: instanceProxyCaSerialNumber, - subject: `O=Infisical,CN=Infisical Instance Proxy CA`, - issuer: rootCaCert.subject, - notBefore: instanceProxyCaIssuedAt, - notAfter: instanceProxyCaExpiration, - signingKey: rootCaKeys.privateKey, - publicKey: instanceProxyCaKeys.publicKey, - signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment, - true - ), - new x509.BasicConstraintsExtension(true, 1, true), - await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(instanceProxyCaKeys.publicKey) - ] - }); - const instanceProxyCaChain = constructPemChainFromCerts([rootCaCert]); - - // generate instance proxy client CA - const instanceProxyClientCaSerialNumber = createSerialNumber(); - const instanceProxyClientCaIssuedAt = new Date(); - const instanceProxyClientCaExpiration = new Date(new Date().setFullYear(2045)); - const instanceProxyClientCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const instanceProxyClientCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceProxyClientCaKeys.privateKey); - const instanceProxyClientCaCert = await x509.X509CertificateGenerator.create({ - serialNumber: instanceProxyClientCaSerialNumber, - subject: `O=Infisical,CN=Infisical Instance Proxy Client CA`, - issuer: instanceProxyCaCert.subject, - notBefore: instanceProxyClientCaIssuedAt, - notAfter: instanceProxyClientCaExpiration, - signingKey: instanceProxyCaKeys.privateKey, - publicKey: instanceProxyClientCaKeys.publicKey, - signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment, - true - ), - new x509.BasicConstraintsExtension(true, 0, true), - await x509.AuthorityKeyIdentifierExtension.create(instanceProxyCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(instanceProxyClientCaKeys.publicKey) - ] - }); - const instanceProxyClientCaChain = constructPemChainFromCerts([instanceProxyCaCert, rootCaCert]); - - // generate instance proxy server CA - const instanceProxyServerCaSerialNumber = createSerialNumber(); - const instanceProxyServerCaIssuedAt = new Date(); - const instanceProxyServerCaExpiration = new Date(new Date().setFullYear(2045)); - const instanceProxyServerCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const instanceProxyServerCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceProxyServerCaKeys.privateKey); - const instanceProxyServerCaCert = await x509.X509CertificateGenerator.create({ - serialNumber: instanceProxyServerCaSerialNumber, - subject: `O=Infisical,CN=Infisical Instance Proxy Server CA`, - issuer: instanceProxyCaCert.subject, - notBefore: instanceProxyServerCaIssuedAt, - notAfter: instanceProxyServerCaExpiration, - signingKey: instanceProxyCaKeys.privateKey, - publicKey: instanceProxyServerCaKeys.publicKey, - signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment, - true - ), - new x509.BasicConstraintsExtension(true, 0, true), - await x509.AuthorityKeyIdentifierExtension.create(instanceProxyCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(instanceProxyServerCaKeys.publicKey) - ] - }); - const instanceProxyServerCaChain = constructPemChainFromCerts([instanceProxyCaCert, rootCaCert]); - - const instanceSshServerCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); - const instanceSshClientCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); - - const encryptWithRoot = kmsService.encryptWithRootKey(); - - // root proxy CA - const encryptedRootProxyPkiCaPrivateKey = encryptWithRoot( - Buffer.from( - rootCaSkObj.export({ - type: "pkcs8", - format: "der" - }) - ) - ); - const encryptedRootProxyPkiCaCertificate = encryptWithRoot(Buffer.from(rootCaCert.rawData)); - - // org proxy CA - const encryptedOrgProxyPkiCaPrivateKey = encryptWithRoot( - Buffer.from( - orgProxyCaSkObj.export({ - type: "pkcs8", - format: "der" - }) - ) - ); - const encryptedOrgProxyPkiCaCertificate = encryptWithRoot(Buffer.from(orgProxyCaCert.rawData)); - const encryptedOrgProxyPkiCaCertificateChain = encryptWithRoot(Buffer.from(orgProxyCaChain)); - - // instance proxy CA - const encryptedInstanceProxyPkiCaPrivateKey = encryptWithRoot( - Buffer.from( - instanceProxyCaSkObj.export({ - type: "pkcs8", - format: "der" - }) - ) - ); - const encryptedInstanceProxyPkiCaCertificate = encryptWithRoot(Buffer.from(instanceProxyCaCert.rawData)); - const encryptedInstanceProxyPkiCaCertificateChain = encryptWithRoot(Buffer.from(instanceProxyCaChain)); - - // instance proxy client CA - const encryptedInstanceProxyPkiClientCaPrivateKey = encryptWithRoot( - Buffer.from( - instanceProxyClientCaSkObj.export({ - type: "pkcs8", - format: "der" - }) - ) - ); - const encryptedInstanceProxyPkiClientCaCertificate = encryptWithRoot( - Buffer.from(instanceProxyClientCaCert.rawData) - ); - const encryptedInstanceProxyPkiClientCaCertificateChain = encryptWithRoot( - Buffer.from(instanceProxyClientCaChain) - ); - - // instance proxy server CA - const encryptedInstanceProxyPkiServerCaPrivateKey = encryptWithRoot( - Buffer.from( - instanceProxyServerCaSkObj.export({ - type: "pkcs8", - format: "der" - }) - ) - ); - const encryptedInstanceProxyPkiServerCaCertificate = encryptWithRoot( - Buffer.from(instanceProxyServerCaCert.rawData) - ); - const encryptedInstanceProxyPkiServerCaCertificateChain = encryptWithRoot( - Buffer.from(instanceProxyServerCaChain) - ); - - const encryptedInstanceProxySshClientCaPublicKey = encryptWithRoot( - Buffer.from(instanceSshClientCaKeyPair.publicKey) - ); - const encryptedInstanceProxySshClientCaPrivateKey = encryptWithRoot( - Buffer.from(instanceSshClientCaKeyPair.privateKey) - ); - - const encryptedInstanceProxySshServerCaPublicKey = encryptWithRoot( - Buffer.from(instanceSshServerCaKeyPair.publicKey) - ); - const encryptedInstanceProxySshServerCaPrivateKey = encryptWithRoot( - Buffer.from(instanceSshServerCaKeyPair.privateKey) - ); - - return instanceProxyConfigDAL.create({ - // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition - id: INSTANCE_PROXY_CONFIG_UUID, - encryptedRootProxyPkiCaPrivateKey, - encryptedRootProxyPkiCaCertificate, - encryptedInstanceProxyPkiCaPrivateKey, - encryptedInstanceProxyPkiCaCertificate, - encryptedInstanceProxyPkiCaCertificateChain, - encryptedInstanceProxyPkiClientCaPrivateKey, - encryptedInstanceProxyPkiClientCaCertificate, - encryptedInstanceProxyPkiClientCaCertificateChain, - encryptedInstanceProxyPkiServerCaPrivateKey, - encryptedInstanceProxyPkiServerCaCertificate, - encryptedInstanceProxyPkiServerCaCertificateChain, - encryptedOrgProxyPkiCaPrivateKey, - encryptedOrgProxyPkiCaCertificate, - encryptedOrgProxyPkiCaCertificateChain, - encryptedInstanceProxySshClientCaPublicKey, - encryptedInstanceProxySshClientCaPrivateKey, - encryptedInstanceProxySshServerCaPublicKey, - encryptedInstanceProxySshServerCaPrivateKey - }); - }); - - // decrypt the instance config - const decryptWithRoot = kmsService.decryptWithRootKey(); - - // decrypt root proxy CA - const rootProxyPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedRootProxyPkiCaPrivateKey); - const rootProxyPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedRootProxyPkiCaCertificate); - - // decrypt org proxy CA - const orgProxyPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedOrgProxyPkiCaPrivateKey); - const orgProxyPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedOrgProxyPkiCaCertificate); - const orgProxyPkiCaCertificateChain = decryptWithRoot(instanceConfig.encryptedOrgProxyPkiCaCertificateChain); - - // decrypt instance proxy CA - const instanceProxyPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedInstanceProxyPkiCaPrivateKey); - const instanceProxyPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedInstanceProxyPkiCaCertificate); - const instanceProxyPkiCaCertificateChain = decryptWithRoot( - instanceConfig.encryptedInstanceProxyPkiCaCertificateChain - ); - - // decrypt instance proxy client CA - const instanceProxyPkiClientCaPrivateKey = decryptWithRoot( - instanceConfig.encryptedInstanceProxyPkiClientCaPrivateKey - ); - const instanceProxyPkiClientCaCertificate = decryptWithRoot( - instanceConfig.encryptedInstanceProxyPkiClientCaCertificate - ); - const instanceProxyPkiClientCaCertificateChain = decryptWithRoot( - instanceConfig.encryptedInstanceProxyPkiClientCaCertificateChain - ); - - // decrypt instance proxy server CA - const instanceProxyPkiServerCaPrivateKey = decryptWithRoot( - instanceConfig.encryptedInstanceProxyPkiServerCaPrivateKey - ); - const instanceProxyPkiServerCaCertificate = decryptWithRoot( - instanceConfig.encryptedInstanceProxyPkiServerCaCertificate - ); - const instanceProxyPkiServerCaCertificateChain = decryptWithRoot( - instanceConfig.encryptedInstanceProxyPkiServerCaCertificateChain - ); - - // decrypt SSH keys - const instanceProxySshClientCaPublicKey = decryptWithRoot( - instanceConfig.encryptedInstanceProxySshClientCaPublicKey - ); - const instanceProxySshClientCaPrivateKey = decryptWithRoot( - instanceConfig.encryptedInstanceProxySshClientCaPrivateKey - ); - const instanceProxySshServerCaPublicKey = decryptWithRoot( - instanceConfig.encryptedInstanceProxySshServerCaPublicKey - ); - const instanceProxySshServerCaPrivateKey = decryptWithRoot( - instanceConfig.encryptedInstanceProxySshServerCaPrivateKey - ); - - return { - rootProxyPkiCaPrivateKey, - rootProxyPkiCaCertificate, - orgProxyPkiCaPrivateKey, - orgProxyPkiCaCertificate, - orgProxyPkiCaCertificateChain, - instanceProxyPkiCaPrivateKey, - instanceProxyPkiCaCertificate, - instanceProxyPkiCaCertificateChain, - instanceProxyPkiClientCaPrivateKey, - instanceProxyPkiClientCaCertificate, - instanceProxyPkiClientCaCertificateChain, - instanceProxyPkiServerCaPrivateKey, - instanceProxyPkiServerCaCertificate, - instanceProxyPkiServerCaCertificateChain, - instanceProxySshClientCaPublicKey, - instanceProxySshClientCaPrivateKey, - instanceProxySshServerCaPublicKey, - instanceProxySshServerCaPrivateKey - }; - }; - - const $getOrgCAs = async (orgId: string) => { - const instanceCAs = await $getInstanceCAs(); - const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId - }); - - const orgProxyConfig = await orgProxyConfigDAL.transaction(async (tx) => { - const existingOrgProxyConfig = await orgProxyConfigDAL.findOne( - { - orgId - }, - tx - ); - - if (existingOrgProxyConfig) { - return existingOrgProxyConfig; - } - - await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgProxyConfigInit(orgId)]); - - const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); - const orgProxyCaCert = new x509.X509Certificate(instanceCAs.orgProxyPkiCaCertificate); - const rootProxyCaCert = new x509.X509Certificate(instanceCAs.rootProxyPkiCaCertificate); - const orgProxyCaSkObj = crypto.nativeCrypto.createPrivateKey({ - key: instanceCAs.orgProxyPkiCaPrivateKey, - format: "der", - type: "pkcs8" - }); - const orgProxyCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( - "pkcs8", - orgProxyCaSkObj.export({ format: "der", type: "pkcs8" }), - alg, - true, - ["sign"] - ); - - // generate org proxy client CA - const orgProxyClientCaSerialNumber = createSerialNumber(); - const orgProxyClientCaIssuedAt = new Date(); - const orgProxyClientCaExpiration = new Date(new Date().setFullYear(2045)); - const orgProxyClientCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const orgProxyClientCaSkObj = crypto.nativeCrypto.KeyObject.from(orgProxyClientCaKeys.privateKey); - const orgProxyClientCaCert = await x509.X509CertificateGenerator.create({ - serialNumber: orgProxyClientCaSerialNumber, - subject: `O=${orgId},CN=Infisical Org Proxy Client CA`, - issuer: orgProxyCaCert.subject, - notBefore: orgProxyClientCaIssuedAt, - notAfter: orgProxyClientCaExpiration, - signingKey: orgProxyCaPrivateKey, - publicKey: orgProxyClientCaKeys.publicKey, - signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment, - true - ), - new x509.BasicConstraintsExtension(true, 0, true), - await x509.AuthorityKeyIdentifierExtension.create(orgProxyCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(orgProxyClientCaKeys.publicKey) - ] - }); - const orgProxyClientCaChain = constructPemChainFromCerts([orgProxyCaCert, rootProxyCaCert]); - - // generate org SSH CA - const orgSshServerCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); - const orgSshClientCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); - - // generate org proxy server CA - const orgProxyServerCaSerialNumber = createSerialNumber(); - const orgProxyServerCaIssuedAt = new Date(); - const orgProxyServerCaExpiration = new Date(new Date().setFullYear(2045)); - const orgProxyServerCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const orgProxyServerCaSkObj = crypto.nativeCrypto.KeyObject.from(orgProxyServerCaKeys.privateKey); - const orgProxyServerCaCert = await x509.X509CertificateGenerator.create({ - serialNumber: orgProxyServerCaSerialNumber, - subject: `O=${orgId},CN=Infisical Org Proxy Server CA`, - issuer: orgProxyCaCert.subject, - notBefore: orgProxyServerCaIssuedAt, - notAfter: orgProxyServerCaExpiration, - signingKey: orgProxyCaPrivateKey, - publicKey: orgProxyServerCaKeys.publicKey, - signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags.keyCertSign | - x509.KeyUsageFlags.cRLSign | - x509.KeyUsageFlags.digitalSignature | - x509.KeyUsageFlags.keyEncipherment, - true - ), - new x509.BasicConstraintsExtension(true, 0, true), - await x509.AuthorityKeyIdentifierExtension.create(orgProxyCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(orgProxyServerCaKeys.publicKey) - ] - }); - const orgProxyServerCaChain = constructPemChainFromCerts([orgProxyCaCert, rootProxyCaCert]); - - const encryptedProxyPkiClientCaPrivateKey = orgKmsEncryptor({ - plainText: Buffer.from( - orgProxyClientCaSkObj.export({ - type: "pkcs8", - format: "der" - }) - ) - }).cipherTextBlob; - const encryptedProxyPkiClientCaCertificate = orgKmsEncryptor({ - plainText: Buffer.from(orgProxyClientCaCert.rawData) - }).cipherTextBlob; - - const encryptedProxyPkiClientCaCertificateChain = orgKmsEncryptor({ - plainText: Buffer.from(orgProxyClientCaChain) - }).cipherTextBlob; - - const encryptedProxyPkiServerCaPrivateKey = orgKmsEncryptor({ - plainText: Buffer.from( - orgProxyServerCaSkObj.export({ - type: "pkcs8", - format: "der" - }) - ) - }).cipherTextBlob; - const encryptedProxyPkiServerCaCertificate = orgKmsEncryptor({ - plainText: Buffer.from(orgProxyServerCaCert.rawData) - }).cipherTextBlob; - const encryptedProxyPkiServerCaCertificateChain = orgKmsEncryptor({ - plainText: Buffer.from(orgProxyServerCaChain) - }).cipherTextBlob; - - const encryptedProxySshClientCaPublicKey = orgKmsEncryptor({ - plainText: Buffer.from(orgSshClientCaKeyPair.publicKey) - }).cipherTextBlob; - const encryptedProxySshClientCaPrivateKey = orgKmsEncryptor({ - plainText: Buffer.from(orgSshClientCaKeyPair.privateKey) - }).cipherTextBlob; - - const encryptedProxySshServerCaPublicKey = orgKmsEncryptor({ - plainText: Buffer.from(orgSshServerCaKeyPair.publicKey) - }).cipherTextBlob; - const encryptedProxySshServerCaPrivateKey = orgKmsEncryptor({ - plainText: Buffer.from(orgSshServerCaKeyPair.privateKey) - }).cipherTextBlob; - - return orgProxyConfigDAL.create({ - orgId, - encryptedProxyPkiClientCaPrivateKey, - encryptedProxyPkiClientCaCertificate, - encryptedProxyPkiClientCaCertificateChain, - encryptedProxyPkiServerCaPrivateKey, - encryptedProxyPkiServerCaCertificate, - encryptedProxyPkiServerCaCertificateChain, - encryptedProxySshClientCaPublicKey, - encryptedProxySshClientCaPrivateKey, - encryptedProxySshServerCaPublicKey, - encryptedProxySshServerCaPrivateKey - }); - }); - - const proxyPkiClientCaPrivateKey = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxyPkiClientCaPrivateKey - }); - const proxyPkiClientCaCertificate = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxyPkiClientCaCertificate - }); - const proxyPkiClientCaCertificateChain = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxyPkiClientCaCertificateChain - }); - - const proxyPkiServerCaPrivateKey = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxyPkiServerCaPrivateKey - }); - const proxyPkiServerCaCertificate = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxyPkiServerCaCertificate - }); - const proxyPkiServerCaCertificateChain = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxyPkiServerCaCertificateChain - }); - - const proxySshClientCaPublicKey = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxySshClientCaPublicKey - }); - const proxySshClientCaPrivateKey = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxySshClientCaPrivateKey - }); - - const proxySshServerCaPublicKey = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxySshServerCaPublicKey - }); - const proxySshServerCaPrivateKey = orgKmsDecryptor({ - cipherTextBlob: orgProxyConfig.encryptedProxySshServerCaPrivateKey - }); - - return { - proxyPkiClientCaPrivateKey, - proxyPkiClientCaCertificate, - proxyPkiClientCaCertificateChain, - proxyPkiServerCaPrivateKey, - proxyPkiServerCaCertificate, - proxyPkiServerCaCertificateChain, - proxySshClientCaPublicKey, - proxySshClientCaPrivateKey, - proxySshServerCaPublicKey, - proxySshServerCaPrivateKey - }; - }; - - const $generateProxyServerCredentials = async ({ - ip, - orgId, - proxyPkiServerCaCertificate, - proxyPkiServerCaPrivateKey, - proxyPkiClientCaCertificate, - proxyPkiClientCaCertificateChain, - proxySshClientCaPublicKey, - proxySshServerCaPrivateKey - }: { - ip: string; - proxyPkiServerCaCertificate: Buffer; - proxyPkiServerCaPrivateKey: Buffer; - proxyPkiClientCaCertificateChain: Buffer; - proxyPkiClientCaCertificate: Buffer; - proxySshServerCaPrivateKey: Buffer; - proxySshClientCaPublicKey: Buffer; - orgId?: string; - }) => { - const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); - const proxyServerCaCert = new x509.X509Certificate(proxyPkiServerCaCertificate); - const proxyClientCaCert = new x509.X509Certificate(proxyPkiClientCaCertificate); - const proxyServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ - key: proxyPkiServerCaPrivateKey, - format: "der", - type: "pkcs8" - }); - - const proxyServerCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( - "pkcs8", - proxyServerCaSkObj.export({ format: "der", type: "pkcs8" }), - alg, - true, - ["sign"] - ); - - const proxyServerKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const proxyServerCertIssuedAt = new Date(); - const proxyServerCertExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1)); - const proxyServerCertPrivateKey = crypto.nativeCrypto.KeyObject.from(proxyServerKeys.privateKey); - - const proxyServerCertExtensions: x509.Extension[] = [ - new x509.BasicConstraintsExtension(false), - await x509.AuthorityKeyIdentifierExtension.create(proxyServerCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(proxyServerKeys.publicKey), - new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT], - true - ), - new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), - // san - new x509.SubjectAlternativeNameExtension([{ type: "ip", value: ip }], false) - ]; - - const proxyServerSerialNumber = createSerialNumber(); - const proxyServerCertificate = await x509.X509CertificateGenerator.create({ - serialNumber: proxyServerSerialNumber, - subject: `CN=${ip},O=${orgId ?? "Infisical"},OU=Proxy`, - issuer: proxyServerCaCert.subject, - notBefore: proxyServerCertIssuedAt, - notAfter: proxyServerCertExpireAt, - signingKey: proxyServerCaPrivateKey, - publicKey: proxyServerKeys.publicKey, - signingAlgorithm: alg, - extensions: proxyServerCertExtensions - }); - - // generate proxy server SSH certificate - const keyAlgorithm = SshCertKeyAlgorithm.RSA_2048; - const { publicKey: proxyServerSshPublicKey, privateKey: proxyServerSshPrivateKey } = - await createSshKeyPair(keyAlgorithm); - - const proxyServerSshCert = await createSshCert({ - caPrivateKey: proxySshServerCaPrivateKey.toString("utf8"), - clientPublicKey: proxyServerSshPublicKey, - keyId: "proxy-server", - principals: [`${ip}:2222`], - certType: SshCertType.HOST, - requestedTtl: "30d" - }); - - return { - pki: { - serverCertificate: proxyServerCertificate.toString("pem"), - serverPrivateKey: proxyServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), - clientCertificateChain: prependCertToPemChain( - proxyClientCaCert, - proxyPkiClientCaCertificateChain.toString("utf8") - ) - }, - ssh: { - serverCertificate: proxyServerSshCert.signedPublicKey, - serverPrivateKey: proxyServerSshPrivateKey, - clientCAPublicKey: proxySshClientCaPublicKey.toString("utf8") - } - }; - }; - - const $generateProxyClientCredentials = async ({ - gatewayId, - orgId, - proxyPkiClientCaCertificate, - proxyPkiClientCaPrivateKey, - proxyPkiServerCaCertificate, - proxyPkiServerCaCertificateChain - }: { - gatewayId: string; - orgId: string; - proxyPkiClientCaCertificate: Buffer; - proxyPkiClientCaPrivateKey: Buffer; - proxyPkiServerCaCertificate: Buffer; - proxyPkiServerCaCertificateChain: Buffer; - }) => { - const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); - const proxyClientCaCert = new x509.X509Certificate(proxyPkiClientCaCertificate); - const proxyServerCaCert = new x509.X509Certificate(proxyPkiServerCaCertificate); - const proxyClientCaSkObj = crypto.nativeCrypto.createPrivateKey({ - key: proxyPkiClientCaPrivateKey, - format: "der", - type: "pkcs8" - }); - - const importedProxyClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( - "pkcs8", - proxyClientCaSkObj.export({ format: "der", type: "pkcs8" }), - alg, - true, - ["sign"] - ); - - const clientCertIssuedAt = new Date(); - const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000); - const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); - const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); - const clientCertSerialNumber = createSerialNumber(); - - // Build standard extensions - const extensions: x509.Extension[] = [ - new x509.BasicConstraintsExtension(false), - await x509.AuthorityKeyIdentifierExtension.create(proxyClientCaCert, false), - await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey), - new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy - new x509.KeyUsagesExtension( - // eslint-disable-next-line no-bitwise - x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | - x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] | - x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], - true - ), - new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true) - ]; - - const clientCert = await x509.X509CertificateGenerator.create({ - serialNumber: clientCertSerialNumber, - subject: `O=${orgId},OU=proxy-client,CN=${gatewayId}`, - issuer: proxyClientCaCert.subject, - notAfter: clientCertExpiration, - notBefore: clientCertIssuedAt, - signingKey: importedProxyClientCaPrivateKey, - publicKey: clientKeys.publicKey, - signingAlgorithm: alg, - extensions - }); - - return { - clientCertificate: clientCert.toString("pem"), - clientPrivateKey: clientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), - serverCertificateChain: prependCertToPemChain( - proxyServerCaCert, - proxyPkiServerCaCertificateChain.toString("utf8") - ) - }; - }; - - const getCredentialsForGateway = async ({ - proxyName, - orgId, - gatewayId - }: { - proxyName: string; - orgId: string; - gatewayId: string; - }) => { - let proxy: TProxies | null; - if (isInstanceProxy(proxyName)) { - proxy = await proxyDAL.findOne({ - name: proxyName - }); - } else { - proxy = await proxyDAL.findOne({ - orgId, - name: proxyName - }); - } - - if (!proxy) { - throw new NotFoundError({ - message: "Proxy not found" - }); - } - - const keyAlgorithm = SshCertKeyAlgorithm.RSA_2048; - const { publicKey: proxyClientSshPublicKey, privateKey: proxyClientSshPrivateKey } = - await createSshKeyPair(keyAlgorithm); - - if (isInstanceProxy(proxyName)) { - const instanceCAs = await $getInstanceCAs(); - const proxyClientSshCert = await createSshCert({ - caPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey.toString("utf8"), - clientPublicKey: proxyClientSshPublicKey, - keyId: `client-${proxyName}`, - principals: [gatewayId], - certType: SshCertType.USER, - requestedTtl: "30d" - }); - - return { - proxyIp: proxy.ip, - clientSshCert: proxyClientSshCert.signedPublicKey, - clientSshPrivateKey: proxyClientSshPrivateKey, - serverCAPublicKey: instanceCAs.instanceProxySshServerCaPublicKey.toString("utf8") - }; - } - - const orgCAs = await $getOrgCAs(orgId); - const proxyClientSshCert = await createSshCert({ - caPrivateKey: orgCAs.proxySshServerCaPrivateKey.toString("utf8"), - clientPublicKey: proxyClientSshPublicKey, - keyId: `proxy-client-${proxy.id}`, - principals: [gatewayId], - certType: SshCertType.USER, - requestedTtl: "30d" - }); - - return { - proxyIp: proxy.ip, - clientSshCert: proxyClientSshCert.signedPublicKey, - clientSshPrivateKey: proxyClientSshPrivateKey, - serverCAPublicKey: orgCAs.proxySshServerCaPublicKey.toString("utf8") - }; - }; - - const getCredentialsForClient = async ({ - proxyId, - orgId, - gatewayId - }: { - proxyId: string; - orgId: string; - gatewayId: string; - }) => { - const proxy = await proxyDAL.findOne({ - id: proxyId - }); - - if (!proxy) { - throw new NotFoundError({ - message: "Proxy not found" - }); - } - - if (isInstanceProxy(proxy.name)) { - const instanceCAs = await $getInstanceCAs(); - const proxyCertificateCredentials = await $generateProxyClientCredentials({ - gatewayId, - orgId, - proxyPkiClientCaCertificate: instanceCAs.instanceProxyPkiClientCaCertificate, - proxyPkiClientCaPrivateKey: instanceCAs.instanceProxyPkiClientCaPrivateKey, - proxyPkiServerCaCertificate: instanceCAs.instanceProxyPkiServerCaCertificate, - proxyPkiServerCaCertificateChain: instanceCAs.instanceProxyPkiServerCaCertificateChain - }); - - return { - ...proxyCertificateCredentials, - proxyIp: proxy.ip - }; - } - - const orgCAs = await $getOrgCAs(orgId); - const proxyCertificateCredentials = await $generateProxyClientCredentials({ - gatewayId, - orgId, - proxyPkiClientCaCertificate: orgCAs.proxyPkiClientCaCertificate, - proxyPkiClientCaPrivateKey: orgCAs.proxyPkiClientCaPrivateKey, - proxyPkiServerCaCertificate: orgCAs.proxyPkiServerCaCertificate, - proxyPkiServerCaCertificateChain: orgCAs.proxyPkiServerCaCertificateChain - }); - - return { - ...proxyCertificateCredentials, - proxyIp: proxy.ip - }; - }; - - const registerProxy = async ({ - ip, - name, - identityId, - orgId - }: { - ip: string; - name: string; - identityId?: string; - orgId?: string; - }) => { - let proxy: TProxies; - const isOrgProxy = identityId && orgId; - - if (isOrgProxy) { - if (isInstanceProxy(name)) { - throw new BadRequestError({ - message: "Org proxy name cannot start with 'infisical-'. This is reserved for internal use." - }); - } - - proxy = await proxyDAL.transaction(async (tx) => { - const existingProxy = await proxyDAL.findOne( - { - identityId, - orgId - }, - tx - ); - - if (existingProxy && (existingProxy.ip !== ip || existingProxy.name !== name)) { - throw new BadRequestError({ - message: "Org proxy with this machine identity already exists." - }); - } - - if (!existingProxy) { - return proxyDAL.create( - { - ip, - name, - identityId, - orgId - }, - tx - ); - } - - return existingProxy; - }); - } else { - if (!isInstanceProxy(name)) { - throw new BadRequestError({ - message: "Instance proxy name must start with 'infisical-'." - }); - } - - proxy = await proxyDAL.transaction(async (tx) => { - const existingProxy = await proxyDAL.findOne( - { - name - }, - tx - ); - - if (existingProxy && existingProxy.ip !== ip) { - throw new BadRequestError({ - message: "Instance proxy with this name already exists with a different IP address" - }); - } - - if (!existingProxy) { - return proxyDAL.create( - { - ip, - name - }, - tx - ); - } - - return existingProxy; - }); - } - - if (isInstanceProxy(name)) { - const instanceCAs = await $getInstanceCAs(); - return $generateProxyServerCredentials({ - ip, - proxyPkiServerCaCertificate: instanceCAs.instanceProxyPkiServerCaCertificate, - proxyPkiServerCaPrivateKey: instanceCAs.instanceProxyPkiServerCaPrivateKey, - proxyPkiClientCaCertificate: instanceCAs.instanceProxyPkiClientCaCertificate, - proxyPkiClientCaCertificateChain: instanceCAs.instanceProxyPkiClientCaCertificateChain, - proxySshServerCaPrivateKey: instanceCAs.instanceProxySshServerCaPrivateKey, - proxySshClientCaPublicKey: instanceCAs.instanceProxySshClientCaPublicKey - }); - } - - if (proxy.orgId) { - const orgCAs = await $getOrgCAs(proxy.orgId); - return $generateProxyServerCredentials({ - ip, - orgId: proxy.orgId, - proxyPkiServerCaCertificate: orgCAs.proxyPkiServerCaCertificate, - proxyPkiServerCaPrivateKey: orgCAs.proxyPkiServerCaPrivateKey, - proxyPkiClientCaCertificate: orgCAs.proxyPkiClientCaCertificate, - proxyPkiClientCaCertificateChain: orgCAs.proxyPkiClientCaCertificateChain, - proxySshServerCaPrivateKey: orgCAs.proxySshServerCaPrivateKey, - proxySshClientCaPublicKey: orgCAs.proxySshClientCaPublicKey - }); - } - - throw new BadRequestError({ - message: "Unhandled proxy type" - }); - }; - - return { - registerProxy, - getCredentialsForGateway, - getCredentialsForClient - }; -}; diff --git a/backend/src/ee/services/relay/instance-relay-config-dal.ts b/backend/src/ee/services/relay/instance-relay-config-dal.ts new file mode 100644 index 000000000..6db3b93e7 --- /dev/null +++ b/backend/src/ee/services/relay/instance-relay-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TInstanceRelayConfigDALFactory = ReturnType; + +export const instanceRelayConfigDalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.InstanceRelayConfig); + + return orm; +}; diff --git a/backend/src/ee/services/relay/org-relay-config-dal.ts b/backend/src/ee/services/relay/org-relay-config-dal.ts new file mode 100644 index 000000000..7da35b9dc --- /dev/null +++ b/backend/src/ee/services/relay/org-relay-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgRelayConfigDALFactory = ReturnType; + +export const orgRelayConfigDalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.OrgRelayConfig); + + return orm; +}; diff --git a/backend/src/ee/services/relay/relay-dal.ts b/backend/src/ee/services/relay/relay-dal.ts new file mode 100644 index 000000000..9107e0807 --- /dev/null +++ b/backend/src/ee/services/relay/relay-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TRelayDALFactory = ReturnType; + +export const relayDalFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Relay); + + return orm; +}; diff --git a/backend/src/ee/services/relay/relay-fns.ts b/backend/src/ee/services/relay/relay-fns.ts new file mode 100644 index 000000000..f33210798 --- /dev/null +++ b/backend/src/ee/services/relay/relay-fns.ts @@ -0,0 +1,5 @@ +export const INSTANCE_RELAY_PREFIX = "infisical-"; + +export const isInstanceRelay = (relayName: string) => { + return relayName.startsWith(INSTANCE_RELAY_PREFIX); +}; diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts new file mode 100644 index 000000000..90e1e02ee --- /dev/null +++ b/backend/src/ee/services/relay/relay-service.ts @@ -0,0 +1,1008 @@ +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 { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns"; +import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + createSerialNumber, + keyAlgorithmToAlgCfg +} 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 { createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns"; +import { SshCertType } from "../ssh/ssh-certificate-authority-types"; +import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types"; +import { TInstanceRelayConfigDALFactory } from "./instance-relay-config-dal"; +import { TOrgRelayConfigDALFactory } from "./org-relay-config-dal"; +import { TRelayDALFactory } from "./relay-dal"; +import { isInstanceRelay } from "./relay-fns"; + +export type TRelayServiceFactory = ReturnType; + +const INSTANCE_RELAY_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; + +export const relayServiceFactory = ({ + instanceRelayConfigDAL, + orgRelayConfigDAL, + relayDAL, + kmsService +}: { + instanceRelayConfigDAL: TInstanceRelayConfigDALFactory; + orgRelayConfigDAL: TOrgRelayConfigDALFactory; + relayDAL: TRelayDALFactory; + kmsService: TKmsServiceFactory; +}) => { + const $getInstanceCAs = async () => { + const instanceConfig = await instanceRelayConfigDAL.transaction(async (tx) => { + const existingInstanceRelayConfig = await instanceRelayConfigDAL.findById(INSTANCE_RELAY_CONFIG_UUID); + if (existingInstanceRelayConfig) return existingInstanceRelayConfig; + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.InstanceRelayConfigInit()]); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const rootCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + // generate root CA + const rootCaSerialNumber = createSerialNumber(); + const rootCaSkObj = crypto.nativeCrypto.KeyObject.from(rootCaKeys.privateKey); + const rootCaIssuedAt = new Date(); + const rootCaExpiration = new Date(new Date().setFullYear(2045)); + const rootCaCert = await x509.X509CertificateGenerator.createSelfSigned({ + name: `O=Infisical,CN=Infisical Instance Root Relay CA`, + serialNumber: rootCaSerialNumber, + notBefore: rootCaIssuedAt, + notAfter: rootCaExpiration, + signingAlgorithm: alg, + keys: rootCaKeys, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(rootCaKeys.publicKey) + ] + }); + + // generate org relay CA + const orgRelayCaSerialNumber = createSerialNumber(); + const orgRelayCaIssuedAt = new Date(); + const orgRelayCaExpiration = new Date(new Date().setFullYear(2045)); + const orgRelayCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const orgRelayCaSkObj = crypto.nativeCrypto.KeyObject.from(orgRelayCaKeys.privateKey); + const orgRelayCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: orgRelayCaSerialNumber, + subject: `O=Infisical,CN=Infisical Organization Relay CA`, + issuer: rootCaCert.subject, + notBefore: orgRelayCaIssuedAt, + notAfter: orgRelayCaExpiration, + signingKey: rootCaKeys.privateKey, + publicKey: orgRelayCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 2, true), + await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(orgRelayCaKeys.publicKey) + ] + }); + const orgRelayCaChain = constructPemChainFromCerts([rootCaCert]); + + // generate instance relay CA + const instanceRelayCaSerialNumber = createSerialNumber(); + const instanceRelayCaIssuedAt = new Date(); + const instanceRelayCaExpiration = new Date(new Date().setFullYear(2045)); + const instanceRelayCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const instanceRelayCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceRelayCaKeys.privateKey); + const instanceRelayCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: instanceRelayCaSerialNumber, + subject: `O=Infisical,CN=Infisical Instance Relay CA`, + issuer: rootCaCert.subject, + notBefore: instanceRelayCaIssuedAt, + notAfter: instanceRelayCaExpiration, + signingKey: rootCaKeys.privateKey, + publicKey: instanceRelayCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 1, true), + await x509.AuthorityKeyIdentifierExtension.create(rootCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(instanceRelayCaKeys.publicKey) + ] + }); + const instanceRelayCaChain = constructPemChainFromCerts([rootCaCert]); + + // generate instance relay client CA + const instanceRelayClientCaSerialNumber = createSerialNumber(); + const instanceRelayClientCaIssuedAt = new Date(); + const instanceRelayClientCaExpiration = new Date(new Date().setFullYear(2045)); + const instanceRelayClientCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const instanceRelayClientCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceRelayClientCaKeys.privateKey); + const instanceRelayClientCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: instanceRelayClientCaSerialNumber, + subject: `O=Infisical,CN=Infisical Instance Relay Client CA`, + issuer: instanceRelayCaCert.subject, + notBefore: instanceRelayClientCaIssuedAt, + notAfter: instanceRelayClientCaExpiration, + signingKey: instanceRelayCaKeys.privateKey, + publicKey: instanceRelayClientCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(instanceRelayCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(instanceRelayClientCaKeys.publicKey) + ] + }); + const instanceRelayClientCaChain = constructPemChainFromCerts([instanceRelayCaCert, rootCaCert]); + + // generate instance relay server CA + const instanceRelayServerCaSerialNumber = createSerialNumber(); + const instanceRelayServerCaIssuedAt = new Date(); + const instanceRelayServerCaExpiration = new Date(new Date().setFullYear(2045)); + const instanceRelayServerCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const instanceRelayServerCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceRelayServerCaKeys.privateKey); + const instanceRelayServerCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: instanceRelayServerCaSerialNumber, + subject: `O=Infisical,CN=Infisical Instance Relay Server CA`, + issuer: instanceRelayCaCert.subject, + notBefore: instanceRelayServerCaIssuedAt, + notAfter: instanceRelayServerCaExpiration, + signingKey: instanceRelayCaKeys.privateKey, + publicKey: instanceRelayServerCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(instanceRelayCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(instanceRelayServerCaKeys.publicKey) + ] + }); + const instanceRelayServerCaChain = constructPemChainFromCerts([instanceRelayCaCert, rootCaCert]); + + const instanceSshServerCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); + const instanceSshClientCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); + + const encryptWithRoot = kmsService.encryptWithRootKey(); + + // root relay CA + const encryptedRootRelayPkiCaPrivateKey = encryptWithRoot( + Buffer.from( + rootCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedRootRelayPkiCaCertificate = encryptWithRoot(Buffer.from(rootCaCert.rawData)); + + // org relay CA + const encryptedOrgRelayPkiCaPrivateKey = encryptWithRoot( + Buffer.from( + orgRelayCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedOrgRelayPkiCaCertificate = encryptWithRoot(Buffer.from(orgRelayCaCert.rawData)); + const encryptedOrgRelayPkiCaCertificateChain = encryptWithRoot(Buffer.from(orgRelayCaChain)); + + // instance relay CA + const encryptedInstanceRelayPkiCaPrivateKey = encryptWithRoot( + Buffer.from( + instanceRelayCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedInstanceRelayPkiCaCertificate = encryptWithRoot(Buffer.from(instanceRelayCaCert.rawData)); + const encryptedInstanceRelayPkiCaCertificateChain = encryptWithRoot(Buffer.from(instanceRelayCaChain)); + + // instance relay client CA + const encryptedInstanceRelayPkiClientCaPrivateKey = encryptWithRoot( + Buffer.from( + instanceRelayClientCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedInstanceRelayPkiClientCaCertificate = encryptWithRoot( + Buffer.from(instanceRelayClientCaCert.rawData) + ); + const encryptedInstanceRelayPkiClientCaCertificateChain = encryptWithRoot( + Buffer.from(instanceRelayClientCaChain) + ); + + // instance relay server CA + const encryptedInstanceRelayPkiServerCaPrivateKey = encryptWithRoot( + Buffer.from( + instanceRelayServerCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + ); + const encryptedInstanceRelayPkiServerCaCertificate = encryptWithRoot( + Buffer.from(instanceRelayServerCaCert.rawData) + ); + const encryptedInstanceRelayPkiServerCaCertificateChain = encryptWithRoot( + Buffer.from(instanceRelayServerCaChain) + ); + + const encryptedInstanceRelaySshClientCaPublicKey = encryptWithRoot( + Buffer.from(instanceSshClientCaKeyPair.publicKey) + ); + const encryptedInstanceRelaySshClientCaPrivateKey = encryptWithRoot( + Buffer.from(instanceSshClientCaKeyPair.privateKey) + ); + + const encryptedInstanceRelaySshServerCaPublicKey = encryptWithRoot( + Buffer.from(instanceSshServerCaKeyPair.publicKey) + ); + const encryptedInstanceRelaySshServerCaPrivateKey = encryptWithRoot( + Buffer.from(instanceSshServerCaKeyPair.privateKey) + ); + + return instanceRelayConfigDAL.create({ + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + id: INSTANCE_RELAY_CONFIG_UUID, + encryptedRootRelayPkiCaPrivateKey, + encryptedRootRelayPkiCaCertificate, + encryptedInstanceRelayPkiCaPrivateKey, + encryptedInstanceRelayPkiCaCertificate, + encryptedInstanceRelayPkiCaCertificateChain, + encryptedInstanceRelayPkiClientCaPrivateKey, + encryptedInstanceRelayPkiClientCaCertificate, + encryptedInstanceRelayPkiClientCaCertificateChain, + encryptedInstanceRelayPkiServerCaPrivateKey, + encryptedInstanceRelayPkiServerCaCertificate, + encryptedInstanceRelayPkiServerCaCertificateChain, + encryptedOrgRelayPkiCaPrivateKey, + encryptedOrgRelayPkiCaCertificate, + encryptedOrgRelayPkiCaCertificateChain, + encryptedInstanceRelaySshClientCaPublicKey, + encryptedInstanceRelaySshClientCaPrivateKey, + encryptedInstanceRelaySshServerCaPublicKey, + encryptedInstanceRelaySshServerCaPrivateKey + }); + }); + + // decrypt the instance config + const decryptWithRoot = kmsService.decryptWithRootKey(); + + // decrypt root relay CA + const rootRelayPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedRootRelayPkiCaPrivateKey); + const rootRelayPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedRootRelayPkiCaCertificate); + + // decrypt org relay CA + const orgRelayPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedOrgRelayPkiCaPrivateKey); + const orgRelayPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedOrgRelayPkiCaCertificate); + const orgRelayPkiCaCertificateChain = decryptWithRoot(instanceConfig.encryptedOrgRelayPkiCaCertificateChain); + + // decrypt instance relay CA + const instanceRelayPkiCaPrivateKey = decryptWithRoot(instanceConfig.encryptedInstanceRelayPkiCaPrivateKey); + const instanceRelayPkiCaCertificate = decryptWithRoot(instanceConfig.encryptedInstanceRelayPkiCaCertificate); + const instanceRelayPkiCaCertificateChain = decryptWithRoot( + instanceConfig.encryptedInstanceRelayPkiCaCertificateChain + ); + + // decrypt instance relay client CA + const instanceRelayPkiClientCaPrivateKey = decryptWithRoot( + instanceConfig.encryptedInstanceRelayPkiClientCaPrivateKey + ); + const instanceRelayPkiClientCaCertificate = decryptWithRoot( + instanceConfig.encryptedInstanceRelayPkiClientCaCertificate + ); + const instanceRelayPkiClientCaCertificateChain = decryptWithRoot( + instanceConfig.encryptedInstanceRelayPkiClientCaCertificateChain + ); + + // decrypt instance relay server CA + const instanceRelayPkiServerCaPrivateKey = decryptWithRoot( + instanceConfig.encryptedInstanceRelayPkiServerCaPrivateKey + ); + const instanceRelayPkiServerCaCertificate = decryptWithRoot( + instanceConfig.encryptedInstanceRelayPkiServerCaCertificate + ); + const instanceRelayPkiServerCaCertificateChain = decryptWithRoot( + instanceConfig.encryptedInstanceRelayPkiServerCaCertificateChain + ); + + // decrypt SSH keys + const instanceRelaySshClientCaPublicKey = decryptWithRoot( + instanceConfig.encryptedInstanceRelaySshClientCaPublicKey + ); + const instanceRelaySshClientCaPrivateKey = decryptWithRoot( + instanceConfig.encryptedInstanceRelaySshClientCaPrivateKey + ); + const instanceRelaySshServerCaPublicKey = decryptWithRoot( + instanceConfig.encryptedInstanceRelaySshServerCaPublicKey + ); + const instanceRelaySshServerCaPrivateKey = decryptWithRoot( + instanceConfig.encryptedInstanceRelaySshServerCaPrivateKey + ); + + return { + rootRelayPkiCaPrivateKey, + rootRelayPkiCaCertificate, + orgRelayPkiCaPrivateKey, + orgRelayPkiCaCertificate, + orgRelayPkiCaCertificateChain, + instanceRelayPkiCaPrivateKey, + instanceRelayPkiCaCertificate, + instanceRelayPkiCaCertificateChain, + instanceRelayPkiClientCaPrivateKey, + instanceRelayPkiClientCaCertificate, + instanceRelayPkiClientCaCertificateChain, + instanceRelayPkiServerCaPrivateKey, + instanceRelayPkiServerCaCertificate, + instanceRelayPkiServerCaCertificateChain, + instanceRelaySshClientCaPublicKey, + instanceRelaySshClientCaPrivateKey, + instanceRelaySshServerCaPublicKey, + instanceRelaySshServerCaPrivateKey + }; + }; + + const $getOrgCAs = async (orgId: string) => { + const instanceCAs = await $getInstanceCAs(); + const { encryptor: orgKmsEncryptor, decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const orgRelayConfig = await orgRelayConfigDAL.transaction(async (tx) => { + const existingOrgRelayConfig = await orgRelayConfigDAL.findOne( + { + orgId + }, + tx + ); + + if (existingOrgRelayConfig) { + return existingOrgRelayConfig; + } + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.OrgRelayConfigInit(orgId)]); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const orgRelayCaCert = new x509.X509Certificate(instanceCAs.orgRelayPkiCaCertificate); + const rootRelayCaCert = new x509.X509Certificate(instanceCAs.rootRelayPkiCaCertificate); + const orgRelayCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: instanceCAs.orgRelayPkiCaPrivateKey, + format: "der", + type: "pkcs8" + }); + const orgRelayCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + orgRelayCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + // generate org relay client CA + const orgRelayClientCaSerialNumber = createSerialNumber(); + const orgRelayClientCaIssuedAt = new Date(); + const orgRelayClientCaExpiration = new Date(new Date().setFullYear(2045)); + const orgRelayClientCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const orgRelayClientCaSkObj = crypto.nativeCrypto.KeyObject.from(orgRelayClientCaKeys.privateKey); + const orgRelayClientCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: orgRelayClientCaSerialNumber, + subject: `O=${orgId},CN=Infisical Org Relay Client CA`, + issuer: orgRelayCaCert.subject, + notBefore: orgRelayClientCaIssuedAt, + notAfter: orgRelayClientCaExpiration, + signingKey: orgRelayCaPrivateKey, + publicKey: orgRelayClientCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(orgRelayCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(orgRelayClientCaKeys.publicKey) + ] + }); + const orgRelayClientCaChain = constructPemChainFromCerts([orgRelayCaCert, rootRelayCaCert]); + + // generate org SSH CA + const orgSshServerCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); + const orgSshClientCaKeyPair = await createSshKeyPair(SshCertKeyAlgorithm.RSA_2048); + + // generate org relay server CA + const orgRelayServerCaSerialNumber = createSerialNumber(); + const orgRelayServerCaIssuedAt = new Date(); + const orgRelayServerCaExpiration = new Date(new Date().setFullYear(2045)); + const orgRelayServerCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const orgRelayServerCaSkObj = crypto.nativeCrypto.KeyObject.from(orgRelayServerCaKeys.privateKey); + const orgRelayServerCaCert = await x509.X509CertificateGenerator.create({ + serialNumber: orgRelayServerCaSerialNumber, + subject: `O=${orgId},CN=Infisical Org Relay Server CA`, + issuer: orgRelayCaCert.subject, + notBefore: orgRelayServerCaIssuedAt, + notAfter: orgRelayServerCaExpiration, + signingKey: orgRelayCaPrivateKey, + publicKey: orgRelayServerCaKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, 0, true), + await x509.AuthorityKeyIdentifierExtension.create(orgRelayCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(orgRelayServerCaKeys.publicKey) + ] + }); + const orgRelayServerCaChain = constructPemChainFromCerts([orgRelayCaCert, rootRelayCaCert]); + + const encryptedRelayPkiClientCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from( + orgRelayClientCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + }).cipherTextBlob; + const encryptedRelayPkiClientCaCertificate = orgKmsEncryptor({ + plainText: Buffer.from(orgRelayClientCaCert.rawData) + }).cipherTextBlob; + + const encryptedRelayPkiClientCaCertificateChain = orgKmsEncryptor({ + plainText: Buffer.from(orgRelayClientCaChain) + }).cipherTextBlob; + + const encryptedRelayPkiServerCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from( + orgRelayServerCaSkObj.export({ + type: "pkcs8", + format: "der" + }) + ) + }).cipherTextBlob; + const encryptedRelayPkiServerCaCertificate = orgKmsEncryptor({ + plainText: Buffer.from(orgRelayServerCaCert.rawData) + }).cipherTextBlob; + const encryptedRelayPkiServerCaCertificateChain = orgKmsEncryptor({ + plainText: Buffer.from(orgRelayServerCaChain) + }).cipherTextBlob; + + const encryptedRelaySshClientCaPublicKey = orgKmsEncryptor({ + plainText: Buffer.from(orgSshClientCaKeyPair.publicKey) + }).cipherTextBlob; + const encryptedRelaySshClientCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from(orgSshClientCaKeyPair.privateKey) + }).cipherTextBlob; + + const encryptedRelaySshServerCaPublicKey = orgKmsEncryptor({ + plainText: Buffer.from(orgSshServerCaKeyPair.publicKey) + }).cipherTextBlob; + const encryptedRelaySshServerCaPrivateKey = orgKmsEncryptor({ + plainText: Buffer.from(orgSshServerCaKeyPair.privateKey) + }).cipherTextBlob; + + return orgRelayConfigDAL.create({ + orgId, + encryptedRelayPkiClientCaPrivateKey, + encryptedRelayPkiClientCaCertificate, + encryptedRelayPkiClientCaCertificateChain, + encryptedRelayPkiServerCaPrivateKey, + encryptedRelayPkiServerCaCertificate, + encryptedRelayPkiServerCaCertificateChain, + encryptedRelaySshClientCaPublicKey, + encryptedRelaySshClientCaPrivateKey, + encryptedRelaySshServerCaPublicKey, + encryptedRelaySshServerCaPrivateKey + }); + }); + + const relayPkiClientCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelayPkiClientCaPrivateKey + }); + const relayPkiClientCaCertificate = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelayPkiClientCaCertificate + }); + const relayPkiClientCaCertificateChain = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelayPkiClientCaCertificateChain + }); + + const relayPkiServerCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelayPkiServerCaPrivateKey + }); + const relayPkiServerCaCertificate = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelayPkiServerCaCertificate + }); + const relayPkiServerCaCertificateChain = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelayPkiServerCaCertificateChain + }); + + const relaySshClientCaPublicKey = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelaySshClientCaPublicKey + }); + const relaySshClientCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelaySshClientCaPrivateKey + }); + + const relaySshServerCaPublicKey = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelaySshServerCaPublicKey + }); + const relaySshServerCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgRelayConfig.encryptedRelaySshServerCaPrivateKey + }); + + return { + relayPkiClientCaPrivateKey, + relayPkiClientCaCertificate, + relayPkiClientCaCertificateChain, + relayPkiServerCaPrivateKey, + relayPkiServerCaCertificate, + relayPkiServerCaCertificateChain, + relaySshClientCaPublicKey, + relaySshClientCaPrivateKey, + relaySshServerCaPublicKey, + relaySshServerCaPrivateKey + }; + }; + + const $generateRelayServerCredentials = async ({ + ip, + orgId, + relayPkiServerCaCertificate, + relayPkiServerCaPrivateKey, + relayPkiClientCaCertificate, + relayPkiClientCaCertificateChain, + relaySshClientCaPublicKey, + relaySshServerCaPrivateKey + }: { + ip: string; + relayPkiServerCaCertificate: Buffer; + relayPkiServerCaPrivateKey: Buffer; + relayPkiClientCaCertificateChain: Buffer; + relayPkiClientCaCertificate: Buffer; + relaySshServerCaPrivateKey: Buffer; + relaySshClientCaPublicKey: Buffer; + orgId?: string; + }) => { + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const relayServerCaCert = new x509.X509Certificate(relayPkiServerCaCertificate); + const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate); + const relayServerCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: relayPkiServerCaPrivateKey, + format: "der", + type: "pkcs8" + }); + + const relayServerCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + relayServerCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const relayServerKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const relayServerCertIssuedAt = new Date(); + const relayServerCertExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1)); + const relayServerCertPrivateKey = crypto.nativeCrypto.KeyObject.from(relayServerKeys.privateKey); + + const relayServerCertExtensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(relayServerCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(relayServerKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), + // san + new x509.SubjectAlternativeNameExtension([{ type: "ip", value: ip }], false) + ]; + + const relayServerSerialNumber = createSerialNumber(); + const relayServerCertificate = await x509.X509CertificateGenerator.create({ + serialNumber: relayServerSerialNumber, + subject: `CN=${ip},O=${orgId ?? "Infisical"},OU=Relay`, + issuer: relayServerCaCert.subject, + notBefore: relayServerCertIssuedAt, + notAfter: relayServerCertExpireAt, + signingKey: relayServerCaPrivateKey, + publicKey: relayServerKeys.publicKey, + signingAlgorithm: alg, + extensions: relayServerCertExtensions + }); + + // generate relay server SSH certificate + const keyAlgorithm = SshCertKeyAlgorithm.RSA_2048; + const { publicKey: relayServerSshPublicKey, privateKey: relayServerSshPrivateKey } = + await createSshKeyPair(keyAlgorithm); + + const relayServerSshCert = await createSshCert({ + caPrivateKey: relaySshServerCaPrivateKey.toString("utf8"), + clientPublicKey: relayServerSshPublicKey, + keyId: "relay-server", + principals: [`${ip}:2222`], + certType: SshCertType.HOST, + requestedTtl: "30d" + }); + + return { + pki: { + serverCertificate: relayServerCertificate.toString("pem"), + serverPrivateKey: relayServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + clientCertificateChain: prependCertToPemChain( + relayClientCaCert, + relayPkiClientCaCertificateChain.toString("utf8") + ) + }, + ssh: { + serverCertificate: relayServerSshCert.signedPublicKey, + serverPrivateKey: relayServerSshPrivateKey, + clientCAPublicKey: relaySshClientCaPublicKey.toString("utf8") + } + }; + }; + + const $generateRelayClientCredentials = async ({ + gatewayId, + orgId, + relayPkiClientCaCertificate, + relayPkiClientCaPrivateKey, + relayPkiServerCaCertificate, + relayPkiServerCaCertificateChain + }: { + gatewayId: string; + orgId: string; + relayPkiClientCaCertificate: Buffer; + relayPkiClientCaPrivateKey: Buffer; + relayPkiServerCaCertificate: Buffer; + relayPkiServerCaCertificateChain: Buffer; + }) => { + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate); + const relayServerCaCert = new x509.X509Certificate(relayPkiServerCaCertificate); + const relayClientCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: relayPkiClientCaPrivateKey, + format: "der", + type: "pkcs8" + }); + + const importedRelayClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + relayClientCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const clientCertIssuedAt = new Date(); + const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000); + const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); + const clientCertSerialNumber = createSerialNumber(); + + // Build standard extensions + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(relayClientCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | + x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] | + x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true) + ]; + + const clientCert = await x509.X509CertificateGenerator.create({ + serialNumber: clientCertSerialNumber, + subject: `O=${orgId},OU=relay-client,CN=${gatewayId}`, + issuer: relayClientCaCert.subject, + notAfter: clientCertExpiration, + notBefore: clientCertIssuedAt, + signingKey: importedRelayClientCaPrivateKey, + publicKey: clientKeys.publicKey, + signingAlgorithm: alg, + extensions + }); + + return { + clientCertificate: clientCert.toString("pem"), + clientPrivateKey: clientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + serverCertificateChain: prependCertToPemChain( + relayServerCaCert, + relayPkiServerCaCertificateChain.toString("utf8") + ) + }; + }; + + const getCredentialsForGateway = async ({ + relayName, + orgId, + gatewayId + }: { + relayName: string; + orgId: string; + gatewayId: string; + }) => { + let relay: TRelays | null; + if (isInstanceRelay(relayName)) { + relay = await relayDAL.findOne({ + name: relayName + }); + } else { + relay = await relayDAL.findOne({ + orgId, + name: relayName + }); + } + + if (!relay) { + throw new NotFoundError({ + message: "Relay not found" + }); + } + + const keyAlgorithm = SshCertKeyAlgorithm.RSA_2048; + const { publicKey: relayClientSshPublicKey, privateKey: relayClientSshPrivateKey } = + await createSshKeyPair(keyAlgorithm); + + if (isInstanceRelay(relayName)) { + const instanceCAs = await $getInstanceCAs(); + const relayClientSshCert = await createSshCert({ + caPrivateKey: instanceCAs.instanceRelaySshClientCaPrivateKey.toString("utf8"), + clientPublicKey: relayClientSshPublicKey, + keyId: `client-${relayName}`, + principals: [gatewayId], + certType: SshCertType.USER, + requestedTtl: "30d" + }); + + return { + relayIp: relay.ip, + clientSshCert: relayClientSshCert.signedPublicKey, + clientSshPrivateKey: relayClientSshPrivateKey, + serverCAPublicKey: instanceCAs.instanceRelaySshServerCaPublicKey.toString("utf8") + }; + } + + const orgCAs = await $getOrgCAs(orgId); + const relayClientSshCert = await createSshCert({ + caPrivateKey: orgCAs.relaySshClientCaPrivateKey.toString("utf8"), + clientPublicKey: relayClientSshPublicKey, + keyId: `relay-client-${relay.id}`, + principals: [gatewayId], + certType: SshCertType.USER, + requestedTtl: "30d" + }); + + return { + relayIp: relay.ip, + clientSshCert: relayClientSshCert.signedPublicKey, + clientSshPrivateKey: relayClientSshPrivateKey, + serverCAPublicKey: orgCAs.relaySshServerCaPublicKey.toString("utf8") + }; + }; + + const getCredentialsForClient = async ({ + relayId, + orgId, + gatewayId + }: { + relayId: string; + orgId: string; + gatewayId: string; + }) => { + const relay = await relayDAL.findOne({ + id: relayId + }); + + if (!relay) { + throw new NotFoundError({ + message: "Relay not found" + }); + } + + if (isInstanceRelay(relay.name)) { + const instanceCAs = await $getInstanceCAs(); + const relayCertificateCredentials = await $generateRelayClientCredentials({ + gatewayId, + orgId, + relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate, + relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey, + relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate, + relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain + }); + + return { + ...relayCertificateCredentials, + relayIp: relay.ip + }; + } + + const orgCAs = await $getOrgCAs(orgId); + const relayCertificateCredentials = await $generateRelayClientCredentials({ + gatewayId, + orgId, + relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate, + relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey, + relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate, + relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain + }); + + return { + ...relayCertificateCredentials, + relayIp: relay.ip + }; + }; + + const registerRelay = async ({ + ip, + name, + identityId, + orgId + }: { + ip: string; + name: string; + identityId?: string; + orgId?: string; + }) => { + let relay: TRelays; + const isOrgRelay = identityId && orgId; + + if (isOrgRelay) { + if (isInstanceRelay(name)) { + throw new BadRequestError({ + message: "Org relay name cannot start with 'infisical-'. This is reserved for internal use." + }); + } + + relay = await relayDAL.transaction(async (tx) => { + const existingRelay = await relayDAL.findOne( + { + identityId, + orgId + }, + tx + ); + + if (existingRelay && (existingRelay.ip !== ip || existingRelay.name !== name)) { + throw new BadRequestError({ + message: "Org relay with this machine identity already exists." + }); + } + + if (!existingRelay) { + return relayDAL.create( + { + ip, + name, + identityId, + orgId + }, + tx + ); + } + + return existingRelay; + }); + } else { + if (!isInstanceRelay(name)) { + throw new BadRequestError({ + message: "Instance relay name must start with 'infisical-'." + }); + } + + relay = await relayDAL.transaction(async (tx) => { + const existingRelay = await relayDAL.findOne( + { + name + }, + tx + ); + + if (existingRelay && existingRelay.ip !== ip) { + throw new BadRequestError({ + message: "Instance relay with this name already exists with a different IP address" + }); + } + + if (!existingRelay) { + return relayDAL.create( + { + ip, + name + }, + tx + ); + } + + return existingRelay; + }); + } + + if (isInstanceRelay(name)) { + const instanceCAs = await $getInstanceCAs(); + return $generateRelayServerCredentials({ + ip, + relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate, + relayPkiServerCaPrivateKey: instanceCAs.instanceRelayPkiServerCaPrivateKey, + relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate, + relayPkiClientCaCertificateChain: instanceCAs.instanceRelayPkiClientCaCertificateChain, + relaySshServerCaPrivateKey: instanceCAs.instanceRelaySshServerCaPrivateKey, + relaySshClientCaPublicKey: instanceCAs.instanceRelaySshClientCaPublicKey + }); + } + + if (relay.orgId) { + const orgCAs = await $getOrgCAs(relay.orgId); + return $generateRelayServerCredentials({ + ip, + orgId: relay.orgId, + relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate, + relayPkiServerCaPrivateKey: orgCAs.relayPkiServerCaPrivateKey, + relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate, + relayPkiClientCaCertificateChain: orgCAs.relayPkiClientCaCertificateChain, + relaySshServerCaPrivateKey: orgCAs.relaySshServerCaPrivateKey, + relaySshClientCaPublicKey: orgCAs.relaySshClientCaPublicKey + }); + } + + throw new BadRequestError({ + message: "Unhandled relay type" + }); + }; + + return { + registerRelay, + getCredentialsForGateway, + getCredentialsForClient + }; +}; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index d7a28da96..13d0b1d1e 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -14,9 +14,9 @@ export const PgSqlLock = { CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`), CreateFolder: (envId: string, projectId: string) => pgAdvisoryLockHashText(`create-folder:${envId}-${projectId}`), SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`), - InstanceProxyConfigInit: () => pgAdvisoryLockHashText("instance-proxy-config-init"), + InstanceRelayConfigInit: () => pgAdvisoryLockHashText("instance-relay-config-init"), OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`), - OrgProxyConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-proxy-config-init:${orgId}`), + OrgRelayConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-relay-config-init:${orgId}`), IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`) } as const; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 6e926b21e..2d8781305 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -233,7 +233,7 @@ const envSchema = z GATEWAY_RELAY_REALM: zpStr(z.string().optional()), GATEWAY_RELAY_AUTH_SECRET: zpStr(z.string().optional()), - PROXY_AUTH_SECRET: zpStr(z.string().optional()), + RELAY_AUTH_SECRET: zpStr(z.string().optional()), DYNAMIC_SECRET_ALLOW_INTERNAL_IP: zodStrBool.default("false"), DYNAMIC_SECRET_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()).default( diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index b8623095f..e41560e86 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -11,26 +11,26 @@ import { BadRequestError } from "../errors"; import { GatewayProxyProtocol } from "../gateway/types"; import { logger } from "../logger"; -interface IGatewayProxyServer { +interface IGatewayRelayServer { server: net.Server; port: number; cleanup: () => Promise; - getProxyError: () => string; + getRelayError: () => string; } -const createProxyConnection = async ({ - proxyIp, +const createRelayConnection = async ({ + relayIp, clientCertificate, clientPrivateKey, serverCertificateChain }: { - proxyIp: string; + relayIp: string; clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string; }): Promise => { - const [targetHost] = await verifyHostInputValidity(proxyIp); - const [, portStr] = proxyIp.split(":"); + const [targetHost] = await verifyHostInputValidity(relayIp); + const [, portStr] = relayIp.split(":"); const port = parseInt(portStr, 10) || 8443; const serverCAs = splitPemChain(serverCertificateChain); @@ -47,7 +47,7 @@ const createProxyConnection = async ({ return new Promise((resolve, reject) => { try { const socket = tls.connect(tlsOptions, () => { - logger.info("Proxy TLS connection established successfully"); + logger.info("Relay TLS connection established successfully"); resolve(socket); }); @@ -75,11 +75,11 @@ const createProxyConnection = async ({ }; const createGatewayConnection = async ( - proxyConn: net.Socket, + relayConn: net.Socket, gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string } ): Promise => { const tlsOptions: tls.ConnectionOptions = { - socket: proxyConn, + socket: relayConn, cert: gateway.clientCertificate, key: gateway.clientPrivateKey, ca: splitPemChain(gateway.serverCertificateChain), @@ -119,20 +119,20 @@ const createGatewayConnection = async ( }); }; -const setupProxyServer = async ({ +const setupRelayServer = async ({ protocol, - proxyIp, + relayIp, gateway, - proxy, + relay, httpsAgent }: { protocol: GatewayProxyProtocol; - proxyIp: string; + relayIp: string; gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; - proxy: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; + relay: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; httpsAgent?: https.Agent; -}): Promise => { - const proxyErrorMsg: string[] = []; +}): Promise => { + const relayErrorMsg: string[] = []; return new Promise((resolve, reject) => { const server = net.createServer(); @@ -143,16 +143,16 @@ const setupProxyServer = async ({ clientConn.setKeepAlive(true, 30000); clientConn.setNoDelay(true); - // Stage 1: Connect to proxy relay with TLS - const proxyConn = await createProxyConnection({ - proxyIp, - clientCertificate: proxy.clientCertificate, - clientPrivateKey: proxy.clientPrivateKey, - serverCertificateChain: proxy.serverCertificateChain + // Stage 1: Connect to relay with TLS + const relayConn = await createRelayConnection({ + relayIp, + clientCertificate: relay.clientCertificate, + clientPrivateKey: relay.clientPrivateKey, + serverCertificateChain: relay.serverCertificateChain }); - // Stage 2: Establish mTLS connection to gateway through the proxy - const gatewayConn = await createGatewayConnection(proxyConn, gateway); + // Stage 2: Establish mTLS connection to gateway through the relay + const gatewayConn = await createGatewayConnection(relayConn, gateway); let command = ""; @@ -191,22 +191,22 @@ const setupProxyServer = async ({ // Handle connection closure clientConn.on("close", () => { - proxyConn.destroy(); + relayConn.destroy(); gatewayConn.destroy(); }); - proxyConn.on("close", () => { + relayConn.on("close", () => { clientConn.destroy(); gatewayConn.destroy(); }); gatewayConn.on("close", () => { clientConn.destroy(); - proxyConn.destroy(); + relayConn.destroy(); }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - proxyErrorMsg.push(errorMsg); + relayErrorMsg.push(errorMsg); clientConn.destroy(); } })(); @@ -234,7 +234,7 @@ const setupProxyServer = async ({ logger.debug("Error closing server:", err instanceof Error ? err.message : String(err)); } }, - getProxyError: () => proxyErrorMsg.join(",") + getRelayError: () => relayErrorMsg.join(",") }); }); }); @@ -244,19 +244,19 @@ export const withGatewayV2Proxy = async ( callback: (port: number) => Promise, options: { protocol: GatewayProxyProtocol; - proxyIp: string; + relayIp: string; gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; - proxy: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; + relay: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; httpsAgent?: https.Agent; } ): Promise => { - const { protocol, proxyIp, gateway, proxy, httpsAgent } = options; + const { protocol, relayIp, gateway, relay, httpsAgent } = options; - const { port, cleanup, getProxyError } = await setupProxyServer({ + const { port, cleanup, getRelayError } = await setupRelayServer({ protocol, - proxyIp, + relayIp, gateway, - proxy, + relay, httpsAgent }); @@ -264,12 +264,12 @@ export const withGatewayV2Proxy = async ( // Execute the callback with the allocated port return await callback(port); } catch (err) { - const proxyErrorMessage = getProxyError(); - if (proxyErrorMessage) { - logger.error("Proxy error:", proxyErrorMessage); + const relayErrorMessage = getRelayError(); + if (relayErrorMessage) { + logger.error("Relay error:", relayErrorMessage); } logger.error("Gateway error:", err instanceof Error ? err.message : String(err)); - let errorMessage = proxyErrorMessage || (err instanceof Error ? err.message : String(err)); + let errorMessage = relayErrorMessage || (err instanceof Error ? err.message : String(err)); if (axios.isAxiosError(err) && (err.response?.data as { message?: string })?.message) { errorMessage = (err.response?.data as { message: string }).message; } diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 0126a4129..a24a09c81 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -122,7 +122,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { } // Authentication is handled on a route-level - if (req.url === "/api/v1/proxies/register-instance-proxy") { + if (req.url === "/api/v1/proxies/register-instance-relay") { return; } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 66718b78f..a20099d66 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -73,12 +73,12 @@ import { projectTemplateDALFactory } from "@app/ee/services/project-template/pro import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; -import { instanceProxyConfigDalFactory } from "@app/ee/services/proxy/instance-proxy-config-dal"; -import { orgProxyConfigDalFactory } from "@app/ee/services/proxy/org-proxy-config-dal"; -import { proxyDalFactory } from "@app/ee/services/proxy/proxy-dal"; -import { proxyServiceFactory } from "@app/ee/services/proxy/proxy-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; import { rateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; +import { instanceRelayConfigDalFactory } from "@app/ee/services/relay/instance-relay-config-dal"; +import { orgRelayConfigDalFactory } from "@app/ee/services/relay/org-relay-config-dal"; +import { relayDalFactory } from "@app/ee/services/relay/relay-dal"; +import { relayServiceFactory } from "@app/ee/services/relay/relay-service"; import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; @@ -948,9 +948,9 @@ export const registerRoutes = async ( const pkiSubscriberDAL = pkiSubscriberDALFactory(db); const pkiTemplatesDAL = pkiTemplatesDALFactory(db); - const instanceProxyConfigDAL = instanceProxyConfigDalFactory(db); - const orgProxyConfigDAL = orgProxyConfigDalFactory(db); - const proxyDAL = proxyDalFactory(db); + const instanceRelayConfigDAL = instanceRelayConfigDalFactory(db); + const orgRelayConfigDAL = orgRelayConfigDalFactory(db); + const relayDAL = relayDalFactory(db); const gatewayV2DAL = gatewayV2DalFactory(db); const orgGatewayConfigV2DAL = orgGatewayConfigV2DalFactory(db); @@ -1073,20 +1073,20 @@ export const registerRoutes = async ( keyStore }); - const proxyService = proxyServiceFactory({ - instanceProxyConfigDAL, - orgProxyConfigDAL, - proxyDAL, + const relayService = relayServiceFactory({ + instanceRelayConfigDAL, + orgRelayConfigDAL, + relayDAL, kmsService }); const gatewayV2Service = gatewayV2ServiceFactory({ kmsService, licenseService, - proxyService, + relayService, orgGatewayConfigV2DAL, gatewayV2DAL, - proxyDAL, + relayDAL, permissionService }); @@ -2138,7 +2138,7 @@ export const registerRoutes = async ( reminder: reminderService, bus: eventBusService, sse: sseService, - proxy: proxyService, + relay: relayService, gatewayV2: gatewayV2Service }); diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index f55fb0eb2..8ed6afad0 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -105,9 +105,9 @@ export const requestWithGitHubGateway = async ( }, { protocol: GatewayProxyProtocol.Tcp, - proxyIp: gatewayConnectionDetails.proxyIp, + relayIp: gatewayConnectionDetails.relayIp, gateway: gatewayConnectionDetails.gateway, - proxy: gatewayConnectionDetails.proxy + relay: gatewayConnectionDetails.relay } ); } diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index 636d59de5..eb0609d5b 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -142,9 +142,9 @@ export const executeWithPotentialGateway = async ( }, { protocol: GatewayProxyProtocol.Tcp, - proxyIp: platformConnectionDetails.proxyIp, + relayIp: platformConnectionDetails.relayIp, gateway: platformConnectionDetails.gateway, - proxy: platformConnectionDetails.proxy + relay: platformConnectionDetails.relay } ); } diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 6b0955dc8..97e86e8fd 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -114,9 +114,9 @@ export const identityKubernetesAuthServiceFactory = ({ }, { protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, - proxyIp: gatewayV2ConnectionDetails.proxyIp, + relayIp: gatewayV2ConnectionDetails.relayIp, gateway: gatewayV2ConnectionDetails.gateway, - proxy: gatewayV2ConnectionDetails.proxy, + relay: gatewayV2ConnectionDetails.relay, httpsAgent } ); From 49742b2a4e40af575f2f955619e72e9acd6d7ff3 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Sep 2025 02:57:22 +0800 Subject: [PATCH 20/46] misc: doc updates --- docs/cli/commands/gateway.mdx | 380 ++++++++++++++- docs/cli/commands/network.mdx | 441 ------------------ docs/cli/commands/relay.mdx | 303 ++++++++++++ docs/docs.json | 2 +- .../platform/gateways/networking.mdx | 78 ++-- .../platform/gateways/overview.mdx | 66 +-- .../gateways/gateway-highlevel-diagram.png | Bin 113242 -> 332077 bytes .../templates/deployment.yaml | 2 +- 8 files changed, 732 insertions(+), 540 deletions(-) delete mode 100644 docs/cli/commands/network.mdx create mode 100644 docs/cli/commands/relay.mdx diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index 0168c7a42..1d66281b8 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -3,43 +3,367 @@ title: "infisical gateway" description: "Run the Infisical gateway or manage its systemd service" --- - -**New Gateway Architecture Available** - -A completely redesigned gateway system is now available under the `infisical network` command with a fundamentally different architecture: - -- **TCP-based SSH tunnels** instead of UDP/TURN protocol -- **Eliminates firewall complexity** - no UDP configuration needed -- **Enhanced security** with certificate-based authentication -- **Flexible deployment options** - instance-wide or organization-specific proxies - -**Learn more:** See [`infisical network`](/cli/commands/network) for the new gateway architecture. - -**Migration:** The current `infisical gateway` command will continue to work but **will be deprecated in a future release**. Migration to `infisical network gateway` requires **complete reconfiguration** - you cannot simply switch commands as this is an entirely different gateway infrastructure. We strongly recommend planning migration to `infisical network gateway` for all deployments. - - - - + ```bash - infisical gateway --token= + infisical gateway start --name= --relay= --auth-method= ``` - + ```bash - sudo infisical gateway install --token= --domain= + sudo infisical gateway systemd install --token= --domain= --name= --relay= ``` ## Description -Run the Infisical gateway in the foreground or manage its systemd service installation. The gateway allows secure communication between your self-hosted Infisical instance and client applications. +The Infisical gateway provides secure access to private resources using modern TCP-based SSH tunnel architecture with enhanced security and flexible deployment options. + +The gateway system uses SSH reverse tunnels over TCP, eliminating firewall complexity and providing excellent performance for enterprise environments. ## Subcommands & flags - - Run the Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection. + + Run the Infisical gateway component within your VPC. The gateway establishes an SSH reverse tunnel to the specified relay server and provides secure access to private resources. + +```bash +infisical gateway start --relay= --name= --auth-method= +``` + +The gateway component: + +- Establishes outbound SSH reverse tunnels to relay servers (no inbound firewall rules needed) +- Authenticates using SSH certificates issued by Infisical +- Automatically reconnects if the connection is lost +- Provides access to private resources within your network + +### Authentication + +The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. + + + + The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. + + + + + Your machine identity client ID. + + + Your machine identity client secret. + + + The authentication method to use. Must be `universal-auth` when using Universal Auth. + + + + + ```bash + infisical gateway start --auth-method=universal-auth --client-id= --client-secret= --relay= --name= + ``` + + + + The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. + + + + + Your machine identity ID. + + + Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + The authentication method to use. Must be `kubernetes` when using Native Kubernetes. + + + + + + + ```bash + infisical gateway start --auth-method=kubernetes --machine-identity-id= --relay= --name= + ``` + + + + The Native Azure method is used to authenticate with Infisical when running in an Azure environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `azure` when using Native Azure. + + + + + + + ```bash + infisical gateway start --auth-method=azure --machine-identity-id= --relay= --name= + ``` + + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. + + + + + + + ```bash + infisical gateway start --auth-method=gcp-id-token --machine-identity-id= --relay= --name= + ``` + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + + Your machine identity ID. + + + Path to your GCP service account key file _(Must be in JSON format!)_ + + + The authentication method to use. Must be `gcp-iam` when using GCP IAM. + + + + + ```bash + infisical gateway start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --relay= --name= + ``` + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `aws-iam` when using Native AWS IAM. + + + + + ```bash + infisical gateway start --auth-method=aws-iam --machine-identity-id= --relay= --name= + ``` + + + + The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. + + + + + Your machine identity ID. + + + The OIDC JWT from the identity provider. + + + The authentication method to use. Must be `oidc-auth` when using OIDC Auth. + + + + + ```bash + infisical gateway start --auth-method=oidc-auth --machine-identity-id= --jwt= --relay= --name= + ``` + + + + + The JWT Auth method is used to authenticate with Infisical via a JWT token. + + + + + The JWT token to use for authentication. + + + Your machine identity ID. + + + The authentication method to use. Must be `jwt-auth` when using JWT Auth. + + + + + + ```bash + infisical gateway start --auth-method=jwt-auth --jwt= --machine-identity-id= --relay= --name= + ``` + + + + You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. + + + + + The machine identity access token to use for authentication. + + + + + ```bash + infisical gateway start --token= --relay= --name= + ``` + + + + +### Other Flags + + + The name of the relay that this gateway should connect to. The relay must be running and registered before starting the gateway. + + ```bash + # Example + infisical gateway start --relay=my-relay --name=my-gateway --token= + ``` + + **Note:** If using organization relays or self-hosted instance relays, you must first start a relay server using `infisical relay start` before connecting gateways to it. For Infisical Cloud users using instance relays, the relay infrastructure is already running and managed by Infisical. + + + + + The name of the gateway instance. + + ```bash + # Example + infisical gateway start --name=my-gateway --relay=my-relay --token= + ``` + + + + + Domain of your self-hosted Infisical instance. + + ```bash + # Example + infisical gateway start --domain=https://app.your-domain.com --relay= --name= + ``` + + + + + + Install and enable the gateway as a systemd service. This command must be run with sudo on Linux. + +```bash +sudo infisical gateway systemd install --token= --domain= --name= --relay= +``` + +### Requirements + +- Must be run on Linux +- Must be run with root/sudo privileges +- Requires systemd + +### Flags + + + The machine identity access token to authenticate with Infisical. + + ```bash + # Example + sudo infisical gateway systemd install --token= --name= --relay= + ``` + + You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the install command. + + + + + Domain of your self-hosted Infisical instance. + + ```bash + # Example + sudo infisical gateway systemd install --domain=https://app.your-domain.com --name= --relay= + ``` + + + + + The name of the gateway instance. + + ```bash + # Example + sudo infisical gateway systemd install --name=my-gateway --token= --relay= + ``` + + + + + The name of the relay that this gateway should connect to. + + ```bash + # Example + sudo infisical gateway systemd install --relay=my-relay --token= --name= + ``` + + + +### Service Details + +The systemd service is installed with secure defaults: + +- Service file: `/etc/systemd/system/infisical-gateway.service` +- Config file: `/etc/infisical/gateway.conf` +- Runs with restricted privileges: + - InaccessibleDirectories=/home + - PrivateTmp=yes + - Resource limits configured for stability +- Automatically restarts on failure +- Enabled to start on boot +- Maintains persistent SSH reverse tunnel connections to the specified relay +- Handles certificate rotation and connection recovery automatically + +After installation, manage the service with standard systemd commands: + +```bash +sudo systemctl start infisical-gateway # Start the service +sudo systemctl stop infisical-gateway # Stop the service +sudo systemctl status infisical-gateway # Check service status +sudo systemctl disable infisical-gateway # Disable auto-start on boot +``` + + + +## Legacy Gateway Commands (Deprecated) + + + + **This command is deprecated and will be removed in a future release.** + + Please migrate to `infisical gateway start` for the new TCP-based SSH tunnel architecture. + + +Run the legacy Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection. ```bash infisical gateway --domain= --auth-method= @@ -256,8 +580,14 @@ The Infisical CLI supports multiple authentication methods. Below are the availa - - Install and enable the gateway as a systemd service. This command must be run with sudo on Linux. + + + **This command is deprecated and will be removed in a future release.** + + Please migrate to `infisical gateway systemd install` for the new TCP-based SSH tunnel architecture with enhanced security and better performance. + + +Install and enable the legacy gateway as a systemd service. This command must be run with sudo on Linux. ```bash sudo infisical gateway install --token= --domain= diff --git a/docs/cli/commands/network.mdx b/docs/cli/commands/network.mdx deleted file mode 100644 index 0ed19a224..000000000 --- a/docs/cli/commands/network.mdx +++ /dev/null @@ -1,441 +0,0 @@ ---- -title: "infisical network" -description: "Network-related commands for Infisical including gateway and proxy components" ---- - - - - ```bash - infisical network gateway --token= - ``` - - - ```bash - sudo infisical network gateway install --token= --domain= --name= --proxy-name= - ``` - - - -## Description - -Network-related commands for Infisical that provide secure access to private resources: - -- **Gateway**: Lightweight agent deployed within your VPCs to provide access to private resources -- **Proxy**: Identity-aware relay infrastructure that routes encrypted traffic (can be instance-wide or organization-specific) - -The gateway system uses SSH reverse tunnels over TCP, eliminating firewall complexity and providing excellent performance for enterprise environments. - -## Subcommands & flags - - - Run the Infisical gateway component within your VPC. The gateway establishes an SSH reverse tunnel to the specified proxy server and provides secure access to private resources. - -```bash -infisical network gateway --proxy-name= --name= --auth-method= -``` - -The gateway component: - -- Establishes outbound SSH reverse tunnels to proxy servers (no inbound firewall rules needed) -- Authenticates using SSH certificates issued by Infisical -- Automatically reconnects if the connection is lost -- Provides access to private resources within your network - -### Authentication - -The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. - - - - The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. - - - - - Your machine identity client ID. - - - Your machine identity client secret. - - - The authentication method to use. Must be `universal-auth` when using Universal Auth. - - - - - ```bash - infisical network gateway --auth-method=universal-auth --client-id= --client-secret= --proxy-name= --name= - ``` - - - - The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. - - - - - Your machine identity ID. - - - Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. - - - The authentication method to use. Must be `kubernetes` when using Native Kubernetes. - - - - - - - ```bash - infisical network gateway --auth-method=kubernetes --machine-identity-id= --proxy-name= --name= - ``` - - - - The Native Azure method is used to authenticate with Infisical when running in an Azure environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `azure` when using Native Azure. - - - - - - - ```bash - infisical network gateway --auth-method=azure --machine-identity-id= --proxy-name= --name= - ``` - - - - The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. - - - - - - - ```bash - infisical network gateway --auth-method=gcp-id-token --machine-identity-id= --proxy-name= --name= - ``` - - - - The GCP IAM method is used to authenticate with Infisical with a GCP service account key. - - - - - Your machine identity ID. - - - Path to your GCP service account key file _(Must be in JSON format!)_ - - - The authentication method to use. Must be `gcp-iam` when using GCP IAM. - - - - - ```bash - infisical network gateway --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --proxy-name= --name= - ``` - - - - The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `aws-iam` when using Native AWS IAM. - - - - - ```bash - infisical network gateway --auth-method=aws-iam --machine-identity-id= --proxy-name= --name= - ``` - - - - The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. - - - - - Your machine identity ID. - - - The OIDC JWT from the identity provider. - - - The authentication method to use. Must be `oidc-auth` when using OIDC Auth. - - - - - ```bash - infisical network gateway --auth-method=oidc-auth --machine-identity-id= --jwt= --proxy-name= --name= - ``` - - - - - The JWT Auth method is used to authenticate with Infisical via a JWT token. - - - - - The JWT token to use for authentication. - - - Your machine identity ID. - - - The authentication method to use. Must be `jwt-auth` when using JWT Auth. - - - - - - ```bash - infisical network gateway --auth-method=jwt-auth --jwt= --machine-identity-id= --proxy-name= --name= - ``` - - - - You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. - - - - - The machine identity access token to use for authentication. - - - - - ```bash - infisical network gateway --token= --proxy-name= --name= - ``` - - - - -### Other Flags - - - The name of the proxy that this gateway should connect to. The proxy must be running and registered before starting the gateway. - - ```bash - # Example - infisical network gateway --proxy-name=my-proxy --name=my-gateway --token= - ``` - - **Note:** If using organization proxies or self-hosted instance proxies, you must first start a proxy server using `infisical network proxy` before connecting gateways to it. For Infisical Cloud users using instance proxies, the proxy infrastructure is already running and managed by Infisical. - - - - - The name of the gateway instance. - - ```bash - # Example - infisical network gateway --name=my-gateway --proxy-name=my-proxy --token= - ``` - - - - - Domain of your self-hosted Infisical instance. - - ```bash - # Example - infisical network gateway --domain=https://app.your-domain.com --proxy-name= --name= - ``` - - - - - - Install and enable the gateway as a systemd service. This command must be run with sudo on Linux. - -```bash -sudo infisical network gateway install --token= --domain= --name= --proxy-name= -``` - -### Requirements - -- Must be run on Linux -- Must be run with root/sudo privileges -- Requires systemd - -### Flags - - - The machine identity access token to authenticate with Infisical. - - ```bash - # Example - sudo infisical network gateway install --token= --name= --proxy-name= - ``` - - You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the install command. - - - - - Domain of your self-hosted Infisical instance. - - ```bash - # Example - sudo infisical network gateway install --domain=https://app.your-domain.com --name= --proxy-name= - ``` - - - - - The name of the gateway instance. - - ```bash - # Example - sudo infisical network gateway install --name=my-gateway --token= --proxy-name= - ``` - - - - - The name of the proxy that this gateway should connect to. - - ```bash - # Example - sudo infisical network gateway install --proxy-name=my-proxy --token= --name= - ``` - - - -### Service Details - -The systemd service is installed with secure defaults: - -- Service file: `/etc/systemd/system/infisical-gateway.service` -- Config file: `/etc/infisical/gateway.conf` -- Runs with restricted privileges: - - InaccessibleDirectories=/home - - PrivateTmp=yes - - Resource limits configured for stability -- Automatically restarts on failure -- Enabled to start on boot -- Maintains persistent SSH reverse tunnel connections to the specified proxy -- Handles certificate rotation and connection recovery automatically - -After installation, manage the service with standard systemd commands: - -```bash -sudo systemctl start infisical-gateway # Start the service -sudo systemctl stop infisical-gateway # Stop the service -sudo systemctl status infisical-gateway # Check service status -sudo systemctl disable infisical-gateway # Disable auto-start on boot -``` - - - - - Run the Infisical proxy component. The proxy handles network traffic routing and can operate in different modes. - -```bash -infisical network proxy --type= --ip= --name= --auth-method= -``` - -### Flags - - - The type of proxy to run. Must be either 'instance' or 'org'. - - - **`instance`**: Shared proxy server that can be used by all organizations on your Infisical instance. Set up by the instance administrator. Uses `INFISICAL_PROXY_AUTH_SECRET` environment variable for authentication, which must be configured by the instance admin. - - **`org`**: Dedicated proxy server that individual organizations deploy and manage in their own infrastructure. Provides enhanced security, custom geographic placement, and compliance benefits. Uses standard Infisical authentication methods. - - ```bash - # Organization proxy (customer-deployed) - infisical network proxy --type=org --ip=192.168.1.100 --name=my-org-proxy - - # Instance proxy (configured by instance admin) - INFISICAL_PROXY_AUTH_SECRET= infisical network proxy --type=instance --ip=10.0.1.50 --name=shared-proxy - ``` - - - - - The public IP address of the instance where the proxy is deployed. This must be a static public IP that gateways can reach. - - ```bash - # Example - infisical network proxy --ip=203.0.113.100 --type=org --name=my-proxy - ``` - - - - - The name of the proxy. - - ```bash - # Example - infisical network proxy --name=my-proxy --type=org --ip=192.168.1.100 - ``` - - - -### Authentication - -**Organization Proxies (`--type=org`):** -Deploy your own proxy server in your infrastructure for enhanced security and reduced latency. Supports all standard Infisical authentication methods documented above in the gateway section. - -**Instance Proxies (`--type=instance`):** -Shared proxy servers that serve all organizations on your Infisical instance. For Infisical Cloud, these are already running and ready to use. For self-hosted deployments, they're set up by the instance administrator. Authentication is handled via the `INFISICAL_PROXY_AUTH_SECRET` environment variable. - -```bash -# Organization proxy with Universal Auth (customer-deployed) -infisical network proxy --type=org --ip=192.168.1.100 --name=my-org-proxy --auth-method=universal-auth --client-id= --client-secret= - -# Instance proxy (configured by instance admin) -INFISICAL_PROXY_AUTH_SECRET= infisical network proxy --type=instance --ip=10.0.1.50 --name=shared-proxy -``` - -### Deployment Considerations - -**When to use Instance Proxies (`--type=instance`):** - -- You want to get started quickly without setting up your own proxy infrastructure -- You're using Infisical Cloud and want to leverage the existing proxy infrastructure -- You're on a self-hosted instance where the admin has already set up shared proxies -- You don't need custom geographic placement of proxy servers -- You don't have specific compliance requirements that require dedicated infrastructure -- You want to minimize operational overhead by using shared infrastructure - -**When to use Organization Proxies (`--type=org`):** - -- You need lower latency by deploying proxy servers closer to your resources -- You have security requirements that mandate running infrastructure in your own environment -- You have compliance requirements such as data sovereignty or air-gapped environments -- You need custom network policies or specific networking configurations -- You have high-scale performance requirements that shared infrastructure can't meet -- You want full control over your proxy infrastructure and its configuration - - diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx new file mode 100644 index 000000000..f7843709c --- /dev/null +++ b/docs/cli/commands/relay.mdx @@ -0,0 +1,303 @@ +--- +title: "infisical relay" +description: "Relay-related commands for Infisical including proxy components" +--- + + + + ```bash + infisical relay start --type= --ip= --name= --auth-method= + ``` + + + +## Description + +Relay-related commands for Infisical that provide identity-aware relay infrastructure for routing encrypted traffic: + +- **Relay**: Identity-aware server that routes encrypted traffic (can be instance-wide or organization-specific) + +The relay system uses SSH reverse tunnels over TCP, eliminating firewall complexity and providing excellent performance for enterprise environments. + +## Subcommands & flags + + + Run the Infisical relay component. The relay handles network traffic routing and can operate in different modes. + +```bash +infisical relay start --type= --ip= --name= --auth-method= +``` + +### Flags + + + The type of relay to run. Must be either 'instance' or 'org'. + + - **`instance`**: Shared relay server that can be used by all organizations on your Infisical instance. Set up by the instance administrator. Uses `INFISICAL_PROXY_AUTH_SECRET` environment variable for authentication, which must be configured by the instance admin. + - **`org`**: Dedicated relay server that individual organizations deploy and manage in their own infrastructure. Provides enhanced security, custom geographic placement, and compliance benefits. Uses standard Infisical authentication methods. + + ```bash + # Organization relay (customer-deployed) + infisical relay start --type=org --ip=192.168.1.100 --name=my-org-relay + + # Instance relay (configured by instance admin) + INFISICAL_PROXY_AUTH_SECRET= infisical relay start --type=instance --ip=10.0.1.50 --name=shared-relay + ``` + + + + + The public IP address of the instance where the relay is deployed. This must be a static public IP that gateways can reach. + + ```bash + # Example + infisical relay start --ip=203.0.113.100 --type=org --name=my-relay + ``` + + + + + The name of the relay. + + ```bash + # Example + infisical relay start --name=my-relay --type=org --ip=192.168.1.100 + ``` + + + +### Authentication + +**Organization Relays (`--type=org`):** +Deploy your own relay server in your infrastructure for enhanced security and reduced latency. Supports all standard Infisical authentication methods documented below. + +**Instance Relays (`--type=instance`):** +Shared relay servers that serve all organizations on your Infisical instance. For Infisical Cloud, these are already running and ready to use. For self-hosted deployments, they're set up by the instance administrator. Authentication is handled via the `INFISICAL_PROXY_AUTH_SECRET` environment variable. + +```bash +# Organization relay with Universal Auth (customer-deployed) +infisical relay start --type=org --ip=192.168.1.100 --name=my-org-relay --auth-method=universal-auth --client-id= --client-secret= + +# Instance relay (configured by instance admin) +INFISICAL_PROXY_AUTH_SECRET= infisical relay start --type=instance --ip=10.0.1.50 --name=shared-relay +``` + +### Authentication Methods + +The Infisical CLI supports multiple authentication methods for organization relays. Below are the available authentication methods, with their respective flags. + + + + The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. + + + + + Your machine identity client ID. + + + Your machine identity client secret. + + + The authentication method to use. Must be `universal-auth` when using Universal Auth. + + + + + ```bash + infisical relay start --auth-method=universal-auth --client-id= --client-secret= --type=org --ip= --name= + ``` + + + + The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. + + + + + Your machine identity ID. + + + Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + The authentication method to use. Must be `kubernetes` when using Native Kubernetes. + + + + + + + ```bash + infisical relay start --auth-method=kubernetes --machine-identity-id= --type=org --ip= --name= + ``` + + + + The Native Azure method is used to authenticate with Infisical when running in an Azure environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `azure` when using Native Azure. + + + + + + + ```bash + infisical relay start --auth-method=azure --machine-identity-id= --type=org --ip= --name= + ``` + + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. + + + + + + + ```bash + infisical relay start --auth-method=gcp-id-token --machine-identity-id= --type=org --ip= --name= + ``` + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + + Your machine identity ID. + + + Path to your GCP service account key file _(Must be in JSON format!)_ + + + The authentication method to use. Must be `gcp-iam` when using GCP IAM. + + + + + ```bash + infisical relay start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --type=org --ip= --name= + ``` + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `aws-iam` when using Native AWS IAM. + + + + + ```bash + infisical relay start --auth-method=aws-iam --machine-identity-id= --type=org --ip= --name= + ``` + + + + The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. + + + + + Your machine identity ID. + + + The OIDC JWT from the identity provider. + + + The authentication method to use. Must be `oidc-auth` when using OIDC Auth. + + + + + ```bash + infisical relay start --auth-method=oidc-auth --machine-identity-id= --jwt= --type=org --ip= --name= + ``` + + + + + The JWT Auth method is used to authenticate with Infisical via a JWT token. + + + + + The JWT token to use for authentication. + + + Your machine identity ID. + + + The authentication method to use. Must be `jwt-auth` when using JWT Auth. + + + + + + ```bash + infisical relay start --auth-method=jwt-auth --jwt= --machine-identity-id= --type=org --ip= --name= + ``` + + + + You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. + + + + + The machine identity access token to use for authentication. + + + + + ```bash + infisical relay start --token= --type=org --ip= --name= + ``` + + + + +### Deployment Considerations + +**When to use Instance Relays (`--type=instance`):** + +- You want to get started quickly without setting up your own relay infrastructure +- You're using Infisical Cloud and want to leverage the existing relay infrastructure +- You're on a self-hosted instance where the admin has already set up shared relays +- You don't need custom geographic placement of relay servers +- You don't have specific compliance requirements that require dedicated infrastructure +- You want to minimize operational overhead by using shared infrastructure + +**When to use Organization Relays (`--type=org`):** + +- You need lower latency by deploying relay servers closer to your resources +- You have security requirements that mandate running infrastructure in your own environment +- You have compliance requirements such as data sovereignty or air-gapped environments +- You need custom network policies or specific networking configurations +- You have high-scale performance requirements that shared infrastructure can't meet +- You want full control over your relay infrastructure and its configuration + + diff --git a/docs/docs.json b/docs/docs.json index 6a82526af..285e11a60 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -774,11 +774,11 @@ "cli/commands/dynamic-secrets", "cli/commands/ssh", "cli/commands/gateway", + "cli/commands/relay", "cli/commands/bootstrap", "cli/commands/export", "cli/commands/token", "cli/commands/service-token", - "cli/commands/network", "cli/commands/vault", "cli/commands/user", "cli/commands/reset", diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx index c7ab95fd9..ca99a4e92 100644 --- a/docs/documentation/platform/gateways/networking.mdx +++ b/docs/documentation/platform/gateways/networking.mdx @@ -3,17 +3,17 @@ title: "Networking" description: "Network configuration and firewall requirements for Infisical Gateway" --- -The Infisical Gateway requires outbound network connectivity to establish secure SSH reverse tunnels with proxy servers. +The Infisical Gateway requires outbound network connectivity to establish secure SSH reverse tunnels with relay servers. This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. ## Network Architecture The gateway uses SSH reverse tunnels to establish secure connections with end-to-end encryption: -1. **Gateway** connects outbound to **Proxy Servers** using SSH over TCP +1. **Gateway** connects outbound to **Relay Servers** using SSH over TCP 2. **Infisical platform** establishes mTLS connections with gateways for application traffic -3. **Proxy Servers** route the doubly-encrypted traffic (mTLS payload within SSH tunnels) between the platform and gateways -4. **Double encryption** ensures proxy servers cannot access application data - only the platform and gateway can decrypt traffic +3. **Relay Servers** route the doubly-encrypted traffic (mTLS payload within SSH tunnels) between the platform and gateways +4. **Double encryption** ensures relay servers cannot access application data - only the platform and gateway can decrypt traffic ## Required Network Connectivity @@ -23,34 +23,34 @@ The gateway requires the following outbound connectivity: | Protocol | Destination | Ports | Purpose | | -------- | ------------------------------------ | ----- | ------------------------------------------ | -| TCP | Proxy Servers | 2222 | SSH reverse tunnel establishment | +| TCP | Relay Servers | 2222 | SSH reverse tunnel establishment | | TCP | app.infisical.com / eu.infisical.com | 443 | API communication and certificate requests | -### Proxy Server Connectivity +### Relay Server Connectivity -**For Instance Proxies (Infisical Cloud):** Your firewall must allow outbound connectivity to Infisical-managed proxy servers. +**For Instance Relays (Infisical Cloud):** Your firewall must allow outbound connectivity to Infisical-managed relay servers. -**For Organization Proxies:** Your firewall must allow outbound connectivity to your own proxy server IP addresses. +**For Organization Relays:** Your firewall must allow outbound connectivity to your own relay server IP addresses. -**For Self-hosted Instance Proxies:** Your firewall must allow outbound connectivity to proxy servers configured by your instance administrator. +**For Self-hosted Instance Relays:** Your firewall must allow outbound connectivity to relay servers configured by your instance administrator. - - Infisical provides multiple managed proxy servers with static IP addresses. - You can whitelist these IPs ahead of time based on which proxy server you + + Infisical provides multiple managed relay servers with static IP addresses. + You can whitelist these IPs ahead of time based on which relay server you choose to connect to. **Firewall requirements:** Allow outbound TCP - connections to the desired proxy server IP on port 2222. + connections to the desired relay server IP on port 2222. - - You control the proxy server IP addresses when deploying your own - organization proxies. **Firewall requirements:** Allow outbound TCP - connections to your proxy server IP on port 2222. For example, if your proxy + + You control the relay server IP addresses when deploying your own + organization relays. **Firewall requirements:** Allow outbound TCP + connections to your relay server IP on port 2222. For example, if your relay is at `203.0.113.100`, allow TCP to `203.0.113.100:2222`. - - Contact your instance administrator for the proxy server IP addresses + + Contact your instance administrator for the relay server IP addresses configured for your deployment. **Firewall requirements:** Allow outbound - TCP connections to instance proxy servers on port 2222. + TCP connections to instance relay servers on port 2222. @@ -60,7 +60,7 @@ The gateway requires the following outbound connectivity: The gateway uses SSH reverse tunnels for primary communication: -- **Port 2222**: SSH connection to proxy servers +- **Port 2222**: SSH connection to relay servers - **Built-in features**: Automatic reconnection, certificate-based authentication, encrypted tunneling - **Encryption**: SSH with certificate-based authentication and key exchange @@ -81,7 +81,7 @@ SSH connections over TCP are stateful and handled seamlessly by all modern firew Since SSH uses TCP, you only need simple outbound rules: -1. **Allow outbound TCP** to proxy servers on port 2222 +1. **Allow outbound TCP** to relay servers on port 2222 2. **Allow outbound HTTPS** to Infisical API endpoints on port 443 3. **No inbound rules required** - all connections are outbound only @@ -91,7 +91,7 @@ Since SSH uses TCP, you only need simple outbound rules: For corporate environments with strict egress filtering: -1. **Allow outbound TCP** to proxy servers on port 2222 +1. **Allow outbound TCP** to relay servers on port 2222 2. **Allow outbound HTTPS** to the Infisical API server on port 443 3. **No inbound rules required** - all connections are outbound only 4. **Standard TCP rules** - simple and straightforward configuration @@ -100,7 +100,7 @@ For corporate environments with strict egress filtering: Configure security groups to allow: -- **Outbound TCP** to proxy servers on port 2222 +- **Outbound TCP** to relay servers on port 2222 - **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 - **No inbound rules required** - SSH reverse tunnels are outbound only @@ -109,7 +109,7 @@ Configure security groups to allow: The gateway is designed to handle network interruptions gracefully: -- **Automatic reconnection**: The gateway will automatically attempt to reconnect to proxy servers if the SSH connection is lost +- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers if the SSH connection is lost - **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention - **Persistent SSH tunnels**: SSH connections are automatically re-established when connectivity is restored - **Certificate rotation**: The gateway handles certificate renewal automatically during reconnection @@ -135,7 +135,7 @@ TCP's reliability and firewall compatibility make it ideal for enterprise enviro No inbound ports need to be opened. The gateway only makes outbound connections: -- **Outbound SSH** to proxy servers on port 2222 +- **Outbound SSH** to relay servers on port 2222 - **Outbound HTTPS** to Infisical API endpoints on port 443 - **SSH reverse tunnels** handle all communication - no return traffic configuration needed @@ -146,32 +146,32 @@ This design maintains security by avoiding the need for inbound firewall rules t If your firewall has strict outbound restrictions: -1. **Work with your network team** to allow outbound TCP connections on port 2222 to proxy servers +1. **Work with your network team** to allow outbound TCP connections on port 2222 to relay servers 2. **Allow standard SSH traffic** - most enterprises already have SSH policies in place 3. **Consider network policy exceptions** for the gateway host if needed 4. **Monitor firewall logs** to identify which specific rules are blocking traffic - -The gateway connects to **one proxy server**: + +The gateway connects to **one relay server**: -- **Single SSH connection**: Each gateway establishes one SSH reverse tunnel to its assigned proxy server -- **Named proxy assignment**: Gateways connect to the specific proxy server specified by `--proxy-name` -- **Automatic reconnection**: If the proxy connection is lost, the gateway automatically reconnects to the same proxy +- **Single SSH connection**: Each gateway establishes one SSH reverse tunnel to its assigned relay server +- **Named relay assignment**: Gateways connect to the specific relay server specified by `--relay` +- **Automatic reconnection**: If the relay connection is lost, the gateway automatically reconnects to the same relay - **Certificate-based authentication**: Each connection uses SSH certificates issued by Infisical for secure authentication - -No, proxy servers cannot decrypt any traffic passing through them due to end-to-end encryption: + +No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: -- **Client-to-Gateway mTLS**: Clients establish mTLS connections directly with gateways, encrypting all application traffic -- **SSH tunnel encryption**: The mTLS-encrypted traffic is then transmitted through SSH reverse tunnels to proxy servers +- **Client-to-Gateway mTLS (via TLS-pinned tunnel)**: Clients connect via a proxy that establishes a TLS-pinned tunnel to the gateway; mTLS between the client and gateway is negotiated inside this tunnel, encrypting all application traffic +- **SSH tunnel encryption**: The mTLS-encrypted traffic is then transmitted through SSH reverse tunnels to relay servers - **Double encryption**: Traffic is encrypted twice - once by client mTLS and again by SSH tunnels -- **Proxy acts as a relay**: The proxy server only routes the doubly-encrypted traffic without access to either encryption layer -- **No data storage**: Proxy servers do not store any traffic or sensitive information +- **Relay only routes traffic**: The relay server only routes the doubly-encrypted traffic without access to either encryption layer +- **No data storage**: Relay servers do not store any traffic or sensitive information - **Certificate isolation**: Each connection uses unique certificates, ensuring complete tenant isolation -The proxy infrastructure is designed as a secure routing mechanism where only the client and gateway can decrypt the actual application traffic. +The relay infrastructure is designed as a secure routing mechanism where only the client and gateway can decrypt the actual application traffic. diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index 58701c41b..35274fd8f 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -11,7 +11,7 @@ The Infisical Gateway provides secure access to private resources within your ne **Architecture Components:** - **Gateway**: Lightweight agent deployed within your VPCs that provides access to private resources -- **Proxy**: Identity-aware relay infrastructure that routes encrypted traffic (instance-wide or organization-specific) +- **Relay**: Infrastructure that routes encrypted traffic (instance-wide or organization-specific) Common use cases include generating dynamic credentials or rotating credentials for private databases. @@ -26,8 +26,8 @@ Common use cases include generating dynamic credentials or rotating credentials The Gateway system uses SSH reverse tunnels for secure, firewall-friendly connectivity: -1. **Gateway Registration**: The gateway establishes an outbound SSH reverse tunnel to a proxy server using SSH certificates issued by Infisical -2. **Proxy Routing**: The proxy server acts as an identity-aware relay that routes encrypted traffic between the Infisical platform and gateways +1. **Gateway Registration**: The gateway establishes an outbound SSH reverse tunnel to a relay server using SSH certificates issued by Infisical +2. **Relay Routing**: The relay server routes encrypted traffic between the Infisical platform and gateways 3. **Resource Access**: The Infisical platform connects to your private resources through the established gateway connections **Key Benefits:** @@ -39,18 +39,18 @@ The Gateway system uses SSH reverse tunnels for secure, firewall-friendly connec ## Deployment -The Infisical Gateway is integrated into the Infisical CLI under the `network gateway` command, making it simple to deploy and manage. +The Infisical Gateway is integrated into the Infisical CLI under the `gateway` command, making it simple to deploy and manage. You can install the Gateway in all the same ways you install the Infisical CLI—whether via npm, Docker, or a binary. For detailed installation instructions, refer to the Infisical [CLI Installation instructions](/cli/overview). **Prerequisites:** -1. **Proxy Server**: Before deploying gateways, you need a running proxy server: - - **Infisical Cloud**: Instance proxies are already available - no setup needed - - **Self-hosted**: Instance admin must set up shared instance proxies, or organizations can deploy their own +1. **Relay Server**: Before deploying gateways, you need a running relay server: + - **Infisical Cloud**: Instance relays are already available - no setup needed + - **Self-hosted**: Instance admin must set up shared instance relays, or organizations can deploy their own 2. **Machine Identity**: Configure a machine identity with appropriate permissions to create and manage gateways -Once authenticated, the Gateway establishes an SSH reverse tunnel to the specified proxy server, allowing secure access to your private resources. +Once authenticated, the Gateway establishes an SSH reverse tunnel to the specified relay server, allowing secure access to your private resources. ### Get started @@ -66,25 +66,25 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi You'll need to choose an authentication method to initiate communication with Infisical. View the available machine identity authentication methods [here](/documentation/platform/identities/machine-identities). - - You have two options for proxy infrastructure: + + You have two options for relay infrastructure: - - **Infisical Cloud:** Instance proxies are already running and available - **no setup required**. You can immediately proceed to deploy gateways using these shared proxies. + + **Infisical Cloud:** Instance relays are already running and available - **no setup required**. You can immediately proceed to deploy gateways using these shared relays. - **Self-hosted:** If your instance admin has set up shared instance proxies, you can use them directly. If not, the instance admin can set them up: + **Self-hosted:** If your instance admin has set up shared instance relays, you can use them directly. If not, the instance admin can set them up: ```bash - # Instance admin sets up shared proxy (one-time setup) - export INFISICAL_PROXY_AUTH_SECRET= - infisical network proxy --type=instance --ip= --name= + # Instance admin sets up shared relay (one-time setup) + export INFISICAL_RELAY_AUTH_SECRET= + infisical relay start --type=instance --ip= --name= ``` - - **Available for all users:** Deploy your own dedicated proxy infrastructure for enhanced control: + + **Available for all users:** Deploy your own dedicated relay infrastructure for enhanced control: ```bash - # Deploy organization-specific proxy - infisical network proxy --type=org --ip= --name= --auth-method=universal-auth --client-id= --client-secret= + # Deploy organization-specific relay + infisical relay start --type=org --ip= --name= --auth-method=universal-auth --client-id= --client-secret= ``` **When to choose this:** @@ -103,7 +103,7 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi For production deployments on Linux, install the Gateway as a systemd service: ```bash - sudo infisical network gateway install --token --domain --name --proxy-name + sudo infisical gateway systemd install --token --domain --name --relay sudo systemctl start infisical-gateway ``` This will install and start the Gateway as a secure systemd service that: @@ -170,7 +170,7 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ - --from-literal=INFISICAL_PROXY_NAME= \ + --from-literal=INFISICAL_RELAY_NAME= \ --from-literal=INFISICAL_GATEWAY_NAME= ``` @@ -343,8 +343,8 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi In addition to the authentication method above, you **must** include these required variables: - - The name of the proxy server that this gateway should connect to. + + The name of the relay server that this gateway should connect to. The name of this gateway instance. @@ -357,7 +357,7 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ - --from-literal=INFISICAL_PROXY_NAME= \ + --from-literal=INFISICAL_RELAY_NAME= \ --from-literal=INFISICAL_GATEWAY_NAME= ``` @@ -388,8 +388,8 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi INF Starting gateway INF Starting gateway certificate renewal goroutine INF Successfully registered gateway and received certificates - INF Connecting to proxy server infisical-start on 152.42.218.156:2222... - INF Proxy connection established for gateway + INF Connecting to relay server infisical-start on 152.42.218.156:2222... + INF Relay connection established for gateway ``` @@ -397,29 +397,29 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: ```bash - infisical network gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) --proxy-name= --name= + infisical gateway start --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) --relay= --name= ``` Alternatively, if you already have the token, use it directly with the `--token` flag: ```bash - infisical network gateway --token --proxy-name= --name= + infisical gateway start --token --relay= --name= ``` Or set it as an environment variable: ```bash export INFISICAL_TOKEN= - infisical network gateway --proxy-name= --name= + infisical gateway start --relay= --name= ``` - For detailed information about the network commands and their options, see the [network command documentation](/cli/commands/network). + For detailed information about the gateway commands and their options, see the [gateway command documentation](/cli/commands/gateway). **Requirements:** - Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical - - The gateway must be able to reach the proxy server (outbound connection only) - - Replace `` with the name of your proxy server and `` with a unique name for this gateway + - The gateway must be able to reach the relay server (outbound connection only) + - Replace `` with the name of your relay server and `` with a unique name for this gateway diff --git a/docs/images/platform/gateways/gateway-highlevel-diagram.png b/docs/images/platform/gateways/gateway-highlevel-diagram.png index 0555cfadd422942006109b41c81872abab9bb0d3..5f942bcf0c3049587e4dd117e5b337cecc1d803e 100644 GIT binary patch literal 332077 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

Vl7cl*;1 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 literal 113242 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

A) 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 Wwjs-fDIxI5F~ys=WlL|E1pFUUG~`48 diff --git a/helm-charts/infisical-gateway/templates/deployment.yaml b/helm-charts/infisical-gateway/templates/deployment.yaml index f55684f3b..d31a9c9e9 100644 --- a/helm-charts/infisical-gateway/templates/deployment.yaml +++ b/helm-charts/infisical-gateway/templates/deployment.yaml @@ -38,8 +38,8 @@ spec: image: "infisical/cli:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} args: - - network - gateway + - start envFrom: - secretRef: name: {{ .Values.secret.name }} From 18398457e06aae67d25851bbab5680fe06c32d94 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Sep 2025 03:42:52 +0800 Subject: [PATCH 21/46] misc: addressed comments --- ...1627_add-gateway-v2-pki-and-ssh-configs.ts | 10 ++- backend/src/db/schemas/relays.ts | 2 +- backend/src/ee/routes/v1/relay-router.ts | 4 +- .../dynamic-secret/providers/kubernetes.ts | 2 +- .../dynamic-secret/providers/sql-database.ts | 2 +- .../services/gateway-v2/gateway-v2-service.ts | 15 ++-- backend/src/ee/services/relay/relay-fns.ts | 5 -- .../src/ee/services/relay/relay-service.ts | 74 ++++++++----------- backend/src/lib/gateway-v2/gateway-v2.ts | 21 +++--- .../github/github-connection-fns.ts | 2 +- .../shared/sql/sql-connection-fns.ts | 2 +- .../identity-kubernetes-auth-service.ts | 2 +- docs/cli/commands/relay.mdx | 43 ++++++----- .../platform/gateways/networking.mdx | 25 ++++--- 14 files changed, 99 insertions(+), 110 deletions(-) delete mode 100644 backend/src/ee/services/relay/relay-fns.ts diff --git a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts index 3c825b08d..812c4f48f 100644 --- a/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts +++ b/backend/src/db/migrations/20250825131627_add-gateway-v2-pki-and-ssh-configs.ts @@ -98,8 +98,10 @@ export async function up(knex: Knex): Promise { t.uuid("identityId"); t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); - t.string("name").notNullable().unique(); - t.string("ip").notNullable(); + t.string("name").notNullable(); + t.string("host").notNullable(); + + t.unique(["orgId", "name"]); }); await createOnUpdateTrigger(knex, TableName.Relay); @@ -119,7 +121,9 @@ export async function up(knex: Knex): Promise { t.uuid("relayId"); t.foreign("relayId").references("id").inTable(TableName.Relay).onDelete("SET NULL"); - t.string("name").notNullable().unique(); + t.string("name").notNullable(); + + t.unique(["orgId", "name"]); t.dateTime("heartbeat"); }); diff --git a/backend/src/db/schemas/relays.ts b/backend/src/db/schemas/relays.ts index d29f2438f..4bb615e96 100644 --- a/backend/src/db/schemas/relays.ts +++ b/backend/src/db/schemas/relays.ts @@ -14,7 +14,7 @@ export const RelaysSchema = z.object({ orgId: z.string().uuid().nullable().optional(), identityId: z.string().uuid().nullable().optional(), name: z.string(), - ip: z.string() + host: z.string() }); 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 a04791797..4cfa2c160 100644 --- a/backend/src/ee/routes/v1/relay-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -18,7 +18,7 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - ip: z.string(), + host: z.string(), name: z.string() }), response: { @@ -68,7 +68,7 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - ip: z.string(), + host: z.string(), name: z.string() }), response: { diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index a7b69d882..3c924458d 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -79,7 +79,7 @@ export const KubernetesProvider = ({ ); }, { - relayIp: gatewayV2ConnectionDetails.relayIp, + relayHost: gatewayV2ConnectionDetails.relayHost, gateway: gatewayV2ConnectionDetails.gateway, relay: gatewayV2ConnectionDetails.relay, protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 0ac4380fd..733def399 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -203,7 +203,7 @@ export const SqlDatabaseProvider = ({ await gatewayCallback("localhost", port); }, { - relayIp: gatewayV2ConnectionDetails.relayIp, + relayHost: gatewayV2ConnectionDetails.relayHost, gateway: gatewayV2ConnectionDetails.gateway, relay: gatewayV2ConnectionDetails.relay, protocol: GatewayProxyProtocol.Tcp 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 6ec379854..64c325177 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -25,7 +25,6 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TRelayDALFactory } from "../relay/relay-dal"; -import { isInstanceRelay } from "../relay/relay-fns"; import { TRelayServiceFactory } from "../relay/relay-service"; import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID } from "./gateway-v2-constants"; import { TGatewayV2DALFactory } from "./gateway-v2-dal"; @@ -399,7 +398,7 @@ export const gatewayV2ServiceFactory = ({ }); return { - relayIp: relayCredentials.relayIp, + relayHost: relayCredentials.relayHost, gateway: { clientCertificate: clientCert.toString("pem"), clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), @@ -429,11 +428,9 @@ export const gatewayV2ServiceFactory = ({ await $validateIdentityAccessToGateway(orgId, actorId, actorAuthMethod); const orgCAs = await $getOrgCAs(orgId); - let relay: TRelays; - if (isInstanceRelay(relayName)) { - relay = await relayDAL.findOne({ name: relayName }); - } else { - relay = await relayDAL.findOne({ orgId, name: relayName }); + let relay: TRelays = await relayDAL.findOne({ orgId, name: relayName }); + if (!relay) { + relay = await relayDAL.findOne({ name: relayName, orgId: null }); } if (!relay) { @@ -515,7 +512,7 @@ export const gatewayV2ServiceFactory = ({ return { gatewayId: gateway.id, - relayIp: relayCredentials.relayIp, + relayHost: relayCredentials.relayHost, pki: { serverCertificate: gatewayServerCertificate.toString("pem"), serverPrivateKey: gatewayServerCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), @@ -613,7 +610,7 @@ export const gatewayV2ServiceFactory = ({ }, { protocol: GatewayProxyProtocol.Ping, - relayIp: gatewayV2ConnectionDetails.relayIp, + relayHost: gatewayV2ConnectionDetails.relayHost, gateway: gatewayV2ConnectionDetails.gateway, relay: gatewayV2ConnectionDetails.relay } diff --git a/backend/src/ee/services/relay/relay-fns.ts b/backend/src/ee/services/relay/relay-fns.ts deleted file mode 100644 index f33210798..000000000 --- a/backend/src/ee/services/relay/relay-fns.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const INSTANCE_RELAY_PREFIX = "infisical-"; - -export const isInstanceRelay = (relayName: string) => { - return relayName.startsWith(INSTANCE_RELAY_PREFIX); -}; diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 90e1e02ee..6bc938c77 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -19,7 +19,6 @@ import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types"; import { TInstanceRelayConfigDALFactory } from "./instance-relay-config-dal"; import { TOrgRelayConfigDALFactory } from "./org-relay-config-dal"; import { TRelayDALFactory } from "./relay-dal"; -import { isInstanceRelay } from "./relay-fns"; export type TRelayServiceFactory = ReturnType; @@ -588,7 +587,7 @@ export const relayServiceFactory = ({ }; const $generateRelayServerCredentials = async ({ - ip, + host, orgId, relayPkiServerCaCertificate, relayPkiServerCaPrivateKey, @@ -597,7 +596,7 @@ export const relayServiceFactory = ({ relaySshClientCaPublicKey, relaySshServerCaPrivateKey }: { - ip: string; + host: string; relayPkiServerCaCertificate: Buffer; relayPkiServerCaPrivateKey: Buffer; relayPkiClientCaCertificateChain: Buffer; @@ -640,13 +639,13 @@ export const relayServiceFactory = ({ ), new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true), // san - new x509.SubjectAlternativeNameExtension([{ type: "ip", value: ip }], false) + new x509.SubjectAlternativeNameExtension([{ type: "ip", value: host }], false) ]; const relayServerSerialNumber = createSerialNumber(); const relayServerCertificate = await x509.X509CertificateGenerator.create({ serialNumber: relayServerSerialNumber, - subject: `CN=${ip},O=${orgId ?? "Infisical"},OU=Relay`, + subject: `CN=${host},O=${orgId ?? "Infisical"},OU=Relay`, issuer: relayServerCaCert.subject, notBefore: relayServerCertIssuedAt, notAfter: relayServerCertExpireAt, @@ -665,7 +664,7 @@ export const relayServiceFactory = ({ caPrivateKey: relaySshServerCaPrivateKey.toString("utf8"), clientPublicKey: relayServerSshPublicKey, keyId: "relay-server", - principals: [`${ip}:2222`], + principals: [`${host}:2222`], certType: SshCertType.HOST, requestedTtl: "30d" }); @@ -772,15 +771,15 @@ export const relayServiceFactory = ({ orgId: string; gatewayId: string; }) => { - let relay: TRelays | null; - if (isInstanceRelay(relayName)) { + let relay: TRelays | null = await relayDAL.findOne({ + orgId, + name: relayName + }); + + if (!relay) { relay = await relayDAL.findOne({ - name: relayName - }); - } else { - relay = await relayDAL.findOne({ - orgId, - name: relayName + name: relayName, + orgId: null }); } @@ -794,7 +793,7 @@ export const relayServiceFactory = ({ const { publicKey: relayClientSshPublicKey, privateKey: relayClientSshPrivateKey } = await createSshKeyPair(keyAlgorithm); - if (isInstanceRelay(relayName)) { + if (relay.orgId === null) { const instanceCAs = await $getInstanceCAs(); const relayClientSshCert = await createSshCert({ caPrivateKey: instanceCAs.instanceRelaySshClientCaPrivateKey.toString("utf8"), @@ -806,7 +805,7 @@ export const relayServiceFactory = ({ }); return { - relayIp: relay.ip, + relayHost: relay.host, clientSshCert: relayClientSshCert.signedPublicKey, clientSshPrivateKey: relayClientSshPrivateKey, serverCAPublicKey: instanceCAs.instanceRelaySshServerCaPublicKey.toString("utf8") @@ -824,7 +823,7 @@ export const relayServiceFactory = ({ }); return { - relayIp: relay.ip, + relayHost: relay.host, clientSshCert: relayClientSshCert.signedPublicKey, clientSshPrivateKey: relayClientSshPrivateKey, serverCAPublicKey: orgCAs.relaySshServerCaPublicKey.toString("utf8") @@ -850,7 +849,7 @@ export const relayServiceFactory = ({ }); } - if (isInstanceRelay(relay.name)) { + if (relay.orgId === null) { const instanceCAs = await $getInstanceCAs(); const relayCertificateCredentials = await $generateRelayClientCredentials({ gatewayId, @@ -863,7 +862,7 @@ export const relayServiceFactory = ({ return { ...relayCertificateCredentials, - relayIp: relay.ip + relayHost: relay.host }; } @@ -879,17 +878,17 @@ export const relayServiceFactory = ({ return { ...relayCertificateCredentials, - relayIp: relay.ip + relayHost: relay.host }; }; const registerRelay = async ({ - ip, + host, name, identityId, orgId }: { - ip: string; + host: string; name: string; identityId?: string; orgId?: string; @@ -898,12 +897,6 @@ export const relayServiceFactory = ({ const isOrgRelay = identityId && orgId; if (isOrgRelay) { - if (isInstanceRelay(name)) { - throw new BadRequestError({ - message: "Org relay name cannot start with 'infisical-'. This is reserved for internal use." - }); - } - relay = await relayDAL.transaction(async (tx) => { const existingRelay = await relayDAL.findOne( { @@ -913,7 +906,7 @@ export const relayServiceFactory = ({ tx ); - if (existingRelay && (existingRelay.ip !== ip || existingRelay.name !== name)) { + if (existingRelay && (existingRelay.host !== host || existingRelay.name !== name)) { throw new BadRequestError({ message: "Org relay with this machine identity already exists." }); @@ -922,7 +915,7 @@ export const relayServiceFactory = ({ if (!existingRelay) { return relayDAL.create( { - ip, + host, name, identityId, orgId @@ -934,30 +927,25 @@ export const relayServiceFactory = ({ return existingRelay; }); } else { - if (!isInstanceRelay(name)) { - throw new BadRequestError({ - message: "Instance relay name must start with 'infisical-'." - }); - } - relay = await relayDAL.transaction(async (tx) => { const existingRelay = await relayDAL.findOne( { - name + name, + orgId: null }, tx ); - if (existingRelay && existingRelay.ip !== ip) { + if (existingRelay && existingRelay.host !== host) { throw new BadRequestError({ - message: "Instance relay with this name already exists with a different IP address" + message: "Instance relay with this name already exists with a different host" }); } if (!existingRelay) { return relayDAL.create( { - ip, + host, name }, tx @@ -968,10 +956,10 @@ export const relayServiceFactory = ({ }); } - if (isInstanceRelay(name)) { + if (relay.orgId === null) { const instanceCAs = await $getInstanceCAs(); return $generateRelayServerCredentials({ - ip, + host, relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate, relayPkiServerCaPrivateKey: instanceCAs.instanceRelayPkiServerCaPrivateKey, relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate, @@ -984,7 +972,7 @@ export const relayServiceFactory = ({ if (relay.orgId) { const orgCAs = await $getOrgCAs(relay.orgId); return $generateRelayServerCredentials({ - ip, + host, orgId: relay.orgId, relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate, relayPkiServerCaPrivateKey: orgCAs.relayPkiServerCaPrivateKey, diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index e41560e86..a7f7db62e 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -19,23 +19,24 @@ interface IGatewayRelayServer { } const createRelayConnection = async ({ - relayIp, + relayHost, clientCertificate, clientPrivateKey, serverCertificateChain }: { - relayIp: string; + relayHost: string; clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string; }): Promise => { - const [targetHost] = await verifyHostInputValidity(relayIp); - const [, portStr] = relayIp.split(":"); + const [targetHost] = await verifyHostInputValidity(relayHost); + const [, portStr] = relayHost.split(":"); const port = parseInt(portStr, 10) || 8443; const serverCAs = splitPemChain(serverCertificateChain); const tlsOptions: tls.ConnectionOptions = { host: targetHost, + servername: relayHost, port, cert: clientCertificate, key: clientPrivateKey, @@ -121,13 +122,13 @@ const createGatewayConnection = async ( const setupRelayServer = async ({ protocol, - relayIp, + relayHost, gateway, relay, httpsAgent }: { protocol: GatewayProxyProtocol; - relayIp: string; + relayHost: string; gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; relay: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; httpsAgent?: https.Agent; @@ -145,7 +146,7 @@ const setupRelayServer = async ({ // Stage 1: Connect to relay with TLS const relayConn = await createRelayConnection({ - relayIp, + relayHost, clientCertificate: relay.clientCertificate, clientPrivateKey: relay.clientPrivateKey, serverCertificateChain: relay.serverCertificateChain @@ -244,17 +245,17 @@ export const withGatewayV2Proxy = async ( callback: (port: number) => Promise, options: { protocol: GatewayProxyProtocol; - relayIp: string; + relayHost: string; gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; relay: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }; httpsAgent?: https.Agent; } ): Promise => { - const { protocol, relayIp, gateway, relay, httpsAgent } = options; + const { protocol, relayHost, gateway, relay, httpsAgent } = options; const { port, cleanup, getRelayError } = await setupRelayServer({ protocol, - relayIp, + relayHost, gateway, relay, httpsAgent diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 8ed6afad0..5cbf5c60d 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -105,7 +105,7 @@ export const requestWithGitHubGateway = async ( }, { protocol: GatewayProxyProtocol.Tcp, - relayIp: gatewayConnectionDetails.relayIp, + relayHost: gatewayConnectionDetails.relayHost, gateway: gatewayConnectionDetails.gateway, relay: gatewayConnectionDetails.relay } diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index 8080b0708..ca50bae8a 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -142,7 +142,7 @@ export const executeWithPotentialGateway = async ( }, { protocol: GatewayProxyProtocol.Tcp, - relayIp: platformConnectionDetails.relayIp, + relayHost: platformConnectionDetails.relayHost, gateway: platformConnectionDetails.gateway, relay: platformConnectionDetails.relay } diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 97e86e8fd..bc231c6d6 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -114,7 +114,7 @@ export const identityKubernetesAuthServiceFactory = ({ }, { protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, - relayIp: gatewayV2ConnectionDetails.relayIp, + relayHost: gatewayV2ConnectionDetails.relayHost, gateway: gatewayV2ConnectionDetails.gateway, relay: gatewayV2ConnectionDetails.relay, httpsAgent diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index f7843709c..7fadfa8d2 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -6,7 +6,7 @@ description: "Relay-related commands for Infisical including proxy components" ```bash - infisical relay start --type= --ip= --name= --auth-method= + infisical relay start --type= --host= --name= --auth-method= ``` @@ -25,7 +25,7 @@ The relay system uses SSH reverse tunnels over TCP, eliminating firewall complex Run the Infisical relay component. The relay handles network traffic routing and can operate in different modes. ```bash -infisical relay start --type= --ip= --name= --auth-method= +infisical relay start --type= --host= --name= --auth-method= ``` ### Flags @@ -38,20 +38,23 @@ infisical relay start --type= --ip= --name= --auth-method= infisical relay start --type=instance --ip=10.0.1.50 --name=shared-relay + INFISICAL_PROXY_AUTH_SECRET= infisical relay start --type=instance --host=10.0.1.50 --name=shared-relay ``` - - The public IP address of the instance where the relay is deployed. This must be a static public IP that gateways can reach. + + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. ```bash - # Example - infisical relay start --ip=203.0.113.100 --type=org --name=my-relay + # Example with IP address + infisical relay start --host=203.0.113.100 --type=org --name=my-relay + + # Example with hostname + infisical relay start --host=relay.example.com --type=org --name=my-relay ``` @@ -61,7 +64,7 @@ infisical relay start --type= --ip= --name= --auth-method= @@ -76,10 +79,10 @@ Shared relay servers that serve all organizations on your Infisical instance. Fo ```bash # Organization relay with Universal Auth (customer-deployed) -infisical relay start --type=org --ip=192.168.1.100 --name=my-org-relay --auth-method=universal-auth --client-id= --client-secret= +infisical relay start --type=org --host=192.168.1.100 --name=my-org-relay --auth-method=universal-auth --client-id= --client-secret= # Instance relay (configured by instance admin) -INFISICAL_PROXY_AUTH_SECRET= infisical relay start --type=instance --ip=10.0.1.50 --name=shared-relay +INFISICAL_PROXY_AUTH_SECRET= infisical relay start --type=instance --host=10.0.1.50 --name=shared-relay ``` ### Authentication Methods @@ -105,7 +108,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=universal-auth --client-id= --client-secret= --type=org --ip= --name= + infisical relay start --auth-method=universal-auth --client-id= --client-secret= --type=org --host= --name= ``` @@ -129,7 +132,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=kubernetes --machine-identity-id= --type=org --ip= --name= + infisical relay start --auth-method=kubernetes --machine-identity-id= --type=org --host= --name= ``` @@ -150,7 +153,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=azure --machine-identity-id= --type=org --ip= --name= + infisical relay start --auth-method=azure --machine-identity-id= --type=org --host= --name= ``` @@ -171,7 +174,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=gcp-id-token --machine-identity-id= --type=org --ip= --name= + infisical relay start --auth-method=gcp-id-token --machine-identity-id= --type=org --host= --name= ``` @@ -193,7 +196,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --type=org --ip= --name= + infisical relay start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --type=org --host= --name= ``` @@ -212,7 +215,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=aws-iam --machine-identity-id= --type=org --ip= --name= + infisical relay start --auth-method=aws-iam --machine-identity-id= --type=org --host= --name= ``` @@ -234,7 +237,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=oidc-auth --machine-identity-id= --jwt= --type=org --ip= --name= + infisical relay start --auth-method=oidc-auth --machine-identity-id= --jwt= --type=org --host= --name= ``` @@ -258,7 +261,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --auth-method=jwt-auth --jwt= --machine-identity-id= --type=org --ip= --name= + infisical relay start --auth-method=jwt-auth --jwt= --machine-identity-id= --type=org --host= --name= ``` @@ -274,7 +277,7 @@ The Infisical CLI supports multiple authentication methods for organization rela ```bash - infisical relay start --token= --type=org --ip= --name= + infisical relay start --token= --type=org --host= --name= ``` diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx index ca99a4e92..2b068a535 100644 --- a/docs/documentation/platform/gateways/networking.mdx +++ b/docs/documentation/platform/gateways/networking.mdx @@ -30,7 +30,7 @@ The gateway requires the following outbound connectivity: **For Instance Relays (Infisical Cloud):** Your firewall must allow outbound connectivity to Infisical-managed relay servers. -**For Organization Relays:** Your firewall must allow outbound connectivity to your own relay server IP addresses. +**For Organization Relays:** Your firewall must allow outbound connectivity to your own relay server IP addresses or hostnames. **For Self-hosted Instance Relays:** Your firewall must allow outbound connectivity to relay servers configured by your instance administrator. @@ -42,15 +42,16 @@ The gateway requires the following outbound connectivity: connections to the desired relay server IP on port 2222. - You control the relay server IP addresses when deploying your own - organization relays. **Firewall requirements:** Allow outbound TCP - connections to your relay server IP on port 2222. For example, if your relay - is at `203.0.113.100`, allow TCP to `203.0.113.100:2222`. + You control the relay server IP addresses or hostnames when deploying your + own organization relays. **Firewall requirements:** Allow outbound TCP + connections to your relay server IP or hostname on port 2222. For example, + if your relay is at `203.0.113.100` or `relay.example.com`, allow TCP to + `203.0.113.100:2222` or `relay.example.com:2222`. - Contact your instance administrator for the relay server IP addresses - configured for your deployment. **Firewall requirements:** Allow outbound - TCP connections to instance relay servers on port 2222. + Contact your instance administrator for the relay server IP addresses or + hostnames configured for your deployment. **Firewall requirements:** Allow + outbound TCP connections to instance relay servers on port 2222. @@ -81,7 +82,7 @@ SSH connections over TCP are stateful and handled seamlessly by all modern firew Since SSH uses TCP, you only need simple outbound rules: -1. **Allow outbound TCP** to relay servers on port 2222 +1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 2. **Allow outbound HTTPS** to Infisical API endpoints on port 443 3. **No inbound rules required** - all connections are outbound only @@ -91,7 +92,7 @@ Since SSH uses TCP, you only need simple outbound rules: For corporate environments with strict egress filtering: -1. **Allow outbound TCP** to relay servers on port 2222 +1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 2. **Allow outbound HTTPS** to the Infisical API server on port 443 3. **No inbound rules required** - all connections are outbound only 4. **Standard TCP rules** - simple and straightforward configuration @@ -100,7 +101,7 @@ For corporate environments with strict egress filtering: Configure security groups to allow: -- **Outbound TCP** to relay servers on port 2222 +- **Outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 - **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 - **No inbound rules required** - SSH reverse tunnels are outbound only @@ -146,7 +147,7 @@ This design maintains security by avoiding the need for inbound firewall rules t If your firewall has strict outbound restrictions: -1. **Work with your network team** to allow outbound TCP connections on port 2222 to relay servers +1. **Work with your network team** to allow outbound TCP connections on port 2222 to relay servers (IP addresses or hostnames) 2. **Allow standard SSH traffic** - most enterprises already have SSH policies in place 3. **Consider network policy exceptions** for the gateway host if needed 4. **Monitor firewall logs** to identify which specific rules are blocking traffic From 27d2512077ff3de3f81c01f6a414c053855145ba Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Sep 2025 03:49:56 +0800 Subject: [PATCH 22/46] misc: corrected gateway registration endpoint --- backend/src/ee/routes/v2/gateway-router.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/ee/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts index 4ab3f5ec2..e4d3b3e88 100644 --- a/backend/src/ee/routes/v2/gateway-router.ts +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -20,13 +20,13 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { url: "/", schema: { body: z.object({ - proxyName: z.string(), + relayName: z.string(), name: z.string() }), response: { 200: z.object({ gatewayId: z.string(), - proxyIp: z.string(), + relayHost: z.string(), pki: z.object({ serverCertificate: z.string(), serverPrivateKey: z.string(), @@ -47,7 +47,7 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { handler: async (req) => { const gateway = await server.services.gatewayV2.registerGateway({ orgId: req.permission.orgId, - proxyName: req.body.proxyName, + relayName: req.body.relayName, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, name: req.body.name From 0ed6601a66133caa72cb0e0dd73a15a46c04b848 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Sep 2025 03:51:52 +0800 Subject: [PATCH 23/46] misc: added version indicator --- .../Gateways/GatewayListPage/GatewayListPage.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx b/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx index 4d21d93b3..729654d76 100644 --- a/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx +++ b/frontend/src/pages/organization/Gateways/GatewayListPage/GatewayListPage.tsx @@ -152,7 +152,14 @@ export const GatewayListPage = withPermission( )} {filteredGateway?.map((el) => ( - {el.name} + +

+ {el.identity.name} {el.heartbeat From c297961d04c6407fab79efbdb994322486ac9089 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Sep 2025 04:09:36 +0800 Subject: [PATCH 24/46] misc: updated remaining proxy remnants --- .../server/plugins/auth/inject-identity.ts | 2 +- .../platform/gateways/gateway-security.mdx | 64 +++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 78b3deaee..1bff11879 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -123,7 +123,7 @@ export const injectIdentity = fp( } // Authentication is handled on a route-level - if (req.url === "/api/v1/proxies/register-instance-relay") { + if (req.url === "/api/v1/relays/register-instance-relay") { return; } diff --git a/docs/documentation/platform/gateways/gateway-security.mdx b/docs/documentation/platform/gateways/gateway-security.mdx index 668d69c3a..70671d164 100644 --- a/docs/documentation/platform/gateways/gateway-security.mdx +++ b/docs/documentation/platform/gateways/gateway-security.mdx @@ -15,28 +15,28 @@ This document explains the internal security architecture and how tenant isolati The gateway system uses multiple certificate authorities depending on deployment configuration: -**For Organizations Using Infisical-Managed Proxies:** +**For Organizations Using Infisical-Managed Relays:** -- **Instance proxy SSH Client CA & Server CA** - Gateway ↔ Infisical Proxy Server authentication -- **Instance proxy PKI Client CA & Server CA** - Platform ↔ Infisical Proxy Server authentication +- **Instance relay SSH Client CA & Server CA** - Gateway ↔ Infisical Relay Server authentication +- **Instance relay PKI Client CA & Server CA** - Platform ↔ Infisical Relay Server authentication - **Organization Gateway Client CA & Server CA** - Platform ↔ Gateway authentication -**For Organizations Using Customer-Deployed Proxies:** +**For Organizations Using Customer-Deployed Relays:** -- **Organization proxy SSH Client CA & Server CA** - Gateway ↔ Customer Proxy Server authentication -- **Organization proxy PKI Client CA & Server CA** - Platform ↔ Customer Proxy Server authentication +- **Organization relay SSH Client CA & Server CA** - Gateway ↔ Customer Relay Server authentication +- **Organization relay PKI Client CA & Server CA** - Platform ↔ Customer Relay Server authentication - **Organization Gateway Client CA & Server CA** - Platform ↔ Gateway authentication ### Certificate Hierarchy ``` -Instance Level (Shared Proxies): -├── Instance Proxy SSH CA (Gateway ↔ Proxy) -├── Instance Proxy PKI CA (Platform ↔ Proxy) +Instance Level (Shared Relays): +├── Instance Relay SSH CA (Gateway ↔ Relay) +├── Instance Relay PKI CA (Platform ↔ Relay) Organization Level: -├── Organization Proxy SSH CA (Gateway ↔ Org Proxy) -├── Organization Proxy PKI CA (Platform ↔ Org Proxy) +├── Organization Relay SSH CA (Gateway ↔ Org Relay) +├── Organization Relay PKI CA (Platform ↔ Org Relay) └── Organization Gateway CA (Platform ↔ Gateway) ``` @@ -47,26 +47,26 @@ Organization Level: When a gateway is first deployed: 1. Authenticates with Infisical using machine identity token -2. Receives SSH certificates for proxy server authentication -3. Establishes SSH reverse tunnel to assigned proxy server -4. Certificate issuance varies by proxy configuration: - - **Infisical-managed proxy**: Receives Instance proxy SSH client certificate + Instance proxy SSH Server CA - - **Customer-deployed proxy**: Receives Organization proxy SSH client certificate + Organization proxy SSH Server CA +2. Receives SSH certificates for relay server authentication +3. Establishes SSH reverse tunnel to assigned relay server +4. Certificate issuance varies by relay configuration: + - **Infisical-managed relay**: Receives Instance relay SSH client certificate + Instance relay SSH Server CA + - **Customer-deployed relay**: Receives Organization relay SSH client certificate + Organization relay SSH Server CA ### 2. SSH Tunnel Authentication -Gateway ↔ Proxy Server communication uses SSH certificate authentication: +Gateway ↔ Relay Server communication uses SSH certificate authentication: - **Gateway Authentication**: - - Presents SSH client certificate (Instance or Organization proxy SSH Client CA) + - Presents SSH client certificate (Instance or Organization relay SSH Client CA) - Certificate contains gateway identification and permissions - - Proxy server validates certificate against appropriate SSH Client CA + - Relay server validates certificate against appropriate SSH Client CA -- **Proxy Server Authentication**: - - Presents SSH server certificate (Instance or Organization proxy SSH Server CA) +- **Relay Server Authentication**: + - Presents SSH server certificate (Instance or Organization relay SSH Server CA) - Gateway validates certificate against appropriate SSH Server CA - - Ensures gateway connects to legitimate proxy infrastructure + - Ensures gateway connects to legitimate relay infrastructure ### 3. Application Traffic Security @@ -82,7 +82,7 @@ End-to-end encryption for application data: - mTLS-encrypted application traffic travels through SSH reverse tunnels - Creates double encryption: mTLS payload within SSH tunnel - - Proxy servers cannot decrypt either encryption layer + - Relay servers cannot decrypt either encryption layer 3. **Traffic Isolation**: - Each gateway maintains separate SSH tunnels @@ -95,23 +95,23 @@ End-to-end encryption for application data: The architecture provides tenant isolation through multiple certificate authority layers: -- **Instance-level CAs**: Shared proxy infrastructure uses instance-level certificates +- **Instance-level CAs**: Shared relay infrastructure uses instance-level certificates - **Organization-level CAs**: Each organization has unique certificate authorities -- **Proxy deployment flexibility**: Organizations can choose shared or dedicated proxy infrastructure +- **Relay deployment flexibility**: Organizations can choose shared or dedicated relay infrastructure - **Cryptographic separation**: Cross-tenant communication is cryptographically impossible ### Authentication Flows by Deployment Type -**Infisical-Managed Proxy Deployments:** +**Infisical-Managed Relay Deployments:** -- Gateway authenticates with proxy using Instance proxy SSH certificates -- Platform authenticates with proxy using Instance proxy PKI certificates +- Gateway authenticates with relay using Instance relay SSH certificates +- Platform authenticates with relay using Instance relay PKI certificates - Platform authenticates with gateway using Organization Gateway certificates -**Customer-Deployed Proxy Deployments:** +**Customer-Deployed Relay Deployments:** -- Gateway authenticates with proxy using Organization proxy SSH certificates -- Platform authenticates with proxy using Organization proxy PKI certificates +- Gateway authenticates with relay using Organization relay SSH certificates +- Platform authenticates with relay using Organization relay PKI certificates - Platform authenticates with gateway using Organization Gateway certificates ### Resource Access Control @@ -125,5 +125,5 @@ The architecture provides tenant isolation through multiple certificate authorit 2. **Network Isolation**: - Each organization's traffic flows through isolated certificate-authenticated channels - - Proxy servers route traffic based on certificate validation without content access + - Relay servers route traffic based on certificate validation without content access - Gateway validates all incoming connections against Organization Gateway Client CA From f86f3cea99e1af0a65822b0e8659dbdc4e39ddd7 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Sep 2025 04:35:13 +0800 Subject: [PATCH 25/46] misc: moved protocol indicator to alpn header --- backend/src/lib/gateway-v2/gateway-v2.ts | 44 +++++++++++------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index a7f7db62e..e6e873f11 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -77,8 +77,15 @@ const createRelayConnection = async ({ const createGatewayConnection = async ( relayConn: net.Socket, - gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string } + gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }, + protocol: GatewayProxyProtocol ): Promise => { + const protocolToAlpn = { + [GatewayProxyProtocol.Http]: "infisical-http-proxy", + [GatewayProxyProtocol.Tcp]: "infisical-tcp-proxy", + [GatewayProxyProtocol.Ping]: "infisical-ping" + }; + const tlsOptions: tls.ConnectionOptions = { socket: relayConn, cert: gateway.clientCertificate, @@ -86,7 +93,8 @@ const createGatewayConnection = async ( ca: splitPemChain(gateway.serverCertificateChain), minVersion: "TLSv1.2", maxVersion: "TLSv1.3", - rejectUnauthorized: true + rejectUnauthorized: true, + ALPNProtocols: [protocolToAlpn[protocol]] }; return new Promise((resolve, reject) => { @@ -153,39 +161,29 @@ const setupRelayServer = async ({ }); // Stage 2: Establish mTLS connection to gateway through the relay - const gatewayConn = await createGatewayConnection(relayConn, gateway); + const gatewayConn = await createGatewayConnection(relayConn, gateway, protocol); - let command = ""; - - // Send protocol data to gateway + // Send protocol-specific configuration for HTTP requests if (protocol === GatewayProxyProtocol.Http) { - command += "FORWARD-HTTP"; - // extract ca certificate from httpsAgent if present if (httpsAgent) { const agentOptions = httpsAgent.options; if (agentOptions && agentOptions.ca) { const caCert = Array.isArray(agentOptions.ca) ? agentOptions.ca.join("\n") : agentOptions.ca; const caB64 = Buffer.from(caCert as string).toString("base64"); - command += ` ca=${caB64}`; - const rejectUnauthorized = agentOptions.rejectUnauthorized !== false; - command += ` verify=${rejectUnauthorized}`; + + const configCommand = `CONFIG ca=${caB64} verify=${rejectUnauthorized}\n`; + gatewayConn.write(Buffer.from(configCommand)); + } else { + // Send empty config to signal end of configuration + gatewayConn.write(Buffer.from("CONFIG\n")); } + } else { + // Send empty config to signal end of configuration + gatewayConn.write(Buffer.from("CONFIG\n")); } - - command += "\n"; - } else if (protocol === GatewayProxyProtocol.Tcp) { - command += `FORWARD-TCP\n`; - } else if (protocol === GatewayProxyProtocol.Ping) { - command += `PING\n`; - } else { - throw new BadRequestError({ - message: `Invalid protocol: ${protocol as string}` - }); } - gatewayConn.write(Buffer.from(command)); - // Bidirectional data forwarding clientConn.pipe(gatewayConn); gatewayConn.pipe(clientConn); From 301b9a46b383fbd88ccca8954787b2d8b16e3659 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 8 Sep 2025 16:16:48 -0700 Subject: [PATCH 26/46] improvement(google-oauth): prevent google oauth enforcement if sso configured, and prevent sso enablement if google oauth enforcement is enabled on both back/frontend and improve labeling --- .../ldap-config/ldap-config-service.ts | 27 +++++++ .../ee/services/oidc/oidc-config-service.ts | 14 ++++ .../saml-config/saml-config-service.ts | 26 +++++++ backend/src/keystore/keystore.ts | 3 +- backend/src/server/routes/index.ts | 1 + backend/src/services/org/org-service.ts | 39 +++++++++++ .../permissions/OrgPermissionCan.tsx | 15 +++- .../OrgSsoTab/OrgGeneralAuthSection.tsx | 70 ++++++++++++++----- .../components/OrgSsoTab/OrgLDAPSection.tsx | 35 +++++++--- .../components/OrgSsoTab/OrgOIDCSection.tsx | 31 ++++++-- .../components/OrgSsoTab/OrgSSOSection.tsx | 31 ++++++-- .../components/OrgSsoTab/OrgSsoTab.tsx | 3 + 12 files changed, 252 insertions(+), 43 deletions(-) diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index a72d50760..4592cf003 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -127,6 +127,20 @@ export const ldapConfigServiceFactory = ({ message: "Failed to create LDAP configuration due to plan restriction. Upgrade plan to create LDAP configuration." }); + + const org = await orgDAL.findOrgById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not find organization with ID "${orgId}"` }); + } + + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable LDAP SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable LDAP SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId @@ -233,6 +247,19 @@ export const ldapConfigServiceFactory = ({ "Failed to update LDAP configuration due to plan restriction. Upgrade plan to update LDAP configuration." }); + const org = await orgDAL.findOrgById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not find organization with ID "${orgId}"` }); + } + + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable LDAP SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable LDAP SSO." + }); + } + const updateQuery: TLdapConfigsUpdate = { isActive, url, diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 8f479b12c..fb1e70a85 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -499,6 +499,13 @@ export const oidcConfigServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable OIDC SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable OIDC SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: org.id @@ -586,6 +593,13 @@ export const oidcConfigServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable OIDC SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable OIDC SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: org.id diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 6b8bbe304..1cbe6a930 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -82,6 +82,19 @@ export const samlConfigServiceFactory = ({ "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to create SSO configuration." }); + const org = await orgDAL.findOrgById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not find organization with ID "${orgId}"` }); + } + + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "You cannot enable SAML SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable SAML SSO." + }); + } + const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId @@ -120,6 +133,19 @@ export const samlConfigServiceFactory = ({ "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." }); + const org = await orgDAL.findOrgById(orgId); + + if (!org) { + throw new NotFoundError({ message: `Could not find organization with ID "${orgId}"` }); + } + + if (org.googleSsoAuthEnforced && isActive) { + throw new BadRequestError({ + message: + "Cannot enable SAML SSO while Google OAuth is enforced. Disable Google OAuth enforcement to enable SAML SSO." + }); + } + const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null }; const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 45589e529..a6135e0ca 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,9 +1,10 @@ +import { Cluster, Redis } from "ioredis"; + import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext"; import { applyJitter } from "@app/lib/dates"; import { delay as delayMs } from "@app/lib/delay"; import { ExecutionResult, Redlock, Settings } from "@app/lib/red-lock"; -import { Redis, Cluster } from "ioredis"; export const PgSqlLock = { BootUpMigration: 2023, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 64c9b3389..4803a6cd2 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -807,6 +807,7 @@ export const registerRoutes = async ( groupDAL, orgBotDAL, oidcConfigDAL, + ldapConfigDAL, loginService, projectBotService, reminderService diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 978bc8dae..f99737231 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -14,6 +14,7 @@ import { TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { TLdapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { @@ -125,6 +126,7 @@ type TOrgServiceFactoryDep = { incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; oidcConfigDAL: Pick; + ldapConfigDAL: Pick; smtpService: TSmtpService; tokenService: TAuthTokenServiceFactory; permissionService: TPermissionServiceFactory; @@ -165,6 +167,7 @@ export const orgServiceFactory = ({ projectRoleDAL, samlConfigDAL, oidcConfigDAL, + ldapConfigDAL, projectUserMembershipRoleDAL, identityMetadataDAL, projectBotService, @@ -483,6 +486,42 @@ export const orgServiceFactory = ({ }); } + const samlCfg = await samlConfigDAL.findOne({ + orgId, + isActive: true + }); + + if (samlCfg) { + throw new BadRequestError({ + message: + "Cannot enable Google OAuth enforcement while SAML SSO is configured. Disable SAML SSO to enforce Google OAuth." + }); + } + + const oidcCfg = await oidcConfigDAL.findOne({ + orgId, + isActive: true + }); + + if (oidcCfg) { + throw new BadRequestError({ + message: + "Cannot enable Google OAuth enforcement while OIDC SSO is configured. Disable OIDC SSO to enforce Google OAuth." + }); + } + + const ldapCfg = await ldapConfigDAL.findOne({ + orgId, + isActive: true + }); + + if (ldapCfg) { + throw new BadRequestError({ + message: + "Cannot enable Google OAuth enforcement while LDAP SSO is configured. Disable LDAP SSO to enforce Google OAuth." + }); + } + if (!currentOrg.googleSsoAuthLastUsed) { throw new BadRequestError({ message: diff --git a/frontend/src/components/permissions/OrgPermissionCan.tsx b/frontend/src/components/permissions/OrgPermissionCan.tsx index d2e698e88..8e0bf08ad 100644 --- a/frontend/src/components/permissions/OrgPermissionCan.tsx +++ b/frontend/src/components/permissions/OrgPermissionCan.tsx @@ -1,6 +1,7 @@ import { FunctionComponent, ReactNode } from "react"; import { BoundCanProps, Can } from "@casl/react"; +import { TooltipProps } from "@app/components/v2/Tooltip/Tooltip"; import { TOrgPermission, useOrgPermission } from "@app/context/OrgPermissionContext"; import { AccessRestrictedBanner, Tooltip } from "../v2"; @@ -20,6 +21,7 @@ type Props = { renderTooltip?: boolean; allowedLabel?: string; renderGuardBanner?: boolean; + tooltipProps?: Omit; } & BoundCanProps; export const OrgPermissionCan: FunctionComponent = ({ @@ -29,6 +31,7 @@ export const OrgPermissionCan: FunctionComponent = ({ renderTooltip, allowedLabel, renderGuardBanner, + tooltipProps, ...props }) => { const { permission } = useOrgPermission(); @@ -43,11 +46,19 @@ export const OrgPermissionCan: FunctionComponent = ({ : children; if (!isAllowed && passThrough) { - return {finalChild}; + return ( + + {finalChild} + + ); } if (isAllowed && renderTooltip && allowedLabel) { - return {finalChild}; + return ( + + {finalChild} + + ); } if (!isAllowed && renderGuardBanner) { diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx index b9ca11748..c3d8cea98 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx @@ -24,11 +24,17 @@ enum EnforceAuthType { export const OrgGeneralAuthSection = ({ isSamlConfigured, isOidcConfigured, - isGoogleConfigured + isGoogleConfigured, + isSamlActive, + isOidcActive, + isLdapActive }: { isSamlConfigured: boolean; isOidcConfigured: boolean; isGoogleConfigured: boolean; + isSamlActive: boolean; + isOidcActive: boolean; + isLdapActive: boolean; }) => { const { currentOrg } = useOrganization(); const { subscription } = useSubscription(); @@ -126,6 +132,15 @@ export const OrgGeneralAuthSection = ({ } }; + const isGoogleOAuthEnforced = currentOrg.googleSsoAuthEnforced; + + const getActiveSsoLabel = () => { + if (isSamlActive) return "SAML"; + if (isOidcActive) return "OIDC"; + if (isLdapActive) return "LDAP"; + return ""; + }; + return (
@@ -135,7 +150,7 @@ export const OrgGeneralAuthSection = ({

-
+
Enforce SAML SSO @@ -160,7 +175,7 @@ export const OrgGeneralAuthSection = ({

-
+
Enforce OIDC SSO @@ -188,26 +203,47 @@ export const OrgGeneralAuthSection = ({
- Enforce Google SSO + Enforce Google OAuth
- + {(isAllowed) => ( - - handleEnforceOrgAuthToggle(value, EnforceAuthType.GOOGLE) - } - isChecked={currentOrg?.googleSsoAuthEnforced ?? false} - isDisabled={!isAllowed || currentOrg?.authEnforced} - /> +
+ + handleEnforceOrgAuthToggle(value, EnforceAuthType.GOOGLE) + } + isChecked={currentOrg?.googleSsoAuthEnforced ?? false} + isDisabled={ + !isAllowed || + currentOrg?.authEnforced || + isOidcActive || + isSamlActive || + isLdapActive + } + /> +
)}

- Enforce users to authenticate via Google OAuth SSO to access this organization. + Enforce users to authenticate via Google OAuth to access this organization.
When this is enabled your organization members will only be able to login with Google - SSO (not Google SAML). + OAuth (not Google SAML).

@@ -267,8 +303,8 @@ export const OrgGeneralAuthSection = ({

- Allow organization admins to bypass SAML enforcement when SSO is unavailable, - misconfigured, or inaccessible. + Allow organization admins to bypass SSO login enforcement when your SSO provider is + unavailable, misconfigured, or inaccessible.

diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx index e66987bae..45392a660 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx @@ -94,6 +94,8 @@ export const OrgLDAPSection = (): JSX.Element => { handlePopUpOpen("ldapGroupMap"); }; + const isGoogleOAuthEnabled = currentOrg.googleSsoAuthEnforced; + return (
@@ -116,16 +118,31 @@ export const OrgLDAPSection = (): JSX.Element => {

Enable LDAP

- + {(isAllowed) => ( - handleLDAPToggle(value)} - isChecked={data ? data.isActive : false} - isDisabled={!isAllowed} - > - Enable - +
+ handleLDAPToggle(value)} + isChecked={data ? data.isActive : false} + isDisabled={!isAllowed || isGoogleOAuthEnabled} + > + Enable + +
)}
diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgOIDCSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgOIDCSection.tsx index ce10c3907..3ba1a8275 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgOIDCSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgOIDCSection.tsx @@ -83,6 +83,8 @@ export const OrgOIDCSection = (): JSX.Element => { } }; + const isGoogleOAuthEnabled = currentOrg.googleSsoAuthEnforced; + return (
@@ -106,14 +108,29 @@ export const OrgOIDCSection = (): JSX.Element => {

Enable OIDC

{!isPending && ( - + {(isAllowed) => ( - handleOIDCToggle(value)} - isChecked={data ? data.isActive : false} - isDisabled={!isAllowed} - /> +
+ handleOIDCToggle(value)} + isChecked={data ? data.isActive : false} + isDisabled={!isAllowed || isGoogleOAuthEnabled} + /> +
)}
)} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx index 33843f50f..53e6cece7 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx @@ -78,6 +78,8 @@ export const OrgSSOSection = (): JSX.Element => { } }; + const isGoogleOAuthEnabled = currentOrg.googleSsoAuthEnforced; + return (
@@ -99,14 +101,29 @@ export const OrgSSOSection = (): JSX.Element => {

Enable SAML

{!isPending && ( - + {(isAllowed) => ( - handleSamlSSOToggle(value)} - isChecked={data ? data.isActive : false} - isDisabled={!isAllowed} - /> +
+ handleSamlSSOToggle(value)} + isChecked={data ? data.isActive : false} + isDisabled={!isAllowed || isGoogleOAuthEnabled} + /> +
)}
)} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx index 9964fdf4d..e4e3a9c23 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx @@ -184,6 +184,9 @@ export const OrgSsoTab = withPermission( isSamlConfigured={isSamlConfigured} isOidcConfigured={isOidcConfigured} isGoogleConfigured={isGoogleConfigured} + isSamlActive={Boolean(samlConfig?.isActive)} + isOidcActive={Boolean(oidcConfig?.isActive)} + isLdapActive={Boolean(ldapConfig?.isActive)} /> )} From 9ad8bc697714aaff400d0aed8a165089eb7917eb Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 8 Sep 2025 16:26:14 -0700 Subject: [PATCH 27/46] improvement: correct switch enable switch id --- .../SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx index 45392a660..a17483fdd 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx @@ -135,7 +135,7 @@ export const OrgLDAPSection = (): JSX.Element => { {(isAllowed) => (
handleLDAPToggle(value)} isChecked={data ? data.isActive : false} isDisabled={!isAllowed || isGoogleOAuthEnabled} From 48f17f4d8db4768c44551401244336257361cbe1 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 8 Sep 2025 20:31:15 -0400 Subject: [PATCH 28/46] feat(ua-login): improve lock error message & disable lock when lockout is disabled --- .../identity-ua/identity-ua-service.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 597747683..8aec16371 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -84,18 +84,20 @@ export const identityUaServiceFactory = ({ const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - let lock: Awaited>; - try { - lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { - retryCount: 3, - retryDelay: 300, - retryJitter: 100 - }); - } catch (e) { - logger.info( - `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` - ); - throw new RateLimitError({ message: "Rate limit exceeded" }); + let lock: Awaited> | undefined; + if (identityUa.lockoutEnabled) { + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { + retryCount: 3, + retryDelay: 300, + retryJitter: 100 + }); + } catch (e) { + logger.info( + `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` + ); + throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); + } } try { @@ -257,7 +259,7 @@ export const identityUaServiceFactory = ({ ...accessTokenTTLParams }; } finally { - await lock.release(); + if (lock) await lock.release(); } }; From 1dcdb90c62c401b61a9d83361436a3492209dde7 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 9 Sep 2025 16:40:32 +0800 Subject: [PATCH 29/46] misc: add fix for v2 to app connection --- backend/src/services/app-connection/app-connection-service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index eb181144f..f5654d2fb 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -386,7 +386,8 @@ export const appConnectionServiceFactory = ({ if (gatewayId) { const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: actor.orgId }); - if (!gateway) { + const [gatewayV2] = await gatewayV2DAL.find({ id: gatewayId, orgId: actor.orgId }); + if (!gateway && !gatewayV2) { throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found for org` }); From b113eabeb70ba4a56511931d99e0b0a89dbccab3 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 9 Sep 2025 10:27:41 -0700 Subject: [PATCH 30/46] improvement: address feedback --- backend/src/services/org/org-service.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index f99737231..ffcf4459e 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -9,8 +9,10 @@ import { ProjectMembershipRole, ProjectVersion, TableName, + TOidcConfigs, TProjectMemberships, TProjectUserMembershipRolesInsert, + TSamlConfigs, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; @@ -449,16 +451,20 @@ export const orgServiceFactory = ({ }); } - if (authEnforced) { - const samlCfg = await samlConfigDAL.findOne({ + let samlCfg: TSamlConfigs | undefined; + let oidcCfg: TOidcConfigs | undefined; + if (authEnforced || googleSsoAuthEnforced) { + samlCfg = await samlConfigDAL.findOne({ orgId, isActive: true }); - const oidcCfg = await oidcConfigDAL.findOne({ + oidcCfg = await oidcConfigDAL.findOne({ orgId, isActive: true }); + } + if (authEnforced) { if (!samlCfg && !oidcCfg) throw new NotFoundError({ message: `SAML or OIDC configuration for organization with ID '${orgId}' not found` @@ -486,11 +492,6 @@ export const orgServiceFactory = ({ }); } - const samlCfg = await samlConfigDAL.findOne({ - orgId, - isActive: true - }); - if (samlCfg) { throw new BadRequestError({ message: @@ -498,11 +499,6 @@ export const orgServiceFactory = ({ }); } - const oidcCfg = await oidcConfigDAL.findOne({ - orgId, - isActive: true - }); - if (oidcCfg) { throw new BadRequestError({ message: From d7fae950bfb36d30fe2cfe6f66cac169f0856ddc Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 10 Sep 2025 15:02:22 +0530 Subject: [PATCH 31/46] fix: resolved using host when it's postgres with gateway --- .../src/ee/services/dynamic-secret/providers/sql-database.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 57ce710b1..e68e41c6b 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -161,7 +161,10 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) connection: { database: providerInputs.database, port: providerInputs.port, - host: providerInputs.client === SqlProviders.Postgres ? providerInputs.hostIp : providerInputs.host, + host: + providerInputs.client === SqlProviders.Postgres && !providerInputs.gatewayId + ? providerInputs.hostIp + : providerInputs.host, user: providerInputs.username, password: providerInputs.password, ssl, From d6f8189a9677397916c852aff15624824988b438 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 9 Sep 2025 14:07:19 +0530 Subject: [PATCH 32/46] feat: added cache versioning to sql for geo invalidation --- backend/e2e-test/mocks/keystore.ts | 9 ++ backend/e2e-test/vitest-environment-knex.ts | 4 +- backend/src/@types/knex.d.ts | 8 ++ .../20250908193226_sql-cache_int.ts | 17 ++++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/key-value-store.ts | 18 ++++ backend/src/db/schemas/models.ts | 3 +- .../secret-approval-request-service.ts | 2 +- .../secret-replication-service.ts | 2 +- .../secret-rotation-queue.ts | 3 +- backend/src/keystore/key-value-store-dal.ts | 91 +++++++++++++++++++ backend/src/keystore/keystore.ts | 73 +++++++++------ backend/src/keystore/memory.ts | 9 ++ backend/src/lib/config/env.ts | 1 + backend/src/lib/knex/index.ts | 8 +- backend/src/main.ts | 4 +- backend/src/server/routes/index.ts | 7 +- .../folder-commit/folder-commit-service.ts | 2 +- .../resource-cleanup-queue.ts | 10 +- .../secret-v2-bridge/secret-v2-bridge-dal.ts | 7 +- .../secret-v2-bridge-service.ts | 41 +++++---- 21 files changed, 258 insertions(+), 62 deletions(-) create mode 100644 backend/src/db/migrations/20250908193226_sql-cache_int.ts create mode 100644 backend/src/db/schemas/key-value-store.ts create mode 100644 backend/src/keystore/key-value-store-dal.ts diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index 91b64ff0d..0cebe6ef3 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -56,6 +56,15 @@ export const mockKeyStore = (): TKeyStoreFactory => { incrementBy: async () => { return 1; }, + pgGetIntItem: async (key) => { + const value = store[key]; + if (typeof value === "number") { + return Number(value); + } + }, + pgIncrementBy: async () => { + return 1; + }, getItems: async (keys) => { const values = keys.map((key) => { const value = store[key]; diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index ff5f42286..085b8fe30 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -15,6 +15,7 @@ import { mockSmtpServer } from "./mocks/smtp"; import { initDbConnection } from "@app/db"; import { queueServiceFactory } from "@app/queue"; import { keyStoreFactory } from "@app/keystore/keystore"; +import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; import { buildRedisFromConfig } from "@app/lib/config/redis"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; @@ -62,7 +63,8 @@ export default { const smtp = mockSmtpServer(); const queue = queueServiceFactory(envCfg, { dbConnectionUrl: envCfg.DB_CONNECTION_URI }); - const keyStore = keyStoreFactory(envCfg); + const keyValueStoreDAL = keyValueStoreDALFactory(db); + const keyStore = keyStoreFactory(envCfg, keyValueStoreDAL); await queue.initialize(); diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 525ad1619..d14084885 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -191,6 +191,9 @@ import { TInternalKms, TInternalKmsInsert, TInternalKmsUpdate, + TKeyValueStore, + TKeyValueStoreInsert, + TKeyValueStoreUpdate, TKmipClientCertificates, TKmipClientCertificatesInsert, TKmipClientCertificatesUpdate, @@ -1264,5 +1267,10 @@ declare module "knex/types/tables" { TUserNotificationsInsert, TUserNotificationsUpdate >; + [TableName.KeyValueStore]: KnexOriginal.CompositeTableType< + TKeyValueStore, + TKeyValueStoreInsert, + TKeyValueStoreUpdate + >; } } diff --git a/backend/src/db/migrations/20250908193226_sql-cache_int.ts b/backend/src/db/migrations/20250908193226_sql-cache_int.ts new file mode 100644 index 000000000..dc4060d02 --- /dev/null +++ b/backend/src/db/migrations/20250908193226_sql-cache_int.ts @@ -0,0 +1,17 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.KeyValueStore))) { + await knex.schema.createTable(TableName.KeyValueStore, (t) => { + t.text("key").primary(); + t.bigint("integerValue"); + t.datetime("expiresAt"); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.KeyValueStore); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 1642c3555..e63ac4901 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -61,6 +61,7 @@ export * from "./integration-auths"; export * from "./integrations"; export * from "./internal-certificate-authorities"; export * from "./internal-kms"; +export * from "./key-value-store"; export * from "./kmip-client-certificates"; export * from "./kmip-clients"; export * from "./kmip-org-configs"; diff --git a/backend/src/db/schemas/key-value-store.ts b/backend/src/db/schemas/key-value-store.ts new file mode 100644 index 000000000..d6ba90b2f --- /dev/null +++ b/backend/src/db/schemas/key-value-store.ts @@ -0,0 +1,18 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KeyValueStoreSchema = z.object({ + key: z.string(), + integerValue: z.coerce.number().nullable().optional(), + expiresAt: z.date().nullable().optional() +}); + +export type TKeyValueStore = z.infer; +export type TKeyValueStoreInsert = Omit, TImmutableDBKeys>; +export type TKeyValueStoreUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 3e3e81fd0..37abc2b61 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -179,7 +179,8 @@ export enum TableName { SecretScanningConfig = "secret_scanning_configs", // reminders Reminder = "reminders", - ReminderRecipient = "reminders_recipients" + ReminderRecipient = "reminders_recipients", + KeyValueStore = "key_value_store" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index d485f7ea0..aa3e3dc0f 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -976,6 +976,7 @@ export const secretApprovalRequestServiceFactory = ({ }, tx ); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId, tx); return { secrets: { created: newSecrets, updated: updatedSecrets, deleted: deletedSecret }, approval: updatedSecretApproval @@ -983,7 +984,6 @@ export const secretApprovalRequestServiceFactory = ({ }); } - await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); const [folder] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); if (!folder) { diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index db41d00f7..93147d9e4 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -509,9 +509,9 @@ export const secretReplicationServiceFactory = ({ tx ); } + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId, tx); }); - await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); await secretQueueService.syncSecrets({ projectId, orgId, diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index 1d5c1cedf..557e71e6c 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts @@ -361,9 +361,8 @@ export const secretRotationQueueFactory = ({ }, tx ); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(secretRotation.projectId, tx); }); - - await secretV2BridgeDAL.invalidateSecretCacheByProjectId(secretRotation.projectId); } else { if (!botKey) throw new NotFoundError({ diff --git a/backend/src/keystore/key-value-store-dal.ts b/backend/src/keystore/key-value-store-dal.ts new file mode 100644 index 000000000..bdf5e8725 --- /dev/null +++ b/backend/src/keystore/key-value-store-dal.ts @@ -0,0 +1,91 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, TOrmify } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; + +export interface TKeyValueStoreDALFactory extends TOrmify { + incrementBy: (key: string, dto: { incr?: number; tx?: Knex; expiresAt?: Date }) => Promise; + findOneInt: (key: string, tx?: Knex) => Promise; + pruneExpiredKeys: () => Promise; +} + +const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const CACHE_KEY_PRUNE_BATCH_SIZE = 10000; +const MAX_RETRY_ON_FAILURE = 3; + +export const keyValueStoreDALFactory = (db: TDbClient): TKeyValueStoreDALFactory => { + const keyValueStoreOrm = ormify(db, TableName.KeyValueStore); + + const incrementBy: TKeyValueStoreDALFactory["incrementBy"] = (key, { incr = 1, tx, expiresAt }) => { + return (tx || db)(TableName.KeyValueStore) + .insert({ key, integerValue: 1, expiresAt }) + .onConflict("key") + .merge({ + integerValue: db.raw(`"${TableName.KeyValueStore}"."integerValue" + ?`, [incr]), + expiresAt + }) + .returning("integerValue"); + }; + + const findOneInt: TKeyValueStoreDALFactory["findOneInt"] = async (key, tx) => { + const doc = await (tx || db.replicaNode())(TableName.KeyValueStore) + .where({ key }) + .andWhere( + (builder) => + void builder + .whereNull("expiresAt") // no expiry + .orWhere("expiresAt", ">", db.fn.now()) // or not expired + ) + .first() + .select("integerValue"); + return Number(doc?.integerValue || 0); + }; + + // delete all audit log that have expired + const pruneExpiredKeys: TKeyValueStoreDALFactory["pruneExpiredKeys"] = async () => { + let deletedIds: { key: string }[] = []; + let numberOfRetryOnFailure = 0; + let isRetrying = false; + + logger.info(`${QueueName.DailyResourceCleanUp}: db key value store clean up started`); + do { + try { + // eslint-disable-next-line no-await-in-loop + deletedIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); + + const findExpiredKeysSubQuery = trx(TableName.KeyValueStore) + .where("expiresAt", "<", db.fn.now()) + .select("key") + .limit(CACHE_KEY_PRUNE_BATCH_SIZE); + + // eslint-disable-next-line no-await-in-loop + const results = await trx(TableName.KeyValueStore) + .whereIn("key", findExpiredKeysSubQuery) + .del() + .returning("key"); + + return results; + }); + + numberOfRetryOnFailure = 0; // reset + } catch (error) { + numberOfRetryOnFailure += 1; + deletedIds = []; + logger.error(error, "Failed to clean up db key value"); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); // time to breathe for db + }); + } + isRetrying = numberOfRetryOnFailure > 0; + } while (deletedIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); + logger.info(`${QueueName.DailyResourceCleanUp}: db key value store clean up completed`); + }; + + return { ...keyValueStoreOrm, incrementBy, findOneInt, pruneExpiredKeys }; +}; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index a6135e0ca..db6adebf1 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,11 +1,15 @@ import { Cluster, Redis } from "ioredis"; +import { Knex } from "knex"; import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext"; import { applyJitter } from "@app/lib/dates"; import { delay as delayMs } from "@app/lib/delay"; +import { ms } from "@app/lib/ms"; import { ExecutionResult, Redlock, Settings } from "@app/lib/red-lock"; +import { TKeyValueStoreDALFactory } from "./key-value-store-dal"; + export const PgSqlLock = { BootUpMigration: 2023, SuperAdminInit: 2024, @@ -95,13 +99,17 @@ export type TKeyStoreFactory = { deleteItemsByKeyIn: (keys: string[]) => Promise; deleteItems: (arg: TDeleteItems) => Promise; incrementBy: (key: string, value: number) => Promise; + getKeysByPattern: (pattern: string, limit?: number) => Promise; + // pg + pgIncrementBy: (key: string, dto: { incr?: number; expiry?: string; tx?: Knex }) => Promise; + pgGetIntItem: (key: string, prefix?: string) => Promise; + // locks acquireLock( resources: string[], duration: number, settings?: Partial ): Promise<{ release: () => Promise }>; waitTillReady: ({ key, waitingCb, keyCheckCb, waitIteration, delay, jitter }: TWaitTillReady) => Promise; - getKeysByPattern: (pattern: string, limit?: number) => Promise; }; const pickPrimaryOrSecondaryRedis = (primary: Redis | Cluster, secondaries?: Array) => { @@ -114,7 +122,10 @@ interface TKeyStoreFactoryDTO extends TRedisConfigKeys { REDIS_READ_REPLICAS?: { host: string; port: number }[]; } -export const keyStoreFactory = (redisConfigKeys: TKeyStoreFactoryDTO): TKeyStoreFactory => { +export const keyStoreFactory = ( + redisConfigKeys: TKeyStoreFactoryDTO, + keyValueStoreDAL: TKeyValueStoreDALFactory +): TKeyStoreFactory => { const primaryRedis = buildRedisFromConfig(redisConfigKeys); const redisReadReplicas = redisConfigKeys.REDIS_READ_REPLICAS?.map((el) => { if (redisConfigKeys.REDIS_URL) { @@ -189,29 +200,6 @@ export const keyStoreFactory = (redisConfigKeys: TKeyStoreFactoryDTO): TKeyStore const setExpiry = async (key: string, expiryInSeconds: number) => primaryRedis.expire(key, expiryInSeconds); - const waitTillReady = async ({ - key, - waitingCb, - keyCheckCb, - waitIteration = 10, - delay = 1000, - jitter = 200 - }: TWaitTillReady) => { - let attempts = 0; - let isReady = keyCheckCb(await getItem(key)); - while (!isReady) { - if (attempts > waitIteration) return; - // eslint-disable-next-line - await new Promise((resolve) => { - waitingCb?.(); - setTimeout(resolve, Math.max(0, applyJitter(delay, jitter))); - }); - attempts += 1; - // eslint-disable-next-line - isReady = keyCheckCb(await getItem(key)); - } - }; - const getKeysByPattern = async (pattern: string, limit?: number) => { let cursor = "0"; const allKeys: string[] = []; @@ -236,6 +224,37 @@ export const keyStoreFactory = (redisConfigKeys: TKeyStoreFactoryDTO): TKeyStore return allKeys; }; + const pgIncrementBy: TKeyStoreFactory["pgIncrementBy"] = async (key, { incr = 1, tx, expiry }) => { + const expiresAt = expiry ? new Date(Date.now() + ms(expiry)) : undefined; + return keyValueStoreDAL.incrementBy(key, { incr, expiresAt, tx }); + }; + + const pgGetIntItem = async (key: string, prefix?: string) => + keyValueStoreDAL.findOneInt(prefix ? `${prefix}:${key}` : key); + + const waitTillReady = async ({ + key, + waitingCb, + keyCheckCb, + waitIteration = 10, + delay = 1000, + jitter = 200 + }: TWaitTillReady) => { + let attempts = 0; + let isReady = keyCheckCb(await getItem(key)); + while (!isReady) { + if (attempts > waitIteration) return; + // eslint-disable-next-line + await new Promise((resolve) => { + waitingCb?.(); + setTimeout(resolve, Math.max(0, applyJitter(delay, jitter))); + }); + attempts += 1; + // eslint-disable-next-line + isReady = keyCheckCb(await getItem(key)); + } + }; + return { setItem, getItem, @@ -250,6 +269,8 @@ export const keyStoreFactory = (redisConfigKeys: TKeyStoreFactoryDTO): TKeyStore waitTillReady, getKeysByPattern, deleteItemsByKeyIn, - getItems + getItems, + pgGetIntItem, + pgIncrementBy }; }; diff --git a/backend/src/keystore/memory.ts b/backend/src/keystore/memory.ts index cf9ba83bd..2f9b77ced 100644 --- a/backend/src/keystore/memory.ts +++ b/backend/src/keystore/memory.ts @@ -53,6 +53,15 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => { } return null; }, + pgGetIntItem: async (key) => { + const value = store[key]; + if (typeof value === "number") { + return Number(value); + } + }, + pgIncrementBy: async () => { + return 1; + }, incrementBy: async () => { return 1; }, diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index c4170aac3..ca76afbd1 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -410,6 +410,7 @@ const envSchema = z Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID) && Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET) && Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET), + isSecondaryInstance: Boolean(data.INFISICAL_PRIMARY_INSTANCE_URL), isHsmConfigured: Boolean(data.HSM_LIB_PATH) && Boolean(data.HSM_PIN) && Boolean(data.HSM_KEY_LABEL) && data.HSM_SLOT !== undefined, samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG, diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index 090df561a..dbfd29b81 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -250,12 +250,12 @@ export const ormify = ( .returning("*"); if ($incr) { Object.entries($incr).forEach(([incrementField, incrementValue]) => { - void query.increment(incrementField, incrementValue); + void query.increment(incrementField, incrementValue as number); }); } if ($decr) { Object.entries($decr).forEach(([incrementField, incrementValue]) => { - void query.decrement(incrementField, incrementValue); + void query.decrement(incrementField, incrementValue as number); }); } const [docs] = await query; @@ -273,12 +273,12 @@ export const ormify = ( // increment and decrement operation in update if ($incr) { Object.entries($incr).forEach(([incrementField, incrementValue]) => { - void query.increment(incrementField, incrementValue); + void query.increment(incrementField, incrementValue as number); }); } if ($decr) { Object.entries($decr).forEach(([incrementField, incrementValue]) => { - void query.increment(incrementField, incrementValue); + void query.increment(incrementField, incrementValue as number); }); } return (await query) as Tables[Tname]["base"][]; diff --git a/backend/src/main.ts b/backend/src/main.ts index 8af47eb0b..7be9f43ec 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -5,6 +5,7 @@ import "./lib/telemetry/instrumentation"; import dotenv from "dotenv"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { runMigrations } from "./auto-start-migrations"; import { initAuditLogDbConnection, initDbConnection } from "./db"; @@ -54,7 +55,8 @@ const run = async () => { await queue.initialize(); - const keyStore = keyStoreFactory(envConfig); + const keyValueStoreDAL = keyValueStoreDALFactory(db); + const keyStore = keyStoreFactory(envConfig, keyValueStoreDAL); const redis = buildRedisFromConfig(envConfig); const hsmModule = initializeHsmModule(envConfig); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4803a6cd2..f79362b69 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -123,6 +123,7 @@ import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-grou import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; +import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -507,6 +508,7 @@ export const registerRoutes = async ( const microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDALFactory(db); const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); const secretScanningV2DAL = secretScanningV2DALFactory(db); + const keyValueStoreDAL = keyValueStoreDALFactory(db); const eventBusService = eventBusFactory(server.redis); const sseService = sseServiceFactory(eventBusService, server.redis); @@ -643,6 +645,7 @@ export const registerRoutes = async ( const folderTreeCheckpointDAL = folderTreeCheckpointDALFactory(db); const folderCommitDAL = folderCommitDALFactory(db); const folderTreeCheckpointResourcesDAL = folderTreeCheckpointResourcesDALFactory(db); + const folderCommitQueueService = folderCommitQueueServiceFactory({ queueService, folderTreeCheckpointDAL, @@ -1682,6 +1685,7 @@ export const registerRoutes = async ( userDAL, identityDAL }); + const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ auditLogDAL, queueService, @@ -1694,7 +1698,8 @@ export const registerRoutes = async ( identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, serviceTokenService, orgService, - userNotificationDAL + userNotificationDAL, + keyValueStoreDAL }); const dailyReminderQueueService = dailyReminderQueueServiceFactory({ diff --git a/backend/src/services/folder-commit/folder-commit-service.ts b/backend/src/services/folder-commit/folder-commit-service.ts index 470edbbba..e4c151ff1 100644 --- a/backend/src/services/folder-commit/folder-commit-service.ts +++ b/backend/src/services/folder-commit/folder-commit-service.ts @@ -1386,7 +1386,7 @@ export const folderCommitServiceFactory = ({ ); // Invalidate cache to reflect the changes - await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId, tx); return { secretChangesCount: secretChanges.length, diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index fbd9d3fd6..185ab5e94 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -1,5 +1,6 @@ import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; +import { TKeyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; @@ -27,6 +28,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { queueService: TQueueServiceFactory; orgService: TOrgServiceFactory; userNotificationDAL: Pick; + keyValueStoreDAL: Pick; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -43,7 +45,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ identityUniversalAuthClientSecretDAL, serviceTokenService, orgService, - userNotificationDAL + userNotificationDAL, + keyValueStoreDAL }: TDailyResourceCleanUpQueueServiceFactoryDep) => { const appCfg = getConfig(); @@ -52,6 +55,10 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ } const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + await queueService.stopRepeatableJob( QueueName.AuditLogPrune, QueueJobs.AuditLogPrune, @@ -82,6 +89,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await orgService.notifyInvitedUsers(); await auditLogDAL.pruneAuditLog(); await userNotificationDAL.pruneNotifications(); + await keyValueStoreDAL.pruneExpiredKeys(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); } catch (error) { logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 8d9c6958a..fb7101071 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -50,15 +50,14 @@ interface TSecretV2DalArg { } export const SECRET_DAL_TTL = () => applyJitter(10 * 60, 2 * 60); -export const SECRET_DAL_VERSION_TTL = 15 * 60; +export const SECRET_DAL_VERSION_TTL = "15m"; export const MAX_SECRET_CACHE_BYTES = 25 * 1024 * 1024; export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const secretOrm = ormify(db, TableName.SecretV2); - const invalidateSecretCacheByProjectId = async (projectId: string) => { + const invalidateSecretCacheByProjectId = async (projectId: string, tx?: Knex) => { const secretDalVersionKey = SecretServiceCacheKeys.getSecretDalVersion(projectId); - await keyStore.incrementBy(secretDalVersionKey, 1); - await keyStore.setExpiry(secretDalVersionKey, SECRET_DAL_VERSION_TTL); + await keyStore.pgIncrementBy(secretDalVersionKey, { incr: 1, tx, expiry: SECRET_DAL_VERSION_TTL }); }; const findOne = async (filter: Partial, tx?: Knex) => { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 48ab07816..9db535fbe 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -118,7 +118,7 @@ type TSecretV2BridgeServiceFactoryDep = { >; snapshotService: Pick; resourceMetadataDAL: Pick; - keyStore: Pick; + keyStore: Pick; reminderService: Pick; }; @@ -360,6 +360,7 @@ export const secretV2BridgeServiceFactory = ({ tx }); + await secretDAL.invalidateSecretCacheByProjectId(projectId, tx); return createdSecret; }); @@ -377,7 +378,6 @@ export const secretV2BridgeServiceFactory = ({ }); } - await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -566,8 +566,8 @@ export const secretV2BridgeServiceFactory = ({ await $validateSecretReferences(projectId, permission, allSecretReferences); } - const updatedSecret = await secretDAL.transaction(async (tx) => - fnSecretBulkUpdate({ + const updatedSecret = await secretDAL.transaction(async (tx) => { + const modifiedSecretsInDB = await fnSecretBulkUpdate({ folderId, orgId: actorOrgId, resourceMetadataDAL, @@ -598,8 +598,11 @@ export const secretV2BridgeServiceFactory = ({ actorId }, tx - }) - ); + }); + + await secretDAL.invalidateSecretCacheByProjectId(projectId, tx); + return modifiedSecretsInDB; + }); if (inputSecret.secretReminderRepeatDays) { await reminderService.createReminder({ actor, @@ -615,7 +618,6 @@ export const secretV2BridgeServiceFactory = ({ }); } - await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -715,8 +717,8 @@ export const secretV2BridgeServiceFactory = ({ ); try { - const deletedSecret = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ + const deletedSecret = await secretDAL.transaction(async (tx) => { + const modifiedSecretsInDB = await fnSecretBulkDelete({ projectId, folderId, actorId, @@ -732,10 +734,11 @@ export const secretV2BridgeServiceFactory = ({ } ], tx - }) - ); + }); + await secretDAL.invalidateSecretCacheByProjectId(projectId, tx); + return modifiedSecretsInDB; + }); - await secretDAL.invalidateSecretCacheByProjectId(projectId); if (inputSecret.type === SecretType.Shared) { await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ @@ -1027,7 +1030,7 @@ export const secretV2BridgeServiceFactory = ({ }); throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); - const cachedSecretDalVersion = await keyStore.getItem(SecretServiceCacheKeys.getSecretDalVersion(projectId)); + const cachedSecretDalVersion = await keyStore.pgGetIntItem(SecretServiceCacheKeys.getSecretDalVersion(projectId)); const secretDalVersion = Number(cachedSecretDalVersion || 0); const cacheKey = SecretServiceCacheKeys.getSecretsOfServiceLayer(projectId, secretDalVersion, { ...dto, @@ -1692,7 +1695,7 @@ export const secretV2BridgeServiceFactory = ({ await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); const executeBulkInsert = async (tx: Knex) => { - return fnSecretBulkInsert({ + const modifiedSecretsInDB = await fnSecretBulkInsert({ inputSecrets: inputSecrets.map((el) => { const references = secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences; @@ -1728,13 +1731,14 @@ export const secretV2BridgeServiceFactory = ({ }, tx }); + await secretDAL.invalidateSecretCacheByProjectId(projectId, tx); + return modifiedSecretsInDB; }; const newSecrets = providedTx ? await executeBulkInsert(providedTx) : await secretDAL.transaction(executeBulkInsert); - await secretDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ actor, @@ -2099,6 +2103,7 @@ export const secretV2BridgeServiceFactory = ({ } } + await secretDAL.invalidateSecretCacheByProjectId(projectId, tx); return updatedSecrets; }; @@ -2106,7 +2111,6 @@ export const secretV2BridgeServiceFactory = ({ ? await executeBulkUpdate(providedTx) : await secretDAL.transaction(executeBulkUpdate); - await secretDAL.invalidateSecretCacheByProjectId(projectId); await Promise.allSettled(folders.map((el) => (el?.id ? snapshotService.performSnapshot(el.id) : undefined))); await Promise.allSettled( folders.map((el) => @@ -2233,7 +2237,7 @@ export const secretV2BridgeServiceFactory = ({ }); const executeBulkDelete = async (tx: Knex) => { - return fnSecretBulkDelete({ + const modifiedSecretsInDB = await fnSecretBulkDelete({ secretDAL, secretQueueService, folderCommitService, @@ -2249,6 +2253,8 @@ export const secretV2BridgeServiceFactory = ({ commitChanges, tx }); + await secretDAL.invalidateSecretCacheByProjectId(projectId, tx); + return modifiedSecretsInDB; }; try { @@ -2256,7 +2262,6 @@ export const secretV2BridgeServiceFactory = ({ ? await executeBulkDelete(providedTx) : await secretDAL.transaction(executeBulkDelete); - await secretDAL.invalidateSecretCacheByProjectId(projectId); await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ actor, From e8e51fb1e1211524bd5dd2ee449200f7c58feacf Mon Sep 17 00:00:00 2001 From: = Date: Tue, 9 Sep 2025 14:28:37 +0530 Subject: [PATCH 33/46] feat: added back missing replica node redirect for read db operations --- .../dynamic-secret-lease/dynamic-secret-lease-dal.ts | 7 +++++-- backend/src/ee/services/gateway/gateway-dal.ts | 2 +- .../ee/services/group/user-group-membership-dal.ts | 4 ++-- backend/src/ee/services/license/license-dal.ts | 4 ++-- .../secret-approval-request-dal.ts | 4 ++-- .../secret-approval-request-secret-dal.ts | 2 +- .../src/ee/services/secret-snapshot/snapshot-dal.ts | 12 ++++++++---- .../integration-auth/integration-auth-dal.ts | 2 +- backend/src/services/kms/kms-root-config-dal.ts | 2 +- backend/src/services/reminder/reminder-dal.ts | 8 ++++---- .../secret-folder/secret-folder-version-dal.ts | 2 +- .../src/services/secret-import/secret-import-dal.ts | 2 +- .../services/secret-sharing/secret-sharing-dal.ts | 2 +- .../secret-v2-bridge/secret-v2-bridge-dal.ts | 12 ++++++------ .../services/secret-v2-bridge/secret-version-dal.ts | 6 +++--- backend/src/services/super-admin/super-admin-dal.ts | 2 +- backend/src/services/user/user-dal.ts | 12 ++++++++---- 17 files changed, 48 insertions(+), 37 deletions(-) diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts index 525de9efd..974e20061 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts @@ -46,7 +46,10 @@ export const dynamicSecretLeaseDALFactory = (db: TDbClient) => { const countLeasesForDynamicSecret = async (dynamicSecretId: string, tx?: Knex) => { try { - const doc = await (tx || db)(TableName.DynamicSecretLease).count("*").where({ dynamicSecretId }).first(); + const doc = await (tx || db.replicaNode())(TableName.DynamicSecretLease) + .count("*") + .where({ dynamicSecretId }) + .first(); return parseInt(doc || "0", 10); } catch (error) { throw new DatabaseError({ error, name: "DynamicSecretCountLeases" }); @@ -55,7 +58,7 @@ export const dynamicSecretLeaseDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex) => { try { - const doc = await (tx || db)(TableName.DynamicSecretLease) + const doc = await (tx || db.replicaNode())(TableName.DynamicSecretLease) .where({ [`${TableName.DynamicSecretLease}.id` as "id"]: id }) .first() .join( diff --git a/backend/src/ee/services/gateway/gateway-dal.ts b/backend/src/ee/services/gateway/gateway-dal.ts index 31b4b727b..c21ff31c0 100644 --- a/backend/src/ee/services/gateway/gateway-dal.ts +++ b/backend/src/ee/services/gateway/gateway-dal.ts @@ -13,7 +13,7 @@ export const gatewayDALFactory = (db: TDbClient) => { { offset, limit, sort, tx }: TFindOpt = {} ) => { try { - const query = (tx || db)(TableName.Gateway) + const query = (tx || db.replicaNode())(TableName.Gateway) // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter(filter, TableName.Gateway, ["orgId"])) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`) diff --git a/backend/src/ee/services/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts index 5ee97e457..374459b0c 100644 --- a/backend/src/ee/services/group/user-group-membership-dal.ts +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -23,7 +23,7 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { .whereIn(`${TableName.ProjectMembership}.projectId`, projectIds) .pluck(`${TableName.ProjectMembership}.projectId`); - const userGroupMemberships: string[] = await (tx || db)(TableName.UserGroupMembership) + const userGroupMemberships: string[] = await (tx || db.replicaNode())(TableName.UserGroupMembership) .where(`${TableName.UserGroupMembership}.userId`, userId) .whereNot(`${TableName.UserGroupMembership}.groupId`, groupId) .join( @@ -79,7 +79,7 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { .pluck(`${TableName.GroupProjectMembership}.groupId`); // main query - const members = await (tx || db)(TableName.UserGroupMembership) + const members = await (tx || db.replicaNode())(TableName.UserGroupMembership) .where(`${TableName.UserGroupMembership}.groupId`, groupId) .where(`${TableName.UserGroupMembership}.isPending`, false) .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index 88a2dadf6..cfea2573d 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -28,7 +28,7 @@ export const licenseDALFactory = (db: TDbClient) => { const countOrgUsersAndIdentities = async (orgId: string | null, tx?: Knex) => { try { // count org users - const userDoc = await (tx || db)(TableName.OrgMembership) + const userDoc = await (tx || db.replicaNode())(TableName.OrgMembership) .where({ status: OrgMembershipStatus.Accepted }) .andWhere((bd) => { if (orgId) { @@ -42,7 +42,7 @@ export const licenseDALFactory = (db: TDbClient) => { const userCount = Number(userDoc?.[0].count); // count org identities - const identityDoc = await (tx || db)(TableName.IdentityOrgMembership) + const identityDoc = await (tx || db.replicaNode())(TableName.IdentityOrgMembership) .where((bd) => { if (orgId) { void bd.where({ orgId }); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index fe4ca94e1..01caef223 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -345,7 +345,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { const findProjectRequestCount = async (projectId: string, userId: string, policyId?: string, tx?: Knex) => { try { - const docs = await (tx || db) + const docs = await (tx || db.replicaNode()) .with( "temp", (tx || db.replicaNode())(TableName.SecretApprovalRequest) @@ -494,7 +494,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .distinctOn(`${TableName.SecretApprovalRequest}.id`) .as("inner"); - const query = (tx || db) + const query = (tx || db.replicaNode()) .select("*") .select(db.raw("count(*) OVER() as total_count")) .from(innerQuery) diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts index c1b18e43d..17182cddf 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts @@ -377,7 +377,7 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { // special query for migration to v2 secret const findByProjectId = async (projectId: string, tx?: Knex) => { try { - const docs = await (tx || db)(TableName.SecretApprovalRequestSecret) + const docs = await (tx || db.replicaNode())(TableName.SecretApprovalRequestSecret) .join( TableName.SecretApprovalRequest, `${TableName.SecretApprovalRequest}.id`, diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index c547d85c2..17f1fad05 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts @@ -265,7 +265,7 @@ export const snapshotDALFactory = (db: TDbClient) => { // then joins with respective secrets and folder const findRecursivelySnapshots = async (snapshotId: string, tx?: Knex) => { try { - const data = await (tx || db) + const data = await (tx || db.replicaNode()) .withRecursive("parent", (qb) => { void qb .from(TableName.Snapshot) @@ -419,7 +419,7 @@ export const snapshotDALFactory = (db: TDbClient) => { // then joins with respective secrets and folder const findRecursivelySnapshotsV2Bridge = async (snapshotId: string, tx?: Knex) => { try { - const data = await (tx || db) + const data = await (tx || db.replicaNode()) .withRecursive("parent", (qb) => { void qb .from(TableName.Snapshot) @@ -581,7 +581,11 @@ export const snapshotDALFactory = (db: TDbClient) => { const docs = await (tx || db.replicaNode())(TableName.Snapshot) .where(`${TableName.Snapshot}.folderId`, folderId) .join( - (tx || db)(TableName.Snapshot).groupBy("folderId").max("createdAt").select("folderId").as("latestVersion"), + (tx || db.replicaNode())(TableName.Snapshot) + .groupBy("folderId") + .max("createdAt") + .select("folderId") + .as("latestVersion"), (bd) => { bd.on(`${TableName.Snapshot}.folderId`, "latestVersion.folderId").andOn( `${TableName.Snapshot}.createdAt`, @@ -766,7 +770,7 @@ export const snapshotDALFactory = (db: TDbClient) => { ) .orderBy(`${TableName.Snapshot}.createdAt`, "desc") .where(`${TableName.Snapshot}.folderId`, folderId); - const data = await (tx || db) + const data = await (tx || db.replicaNode()) .with("w", query) .select("*") .from[number]>("w") diff --git a/backend/src/services/integration-auth/integration-auth-dal.ts b/backend/src/services/integration-auth/integration-auth-dal.ts index 7a56afcbb..d3ccf610b 100644 --- a/backend/src/services/integration-auth/integration-auth-dal.ts +++ b/backend/src/services/integration-auth/integration-auth-dal.ts @@ -30,7 +30,7 @@ export const integrationAuthDALFactory = (db: TDbClient) => { const getByOrg = async (orgId: string, tx?: Knex) => { try { - const integrationAuths = await (tx || db)(TableName.IntegrationAuth) + const integrationAuths = await (tx || db.replicaNode())(TableName.IntegrationAuth) .join(TableName.Project, `${TableName.Project}.id`, `${TableName.IntegrationAuth}.projectId`) .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) .where(`${TableName.Organization}.id`, "=", orgId) diff --git a/backend/src/services/kms/kms-root-config-dal.ts b/backend/src/services/kms/kms-root-config-dal.ts index 31826b79d..4de3fc15a 100644 --- a/backend/src/services/kms/kms-root-config-dal.ts +++ b/backend/src/services/kms/kms-root-config-dal.ts @@ -12,7 +12,7 @@ export const kmsRootConfigDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex) => { try { - const result = await (tx || db)(TableName.KmsServerRootConfig) + const result = await (tx || db.replicaNode())(TableName.KmsServerRootConfig) .where({ id } as never) .first("*"); return result; diff --git a/backend/src/services/reminder/reminder-dal.ts b/backend/src/services/reminder/reminder-dal.ts index 897a75234..4161552a9 100644 --- a/backend/src/services/reminder/reminder-dal.ts +++ b/backend/src/services/reminder/reminder-dal.ts @@ -39,7 +39,7 @@ export const reminderDALFactory = (db: TDbClient) => { const findSecretDailyReminders = async (tx?: Knex) => { const { startOfDay, endOfDay } = getTodayDateRange(); - const rawReminders = await (tx || db)(TableName.Reminder) + const rawReminders = await (tx || db.replicaNode())(TableName.Reminder) .whereBetween("nextReminderDate", [startOfDay, endOfDay]) .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) .leftJoin(TableName.Users, `${TableName.ReminderRecipient}.userId`, `${TableName.Users}.id`) @@ -90,7 +90,7 @@ export const reminderDALFactory = (db: TDbClient) => { const futureDate = new Date(startOfDay); futureDate.setDate(futureDate.getDate() + daysAhead); - const reminders = await (tx || db)(TableName.Reminder) + const reminders = await (tx || db.replicaNode())(TableName.Reminder) .where("nextReminderDate", ">=", startOfDay) .where("nextReminderDate", "<=", futureDate) .orderBy("nextReminderDate", "asc") @@ -101,7 +101,7 @@ export const reminderDALFactory = (db: TDbClient) => { }; const findSecretReminder = async (secretId: string, tx?: Knex) => { - const rawReminders = await (tx || db)(TableName.Reminder) + const rawReminders = await (tx || db.replicaNode())(TableName.Reminder) .where(`${TableName.Reminder}.secretId`, secretId) .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) .select(selectAllTableCols(TableName.Reminder)) @@ -125,7 +125,7 @@ export const reminderDALFactory = (db: TDbClient) => { }; const findSecretReminders = async (secretIds: string[], tx?: Knex) => { - const rawReminders = await (tx || db)(TableName.Reminder) + const rawReminders = await (tx || db.replicaNode())(TableName.Reminder) .whereIn(`${TableName.Reminder}.secretId`, secretIds) .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) .select(selectAllTableCols(TableName.Reminder)) diff --git a/backend/src/services/secret-folder/secret-folder-version-dal.ts b/backend/src/services/secret-folder/secret-folder-version-dal.ts index 46ff49692..5504c6e0b 100644 --- a/backend/src/services/secret-folder/secret-folder-version-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-version-dal.ts @@ -45,7 +45,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { ) .whereIn(`${TableName.SecretFolderVersion}.folderId`, folderIds) .join( - (tx || db)(TableName.SecretFolderVersion) + (tx || db.replicaNode())(TableName.SecretFolderVersion) .groupBy("folderId") .max("version") .select("folderId") diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index db611dc6c..2261c2418 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -15,7 +15,7 @@ export const secretImportDALFactory = (db: TDbClient) => { // we are using postion based sorting as its a small list // this will return the last value of the position in a folder with secret imports const findLastImportPosition = async (folderId: string, tx?: Knex) => { - const lastPos = await (tx || db)(TableName.SecretImport) + const lastPos = await (tx || db.replicaNode())(TableName.SecretImport) .where({ folderId }) .max("position", { as: "position" }) .first(); diff --git a/backend/src/services/secret-sharing/secret-sharing-dal.ts b/backend/src/services/secret-sharing/secret-sharing-dal.ts index 7cdccd4f8..08e0ad257 100644 --- a/backend/src/services/secret-sharing/secret-sharing-dal.ts +++ b/backend/src/services/secret-sharing/secret-sharing-dal.ts @@ -119,7 +119,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { const findActiveSharedSecrets = async (filters: Partial, tx?: Knex) => { try { const now = new Date(); - return await (tx || db)(TableName.SecretSharing) + return await (tx || db.replicaNode())(TableName.SecretSharing) .where(filters) .andWhere("expiresAt", ">", now) .andWhere("encryptedValue", "<>", "") diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index fb7101071..93afb3b55 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -62,7 +62,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const findOne = async (filter: Partial, tx?: Knex) => { try { - const docs = await (tx || db)(TableName.SecretV2) + const docs = await (tx || db.replicaNode())(TableName.SecretV2) // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter(filter, TableName.SecretV2)) .leftJoin( @@ -143,7 +143,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const find = async (filter: TFindFilter, opts: TFindOpt = {}) => { const { offset, limit, sort, tx } = opts; try { - const query = (tx || db)(TableName.SecretV2) + const query = (tx || db.replicaNode())(TableName.SecretV2) // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter(filter)) .leftJoin( @@ -887,13 +887,13 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const findSecretsWithReminderRecipients = async (ids: string[], limit: number, tx?: Knex) => { try { // Create a subquery to get limited secret IDs - const limitedSecretIds = (tx || db)(TableName.SecretV2) + const limitedSecretIds = (tx || db.replicaNode())(TableName.SecretV2) .whereIn(`${TableName.SecretV2}.id`, ids) .limit(limit) .select("id"); // Join with all recipients for the limited secrets - const docs = await (tx || db)(TableName.SecretV2) + const docs = await (tx || db.replicaNode())(TableName.SecretV2) .whereIn(`${TableName.SecretV2}.id`, limitedSecretIds) .leftJoin(TableName.Reminder, `${TableName.SecretV2}.id`, `${TableName.Reminder}.secretId`) .leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`) @@ -925,13 +925,13 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { const findSecretsWithReminderRecipientsOld = async (ids: string[], limit: number, tx?: Knex) => { try { // Create a subquery to get limited secret IDs - const limitedSecretIds = (tx || db)(TableName.SecretV2) + const limitedSecretIds = (tx || db.replicaNode())(TableName.SecretV2) .whereIn(`${TableName.SecretV2}.id`, ids) .limit(limit) .select("id"); // Join with all recipients for the limited secrets - const docs = await (tx || db)(TableName.SecretV2) + const docs = await (tx || db.replicaNode())(TableName.SecretV2) .whereIn(`${TableName.SecretV2}.id`, limitedSecretIds) .leftJoin(TableName.Reminder, `${TableName.SecretV2}.id`, `${TableName.Reminder}.secretId`) .leftJoin( diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index 9537b79e3..0282fa537 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -72,7 +72,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { .where(`${TableName.SecretVersionV2}.folderId`, folderId) .join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretVersionV2}.secretId`) .join( - (tx || db)(TableName.SecretVersionV2) + (tx || db.replicaNode())(TableName.SecretVersionV2) .where(`${TableName.SecretVersionV2}.folderId`, folderId) .groupBy("secretId") .max("version") @@ -121,7 +121,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { .where("folderId", folderId) .whereIn(`${TableName.SecretVersionV2}.secretId`, secretIds) .join( - (tx || db)(TableName.SecretVersionV2) + (tx || db.replicaNode())(TableName.SecretVersionV2) .groupBy("secretId") .max("version") .select("secretId") @@ -189,7 +189,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { }) => { try { const { offset, limit, sort = [["createdAt", "desc"]] } = findOpt; - const query = (tx || db)(TableName.SecretVersionV2) + const query = (tx || db.replicaNode())(TableName.SecretVersionV2) .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`) .leftJoin( TableName.ProjectMembership, diff --git a/backend/src/services/super-admin/super-admin-dal.ts b/backend/src/services/super-admin/super-admin-dal.ts index d7d11a5d2..571583cc3 100644 --- a/backend/src/services/super-admin/super-admin-dal.ts +++ b/backend/src/services/super-admin/super-admin-dal.ts @@ -11,7 +11,7 @@ export const superAdminDALFactory = (db: TDbClient) => { const superAdminOrm = ormify(db, TableName.SuperAdmin); const findById = async (id: string, tx?: Knex) => { - const config = await (tx || db)(TableName.SuperAdmin) + const config = await (tx || db.replicaNode())(TableName.SuperAdmin) .where(`${TableName.SuperAdmin}.id`, id) .leftJoin(TableName.Organization, `${TableName.SuperAdmin}.defaultAuthOrgId`, `${TableName.Organization}.id`) .leftJoin(TableName.SamlConfig, (qb) => { diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 0f623dff1..4267d13ee 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -19,12 +19,16 @@ export type TUserDALFactory = ReturnType; export const userDALFactory = (db: TDbClient) => { const userOrm = ormify(db, TableName.Users); const findUserByUsername = async (username: string, tx?: Knex) => - (tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() }); + (tx || db.replicaNode())(TableName.Users).whereRaw('lower("username") = :username', { + username: username.toLowerCase() + }); const findUserByEmail = async (email: string, tx?: Knex) => - (tx || db)(TableName.Users).whereRaw('lower("email") = :email', { email: email.toLowerCase() }).where({ - isEmailVerified: true - }); + (tx || db.replicaNode())(TableName.Users) + .whereRaw('lower("email") = :email', { email: email.toLowerCase() }) + .where({ + isEmailVerified: true + }); const getUsersByFilter = async ({ limit, From ae42f31d694c43ff1b3f57bf0b419428d412d1f1 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 9 Sep 2025 21:50:14 +0530 Subject: [PATCH 34/46] feat: added replica node for auth method fetch --- api_requests.sh | 89 +++++++++++++++++++ backend/src/services/identity/identity-dal.ts | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100755 api_requests.sh diff --git a/api_requests.sh b/api_requests.sh new file mode 100755 index 000000000..5d21f114f --- /dev/null +++ b/api_requests.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Configuration +BEARER_TOKEN="your-token-here" +BASE_URL="https://api.example.com" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo "Starting API requests with timing metrics..." +echo "==========================================" + +# Request 1: POST with body +echo -e "\n${BLUE}1. POST Request${NC}" +echo "URL: $BASE_URL/endpoint1" +start_time=$(date +%s.%3N) +response1=$(curl -s -w "%{http_code}|%{time_total}" \ + -X POST \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "key1": "value1", + "key2": "value2" + }' \ + "$BASE_URL/endpoint1") +end_time=$(date +%s.%3N) + +http_code1=$(echo "$response1" | tail -c 10 | cut -d'|' -f1) +curl_time1=$(echo "$response1" | tail -c 10 | cut -d'|' -f2) +duration1=$(echo "$end_time - $start_time" | bc) + +echo "Status Code: $http_code1" +echo "Duration: ${duration1}s (curl time: ${curl_time1}s)" +echo -e "${GREEN}✓ POST request completed${NC}" + +# Request 2: GET with query params +echo -e "\n${BLUE}2. GET Request with Query Parameters${NC}" +echo "URL: $BASE_URL/endpoint2?param1=value1¶m2=value2" +start_time=$(date +%s.%3N) +response2=$(curl -s -w "%{http_code}|%{time_total}" \ + -X GET \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + "$BASE_URL/endpoint2?param1=value1¶m2=value2") +end_time=$(date +%s.%3N) + +http_code2=$(echo "$response2" | tail -c 10 | cut -d'|' -f1) +curl_time2=$(echo "$response2" | tail -c 10 | cut -d'|' -f2) +duration2=$(echo "$end_time - $start_time" | bc) + +echo "Status Code: $http_code2" +echo "Duration: ${duration2}s (curl time: ${curl_time2}s)" +echo -e "${GREEN}✓ GET request completed${NC}" + +# Request 3: DELETE with body +echo -e "\n${BLUE}3. DELETE Request with Body${NC}" +echo "URL: $BASE_URL/endpoint3" +start_time=$(date +%s.%3N) +response3=$(curl -s -w "%{http_code}|%{time_total}" \ + -X DELETE \ + -H "Authorization: Bearer $BEARER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "123", + "reason": "cleanup" + }' \ + "$BASE_URL/endpoint3") +end_time=$(date +%s.%3N) + +http_code3=$(echo "$response3" | tail -c 10 | cut -d'|' -f1) +curl_time3=$(echo "$response3" | tail -c 10 | cut -d'|' -f2) +duration3=$(echo "$end_time - $start_time" | bc) + +echo "Status Code: $http_code3" +echo "Duration: ${duration3}s (curl time: ${curl_time3}s)" +echo -e "${GREEN}✓ DELETE request completed${NC}" + +# Summary +echo -e "\n==========================================" +echo -e "${BLUE}SUMMARY${NC}" +echo "==========================================" +echo "Request 1 (POST): ${duration1}s" +echo "Request 2 (GET): ${duration2}s" +echo "Request 3 (DELETE): ${duration3}s" +total_time=$(echo "$duration1 + $duration2 + $duration3" | bc) +echo "Total Time: ${total_time}s" +echo -e "${GREEN}All requests completed successfully!${NC}" \ No newline at end of file diff --git a/backend/src/services/identity/identity-dal.ts b/backend/src/services/identity/identity-dal.ts index 363412493..7bc797600 100644 --- a/backend/src/services/identity/identity-dal.ts +++ b/backend/src/services/identity/identity-dal.ts @@ -25,7 +25,7 @@ export const identityDALFactory = (db: TDbClient) => { } as const; const tableName = authMethodToTableName[authMethod]; if (!tableName) return; - const data = await db(tableName).where({ identityId }).first(); + const data = await db.replicaNode()(tableName).where({ identityId }).first(); if (!data) return; return data.accessTokenTrustedIps; }; From c682d87d959b4221e653a9a6f14afb028b4b1af0 Mon Sep 17 00:00:00 2001 From: = Date: Tue, 9 Sep 2025 22:55:32 +0530 Subject: [PATCH 35/46] feat: reptile review fixed --- .../secret-approval-request-service.ts | 1 + backend/src/keystore/key-value-store-dal.ts | 6 +++--- backend/src/lib/knex/index.ts | 2 +- .../services/folder-commit/folder-commit-service.test.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index aa3e3dc0f..17b7d8347 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -787,6 +787,7 @@ export const secretApprovalRequestServiceFactory = ({ }, tx ); + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId, tx); return { secrets: { created: newSecrets, updated: updatedSecrets, deleted: deletedSecret }, approval: updatedSecretApproval diff --git a/backend/src/keystore/key-value-store-dal.ts b/backend/src/keystore/key-value-store-dal.ts index bdf5e8725..bccedf4ac 100644 --- a/backend/src/keystore/key-value-store-dal.ts +++ b/backend/src/keystore/key-value-store-dal.ts @@ -19,7 +19,7 @@ const MAX_RETRY_ON_FAILURE = 3; export const keyValueStoreDALFactory = (db: TDbClient): TKeyValueStoreDALFactory => { const keyValueStoreOrm = ormify(db, TableName.KeyValueStore); - const incrementBy: TKeyValueStoreDALFactory["incrementBy"] = (key, { incr = 1, tx, expiresAt }) => { + const incrementBy: TKeyValueStoreDALFactory["incrementBy"] = async (key, { incr = 1, tx, expiresAt }) => { return (tx || db)(TableName.KeyValueStore) .insert({ key, integerValue: 1, expiresAt }) .onConflict("key") @@ -27,7 +27,8 @@ export const keyValueStoreDALFactory = (db: TDbClient): TKeyValueStoreDALFactory integerValue: db.raw(`"${TableName.KeyValueStore}"."integerValue" + ?`, [incr]), expiresAt }) - .returning("integerValue"); + .returning("integerValue") + .then((result) => Number(result[0]?.integerValue || 0)); }; const findOneInt: TKeyValueStoreDALFactory["findOneInt"] = async (key, tx) => { @@ -44,7 +45,6 @@ export const keyValueStoreDALFactory = (db: TDbClient): TKeyValueStoreDALFactory return Number(doc?.integerValue || 0); }; - // delete all audit log that have expired const pruneExpiredKeys: TKeyValueStoreDALFactory["pruneExpiredKeys"] = async () => { let deletedIds: { key: string }[] = []; let numberOfRetryOnFailure = 0; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index dbfd29b81..499e7cb26 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -278,7 +278,7 @@ export const ormify = ( } if ($decr) { Object.entries($decr).forEach(([incrementField, incrementValue]) => { - void query.increment(incrementField, incrementValue as number); + void query.decrement(incrementField, incrementValue as number); }); } return (await query) as Tables[Tname]["base"][]; diff --git a/backend/src/services/folder-commit/folder-commit-service.test.ts b/backend/src/services/folder-commit/folder-commit-service.test.ts index 28d603829..0a73d6e0c 100644 --- a/backend/src/services/folder-commit/folder-commit-service.test.ts +++ b/backend/src/services/folder-commit/folder-commit-service.test.ts @@ -661,7 +661,7 @@ describe("folderCommitServiceFactory", () => { // Assert expect(mockFolderCommitDAL.create).toHaveBeenCalled(); - expect(mockSecretV2BridgeDAL.invalidateSecretCacheByProjectId).toHaveBeenCalledWith(projectId); + expect(mockSecretV2BridgeDAL.invalidateSecretCacheByProjectId).toHaveBeenCalledWith(projectId, {}); // Check that we got the right counts expect(result.totalChanges).toEqual(2); From 6f1c884bf5f3be08f2918cb0129f84fd828b98a9 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 10 Sep 2025 16:11:20 +0530 Subject: [PATCH 36/46] feat: review comments --- api_requests.sh | 89 ------------------- .../20250908193226_sql-cache_int.ts | 1 + backend/src/db/schemas/key-value-store.ts | 4 +- 3 files changed, 4 insertions(+), 90 deletions(-) delete mode 100755 api_requests.sh diff --git a/api_requests.sh b/api_requests.sh deleted file mode 100755 index 5d21f114f..000000000 --- a/api_requests.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash - -# Configuration -BEARER_TOKEN="your-token-here" -BASE_URL="https://api.example.com" - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo "Starting API requests with timing metrics..." -echo "==========================================" - -# Request 1: POST with body -echo -e "\n${BLUE}1. POST Request${NC}" -echo "URL: $BASE_URL/endpoint1" -start_time=$(date +%s.%3N) -response1=$(curl -s -w "%{http_code}|%{time_total}" \ - -X POST \ - -H "Authorization: Bearer $BEARER_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "key1": "value1", - "key2": "value2" - }' \ - "$BASE_URL/endpoint1") -end_time=$(date +%s.%3N) - -http_code1=$(echo "$response1" | tail -c 10 | cut -d'|' -f1) -curl_time1=$(echo "$response1" | tail -c 10 | cut -d'|' -f2) -duration1=$(echo "$end_time - $start_time" | bc) - -echo "Status Code: $http_code1" -echo "Duration: ${duration1}s (curl time: ${curl_time1}s)" -echo -e "${GREEN}✓ POST request completed${NC}" - -# Request 2: GET with query params -echo -e "\n${BLUE}2. GET Request with Query Parameters${NC}" -echo "URL: $BASE_URL/endpoint2?param1=value1¶m2=value2" -start_time=$(date +%s.%3N) -response2=$(curl -s -w "%{http_code}|%{time_total}" \ - -X GET \ - -H "Authorization: Bearer $BEARER_TOKEN" \ - "$BASE_URL/endpoint2?param1=value1¶m2=value2") -end_time=$(date +%s.%3N) - -http_code2=$(echo "$response2" | tail -c 10 | cut -d'|' -f1) -curl_time2=$(echo "$response2" | tail -c 10 | cut -d'|' -f2) -duration2=$(echo "$end_time - $start_time" | bc) - -echo "Status Code: $http_code2" -echo "Duration: ${duration2}s (curl time: ${curl_time2}s)" -echo -e "${GREEN}✓ GET request completed${NC}" - -# Request 3: DELETE with body -echo -e "\n${BLUE}3. DELETE Request with Body${NC}" -echo "URL: $BASE_URL/endpoint3" -start_time=$(date +%s.%3N) -response3=$(curl -s -w "%{http_code}|%{time_total}" \ - -X DELETE \ - -H "Authorization: Bearer $BEARER_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "id": "123", - "reason": "cleanup" - }' \ - "$BASE_URL/endpoint3") -end_time=$(date +%s.%3N) - -http_code3=$(echo "$response3" | tail -c 10 | cut -d'|' -f1) -curl_time3=$(echo "$response3" | tail -c 10 | cut -d'|' -f2) -duration3=$(echo "$end_time - $start_time" | bc) - -echo "Status Code: $http_code3" -echo "Duration: ${duration3}s (curl time: ${curl_time3}s)" -echo -e "${GREEN}✓ DELETE request completed${NC}" - -# Summary -echo -e "\n==========================================" -echo -e "${BLUE}SUMMARY${NC}" -echo "==========================================" -echo "Request 1 (POST): ${duration1}s" -echo "Request 2 (GET): ${duration2}s" -echo "Request 3 (DELETE): ${duration3}s" -total_time=$(echo "$duration1 + $duration2 + $duration3" | bc) -echo "Total Time: ${total_time}s" -echo -e "${GREEN}All requests completed successfully!${NC}" \ No newline at end of file diff --git a/backend/src/db/migrations/20250908193226_sql-cache_int.ts b/backend/src/db/migrations/20250908193226_sql-cache_int.ts index dc4060d02..0e15c10d1 100644 --- a/backend/src/db/migrations/20250908193226_sql-cache_int.ts +++ b/backend/src/db/migrations/20250908193226_sql-cache_int.ts @@ -8,6 +8,7 @@ export async function up(knex: Knex): Promise { t.text("key").primary(); t.bigint("integerValue"); t.datetime("expiresAt"); + t.timestamps(true, true, true); }); } } diff --git a/backend/src/db/schemas/key-value-store.ts b/backend/src/db/schemas/key-value-store.ts index d6ba90b2f..448c78f24 100644 --- a/backend/src/db/schemas/key-value-store.ts +++ b/backend/src/db/schemas/key-value-store.ts @@ -10,7 +10,9 @@ import { TImmutableDBKeys } from "./models"; export const KeyValueStoreSchema = z.object({ key: z.string(), integerValue: z.coerce.number().nullable().optional(), - expiresAt: z.date().nullable().optional() + expiresAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() }); export type TKeyValueStore = z.infer; From a0595fa9b4ba2265049566d76a6330545cce3858 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 10 Sep 2025 19:59:29 +0800 Subject: [PATCH 37/46] fix: address sql server dynamic secret integration --- .../dynamic-secret/providers/models.ts | 1 + .../dynamic-secret/providers/sql-database.ts | 37 ++++++++++++++++--- .../SqlDatabaseInputForm.tsx | 26 ++++++++++++- .../EditDynamicSecretSqlProviderForm.tsx | 26 ++++++++++++- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index ae1bcfc25..c618a308b 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -165,6 +165,7 @@ export const DynamicSecretSqlDBSchema = z.object({ revocationStatement: z.string().trim(), renewStatement: z.string().trim().optional(), ca: z.string().optional(), + sslEnabled: z.boolean().optional(), gatewayId: z.string().nullable().optional() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 57ce710b1..babbe1912 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -1,4 +1,5 @@ import handlebars from "handlebars"; +import RE2 from "re2"; import knex from "knex"; import { z } from "zod"; @@ -154,7 +155,15 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host } : undefined; + const isMsSQLClient = providerInputs.client === SqlProviders.MsSQL; + const isAzureSql = isMsSQLClient && new RE2(/\.database\.windows\.net$/i).test(providerInputs.host); + const azureServerLabel = isAzureSql ? providerInputs.host.split(".")[0] : undefined; + + const effectiveUser = + isAzureSql && !providerInputs.username.includes("@") + ? `${providerInputs.username}@${azureServerLabel}` + : providerInputs.username; const db = knex({ client: providerInputs.client, @@ -162,7 +171,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) database: providerInputs.database, port: providerInputs.port, host: providerInputs.client === SqlProviders.Postgres ? providerInputs.hostIp : providerInputs.host, - user: providerInputs.username, + user: effectiveUser, password: providerInputs.password, ssl, // @ts-expect-error this is because of knexjs type signature issue. This is directly passed to driver @@ -170,6 +179,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) // https://github.com/tediousjs/tedious/blob/ebb023ed90969a7ec0e4b036533ad52739d921f7/test/config.ci.ts#L19 options: isMsSQLClient ? { + ...(providerInputs.sslEnabled !== undefined ? { encrypt: providerInputs.sslEnabled } : {}), trustServerCertificate: !providerInputs.ca, cryptoCredentialsDetails: providerInputs.ca ? { ca: providerInputs.ca } : {} } @@ -212,7 +222,12 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const providerInputs = await validateProviderInputs(inputs); let isConnected = false; const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { - const db = await $getClient({ ...providerInputs, port, host, hostIp: providerInputs.hostIp }); + const db = await $getClient({ + ...providerInputs, + port, + host, + hostIp: providerInputs.hostIp + }); // oracle needs from keyword const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; @@ -253,7 +268,11 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { - const db = await $getClient({ ...providerInputs, port, host }); + const db = await $getClient({ + ...providerInputs, + port, + host + }); try { const expiration = new Date(expireAt).toISOString(); @@ -296,7 +315,11 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const username = entityId; const { database } = providerInputs; const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { - const db = await $getClient({ ...providerInputs, port, host }); + const db = await $getClient({ + ...providerInputs, + port, + host + }); try { const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); const queries = revokeStatement.toString().split(";").filter(Boolean); @@ -331,7 +354,11 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) if (!providerInputs.renewStatement) return { entityId }; const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { - const db = await $getClient({ ...providerInputs, port, host }); + const db = await $getClient({ + ...providerInputs, + port, + host + }); const expiration = new Date(expireAt).toISOString(); const { database } = providerInputs; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx index 8bedbbaeb..050a51754 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx @@ -19,6 +19,7 @@ import { SecretInput, Select, SelectItem, + Switch, TextArea, Tooltip } from "@app/components/v2"; @@ -66,6 +67,7 @@ const formSchema = z.object({ creationStatement: z.string().min(1), revocationStatement: z.string().min(1), renewStatement: z.string().optional(), + sslEnabled: z.boolean().optional(), ca: z.string().optional(), gatewayId: z.string().optional() }), @@ -200,6 +202,7 @@ export const SqlDatabaseInputForm = ({ const createDynamicSecret = useCreateDynamicSecret(); const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + const selectedClient = watch("provider.client"); const handleCreateDynamicSecret = async ({ name, @@ -458,13 +461,34 @@ export const SqlDatabaseInputForm = ({ />
+ {selectedClient === SqlProviders.MsSQL && ( +
+ ( + + + Encrypt Connection (SSL) + + + )} + /> +
+ )} ( diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx index 2cbad1e19..1eda17bc8 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx @@ -18,6 +18,7 @@ import { SecretInput, Select, SelectItem, + Switch, TextArea, Tooltip } from "@app/components/v2"; @@ -63,6 +64,7 @@ const formSchema = z.object({ creationStatement: z.string().min(1), revocationStatement: z.string().min(1), renewStatement: z.string().optional(), + sslEnabled: z.boolean().optional(), ca: z.string().optional(), gatewayId: z.string().optional().nullable() }) @@ -151,6 +153,7 @@ export const EditDynamicSecretSqlProviderForm = ({ }); const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + const selectedClient = watch("inputs.client"); const updateDynamicSecret = useUpdateDynamicSecret(); const selectedGatewayId = watch("inputs.gatewayId"); @@ -407,13 +410,34 @@ export const EditDynamicSecretSqlProviderForm = ({ />
+ {selectedClient === SqlProviders.MsSQL && ( +
+ ( + + + Encrypt Connection (SSL) + + + )} + /> +
+ )} ( From 7ea0cc6cb7e41bd19c0e5149a5c0ea0a4f6eb4c1 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 10 Sep 2025 20:47:14 +0800 Subject: [PATCH 38/46] fix: passed original host and added context comment --- .../dynamic-secret/providers/sql-database.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index babbe1912..562aa58f2 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -151,15 +151,26 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) return { ...providerInputs, hostIp }; }; - const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const $getClient = async ( + providerInputs: z.infer & { hostIp: string; originalHost: string } + ) => { const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host } : undefined; const isMsSQLClient = providerInputs.client === SqlProviders.MsSQL; - const isAzureSql = isMsSQLClient && new RE2(/\.database\.windows\.net$/i).test(providerInputs.host); - const azureServerLabel = isAzureSql ? providerInputs.host.split(".")[0] : undefined; + /* + We route through the gateway by setting connection.host = "localhost". + Azure SQL identifies the logical server from the TDS login name when the host + isn’t the Azure FQDN. Therefore, when using the gateway, ensure username is + "user@" so Azure opens the correct logical server. + Direct connections to the Azure FQDN usually don’t require this suffix. + */ + const isGatewayForwardedTraffic = providerInputs.host === "localhost"; + const isAzureSql = isMsSQLClient && new RE2(/\.database\.windows\.net$/i).test(providerInputs.originalHost); + const azureServerLabel = + isAzureSql && isGatewayForwardedTraffic ? providerInputs.originalHost?.split(".")[0] : undefined; const effectiveUser = isAzureSql && !providerInputs.username.includes("@") ? `${providerInputs.username}@${azureServerLabel}` @@ -226,7 +237,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) ...providerInputs, port, host, - hostIp: providerInputs.hostIp + hostIp: providerInputs.hostIp, + originalHost: providerInputs.host }); // oracle needs from keyword const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; @@ -271,7 +283,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const db = await $getClient({ ...providerInputs, port, - host + host, + originalHost: providerInputs.host }); try { const expiration = new Date(expireAt).toISOString(); @@ -318,7 +331,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const db = await $getClient({ ...providerInputs, port, - host + host, + originalHost: providerInputs.host }); try { const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); @@ -357,7 +371,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const db = await $getClient({ ...providerInputs, port, - host + host, + originalHost: providerInputs.host }); const expiration = new Date(expireAt).toISOString(); const { database } = providerInputs; From a2a96d41cfc94f45c35c7c09911847b86aa70b29 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 10 Sep 2025 21:41:03 +0800 Subject: [PATCH 39/46] misc: added azure server label condition --- .../src/ee/services/dynamic-secret/providers/sql-database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index e2b89ac04..2b9636724 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -171,7 +171,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const azureServerLabel = isAzureSql && providerInputs.gatewayId ? providerInputs.originalHost?.split(".")[0] : undefined; const effectiveUser = - isAzureSql && !providerInputs.username.includes("@") + isAzureSql && !providerInputs.username.includes("@") && azureServerLabel ? `${providerInputs.username}@${azureServerLabel}` : providerInputs.username; From 0c2ffc75f6a472260d9cf39ddc087580c8718c65 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Wed, 10 Sep 2025 12:36:38 -0300 Subject: [PATCH 40/46] Improve self-hosted refresh logic --- .../src/ee/services/license/license-service.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 23d8b0646..ca1615ce8 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -99,6 +99,17 @@ export const licenseServiceFactory = ({ const workspacesUsed = await projectDAL.countOfOrgProjects(null); currentPlan.workspacesUsed = workspacesUsed; + const usedIdentitySeats = await licenseDAL.countOrgUsersAndIdentities(null); + if (usedIdentitySeats !== currentPlan.identitiesUsed) { + const usedSeats = await licenseDAL.countOfOrgMembers(null); + await licenseServerOnPremApi.request.patch(`/api/license/v1/license`, { + usedSeats, + usedIdentitySeats + }); + currentPlan.identitiesUsed = usedIdentitySeats; + currentPlan.membersUsed = usedSeats; + } + onPremFeatures = currentPlan; logger.info("Successfully synchronized license key features"); } catch (error) { @@ -226,10 +237,13 @@ export const licenseServiceFactory = ({ }; const refreshPlan = async (orgId: string) => { + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); if (instanceType === InstanceType.Cloud) { - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); await getPlan(orgId); } + if (instanceType === InstanceType.EnterpriseOnPrem) { + await syncLicenseKeyOnPremFeatures(true); + } }; const generateOrgCustomerId = async (orgName: string, email?: string | null) => { From feff3dd765d4e7256bbec5f695bc1adce003450e Mon Sep 17 00:00:00 2001 From: x032205 Date: Wed, 10 Sep 2025 12:02:41 -0400 Subject: [PATCH 41/46] feat(dynamic-secret): made POST and PATCH endpoints return inputs --- backend/src/ee/routes/v1/dynamic-secret-router.ts | 8 ++++++-- .../ee/services/dynamic-secret/dynamic-secret-service.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index b916bab67..b1b3cea8e 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -84,7 +84,9 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - dynamicSecret: SanitizedDynamicSecretSchema + dynamicSecret: SanitizedDynamicSecretSchema.extend({ + inputs: z.unknown() + }) }) } }, @@ -151,7 +153,9 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - dynamicSecret: SanitizedDynamicSecretSchema + dynamicSecret: SanitizedDynamicSecretSchema.extend({ + inputs: z.unknown() + }) }) } }, diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 73dcbe6e3..2baafb053 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -180,7 +180,7 @@ export const dynamicSecretServiceFactory = ({ return cfg; }); - return dynamicSecretCfg; + return { ...dynamicSecretCfg, inputs }; }; const updateByName: TDynamicSecretServiceFactory["updateByName"] = async ({ @@ -337,7 +337,7 @@ export const dynamicSecretServiceFactory = ({ return cfg; }); - return updatedDynamicCfg; + return { ...updatedDynamicCfg, inputs: updatedInput }; }; const deleteByName: TDynamicSecretServiceFactory["deleteByName"] = async ({ From 71ff01daf2e429a00dc8b4922761b81aee174d62 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 11 Sep 2025 00:45:27 +0800 Subject: [PATCH 42/46] misc: addressed comments --- backend/src/ee/routes/v1/relay-router.ts | 5 ++- backend/src/ee/routes/v2/gateway-router.ts | 5 ++- .../ee/services/gateway-v2/gateway-v2-dal.ts | 21 ++++++++++- .../services/gateway-v2/gateway-v2-service.ts | 1 + .../src/ee/services/relay/relay-service.ts | 21 +++++++---- docs/cli/commands/gateway.mdx | 13 +++++++ docs/cli/commands/relay.mdx | 10 ++--- .../platform/gateways/gateway-security.mdx | 37 ++++++++++++------- .../platform/gateways/overview.mdx | 30 ++++++++++++--- 9 files changed, 105 insertions(+), 38 deletions(-) diff --git a/backend/src/ee/routes/v1/relay-router.ts b/backend/src/ee/routes/v1/relay-router.ts index 4cfa2c160..e20480088 100644 --- a/backend/src/ee/routes/v1/relay-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -4,6 +4,7 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -19,7 +20,7 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ host: z.string(), - name: z.string() + name: slugSchema({ min: 1, max: 32, field: "name" }) }), response: { 200: z.object({ @@ -69,7 +70,7 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ host: z.string(), - name: z.string() + name: slugSchema({ min: 1, max: 32, field: "name" }) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts index e4d3b3e88..a7e656a64 100644 --- a/backend/src/ee/routes/v2/gateway-router.ts +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -2,6 +2,7 @@ import z from "zod"; import { GatewaysV2Schema } from "@app/db/schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -20,8 +21,8 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { url: "/", schema: { body: z.object({ - relayName: z.string(), - name: z.string() + relayName: slugSchema({ min: 1, max: 32, field: "relayName" }), + name: slugSchema({ min: 1, max: 32, field: "name" }) }), response: { 200: z.object({ 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 6154d3357..da9d3c1ef 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { GatewaysV2Schema, TableName, TGatewaysV2 } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -10,7 +12,7 @@ export const gatewayV2DalFactory = (db: TDbClient) => { const find = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { try { - const query = (tx || db)(TableName.GatewayV2) + const query = (tx || db.replicaNode())(TableName.GatewayV2) // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter(filter, TableName.GatewayV2)) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.GatewayV2}.identityId`) @@ -39,5 +41,20 @@ export const gatewayV2DalFactory = (db: TDbClient) => { } }; - return { ...orm, find }; + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await (tx || db.replicaNode())(TableName.GatewayV2) + .join(TableName.Organization, `${TableName.GatewayV2}.orgId`, `${TableName.Organization}.id`) + .where(`${TableName.GatewayV2}.id`, id) + .select(selectAllTableCols(TableName.GatewayV2)) + .select(db.ref("name").withSchema(TableName.Organization).as("orgName")) + .first(); + + return doc; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.GatewayV2}: Find by id` }); + } + }; + + return { ...orm, find, findById }; }; 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 64c325177..35f8cdde5 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -394,6 +394,7 @@ export const gatewayV2ServiceFactory = ({ const relayCredentials = await relayService.getCredentialsForClient({ relayId: gateway.relayId, orgId: gateway.orgId, + orgName: gateway.orgName, gatewayId }); diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 6bc938c77..7f0d19e47 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -13,6 +13,7 @@ import { import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { verifyHostInputValidity } from "../dynamic-secret/dynamic-secret-fns"; import { createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns"; import { SshCertType } from "../ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types"; @@ -689,6 +690,7 @@ export const relayServiceFactory = ({ const $generateRelayClientCredentials = async ({ gatewayId, orgId, + orgName, relayPkiClientCaCertificate, relayPkiClientCaPrivateKey, relayPkiServerCaCertificate, @@ -696,6 +698,7 @@ export const relayServiceFactory = ({ }: { gatewayId: string; orgId: string; + orgName: string; relayPkiClientCaCertificate: Buffer; relayPkiClientCaPrivateKey: Buffer; relayPkiServerCaCertificate: Buffer; @@ -742,7 +745,7 @@ export const relayServiceFactory = ({ const clientCert = await x509.X509CertificateGenerator.create({ serialNumber: clientCertSerialNumber, - subject: `O=${orgId},OU=relay-client,CN=${gatewayId}`, + subject: `O=${orgName}-${orgId},OU=relay-client,CN=${gatewayId}`, issuer: relayClientCaCert.subject, notAfter: clientCertExpiration, notBefore: clientCertIssuedAt, @@ -833,10 +836,12 @@ export const relayServiceFactory = ({ const getCredentialsForClient = async ({ relayId, orgId, + orgName, gatewayId }: { relayId: string; orgId: string; + orgName: string; gatewayId: string; }) => { const relay = await relayDAL.findOne({ @@ -849,11 +854,14 @@ export const relayServiceFactory = ({ }); } + await verifyHostInputValidity(relay.host); + if (relay.orgId === null) { const instanceCAs = await $getInstanceCAs(); const relayCertificateCredentials = await $generateRelayClientCredentials({ gatewayId, orgId, + orgName, relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate, relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey, relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate, @@ -870,6 +878,7 @@ export const relayServiceFactory = ({ const relayCertificateCredentials = await $generateRelayClientCredentials({ gatewayId, orgId, + orgName, relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate, relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey, relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate, @@ -896,6 +905,8 @@ export const relayServiceFactory = ({ let relay: TRelays; const isOrgRelay = identityId && orgId; + await verifyHostInputValidity(host); + if (isOrgRelay) { relay = await relayDAL.transaction(async (tx) => { const existingRelay = await relayDAL.findOne( @@ -907,9 +918,7 @@ export const relayServiceFactory = ({ ); if (existingRelay && (existingRelay.host !== host || existingRelay.name !== name)) { - throw new BadRequestError({ - message: "Org relay with this machine identity already exists." - }); + return relayDAL.updateById(existingRelay.id, { host, name }, tx); } if (!existingRelay) { @@ -937,9 +946,7 @@ export const relayServiceFactory = ({ ); if (existingRelay && existingRelay.host !== host) { - throw new BadRequestError({ - message: "Instance relay with this name already exists with a different host" - }); + return relayDAL.updateById(existingRelay.id, { host }, tx); } if (!existingRelay) { diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index 1d66281b8..99d0e1086 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -22,6 +22,13 @@ The Infisical gateway provides secure access to private resources using modern T The gateway system uses SSH reverse tunnels over TCP, eliminating firewall complexity and providing excellent performance for enterprise environments. + +**Deprecation and Migration Notice:** The legacy `infisical gateway` command (v1) will be removed in a future release. Please migrate to `infisical gateway start` (Gateway v2). + +If you are moving from Gateway v1 to Gateway v2, this is NOT a drop-in switch. Gateway v2 creates new gateway instances with new gateway IDs. You must update any existing resources that reference gateway IDs (for example: dynamic secret configs, app connections, or other gateway-bound resources) to point to the new Gateway v2 gateway ID. Until you update those references, traffic will continue to target the old v1 gateway. + + + ## Subcommands & flags @@ -361,6 +368,9 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot **This command is deprecated and will be removed in a future release.** Please migrate to `infisical gateway start` for the new TCP-based SSH tunnel architecture. + +**Migration required:** If you are currently using Gateway v1 (via `infisical gateway`), moving to Gateway v2 is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. Until you update those references, traffic will continue to target the old v1 gateway. + Run the legacy Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection. @@ -585,6 +595,9 @@ The Infisical CLI supports multiple authentication methods. Below are the availa **This command is deprecated and will be removed in a future release.** Please migrate to `infisical gateway systemd install` for the new TCP-based SSH tunnel architecture with enhanced security and better performance. + +**Migration required:** If you previously installed Gateway v1 via `infisical gateway install`, moving to Gateway v2 is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. Until you update those references, traffic will continue to target the old v1 gateway. + Install and enable the legacy gateway as a systemd service. This command must be run with sudo on Linux. diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index 7fadfa8d2..46a061da3 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -1,6 +1,6 @@ --- title: "infisical relay" -description: "Relay-related commands for Infisical including proxy components" +description: "Relay-related commands for Infisical" --- @@ -33,7 +33,7 @@ infisical relay start --type= --host= --name= --auth-method= The type of relay to run. Must be either 'instance' or 'org'. - - **`instance`**: Shared relay server that can be used by all organizations on your Infisical instance. Set up by the instance administrator. Uses `INFISICAL_PROXY_AUTH_SECRET` environment variable for authentication, which must be configured by the instance admin. + - **`instance`**: Shared relay server that can be used by all organizations on your Infisical instance. Set up by the instance administrator. Uses `INFISICAL_RELAY_AUTH_SECRET` environment variable for authentication, which must be configured by the instance admin. - **`org`**: Dedicated relay server that individual organizations deploy and manage in their own infrastructure. Provides enhanced security, custom geographic placement, and compliance benefits. Uses standard Infisical authentication methods. ```bash @@ -41,7 +41,7 @@ infisical relay start --type= --host= --name= --auth-method= infisical relay start --type=instance --host=10.0.1.50 --name=shared-relay + INFISICAL_RELAY_AUTH_SECRET= infisical relay start --type=instance --host=10.0.1.50 --name=shared-relay ``` @@ -75,14 +75,14 @@ infisical relay start --type= --host= --name= --auth-method= --client-secret= # Instance relay (configured by instance admin) -INFISICAL_PROXY_AUTH_SECRET= infisical relay start --type=instance --host=10.0.1.50 --name=shared-relay +INFISICAL_RELAY_AUTH_SECRET= infisical relay start --type=instance --host=10.0.1.50 --name=shared-relay ``` ### Authentication Methods diff --git a/docs/documentation/platform/gateways/gateway-security.mdx b/docs/documentation/platform/gateways/gateway-security.mdx index 70671d164..6962c627e 100644 --- a/docs/documentation/platform/gateways/gateway-security.mdx +++ b/docs/documentation/platform/gateways/gateway-security.mdx @@ -68,26 +68,35 @@ Gateway ↔ Relay Server communication uses SSH certificate authentication: - Gateway validates certificate against appropriate SSH Server CA - Ensures gateway connects to legitimate relay infrastructure -### 3. Application Traffic Security +### 3. Platform-to-Gateway Direct Connection -End-to-end encryption for application data: +The platform establishes secure direct connections with gateways through a **TLS-pinned tunnel** mechanism: -1. **mTLS Layer**: +1. **TLS-Pinned Tunnel Establishment**: - - Infisical platform establishes mTLS connections directly with gateways - - Uses Organization Gateway certificates for authentication - - Application traffic is encrypted end-to-end between platform and gateway + - Gateway initiates outbound connection to platform through SSH reverse tunnel + - Platform establishes direct mTLS connection with gateway using Organization Gateway certificates + - TLS certificate pinning ensures the connection is bound to the specific gateway identity + - No inbound connections required - all communication flows through the outbound tunnel -2. **SSH Tunnel Layer**: +2. **Connection Flow**: - - mTLS-encrypted application traffic travels through SSH reverse tunnels - - Creates double encryption: mTLS payload within SSH tunnel - - Relay servers cannot decrypt either encryption layer + ``` + Platform ←→ [SSH Reverse Tunnel] ←→ Gateway + ``` -3. **Traffic Isolation**: - - Each gateway maintains separate SSH tunnels - - Organization's private keys never leave their environment - - Complete cryptographic isolation between organizations + - Gateway maintains persistent outbound SSH tunnel to relay server + - Platform connects directly to gateway through this tunnel + - TLS handshake occurs over the SSH tunnel, establishing mTLS connection + - Application traffic flows through the TLS-pinned tunnel + +3. **Security Benefits**: + + - **No inbound connections**: Gateway never needs to accept incoming connections + - **Certificate-based authentication**: Uses Organization Gateway certificates for mutual TLS + - **Double encryption**: TLS traffic within SSH tunnel provides layered security + - **Relay server isolation**: Relay cannot decrypt either TLS or application data + - **Tenant isolation**: Each organization's traffic flows through separate authenticated channels ## Tenant Isolation diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index 35274fd8f..b8ea0102a 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -102,6 +102,13 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi For production deployments on Linux, install the Gateway as a systemd service: + + + **Gateway v2:** The `infisical gateway systemd install` command deploys the new Gateway v2 component. + + If you are migrating from Gateway v1 (legacy `infisical gateway install` command), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. + + ```bash sudo infisical gateway systemd install --token --domain --name --relay sudo systemctl start infisical-gateway @@ -369,8 +376,13 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi - ### Install the Infisical Gateway Helm Chart + + **Version mapping:** Helm chart versions `>= 1.0.0` contain the new Gateway v2 component. Helm chart versions `<= 0.0.5` contain the legacy Gateway v1 component. + + If you are moving from Gateway v1 (chart `<= 0.0.5`) to Gateway v2 (chart `>= 1.0.0`), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. + + ```bash helm install infisical-gateway infisical-helm-charts/infisical-gateway ``` @@ -385,11 +397,17 @@ Once authenticated, the Gateway establishes an SSH reverse tunnel to the specifi You should see the following output which indicates the gateway is running as expected. ```bash $ kubectl logs deployment/infisical-gateway - INF Starting gateway - INF Starting gateway certificate renewal goroutine - INF Successfully registered gateway and received certificates - INF Connecting to relay server infisical-start on 152.42.218.156:2222... - INF Relay connection established for gateway + 12:43AM INF Starting gateway + 12:43AM INF Starting gateway certificate renewal goroutine + 12:43AM INF Successfully registered gateway and received certificates + 12:43AM INF Connecting to relay server infisical-start on 152.42.218.156:2222... + 12:43AM INF Relay connection established for gateway + 12:43AM INF Received incoming connection, starting TLS handshake + 12:43AM INF TLS handshake completed successfully + 12:43AM INF Negotiated ALPN protocol: infisical-ping + 12:43AM INF Starting ping handler + 12:43AM INF Ping handler completed + 12:43AM INF Gateway is reachable by Infisical ``` From 0153cf946713643091b19538948c41148bb147c9 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 10 Sep 2025 22:55:15 +0530 Subject: [PATCH 43/46] fix: resolved validate schema failing in pipeline --- backend/src/services/kms/kms-root-config-dal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/kms/kms-root-config-dal.ts b/backend/src/services/kms/kms-root-config-dal.ts index 4de3fc15a..1b9b9a230 100644 --- a/backend/src/services/kms/kms-root-config-dal.ts +++ b/backend/src/services/kms/kms-root-config-dal.ts @@ -12,7 +12,7 @@ export const kmsRootConfigDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex) => { try { - const result = await (tx || db.replicaNode())(TableName.KmsServerRootConfig) + const result = await (tx || db?.replicaNode?.() || db)(TableName.KmsServerRootConfig) .where({ id } as never) .first("*"); return result; From 75da787cf10b81b4307725fcadddf44424b6771e Mon Sep 17 00:00:00 2001 From: x032205 Date: Wed, 10 Sep 2025 14:40:56 -0400 Subject: [PATCH 44/46] feat(dynamic-secret): fix mongodb schema --- backend/src/ee/services/dynamic-secret/providers/models.ts | 4 ++-- backend/src/ee/services/dynamic-secret/providers/mongo-db.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index c618a308b..f076bf883 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -276,11 +276,11 @@ export const DynamicSecretMongoAtlasSchema = z.object({ export const DynamicSecretMongoDBSchema = z.object({ host: z.string().min(1).trim().toLowerCase(), - port: z.number().optional(), + port: z.number().optional().nullable(), username: z.string().min(1).trim(), password: z.string().min(1).trim(), database: z.string().min(1).trim(), - ca: z.string().min(1).optional(), + ca: z.string().trim().optional().nullable(), roles: z .string() .array() diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index db1d30dfa..8154f3e13 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -44,7 +44,7 @@ export const MongoDBProvider = (): TDynamicProviderFns => { password: providerInputs.password }, directConnection: !isSrv, - ca: providerInputs.ca + ca: providerInputs.ca || undefined }); return client; }; From cb3ba75bc02f5d785f046781c37391036de0882f Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 11 Sep 2025 03:26:31 +0800 Subject: [PATCH 45/46] misc: shortened cert expiry --- backend/src/ee/services/gateway-v2/gateway-v2-service.ts | 2 +- backend/src/ee/services/relay/relay-service.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 35f8cdde5..317e5da6d 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -471,7 +471,7 @@ export const gatewayV2ServiceFactory = ({ const gatewayServerKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const gatewayServerCertIssuedAt = new Date(); - const gatewayServerCertExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1)); + const gatewayServerCertExpireAt = new Date(new Date().setDate(new Date().getDate() + 1)); const gatewayServerCertPrivateKey = crypto.nativeCrypto.KeyObject.from(gatewayServerKeys.privateKey); const gatewayServerCertExtensions: x509.Extension[] = [ diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 7f0d19e47..92401faaf 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -625,7 +625,7 @@ export const relayServiceFactory = ({ const relayServerKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const relayServerCertIssuedAt = new Date(); - const relayServerCertExpireAt = new Date(new Date().setMonth(new Date().getMonth() + 1)); + const relayServerCertExpireAt = new Date(new Date().setDate(new Date().getDate() + 1)); const relayServerCertPrivateKey = crypto.nativeCrypto.KeyObject.from(relayServerKeys.privateKey); const relayServerCertExtensions: x509.Extension[] = [ @@ -804,7 +804,7 @@ export const relayServiceFactory = ({ keyId: `client-${relayName}`, principals: [gatewayId], certType: SshCertType.USER, - requestedTtl: "30d" + requestedTtl: "1d" }); return { From d0ec4810d1761bb93e25a92d3496337c7564318e Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Wed, 10 Sep 2025 12:56:13 -0700 Subject: [PATCH 46/46] fix(frontend): use unique formatter key in for secret references --- frontend/src/components/v2/SecretInput/SecretInput.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 747af73d5..c320bb0af 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -34,7 +34,9 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport? // akhilmhdh: Dont remove this br. I am still clueless how this works but weirdly enough // when break is added a line break works properly - return formattedContent.concat(
); + return formattedContent.concat( +
+ ); }; type Props = TextareaHTMLAttributes & {