From 2382937385df1eb62fd9f208e1e4c1cee8426d2e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 9 Apr 2025 14:41:10 -0700 Subject: [PATCH] Add configure sshd flag to infisical ssh add-host command, update issue user cert permissioning --- .../services/permission/project-permission.ts | 3 - .../ee/services/ssh-host/ssh-host-service.ts | 54 +++--- cli/packages/cmd/ssh.go | 160 ++++++++++++++++-- .../context/ProjectPermissionContext/types.ts | 1 - .../ProjectRoleModifySection.utils.tsx | 5 - 5 files changed, 167 insertions(+), 56 deletions(-) diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 12c9c2bcc..7aef8d7c7 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -631,7 +631,6 @@ const buildAdminPermissionRules = () => { ProjectPermissionSshHostActions.Edit, ProjectPermissionSshHostActions.Create, ProjectPermissionSshHostActions.Delete, - ProjectPermissionSshHostActions.IssueUserCert, ProjectPermissionSshHostActions.IssueHostCert ], ProjectPermissionSub.SshHosts @@ -885,8 +884,6 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); - can([ProjectPermissionSshHostActions.IssueUserCert], ProjectPermissionSub.SshHosts); - can( [ ProjectPermissionCmekActions.Create, diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts index dd3e64b29..e462e2282 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -92,11 +92,11 @@ export const sshHostServiceFactory = ({ userDAL }); - const allAllowedHosts = []; + const allowedHosts = []; for await (const project of sshProjects) { try { - const { permission } = await permissionService.getProjectPermission({ + await permissionService.getProjectPermission({ actor, actorId, projectId: project.id, @@ -107,20 +107,13 @@ export const sshHostServiceFactory = ({ const projectHosts = await sshHostDAL.findSshHostsWithPrincipalsAcrossProjects([project.id], principals); - const allowedHosts = projectHosts.filter((host) => - permission.can( - ProjectPermissionSshHostActions.IssueUserCert, - subject(ProjectPermissionSub.SshHosts, { hostname: host.hostname }) - ) - ); - - allAllowedHosts.push(...allowedHosts); + allowedHosts.push(...projectHosts); } catch { // intentionally ignore projects where user lacks access } } - return allAllowedHosts; + return allowedHosts; }; const createSshHost = async ({ @@ -370,7 +363,7 @@ export const sshHostServiceFactory = ({ }); } - const { permission } = await permissionService.getProjectPermission({ + await permissionService.getProjectPermission({ actor, actorId, projectId: host.projectId, @@ -379,13 +372,22 @@ export const sshHostServiceFactory = ({ actionProjectType: ActionProjectType.SSH }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSshHostActions.IssueUserCert, - subject(ProjectPermissionSub.SshHosts, { - hostname: host.hostname - }) + const internalPrincipals = await convertActorToPrincipals({ + actor, + actorId, + userDAL + }); + + const mapping = host.loginMappings.find( + (m) => m.loginUser === loginUser && m.allowedPrincipals.some((allowed) => internalPrincipals.includes(allowed)) ); + if (!mapping) { + throw new UnauthorizedError({ + message: `You are not allowed to login as ${loginUser} on this host` + }); + } + const keyId = `${actor}-${actorId}`; const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: host.userSshCaId }); @@ -403,22 +405,6 @@ export const sshHostServiceFactory = ({ const keyAlgorithm = SshCertKeyAlgorithm.ED25519; const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); - const internalPrincipals = await convertActorToPrincipals({ - actor, - actorId, - userDAL - }); - - const mapping = host.loginMappings.find( - (m) => m.loginUser === loginUser && m.allowedPrincipals.some((allowed) => internalPrincipals.includes(allowed)) - ); - - if (!mapping) { - throw new UnauthorizedError({ - message: `You are not allowed to login as ${loginUser} on this host` - }); - } - // (dangtony98): include the loginUser as a principal on the issued certificate const principals = [...internalPrincipals, loginUser]; @@ -519,7 +505,7 @@ export const sshHostServiceFactory = ({ }); const principals = [host.hostname]; - const keyId = `host:${host.hostname}`; + const keyId = `host-${host.id}`; const { serialNumber, signedPublicKey, ttl } = await createSshCert({ caPrivateKey: decryptedCaPrivateKey.toString("utf8"), diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index ba6e73a95..f74fa2071 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -254,7 +254,7 @@ func issueCredentials(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse addToAgent flag") } - if outFilePath == "" && addToAgent == false { + if outFilePath == "" && !addToAgent { util.PrintErrorMessageAndExit("You must provide either --outFilePath or --addToAgent flag to use this command") } @@ -774,6 +774,85 @@ func sshAddHost(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse --userCaOutFilePath flag") } + writeHostCertToFile, err := cmd.Flags().GetBool("writeHostCertToFile") + if err != nil { + util.HandleError(err, "Unable to parse --writeHostCertToFile flag") + } + + configureSshd, err := cmd.Flags().GetBool("configureSshd") + if err != nil { + util.HandleError(err, "Unable to parse --configureSshd flag") + } + + forceOverwrite, err := cmd.Flags().GetBool("force") + if err != nil { + util.HandleError(err, "Unable to parse --force flag") + } + + if configureSshd && (!writeUserCaToFile || !writeHostCertToFile) { + util.PrintErrorMessageAndExit("--configureSshd requires both --writeUserCaToFile and --writeHostCertToFile to also be set") + } + + // Pre-check for file overwrites before proceeding + if writeUserCaToFile { + if strings.HasPrefix(userCaOutFilePath, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + util.HandleError(err, "Unable to resolve ~ in userCaOutFilePath") + } + userCaOutFilePath = strings.Replace(userCaOutFilePath, "~", homeDir, 1) + } + if _, err := os.Stat(userCaOutFilePath); err == nil && !forceOverwrite { + util.PrintErrorMessageAndExit("File already exists at " + userCaOutFilePath + ". Use --force to overwrite.") + } + } + + keyTypes := []string{"ed25519", "ecdsa", "rsa"} + var hostKeyPath, certOutPath, hostPrivateKeyPath string + if writeHostCertToFile { + for _, keyType := range keyTypes { + pub := fmt.Sprintf("/etc/ssh/ssh_host_%s_key.pub", keyType) + cert := fmt.Sprintf("/etc/ssh/ssh_host_%s_key-cert.pub", keyType) + priv := fmt.Sprintf("/etc/ssh/ssh_host_%s_key", keyType) + + if _, err := os.Stat(pub); err == nil { + hostKeyPath = pub + certOutPath = cert + hostPrivateKeyPath = priv + break + } + } + + if hostKeyPath == "" { + util.PrintErrorMessageAndExit("No supported SSH host public key found at /etc/ssh") + } + + if _, err := os.Stat(certOutPath); err == nil && !forceOverwrite && writeHostCertToFile { + util.PrintErrorMessageAndExit("File already exists at " + certOutPath + ". Use --force to overwrite.") + } + } + + if configureSshd { + sshdConfig := "/etc/ssh/sshd_config" + existing, err := os.ReadFile(sshdConfig) + if err != nil { + util.HandleError(err, "Failed to read sshd_config") + } + configLines := []string{ + "TrustedUserCAKeys " + userCaOutFilePath, + "HostKey " + hostPrivateKeyPath, + "HostCertificate " + certOutPath, + } + for _, line := range configLines { + for _, existingLine := range strings.Split(string(existing), "\n") { + trimmed := strings.TrimSpace(existingLine) + if trimmed == line && !strings.HasPrefix(trimmed, "#") && !forceOverwrite { + util.PrintErrorMessageAndExit("sshd_config already contains: " + line + ". Use --force to overwrite.") + } + } + } + } + customHeaders, err := util.GetInfisicalCustomHeadersMap() if err != nil { util.HandleError(err, "Unable to get custom headers") @@ -797,19 +876,10 @@ func sshAddHost(cmd *cobra.Command, args []string) { fmt.Println("✅ Successfully registered host:", host.Hostname) - publicKey, err := client.Ssh().GetSshHostUserCaPublicKey(host.ID) - if err != nil { - util.HandleError(err, "Failed to fetch associated User CA public key") - } - if writeUserCaToFile { - // Expand ~ if used in file path - if strings.HasPrefix(userCaOutFilePath, "~") { - homeDir, err := os.UserHomeDir() - if err != nil { - util.HandleError(err, "Unable to resolve ~ in userCaOutFilePath") - } - userCaOutFilePath = strings.Replace(userCaOutFilePath, "~", homeDir, 1) + publicKey, err := client.Ssh().GetSshHostUserCaPublicKey(host.ID) + if err != nil { + util.HandleError(err, "Failed to fetch associated User CA public key") } if err := writeToFile(userCaOutFilePath, publicKey, 0644); err != nil { @@ -818,7 +888,68 @@ func sshAddHost(cmd *cobra.Command, args []string) { fmt.Println("📁 Wrote User CA public key to:", userCaOutFilePath) } + + if writeHostCertToFile { + pubKeyBytes, err := os.ReadFile(hostKeyPath) + if err != nil { + util.HandleError(err, "Failed to read SSH host public key") + } + res, err := client.Ssh().IssueSshHostHostCert(host.ID, infisicalSdk.IssueSshHostHostCertOptions{ + PublicKey: string(pubKeyBytes), + }) + if err != nil { + util.HandleError(err, "Failed to issue SSH host certificate") + } + if err := writeToFile(certOutPath, res.SignedKey, 0644); err != nil { + util.HandleError(err, "Failed to write SSH host certificate to file") + } + fmt.Println("📁 Wrote host certificate to:", certOutPath) + } + + if configureSshd { + sshdConfig := "/etc/ssh/sshd_config" + contentBytes, err := os.ReadFile(sshdConfig) + if err != nil { + util.HandleError(err, "Failed to read sshd_config") + } + lines := strings.Split(string(contentBytes), "\n") + + configMap := map[string]string{ + "TrustedUserCAKeys": userCaOutFilePath, + "HostKey": hostPrivateKeyPath, + "HostCertificate": certOutPath, + } + + seenKeys := map[string]bool{} + for i, line := range lines { + trimmed := strings.TrimSpace(line) + for key, value := range configMap { + if strings.HasPrefix(trimmed, key+" ") { + seenKeys[key] = true + if strings.HasPrefix(trimmed, "#") || forceOverwrite { + lines[i] = fmt.Sprintf("%s %s", key, value) + } else { + util.PrintErrorMessageAndExit("sshd_config already contains: " + trimmed + ". Use --force to overwrite.") + } + } + } + } + + // Append missing lines + for key, value := range configMap { + if !seenKeys[key] { + lines = append(lines, fmt.Sprintf("%s %s", key, value)) + } + } + + // Write back to file + if err := os.WriteFile(sshdConfig, []byte(strings.Join(lines, "\n")), 0644); err != nil { + util.HandleError(err, "Failed to update sshd_config") + } + fmt.Println("📄 Updated sshd_config entries") + } } + func init() { sshSignKeyCmd.Flags().String("token", "", "Issue SSH certificate using machine identity access token") sshSignKeyCmd.Flags().String("certificateTemplateId", "", "The ID of the SSH certificate template to issue the SSH certificate for") @@ -850,6 +981,9 @@ func init() { 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("force", false, "Force overwrite of existing certificate files as part of writeUserCaToFile and writeHostCertToFile") sshCmd.AddCommand(sshAddHostCmd) diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index f8b0268af..ba89e0981 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -79,7 +79,6 @@ export enum ProjectPermissionSshHostActions { Create = "create", Edit = "edit", Delete = "delete", - IssueUserCert = "issue-user-cert", IssueHostCert = "issue-host-cert" } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index c62d57411..bb42dea0e 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -103,7 +103,6 @@ const SshHostPolicyActionSchema = z.object({ [ProjectPermissionSshHostActions.Create]: z.boolean().optional(), [ProjectPermissionSshHostActions.Edit]: z.boolean().optional(), [ProjectPermissionSshHostActions.Delete]: z.boolean().optional(), - [ProjectPermissionSshHostActions.IssueUserCert]: z.boolean().optional(), [ProjectPermissionSshHostActions.IssueHostCert]: z.boolean().optional() }); @@ -570,9 +569,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { [ProjectPermissionSshHostActions.Create]: action.includes( ProjectPermissionSshHostActions.Create ), - [ProjectPermissionSshHostActions.IssueUserCert]: action.includes( - ProjectPermissionSshHostActions.IssueUserCert - ), [ProjectPermissionSshHostActions.IssueHostCert]: action.includes( ProjectPermissionSshHostActions.IssueHostCert ), @@ -911,7 +907,6 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Create", value: ProjectPermissionSshHostActions.Create }, { label: "Modify", value: ProjectPermissionSshHostActions.Edit }, { label: "Remove", value: ProjectPermissionSshHostActions.Delete }, - { label: "Issue User Certificate", value: ProjectPermissionSshHostActions.IssueUserCert }, { label: "Issue Host Certificate", value: ProjectPermissionSshHostActions.IssueHostCert } ] },