Merge pull request #3446 from Infisical/ssh-non-interactive

Improvements to Infisical V2: Support for Non-Interactive Mode, Updating Default SSH CAs.
This commit is contained in:
BlackMagiq
2025-04-25 10:15:06 -07:00
committed by GitHub
21 changed files with 835 additions and 136 deletions

View File

@@ -0,0 +1,47 @@
import { Knex } from "knex";
import { ProjectType, TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {}

View File

@@ -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

View File

@@ -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);

View File

@@ -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",

View File

@@ -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<TProjectSshConfigDALFactory, "create">;
projectSshConfigDAL: Pick<TProjectSshConfigDALFactory, "transaction" | "create" | "findOne" | "updateById">;
projectQueue: TProjectQueueFactory;
userDAL: TUserDALFactory;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
@@ -129,7 +131,7 @@ type TProjectServiceFactoryDep = {
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">;
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "find" | "create" | "transaction">;
sshCertificateAuthorityDAL: Pick<TSshCertificateAuthorityDALFactory, "find" | "findOne" | "create" | "transaction">;
sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "create">;
sshCertificateDAL: Pick<TSshCertificateDALFactory, "find" | "countSshCertificatesInProject">;
sshCertificateTemplateDAL: Pick<TSshCertificateTemplateDALFactory, "find">;
@@ -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,

View File

@@ -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 = {

View File

@@ -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_<type>_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_<type>_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)

View File

@@ -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
<Accordion title="infisical ssh connect">
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
<Accordion title="--hostname">
The hostname of the SSH host to connect to. If not provided, you will be prompted to select from available hosts.
</Accordion>
<Accordion title="--loginUser">
The login user for the SSH connection. If not provided, you will be prompted to select from available login users.
</Accordion>
<Accordion title="--writeHostCaToFile">
Whether to write the Host CA public key to `~/.ssh/known_hosts` if it doesn't already exist.
Default value: `true`
</Accordion>
<Accordion title="--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.
</Accordion>
<Accordion title="--token">
An authenticated token to use to authenticate with Infisical.
</Accordion>
</Accordion>
<Accordion title="infisical ssh issue-credentials">
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;
</Accordion>
<Accordion title="--addToAgent">
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.
</Accordion>
<Accordion title="--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 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.
</Accordion>
<Accordion title="--keyAlgorithm">
The key algorithm to issue SSH credentials for.
Default value: `RSA_2048`
Available options: `RSA_2048`, `RSA_4096`, `EC_prime256v1`, `EC_secp384r1`.
</Accordion>
<Accordion title="--certType">
The certificate type to issue SSH credentials for.
Default value: `user`
Available options: `user` or `host`
</Accordion>
<Accordion title="--ttl">
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.
</Accordion>
<Accordion title="--keyId">
A custom Key ID to issue SSH credentials for.
Defaults to the autogenerated Key ID by Infisical.
</Accordion>
<Accordion title="--token">
An authenticated token to use to issue SSH credentials.
</Accordion>
</Accordion>
<Accordion title="infisical ssh sign-key">
@@ -95,22 +124,23 @@ This command enables you to obtain SSH credentials used to access a remote host;
</Accordion>
<Accordion title="--certType">
The certificate type to issue SSH credentials for.
Default value: `user`
Available options: `user` or `host`
</Accordion>
<Accordion title="--ttl">
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.
</Accordion>
<Accordion title="--keyId">
A custom Key ID to issue SSH credentials for.
Defaults to the autogenerated Key ID by Infisical.
</Accordion>
<Accordion title="--token">
An authenticated token to use to issue SSH credentials.
</Accordion>
</Accordion>
</Accordion>

View File

@@ -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=<project-id> --hostname=<hostname> --token="$INFISICAL_TOKEN" --writeUserCaToFile --writeHostCertToFile --configureSshd
sudo infisical ssh add-host --projectId=<project-id> --hostname=<hostname> --token="$INFISICAL_TOKEN" --write-user-ca-to-file --write-host-cert-to-file --configure-sshd
```
<Tip>
@@ -136,44 +136,66 @@ Once Infisical SSH is configured by an administrator, users can SSH to the remot
<Step title="Install the Infisical CLI">
Follow the instructions [here](/cli/overview) to install the Infisical CLI onto your local machine.
</Step>
<Step title="Log in with the CLI">
Run the `infisical login` command to authenticate with Infisical.
```bash
infisical login
```
</Step>
<Step title="Connect to the remote host">
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
```
<Tabs>
<Tab title="Interactive Mode">
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...
```
</Tab>
<Tab title="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 \
--login-user ec2-user \
--out-file-path ~/.ssh/id_rsa-cert.pub \
--token <your-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
</Tab>
</Tabs>
</Step>
</Steps>

View File

@@ -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,

View File

@@ -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<TProjectSshConfig, object, TUpdateProjectSshConfigDTO>({
mutationFn: ({ projectId, defaultUserSshCaId, defaultHostSshCaId }) => {
return apiRequest.patch(`/api/v1/workspace/${projectId}/ssh-config`, {
defaultUserSshCaId,
defaultHostSshCaId
});
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getProjectSshConfig(projectId)
});
}
});
};

View File

@@ -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<TProjectSshConfig>(
`/api/v1/workspace/${projectId}/ssh-config`
);
return data;
},
enabled: Boolean(projectId)
});
};

View File

@@ -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
};

View File

@@ -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;
};

View File

@@ -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 = () => {
</MenuItem>
)}
</Link> */}
{/* <Link
to={`/${ProjectType.SSH}/$projectId/cas` as const}
params={{
projectId: currentWorkspace.id
}}
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{({ isActive }) => (
<MenuItem
isSelected={isActive}
icon="certificate-authority"
iconMode="reverse"
>
Certificate Authorities
</MenuItem>
)}
</Link> */}
{(isAllowed) =>
isAllowed && (
<Link
to={`/${ProjectType.SSH}/$projectId/cas` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem
isSelected={isActive}
icon="certificate-authority"
iconMode="reverse"
>
Certificate Authorities
</MenuItem>
)}
</Link>
)
}
</ProjectPermissionCan>
</>
)}
{isSecretManager && (

View File

@@ -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 = () => {
</Helmet>
<div className="w-full max-w-7xl">
<PageHeader title={t("settings.project.title")} />
<Tabs defaultValue={tabs[0].key}>
<Tabs defaultValue="tab-project-general">
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
<Tab value="tab-project-general">General</Tab>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => isAllowed && <Tab value="tab-project-ssh">SSH Settings</Tab>}
</ProjectPermissionCan>
</TabList>
{tabs.map(({ key, Component }) => (
<TabPanel value={key} key={key}>
<Component />
</TabPanel>
))}
<TabPanel value="tab-project-general">
<ProjectGeneralTab />
</TabPanel>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) =>
isAllowed && (
<TabPanel value="tab-project-ssh">
<ProjectSshTab />
</TabPanel>
)
}
</ProjectPermissionCan>
</Tabs>
</div>
</div>

View File

@@ -0,0 +1,9 @@
import { ProjectSshConfigCasSection } from "./components";
export const ProjectSshTab = () => {
return (
<div>
<ProjectSshConfigCasSection />
</div>
);
};

View File

@@ -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<typeof schema>;
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<FormData>({
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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-8 text-xl font-semibold">Certificate Authorities</p>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="defaultUserSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default User CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="defaultHostSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default Host CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</form>
</div>
);
};

View File

@@ -0,0 +1 @@
export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection";

View File

@@ -0,0 +1 @@
export { ProjectSshTab } from "./ProjectSshTab";

View File

@@ -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<typeof schema>;
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<Record<number, boolean>>({});
@@ -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,