diff --git a/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts b/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts new file mode 100644 index 000000000..2a0b85e1c --- /dev/null +++ b/backend/src/db/migrations/20250425163216_ssh-nullable-ca-defaults.ts @@ -0,0 +1,47 @@ +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/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index a31200a1b..f85fbd6a3 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -249,6 +249,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", @@ -1992,6 +1994,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: { @@ -2677,6 +2698,8 @@ export type Event = | GetSlackIntegration | UpdateProjectSlackConfig | GetProjectSlackConfig + | GetProjectSshConfig + | UpdateProjectSshConfig | IntegrationSyncedEvent | CreateCmekEvent | UpdateCmekEvent 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 19423901b..09ed85315 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, @@ -623,6 +624,107 @@ 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 + }); + + 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; + } + }); + + server.route({ + method: "PATCH", + url: "/:workspaceId/ssh-config", + config: { + rateLimit: writeLimit + }, + 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 + }); + + 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; + } + }); + 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..a11e4da4c 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 = writeToFile(privateKeyPath, creds.PrivateKey, 0600) + if err != nil { + util.HandleError(err, "Failed to write private key") + } + err = writeToFile(publicKeyPath, creds.PublicKey, 0644) + if err != nil { + util.HandleError(err, "Failed to write public key") + } + err = writeToFile(signedKeyPath, 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,16 +1102,20 @@ func init() { sshIssueCredentialsCmd.Flags().Bool("addToAgent", false, "Whether to add issued SSH credentials to the SSH agent") sshCmd.AddCommand(sshIssueCredentialsCmd) - 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("token", "", "Use a machine identity access token") + 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("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/cli/commands/ssh.mdx b/docs/cli/commands/ssh.mdx index 78712ba6f..d99a69dda 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 + + diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh.mdx index ae1e43df5..2bc433f75 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 @@ -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 ``` @@ -136,44 +136,66 @@ 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. - - ```bash - infisical login - ``` - - Run the `infisical ssh connect` command to connect to a remote host. + The `infisical ssh connect` command can be used in either interactive or non-interactive mode to connect to a remote host. - ```bash - infisical ssh connect - ``` + + + In interactive mode, you'll first need to authenticate with Infisical by running: - 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 + infisical login + ``` - ```bash - Use the arrow keys to navigate: ↓ ↑ → ← - ? Select an SSH Host: - ▸ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com - ``` + Then simply run: - After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + ```bash + infisical ssh connect + ``` - ```bash - ? Select Login User: - ▸ ec2-user - ``` + 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. - If successful, you should be able to SSH to the remote host. + ```bash + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select an SSH Host: + ▸ 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... - ``` + 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 + ``` + + 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... + ``` + + + 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 \ + --login-user ec2-user \ + --out-file-path ~/.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 + + 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 7aed0a6e8..dc64ccb12 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, @@ -187,22 +193,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..503717326 --- /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(), + defaultHostSshCaId: z.string() + }) + .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 || "", + defaultHostSshCaId: sshConfig.defaultHostSshCaId || "" + }); + } + }, [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"; 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,