From 0265665e83764fb88ad348e1d68f9417a887d0bc Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 17 Apr 2025 22:35:25 -0700 Subject: [PATCH 01/10] Make infisical ssh v2 work in non-interactive mode, allow reassignment of default ssh cas --- ...20250418003930_ssh-nullable-ca-defaults.ts | 49 +++++ .../services/permission/project-permission.ts | 2 - .../src/server/routes/v1/project-router.ts | 78 +++++++ .../src/services/project/project-service.ts | 131 +++++++++++- backend/src/services/project/project-types.ts | 7 + cli/packages/cmd/ssh.go | 194 +++++++++++++----- docs/documentation/platform/ssh.mdx | 4 +- frontend/src/hooks/api/workspace/index.tsx | 4 +- .../src/hooks/api/workspace/mutations.tsx | 23 ++- frontend/src/hooks/api/workspace/queries.tsx | 15 ++ .../src/hooks/api/workspace/query-keys.tsx | 3 +- frontend/src/hooks/api/workspace/types.ts | 15 ++ .../layouts/ProjectLayout/ProjectLayout.tsx | 47 +++-- .../pages/ssh/SettingsPage/SettingsPage.tsx | 36 ++-- .../ProjectSshTab/ProjectSshTab.tsx | 9 + .../components/ProjectSshConfigCasSection.tsx | 139 +++++++++++++ .../ProjectSshTab/components/index.tsx | 1 + .../components/ProjectSshTab/index.tsx | 1 + 18 files changed, 673 insertions(+), 85 deletions(-) create mode 100644 backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts create mode 100644 frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx create mode 100644 frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx create mode 100644 frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx create mode 100644 frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx diff --git a/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts b/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts new file mode 100644 index 000000000..3bc64d86b --- /dev/null +++ b/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { ProjectType, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasDefaultUserCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultUserSshCaId"); + const hasDefaultHostCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultHostSshCaId"); + + if (hasDefaultUserCaCol && hasDefaultHostCaCol) { + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + t.dropForeign(["defaultUserSshCaId"]); + t.dropForeign(["defaultHostSshCaId"]); + }); + + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + // allow nullable (does not wipe existing values) + t.uuid("defaultUserSshCaId").nullable().alter(); + t.uuid("defaultHostSshCaId").nullable().alter(); + + // re-add with SET NULL behavior (previously CASCADE) + t.foreign("defaultUserSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + t.foreign("defaultHostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + }); + } + + // (dangtony98): backfill by adding null defaults CAs for all existing Infisical SSH projects + // that do not have an associated ProjectSshConfig record introduced in Infisical SSH V2. + + const allProjects = await knex(TableName.Project).where("type", ProjectType.SSH).select("id"); + + const projectsWithConfig = await knex(TableName.ProjectSshConfig).select("projectId"); + const projectIdsWithConfig = new Set(projectsWithConfig.map((config) => config.projectId)); + + const projectsNeedingConfig = allProjects.filter((project) => !projectIdsWithConfig.has(project.id)); + + if (projectsNeedingConfig.length > 0) { + const configsToInsert = projectsNeedingConfig.map((project) => ({ + projectId: project.id, + defaultUserSshCaId: null, + defaultHostSshCaId: null, + createdAt: new Date(), + updatedAt: new Date() + })); + + await knex.batchInsert(TableName.ProjectSshConfig, configsToInsert); + } +} + +export async function down(): Promise {} diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b5cfadbeb..d171fb3d8 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -965,7 +965,6 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); @@ -1031,7 +1030,6 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 4182ce389..ea899fcea 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -6,6 +6,7 @@ import { ProjectMembershipsSchema, ProjectRolesSchema, ProjectSlackConfigsSchema, + ProjectSshConfigsSchema, ProjectType, SecretFoldersSchema, SortDirection, @@ -612,6 +613,83 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.getProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId + }); + + // TODO: consider adding audit logs + + return sshConfig; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + defaultUserSshCaId: z.string().optional(), + defaultHostSshCaId: z.string().optional() + }), + response: { + 200: ProjectSshConfigsSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, + defaultUserSshCaId: true, + defaultHostSshCaId: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const sshConfig = await server.services.project.updateProjectSshConfig({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + ...req.body + }); + + return sshConfig; + } + }); + server.route({ method: "GET", url: "/:workspaceId/slack-config", diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 9b7c29c1b..9f2de8a85 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -73,6 +73,7 @@ import { TGetProjectDTO, TGetProjectKmsKey, TGetProjectSlackConfig, + TGetProjectSshConfig, TListProjectAlertsDTO, TListProjectCasDTO, TListProjectCertificateTemplatesDTO, @@ -92,6 +93,7 @@ import { TUpdateProjectKmsDTO, TUpdateProjectNameDTO, TUpdateProjectSlackConfig, + TUpdateProjectSshConfig, TUpdateProjectVersionLimitDTO, TUpgradeProjectDTO } from "./project-types"; @@ -104,7 +106,7 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; - projectSshConfigDAL: Pick; + projectSshConfigDAL: Pick; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; projectBotService: Pick; @@ -129,7 +131,7 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; - sshCertificateAuthorityDAL: Pick; + sshCertificateAuthorityDAL: Pick; sshCertificateAuthoritySecretDAL: Pick; sshCertificateDAL: Pick; sshCertificateTemplateDAL: Pick; @@ -1327,6 +1329,129 @@ export const projectServiceFactory = ({ return { secretManagerKmsKey: kmsKey }; }; + const getProjectSshConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TGetProjectSshConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + + const projectSshConfig = await projectSshConfigDAL.findOne({ + projectId: project.id + }); + + if (!projectSshConfig) { + throw new NotFoundError({ + message: `Project SSH config with ID '${project.id}' not found` + }); + } + + return projectSshConfig; + }; + + const updateProjectSshConfig = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + defaultUserSshCaId, + defaultHostSshCaId + }: TUpdateProjectSshConfig) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SSH + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + let projectSshConfig = await projectSshConfigDAL.findOne({ + projectId: project.id + }); + + if (!projectSshConfig) { + throw new NotFoundError({ + message: `Project SSH config with ID '${project.id}' not found` + }); + } + + projectSshConfig = await projectSshConfigDAL.transaction(async (tx) => { + if (defaultUserSshCaId) { + const userSshCa = await sshCertificateAuthorityDAL.findOne( + { + id: defaultUserSshCaId, + projectId: project.id + }, + tx + ); + + if (!userSshCa) { + throw new NotFoundError({ + message: "User SSH CA must exist and belong to this project" + }); + } + } + + if (defaultHostSshCaId) { + const hostSshCa = await sshCertificateAuthorityDAL.findOne( + { + id: defaultHostSshCaId, + projectId: project.id + }, + tx + ); + + if (!hostSshCa) { + throw new NotFoundError({ + message: "Host SSH CA must exist and belong to this project" + }); + } + } + + const updatedProjectSshConfig = await projectSshConfigDAL.updateById( + projectSshConfig.id, + { + defaultUserSshCaId, + defaultHostSshCaId + }, + tx + ); + + return updatedProjectSshConfig; + }); + + return projectSshConfig; + }; + const getProjectSlackConfig = async ({ actorId, actor, @@ -1548,6 +1673,8 @@ export const projectServiceFactory = ({ getProjectKmsBackup, loadProjectKmsBackup, getProjectKmsKeys, + getProjectSshConfig, + updateProjectSshConfig, getProjectSlackConfig, updateProjectSlackConfig, requestProjectAccess, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 444f6309c..274189668 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -159,6 +159,13 @@ export type TListProjectSshCertificatesDTO = { limit: number; } & TProjectPermission; +export type TUpdateProjectSshConfig = { + defaultUserSshCaId?: string; + defaultHostSshCaId?: string; +} & TProjectPermission; + +export type TGetProjectSshConfig = TProjectPermission; + export type TGetProjectSlackConfig = TProjectPermission; export type TUpdateProjectSlackConfig = { diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index 5b2bb37bb..94519dcc8 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -177,7 +177,6 @@ func issueCredentials(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -411,7 +410,6 @@ func signKey(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -610,25 +608,82 @@ func signKey(cmd *cobra.Command, args []string) { } func sshConnect(cmd *cobra.Command, args []string) { - util.RequireLogin() - util.RequireLocalWorkspaceFile() - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + token, err := util.GetInfisicalToken(cmd) if err != nil { - util.HandleError(err, "Unable to authenticate") + util.HandleError(err, "Unable to parse flag") } + + var infisicalToken string - if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + infisicalToken = token.Token + } else { + util.RequireLogin() + + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } - infisicalToken := loggedInUserDetails.UserCredentials.JTWToken - writeHostCaToFile, err := cmd.Flags().GetBool("writeHostCaToFile") if err != nil { util.HandleError(err, "Unable to parse --writeHostCaToFile flag") } + outFilePath, err := cmd.Flags().GetString("outFilePath") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + hostname, _ := cmd.Flags().GetString("hostname") + loginUser, _ := cmd.Flags().GetString("loginUser") + + var outputDir, privateKeyPath, publicKeyPath, signedKeyPath string + if outFilePath != "" { + if strings.HasPrefix(outFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Failed to resolve home directory") + } + outFilePath = strings.Replace(outFilePath, "~", homeDir, 1) + } + + if strings.HasSuffix(outFilePath, "-cert.pub") { + signedKeyPath = outFilePath + baseName := strings.TrimSuffix(filepath.Base(outFilePath), "-cert.pub") + outputDir = filepath.Dir(outFilePath) + privateKeyPath = filepath.Join(outputDir, baseName) + publicKeyPath = filepath.Join(outputDir, baseName+".pub") + } else { + outputDir = outFilePath + info, err := os.Stat(outputDir) + if os.IsNotExist(err) { + err = os.MkdirAll(outputDir, 0755) + if err != nil { + util.HandleError(err, "Failed to create output directory") + } + } else if err != nil { + util.HandleError(err, "Failed to access output directory") + } else if !info.IsDir() { + util.PrintErrorMessageAndExit("The provided --outFilePath is not a directory") + } + fileName := "id_ed25519" + privateKeyPath = filepath.Join(outputDir, fileName) + publicKeyPath = filepath.Join(outputDir, fileName+".pub") + signedKeyPath = filepath.Join(outputDir, fileName+"-cert.pub") + } + + if privateKeyPath == "" || publicKeyPath == "" || signedKeyPath == "" { + util.PrintErrorMessageAndExit("Failed to resolve file paths for writing credentials") + } + } + customHeaders, err := util.GetInfisicalCustomHeadersMap() if err != nil { util.HandleError(err, "Unable to get custom headers") @@ -651,43 +706,68 @@ func sshConnect(cmd *cobra.Command, args []string) { util.PrintErrorMessageAndExit("You do not have access to any SSH hosts") } - // Prompt to select host - hostNames := make([]string, len(hosts)) - for i, h := range hosts { - hostNames[i] = h.Hostname + var selectedHost = hosts[0] + if hostname != "" { + foundHost := false + for _, h := range hosts { + if h.Hostname == hostname { + selectedHost = h + foundHost = true + break + } + } + if !foundHost { + util.PrintErrorMessageAndExit("Specified --hostname not found or not accessible") + } + } else { + hostNames := make([]string, len(hosts)) + for i, h := range hosts { + hostNames[i] = h.Hostname + } + hostPrompt := promptui.Select{ + Label: "Select an SSH Host", + Items: hostNames, + Size: 10, + } + hostIdx, _, err := hostPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedHost = hosts[hostIdx] } - hostPrompt := promptui.Select{ - Label: "Select an SSH Host", - Items: hostNames, - Size: 10, + var selectedLoginUser string + if loginUser != "" { + foundLoginUser := false + for _, m := range selectedHost.LoginMappings { + if m.LoginUser == loginUser { + selectedLoginUser = loginUser + foundLoginUser = true + break + } + } + if !foundLoginUser { + util.PrintErrorMessageAndExit("Specified --loginUser not valid for selected host") + } + } else { + if len(selectedHost.LoginMappings) == 0 { + util.PrintErrorMessageAndExit("No login users available for selected host") + } + loginUsers := make([]string, len(selectedHost.LoginMappings)) + for i, m := range selectedHost.LoginMappings { + loginUsers[i] = m.LoginUser + } + loginPrompt := promptui.Select{ + Label: "Select Login User", + Items: loginUsers, + Size: 5, + } + loginIdx, _, err := loginPrompt.Run() + if err != nil { + util.HandleError(err, "Prompt failed") + } + selectedLoginUser = selectedHost.LoginMappings[loginIdx].LoginUser } - hostIdx, _, err := hostPrompt.Run() - if err != nil { - util.HandleError(err, "Prompt failed") - } - selectedHost := hosts[hostIdx] - - // Prompt to select login user - if len(selectedHost.LoginMappings) == 0 { - util.PrintErrorMessageAndExit("No login users available for selected host") - } - - loginUsers := make([]string, len(selectedHost.LoginMappings)) - for i, m := range selectedHost.LoginMappings { - loginUsers[i] = m.LoginUser - } - - loginPrompt := promptui.Select{ - Label: "Select Login User", - Items: loginUsers, - Size: 5, - } - loginIdx, _, err := loginPrompt.Run() - if err != nil { - util.HandleError(err, "Prompt failed") - } - selectedLoginUser := selectedHost.LoginMappings[loginIdx].LoginUser // Issue SSH creds for host creds, err := infisicalClient.Ssh().IssueSshHostUserCert(selectedHost.ID, infisicalSdk.IssueSshHostUserCertOptions{ @@ -731,10 +811,27 @@ func sshConnect(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to write Host CA to known_hosts") } - fmt.Printf("📁 Wrote Host CA entry to %s\n", knownHostsPath) + fmt.Printf("Successfully wrote Host CA entry to %s\n", knownHostsPath) } } + if outFilePath != "" { + err = os.WriteFile(privateKeyPath, []byte(creds.PrivateKey), 0600) + if err != nil { + util.HandleError(err, "Failed to write private key") + } + err = os.WriteFile(publicKeyPath, []byte(creds.PublicKey), 0644) + if err != nil { + util.HandleError(err, "Failed to write public key") + } + err = os.WriteFile(signedKeyPath, []byte(creds.SignedKey), 0644) + if err != nil { + util.HandleError(err, "Failed to write signed cert") + } + fmt.Printf("Successfully wrote credentials to %s, %s, and %s\n", privateKeyPath, publicKeyPath, signedKeyPath) + return + } + // Load credentials into SSH agent err = addCredentialsToAgent(creds.PrivateKey, creds.SignedKey) if err != nil { @@ -769,7 +866,6 @@ func sshAddHost(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -1006,7 +1102,11 @@ func init() { sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") sshCmd.AddCommand(sshIssueCredentialsCmd) + sshConnectCmd.Flags().String("token", "", "Use a machine identity access token") sshConnectCmd.Flags().Bool("writeHostCaToFile", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshConnectCmd.Flags().String("hostname", "", "Hostname of the SSH host to connect to") + sshConnectCmd.Flags().String("loginUser", "", "Login user for the SSH connection") + sshConnectCmd.Flags().String("outFilePath", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection") sshCmd.AddCommand(sshConnectCmd) sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token") diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index ae1e43df5..1f8856a5b 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -10,10 +10,10 @@ Infisical SSH can be configured to provide users on your team short-lived, secur and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management, unauthorized access, and SSH key sprawl. -The following entities and concepts are important to understand when using Infisical SSH: +The following entities are important to understand when configuring and using Infisical SSH: - Administrator: An individual on your team who is responsible for configuring Infisical SSH. -- Users: Other individuals on your team that need access to the remote host. +- Users: Other individuals that gain access to remote hosts through Infisical SSH. - Host: A remote machine (e.g. EC2 instance, GCP VM, Azure VM, on-prem Linux server, Raspberry Pi, VMware VM, etc.) that users need SSH access to that is registered with Infisical SSH. ## Workflow diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index c0f5f027d..c4defb68d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -4,7 +4,8 @@ export { useLeaveProject, useMigrateProjectToV3, useRequestProjectAccess, - useUpdateGroupWorkspaceRole + useUpdateGroupWorkspaceRole, + useUpdateProjectSshConfig } from "./mutations"; export { useAddIdentityToWorkspace, @@ -14,6 +15,7 @@ export { useDeleteUserFromWorkspace, useDeleteWorkspace, useDeleteWsEnvironment, + useGetProjectSshConfig, useGetUpgradeProjectStatus, useGetUserWorkspaceMemberships, useGetUserWorkspaces, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 56f83f601..ea7376d3d 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -4,7 +4,11 @@ import { apiRequest } from "@app/config/request"; import { userKeys } from "../users/query-keys"; import { workspaceKeys } from "./query-keys"; -import { TUpdateWorkspaceGroupRoleDTO } from "./types"; +import { + TProjectSshConfig, + TUpdateProjectSshConfigDTO, + TUpdateWorkspaceGroupRoleDTO +} from "./types"; export const useAddGroupToWorkspace = () => { const queryClient = useQueryClient(); @@ -117,3 +121,20 @@ export const useRequestProjectAccess = () => { } }); }; + +export const useUpdateProjectSshConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, defaultUserSshCaId, defaultHostSshCaId }) => { + return apiRequest.patch(`/api/v1/workspace/${projectId}/ssh-config`, { + defaultUserSshCaId, + defaultHostSshCaId + }); + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getProjectSshConfig(projectId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ca8feb6b6..8145e1904 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -34,6 +34,7 @@ import { TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, ToggleDeleteProjectProtectionDTO, + TProjectSshConfig, TSearchProjectsDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, @@ -887,3 +888,17 @@ export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: strin enabled: Boolean(workspaceId) }); }; + +export const useGetProjectSshConfig = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getProjectSshConfig(projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/workspace/${projectId}/ssh-config` + ); + + return data; + }, + enabled: Boolean(projectId) + }); +}; diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 539ed2ac7..b23ca6868 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -69,5 +69,6 @@ export const workspaceKeys = { projectId: string; }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, getWorkspaceSshCertificateTemplates: (projectId: string) => - [{ projectId }, "workspace-ssh-certificate-templates"] as const + [{ projectId }, "workspace-ssh-certificate-templates"] as const, + getProjectSshConfig: (projectId: string) => [{ projectId }, "project-ssh-config"] as const }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 814a920c2..ddcf383fb 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -184,3 +184,18 @@ export type TSearchProjectsDTO = { orderBy?: ProjectIdentityOrderBy; orderDirection?: OrderByDirection; }; + +export type TProjectSshConfig = { + id: string; + createdAt: string; + updatedAt: string; + projectId: string; + defaultUserSshCaId: string | null; + defaultHostSshCaId: string | null; +}; + +export type TUpdateProjectSshConfigDTO = { + projectId: string; + defaultUserSshCaId?: string; + defaultHostSshCaId?: string; +}; diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index 8b0d88ad5..b34c45a37 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import { motion } from "framer-motion"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { BreadcrumbContainer, Menu, @@ -11,7 +12,12 @@ import { MenuItem, TBreadcrumbFormat } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount, @@ -159,22 +165,31 @@ export const ProjectLayout = () => { )} */} - {/* - {({ isActive }) => ( - - Certificate Authorities - - )} - */} + {(isAllowed) => + isAllowed && ( + + {({ isActive }) => ( + + Certificate Authorities + + )} + + ) + } + )} {isSecretManager && ( diff --git a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx index dce31f01b..03d0e1c81 100644 --- a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx @@ -1,11 +1,12 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; - -const tabs = [{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab }]; +import { ProjectSshTab } from "./components/ProjectSshTab"; export const SettingsPage = () => { const { t } = useTranslation(); @@ -17,19 +18,28 @@ export const SettingsPage = () => {
- + - {tabs.map((tab) => ( - - {tab.name} - - ))} + General + + {(isAllowed) => isAllowed && SSH Settings} + - {tabs.map(({ key, Component }) => ( - - - - ))} + + + + + {(isAllowed) => + isAllowed && ( + + + + ) + } +
diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx new file mode 100644 index 000000000..8a79591bc --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/ProjectSshTab.tsx @@ -0,0 +1,9 @@ +import { ProjectSshConfigCasSection } from "./components"; + +export const ProjectSshTab = () => { + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx new file mode 100644 index 000000000..36a6df348 --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx @@ -0,0 +1,139 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, Select, SelectItem } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + useGetProjectSshConfig, + useListWorkspaceSshCas, + useUpdateProjectSshConfig +} from "@app/hooks/api"; + +const schema = z + .object({ + defaultUserSshCaId: z.string().optional(), + defaultHostSshCaId: z.string().optional() + }) + .required(); + +export type FormData = z.infer; + +export const ProjectSshConfigCasSection = () => { + const { currentWorkspace } = useWorkspace(); + const { data: sshConfig } = useGetProjectSshConfig(currentWorkspace.id); + const { data: sshCas } = useListWorkspaceSshCas(currentWorkspace.id); + const { mutate: updateProjectSshConfig } = useUpdateProjectSshConfig(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (sshConfig) { + reset({ + defaultUserSshCaId: sshConfig.defaultUserSshCaId || undefined, + defaultHostSshCaId: sshConfig.defaultHostSshCaId || undefined + }); + } + }, [sshConfig]); + + const onFormSubmit = async ({ defaultUserSshCaId, defaultHostSshCaId }: FormData) => { + try { + await updateProjectSshConfig({ + projectId: currentWorkspace.id, + defaultUserSshCaId: defaultUserSshCaId || undefined, + defaultHostSshCaId: defaultHostSshCaId || undefined + }); + + createNotification({ + text: "Successfully updated SSH project settings", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update SSH project settings", + type: "error" + }); + } + }; + + return ( +
+

Certificate Authorities

+
+ ( + + + + )} + /> + ( + + + + )} + /> + + {(isAllowed) => ( + + )} + + +
+ ); +}; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx new file mode 100644 index 000000000..79010edb9 --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/index.tsx @@ -0,0 +1 @@ +export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection"; diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx new file mode 100644 index 000000000..2fff359fe --- /dev/null +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/index.tsx @@ -0,0 +1 @@ +export { ProjectSshTab } from "./ProjectSshTab"; From 796f5510ca8af2dca7b1747e1e5b5547f0100046 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 17 Apr 2025 22:40:43 -0700 Subject: [PATCH 02/10] Add cli docs for infisical ssh connect command --- docs/cli/commands/ssh.mdx | 60 +++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/docs/cli/commands/ssh.mdx b/docs/cli/commands/ssh.mdx index 78712ba6f..534c55b66 100644 --- a/docs/cli/commands/ssh.mdx +++ b/docs/cli/commands/ssh.mdx @@ -7,10 +7,38 @@ description: "Generate SSH credentials with the CLI" [Infisical SSH](/documentation/platform/ssh) lets you issue SSH credentials to clients to provide short-lived, secure SSH access to infrastructure. -This command enables you to obtain SSH credentials used to access a remote host; we recommend using the `issue-credentials` sub-command to generate dynamic SSH credentials for each SSH session. +This command enables you to obtain SSH credentials used to access a remote host. We recommend using the `connect` sub-command which handles the full workflow of issuing credentials and establishing an SSH connection in one step. ### Sub-commands + + This command is used to connect to an SSH host using issued credentials. It will automatically issue credentials and either add them to your SSH agent or write them to disk before establishing an SSH connection. + + ```bash + $ infisical ssh connect + ``` + + ### Flags + + The hostname of the SSH host to connect to. If not provided, you will be prompted to select from available hosts. + + + The login user for the SSH connection. If not provided, you will be prompted to select from available login users. + + + Whether to write the Host CA public key to ~/.ssh/known_hosts if it doesn't already exist. + + Default value: `true` + + + The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection. + + + An authenticated token to use to authenticate with Infisical. + + + + This command is used to issue SSH credentials (SSH certificate, public key, and private key) against a certificate template. @@ -29,43 +57,44 @@ This command enables you to obtain SSH credentials used to access a remote host; Whether to add issued SSH credentials to the SSH agent. - + Default value: `false` - + Note that either the `--outFilePath` or `--addToAgent` flag must be set for the sub-command to execute successfully. The path to write the SSH credentials to such as `~/.ssh`, `./some_folder`, `./some_folder/id_rsa-cert.pub`. If not provided, the credentials will be saved to the current working directory where the command is run. - + Note that either the `--outFilePath` or `--addToAgent` flag must be set for the sub-command to execute successfully. The key algorithm to issue SSH credentials for. - + Default value: `RSA_2048` - + Available options: `RSA_2048`, `RSA_4096`, `EC_prime256v1`, `EC_secp384r1`. The certificate type to issue SSH credentials for. - + Default value: `user` - + Available options: `user` or `host` The time-to-live (TTL) for the issued SSH certificate (e.g. `2 days`, `1d`, `2h`, `1y`). - + Defaults to the Default TTL value set in the certificate template. A custom Key ID to issue SSH credentials for. - + Defaults to the autogenerated Key ID by Infisical. An authenticated token to use to issue SSH credentials. + @@ -95,22 +124,23 @@ This command enables you to obtain SSH credentials used to access a remote host; The certificate type to issue SSH credentials for. - + Default value: `user` - + Available options: `user` or `host` The time-to-live (TTL) for the issued SSH certificate (e.g. `2 days`, `1d`, `2h`, `1y`). - + Defaults to the Default TTL value set in the certificate template. A custom Key ID to issue SSH credentials for. - + Defaults to the autogenerated Key ID by Infisical. An authenticated token to use to issue SSH credentials. - \ No newline at end of file + + From c6cd3a8cc01a1267c976bd3f997ce06fe56e059b Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 17 Apr 2025 23:00:46 -0700 Subject: [PATCH 03/10] Add audit logs to project ssh config endpoints --- .../ee/services/audit-log/audit-log-types.ts | 23 +++++++++++++++ .../src/server/routes/v1/project-router.ts | 28 +++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 91464bc0b..3e0699e12 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -248,6 +248,8 @@ export enum EventType { DELETE_SLACK_INTEGRATION = "delete-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + GET_PROJECT_SSH_CONFIG = "get-project-ssh-config", + UPDATE_PROJECT_SSH_CONFIG = "update-project-ssh-config", INTEGRATION_SYNCED = "integration-synced", CREATE_CMEK = "create-cmek", UPDATE_CMEK = "update-cmek", @@ -1986,6 +1988,25 @@ interface GetProjectSlackConfig { id: string; }; } + +interface GetProjectSshConfig { + type: EventType.GET_PROJECT_SSH_CONFIG; + metadata: { + id: string; + projectId: string; + }; +} + +interface UpdateProjectSshConfig { + type: EventType.UPDATE_PROJECT_SSH_CONFIG; + metadata: { + id: string; + projectId: string; + defaultUserSshCaId?: string | null; + defaultHostSshCaId?: string | null; + }; +} + interface IntegrationSyncedEvent { type: EventType.INTEGRATION_SYNCED; metadata: { @@ -2670,6 +2691,8 @@ export type Event = | GetSlackIntegration | UpdateProjectSlackConfig | GetProjectSlackConfig + | GetProjectSshConfig + | UpdateProjectSshConfig | IntegrationSyncedEvent | CreateCmekEvent | UpdateCmekEvent diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index ea899fcea..798e41087 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -644,7 +644,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId }); - // TODO: consider adding audit logs + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshConfig.projectId, + event: { + type: EventType.GET_PROJECT_SSH_CONFIG, + metadata: { + id: sshConfig.id, + projectId: sshConfig.projectId + } + } + }); return sshConfig; } @@ -654,7 +664,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { method: "PATCH", url: "/:workspaceId/ssh-config", config: { - rateLimit: readLimit + rateLimit: writeLimit }, schema: { params: z.object({ @@ -686,6 +696,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { ...req.body }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshConfig.projectId, + event: { + type: EventType.UPDATE_PROJECT_SSH_CONFIG, + metadata: { + id: sshConfig.id, + projectId: sshConfig.projectId, + defaultUserSshCaId: sshConfig.defaultUserSshCaId, + defaultHostSshCaId: sshConfig.defaultHostSshCaId + } + } + }); + return sshConfig; } }); From 846a5a6e19fe5e97b077d311ca623ab93e20674e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 17 Apr 2025 23:08:33 -0700 Subject: [PATCH 04/10] impl improvements according to greptile --- ...20250418003930_ssh-nullable-ca-defaults.ts | 32 +++++++++++-------- docs/cli/commands/ssh.mdx | 4 +-- .../components/ProjectSshConfigCasSection.tsx | 8 ++--- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts b/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts index 3bc64d86b..0711f8c18 100644 --- a/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts +++ b/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts @@ -7,19 +7,25 @@ export async function up(knex: Knex): Promise { const hasDefaultHostCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultHostSshCaId"); if (hasDefaultUserCaCol && hasDefaultHostCaCol) { - await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { - t.dropForeign(["defaultUserSshCaId"]); - t.dropForeign(["defaultHostSshCaId"]); - }); - - await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { - // allow nullable (does not wipe existing values) - t.uuid("defaultUserSshCaId").nullable().alter(); - t.uuid("defaultHostSshCaId").nullable().alter(); - - // re-add with SET NULL behavior (previously CASCADE) - t.foreign("defaultUserSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); - t.foreign("defaultHostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + await knex.transaction(async (trx) => { + await trx.schema.alterTable(TableName.ProjectSshConfig, (t) => { + t.dropForeign(["defaultUserSshCaId"]); + t.dropForeign(["defaultHostSshCaId"]); + }); + await trx.schema.alterTable(TableName.ProjectSshConfig, (t) => { + // allow nullable (does not wipe existing values) + t.uuid("defaultUserSshCaId").nullable().alter(); + t.uuid("defaultHostSshCaId").nullable().alter(); + // re-add with SET NULL behavior (previously CASCADE) + t.foreign("defaultUserSshCaId") + .references("id") + .inTable(TableName.SshCertificateAuthority) + .onDelete("SET NULL"); + t.foreign("defaultHostSshCaId") + .references("id") + .inTable(TableName.SshCertificateAuthority) + .onDelete("SET NULL"); + }); }); } diff --git a/docs/cli/commands/ssh.mdx b/docs/cli/commands/ssh.mdx index 534c55b66..d99a69dda 100644 --- a/docs/cli/commands/ssh.mdx +++ b/docs/cli/commands/ssh.mdx @@ -26,12 +26,12 @@ This command enables you to obtain SSH credentials used to access a remote host. The login user for the SSH connection. If not provided, you will be prompted to select from available login users. - Whether to write the Host CA public key to ~/.ssh/known_hosts if it doesn't already exist. + Whether to write the Host CA public key to `~/.ssh/known_hosts` if it doesn't already exist. Default value: `true` - The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection. + The path to write the SSH credentials to such as `~/.ssh`, `./some_folder`, `./some_folder/id_rsa-cert.pub`. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection. An authenticated token to use to authenticate with Infisical. diff --git a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx index 36a6df348..503717326 100644 --- a/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx +++ b/frontend/src/pages/ssh/SettingsPage/components/ProjectSshTab/components/ProjectSshConfigCasSection.tsx @@ -15,8 +15,8 @@ import { const schema = z .object({ - defaultUserSshCaId: z.string().optional(), - defaultHostSshCaId: z.string().optional() + defaultUserSshCaId: z.string(), + defaultHostSshCaId: z.string() }) .required(); @@ -40,8 +40,8 @@ export const ProjectSshConfigCasSection = () => { useEffect(() => { if (sshConfig) { reset({ - defaultUserSshCaId: sshConfig.defaultUserSshCaId || undefined, - defaultHostSshCaId: sshConfig.defaultHostSshCaId || undefined + defaultUserSshCaId: sshConfig.defaultUserSshCaId || "", + defaultHostSshCaId: sshConfig.defaultHostSshCaId || "" }); } }, [sshConfig]); From b2360f9cc802c7223008ae8a9baa60eac2f3fd2a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 17 Apr 2025 23:12:44 -0700 Subject: [PATCH 05/10] Reuse writeToFile fn in ssh connect command --- cli/packages/cmd/ssh.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index 94519dcc8..596478554 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -816,15 +816,15 @@ func sshConnect(cmd *cobra.Command, args []string) { } if outFilePath != "" { - err = os.WriteFile(privateKeyPath, []byte(creds.PrivateKey), 0600) + err = writeToFile(privateKeyPath, creds.PrivateKey, 0600) if err != nil { util.HandleError(err, "Failed to write private key") } - err = os.WriteFile(publicKeyPath, []byte(creds.PublicKey), 0644) + err = writeToFile(publicKeyPath, creds.PublicKey, 0644) if err != nil { util.HandleError(err, "Failed to write public key") } - err = os.WriteFile(signedKeyPath, []byte(creds.SignedKey), 0644) + err = writeToFile(signedKeyPath, creds.SignedKey, 0644) if err != nil { util.HandleError(err, "Failed to write signed cert") } From 184d353de51b3e93cec6bec8da8c31fea9f581d3 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 17 Apr 2025 23:29:20 -0700 Subject: [PATCH 06/10] Update infisical ssh docs to clarify ssh connect command in different modes --- docs/documentation/platform/ssh.mdx | 31 +++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index 1f8856a5b..8b04552e7 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -136,15 +136,17 @@ Once Infisical SSH is configured by an administrator, users can SSH to the remot Follow the instructions [here](/cli/overview) to install the Infisical CLI onto your local machine. - - Run the `infisical login` command to authenticate with Infisical. - + + The `infisical ssh connect` command can be used in either interactive or non-interactive mode to connect to a remote host. + + ### Interactive Mode + In interactive mode, you'll first need to authenticate with Infisical by running: + ```bash infisical login ``` - - - Run the `infisical ssh connect` command to connect to a remote host. + + Then simply run: ```bash infisical ssh connect @@ -174,6 +176,23 @@ Once Infisical SSH is configured by an administrator, users can SSH to the remot ✔ SSH credentials successfully added to agent Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... ``` + + ### Non-Interactive Mode + For CI/CD pipelines or automation scenarios, you can use the non-interactive mode with an Infisical token: + + ```bash + infisical ssh connect \ + --hostname ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com \ + --loginUser ec2-user \ + --outFilePath ~/.ssh/id_rsa-cert.pub \ + --token + ``` + + This will: + - Connect to the specified hostname + - Use the specified login user + - Write the SSH credentials to the specified path instead of adding them to the SSH agent + - Authenticate using the provided Infisical token From 42aa3c3d466d322b2f7f45807763a81d78722e53 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 18 Apr 2025 11:06:59 -0700 Subject: [PATCH 07/10] Remove extra tx in ssh nullable ca defaults migration, update ssh docs --- ...20250418003930_ssh-nullable-ca-defaults.ts | 30 +++---- docs/documentation/platform/ssh.mdx | 89 ++++++++++--------- 2 files changed, 57 insertions(+), 62 deletions(-) diff --git a/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts b/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts index 0711f8c18..2a0b85e1c 100644 --- a/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts +++ b/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts @@ -7,25 +7,17 @@ export async function up(knex: Knex): Promise { const hasDefaultHostCaCol = await knex.schema.hasColumn(TableName.ProjectSshConfig, "defaultHostSshCaId"); if (hasDefaultUserCaCol && hasDefaultHostCaCol) { - await knex.transaction(async (trx) => { - await trx.schema.alterTable(TableName.ProjectSshConfig, (t) => { - t.dropForeign(["defaultUserSshCaId"]); - t.dropForeign(["defaultHostSshCaId"]); - }); - await trx.schema.alterTable(TableName.ProjectSshConfig, (t) => { - // allow nullable (does not wipe existing values) - t.uuid("defaultUserSshCaId").nullable().alter(); - t.uuid("defaultHostSshCaId").nullable().alter(); - // re-add with SET NULL behavior (previously CASCADE) - t.foreign("defaultUserSshCaId") - .references("id") - .inTable(TableName.SshCertificateAuthority) - .onDelete("SET NULL"); - t.foreign("defaultHostSshCaId") - .references("id") - .inTable(TableName.SshCertificateAuthority) - .onDelete("SET NULL"); - }); + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + t.dropForeign(["defaultUserSshCaId"]); + t.dropForeign(["defaultHostSshCaId"]); + }); + await knex.schema.alterTable(TableName.ProjectSshConfig, (t) => { + // allow nullable (does not wipe existing values) + t.uuid("defaultUserSshCaId").nullable().alter(); + t.uuid("defaultHostSshCaId").nullable().alter(); + // re-add with SET NULL behavior (previously CASCADE) + t.foreign("defaultUserSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); + t.foreign("defaultHostSshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); }); } diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index 8b04552e7..036968930 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -139,60 +139,63 @@ Once Infisical SSH is configured by an administrator, users can SSH to the remot The `infisical ssh connect` command can be used in either interactive or non-interactive mode to connect to a remote host. - ### Interactive Mode - In interactive mode, you'll first need to authenticate with Infisical by running: + + + In interactive mode, you'll first need to authenticate with Infisical by running: - ```bash - infisical login - ``` + ```bash + infisical login + ``` - Then simply run: + Then simply run: - ```bash - infisical ssh connect - ``` + ```bash + infisical ssh connect + ``` - You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by - the administrator. + You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by + the administrator. - ```bash - Use the arrow keys to navigate: ↓ ↑ → ← - ? Select an SSH Host: - ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com - ``` + ```bash + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select an SSH Host: + ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com + ``` - After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + After selecting a host, you'll be prompted to select a login user from a list of allowed login users: - ```bash - ? Select Login User: - ▸ ec2-user - ``` + ```bash + ? Select Login User: + ▸ ec2-user + ``` - If successful, you should be able to SSH to the remote host. + If successful, you should be able to SSH to the remote host. - ```bash - ✔ ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com - ✔ ec2-user - ✔ SSH credentials successfully added to agent - Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... - ``` + ```bash + ✔ ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com + ✔ ec2-user + ✔ SSH credentials successfully added to agent + Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... + ``` + + + For CI/CD pipelines or automation scenarios, you can use the non-interactive mode with an Infisical token: - ### Non-Interactive Mode - For CI/CD pipelines or automation scenarios, you can use the non-interactive mode with an Infisical token: + ```bash + infisical ssh connect \ + --hostname ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com \ + --loginUser ec2-user \ + --outFilePath ~/.ssh/id_rsa-cert.pub \ + --token + ``` - ```bash - infisical ssh connect \ - --hostname ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com \ - --loginUser ec2-user \ - --outFilePath ~/.ssh/id_rsa-cert.pub \ - --token - ``` - - This will: - - Connect to the specified hostname - - Use the specified login user - - Write the SSH credentials to the specified path instead of adding them to the SSH agent - - Authenticate using the provided Infisical token + This will: + - Connect to the specified hostname + - Use the specified login user + - Write the SSH credentials to the specified path instead of adding them to the SSH agent + - Authenticate using the provided Infisical token + + From 1ea8e5a81e38929175d94a82cafebce85ee4b2aa Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 18 Apr 2025 15:25:13 -0700 Subject: [PATCH 08/10] Add frontend uniqueness check for ssh hostnames --- .../ssh/SshHostsPage/components/SshHostModal.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index a779716d4..249fc1b35 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -23,6 +23,7 @@ import { useCreateSshHost, useGetSshHostById, useGetWorkspaceUsers, + useListWorkspaceSshHosts, useUpdateSshHost } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -58,6 +59,7 @@ export type FormData = z.infer; export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); const projectId = currentWorkspace?.id || ""; + const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id); const { data: members = [] } = useGetWorkspaceUsers(projectId); const [expandedMappings, setExpandedMappings] = useState>({}); @@ -116,6 +118,18 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { try { if (!projectId) return; + // check if there is already a different host with the same hostname + const existingHostnames = + sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || []; + + if (existingHostnames.includes(hostname)) { + createNotification({ + text: "A host with this hostname already exists.", + type: "error" + }); + return; + } + if (sshHost) { await updateMutateAsync({ sshHostId: sshHost.id, From f5862cbb9a1ed17d5a552b0c194df7285aee9b8a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 25 Apr 2025 09:32:48 -0700 Subject: [PATCH 09/10] Merge --- ...-ca-defaults.ts => 20250425163216_ssh-nullable-ca-defaults.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/src/db/migrations/{20250418003930_ssh-nullable-ca-defaults.ts => 20250425163216_ssh-nullable-ca-defaults.ts} (100%) diff --git a/backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts b/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts similarity index 100% rename from backend/src/db/migrations/20250418003930_ssh-nullable-ca-defaults.ts rename to backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts From 0c214a2f26b464ee99a266ca53eb681b6d1c8a10 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 25 Apr 2025 10:03:51 -0700 Subject: [PATCH 10/10] Adjust CLI flags to be dash-case --- cli/packages/cmd/ssh.go | 14 +++++++------- docs/documentation/platform/ssh.mdx | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index 596478554..a11e4da4c 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -1103,19 +1103,19 @@ func init() { sshCmd.AddCommand(sshIssueCredentialsCmd) sshConnectCmd.Flags().String("token", "", "Use a machine identity access token") - sshConnectCmd.Flags().Bool("writeHostCaToFile", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") + sshConnectCmd.Flags().Bool("write-host-ca-to-file", true, "Write Host CA public key to ~/.ssh/known_hosts as a separate entry if doesn't already exist") sshConnectCmd.Flags().String("hostname", "", "Hostname of the SSH host to connect to") - sshConnectCmd.Flags().String("loginUser", "", "Login user for the SSH connection") - sshConnectCmd.Flags().String("outFilePath", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection") + sshConnectCmd.Flags().String("login-user", "", "Login user for the SSH connection") + sshConnectCmd.Flags().String("out-file-path", "", "The path to write the SSH credentials to such as ~/.ssh, ./some_folder, ./some_folder/id_rsa-cert.pub. If not provided, the credentials will be added to the SSH agent and used to establish an interactive SSH connection") sshCmd.AddCommand(sshConnectCmd) sshAddHostCmd.Flags().String("token", "", "Use a machine identity access token") sshAddHostCmd.Flags().String("projectId", "", "Project ID the host belongs to (required)") sshAddHostCmd.Flags().String("hostname", "", "Hostname of the SSH host (required)") - sshAddHostCmd.Flags().Bool("writeUserCaToFile", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub") - sshAddHostCmd.Flags().String("userCaOutFilePath", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key") - sshAddHostCmd.Flags().Bool("writeHostCertToFile", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub") - sshAddHostCmd.Flags().Bool("configureSshd", false, "Update TrustedUserCAKeys, HostKey, and HostCertificate in the sshd_config file") + sshAddHostCmd.Flags().Bool("write-user-ca-to-file", false, "Write User CA public key to /etc/ssh/infisical_user_ca.pub") + sshAddHostCmd.Flags().String("user-ca-out-file-path", "/etc/ssh/infisical_user_ca.pub", "Custom file path to write the User CA public key") + sshAddHostCmd.Flags().Bool("write-host-cert-to-file", false, "Write SSH host certificate to /etc/ssh/ssh_host__key-cert.pub") + sshAddHostCmd.Flags().Bool("configure-sshd", false, "Update TrustedUserCAKeys, HostKey, and HostCertificate in the sshd_config file") sshAddHostCmd.Flags().Bool("force", false, "Force overwrite of existing certificate files as part of writeUserCaToFile and writeHostCertToFile") sshCmd.AddCommand(sshAddHostCmd) diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index 036968930..2bc433f75 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh.mdx @@ -72,7 +72,7 @@ we will register a remote host with Infisical through a [machine identity](/docu Next, use the `infisical ssh add-host` command to register the remote host with Infisical. As part of this command, input the ID of the Infisical SSH project you created in step 1 for the `--projectId` flag and the hostname of the remote host for the `--hostname` flag. ```bash - sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --writeUserCaToFile --writeHostCertToFile --configureSshd + sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --write-user-ca-to-file --write-host-cert-to-file --configure-sshd ``` @@ -184,8 +184,8 @@ Once Infisical SSH is configured by an administrator, users can SSH to the remot ```bash infisical ssh connect \ --hostname ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com \ - --loginUser ec2-user \ - --outFilePath ~/.ssh/id_rsa-cert.pub \ + --login-user ec2-user \ + --out-file-path ~/.ssh/id_rsa-cert.pub \ --token ```