Skip to content

Only use the SSH certificate if available #1370

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 26 additions & 23 deletions config-defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,6 @@ api:
##
# certifier_url: 'https://auth.example.com'

## Wildcard domains for SSH configuration.
## This is also used to detect whether to run diagnostics on an SSH connection error.
## Can be replaced by a single value using the {application.env_prefix}SSH_DOMAIN_WILDCARD env var.
##
# ssh_domain_wildcards: ['*.example.com']

# The maximum lifetime (TTL) of objects in the local cache, in seconds.
projects_ttl: 600
environments_ttl: 120
Expand Down Expand Up @@ -246,23 +240,6 @@ api:
# Overridden by the {application.env_prefix}SESSION_ID env var.
session_id: 'default'

# Whether auto-generated SSH certificate identities should be added to the SSH agent.
# Enabling this may be useful for those who use agent forwarding.
add_to_ssh_agent: false

# Whether to auto-load an SSH certificate on login and SSH commands.
# Overridden by the {application.env_prefix}AUTO_LOAD_SSH_CERT env var
auto_load_ssh_cert: true

# Whether the user's SSH config file (~/.ssh/config) can be written automatically.
#
# The file is validated when SSH certificates are installed or refreshed.
#
# Setting this to true means it will be created or updated automatically,
# null means the user will be asked, false means it will not be touched
# though a recommendation will be displayed.
write_user_ssh_config: null

# Whether the Organizations API is enabled.
organizations: false

Expand All @@ -289,6 +266,12 @@ api:
git_push_timeout: 3600

ssh:
## Wildcard domains for SSH configuration.
## This is also used to detect whether to run diagnostics on an SSH connection error.
## Can be replaced by a single value using the {application.env_prefix}SSH_DOMAIN_WILDCARD env var.
##
# domain_wildcards: ['*.example.com']

# A file containing SSH host keys to install for the user.
# The filename is relative to the CLI root. Note it would need to be built
# into the Phar.
Expand All @@ -300,6 +283,26 @@ ssh:
# files will be written. If both are specified, both will be used.
host_keys: ''

# If an SSH certificate is available, configure SSH to ignore other keys.
cert_only: false

# Whether the user's SSH config file (~/.ssh/config) can be written automatically.
#
# The file is validated when SSH certificates are installed or refreshed.
#
# Setting this to true means it will be created or updated automatically,
# null means the user will be asked, false means it will not be touched
# though a recommendation will be displayed.
write_user_config: null

# Whether auto-generated SSH certificate identities should be added to the SSH agent.
# Enabling this may be useful for those who use agent forwarding.
add_to_agent: false

# Whether to auto-load an SSH certificate on login and SSH commands.
# Overridden by the {application.env_prefix}AUTO_LOAD_SSH_CERT env var
auto_load_cert: true

# How the CLI detects and configures Git repositories as projects.
detection:
## Required keys that must be defined elsewhere:
Expand Down
5 changes: 3 additions & 2 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,16 @@ api:
auth_url: 'https://auth.api.platform.sh'
oauth2_client_id: 'platform-cli'

ssh_domain_wildcards: ['*.platform.sh']

organizations: true
centralized_permissions: true
user_verification: true
metrics: true

vendor_filter: 'platformsh'

ssh:
domain_wildcards: ['*.platform.sh']

detection:
git_remote_name: 'platform'
git_domain: 'platform.sh' # matches git.eu-5.platform.sh, etc.
Expand Down
2 changes: 1 addition & 1 deletion src/Command/SshCert/SshCertLoadCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ protected function configure()
->addOption('new-key', null, InputOption::VALUE_NONE, '[Deprecated] Use --new instead')
->setDescription('Generate an SSH certificate');
$help = 'This command checks if a valid SSH certificate is present, and generates a new one if necessary.';
if ($this->config()->getWithDefault('api.auto_load_ssh_cert', false)) {
if ($this->config()->getWithDefault('ssh.auto_load_cert', false)) {
$envPrefix = $this->config()->get('application.env_prefix');
$help .= "\n\nCertificates allow you to make SSH connections without having previously uploaded a public key. They are more secure than keys and they allow for other features."
. "\n\nNormally the certificate is loaded automatically during login, or when making an SSH connection. So this command is seldom needed."
Expand Down
2 changes: 1 addition & 1 deletion src/Command/WelcomeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ protected function execute(InputInterface $input, OutputInterface $output)

$this->showSessionInfo();

if ($this->api()->isLoggedIn() && !$this->config()->getWithDefault('api.auto_load_ssh_cert', false)) {
if ($this->api()->isLoggedIn() && !$this->config()->getWithDefault('ssh.auto_load_cert', false)) {
/** @var \Platformsh\Cli\Service\SshKey $sshKey */
$sshKey = $this->getService('ssh_key');
if (!$sshKey->hasLocalKey()) {
Expand Down
20 changes: 17 additions & 3 deletions src/Service/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,12 @@ private function applyEnvironmentOverrides()
'OAUTH2_TOKEN_URL' => 'api.oauth2_token_url',
'OAUTH2_REVOKE_URL' => 'api.oauth2_revoke_url',
'CERTIFIER_URL' => 'api.certifier_url',
'AUTO_LOAD_SSH_CERT' => 'api.auto_load_ssh_cert',
'AUTO_LOAD_SSH_CERT' => 'ssh.auto_load_cert',
'API_AUTO_LOAD_SSH_CERT' => 'ssh.auto_load_cert',
'USER_AGENT' => 'api.user_agent',
'API_DOMAIN_SUFFIX' => 'detection.api_domain_suffix',
'API_WRITE_USER_SSH_CONFIG' => 'ssh.write_user_config',
'API_ADD_TO_SSH_AGENT' => 'ssh.add_to_agent',
]);

foreach ($overrideMap as $var => $key) {
Expand All @@ -382,9 +385,9 @@ private function applyEnvironmentOverrides()
}
}

// Special case: replace the list api.ssh_domain_wildcards with the value of {PREFIX}SSH_DOMAIN_WILDCARD.
// Special case: replace the list ssh.domain_wildcards with the value of {PREFIX}SSH_DOMAIN_WILDCARD.
if (($value = $this->getEnv('SSH_DOMAIN_WILDCARD')) !== false) {
$this->config['api']['ssh_domain_wildcards'] = [$value];
$this->config['ssh']['domain_wildcards'] = [$value];
}

// Special case: {PREFIX}NO_LEGACY_WARNING disables the migration prompt.
Expand Down Expand Up @@ -637,6 +640,17 @@ private function applyDynamicDefaults()
if (!isset($this->config['service']['applications_config_file'])) {
$this->config['service']['applications_config_file'] = $this->get('service.project_config_dir') . '/applications.yaml';
}

// Migrate renamed config keys.
if (isset($this->config['api']['add_to_ssh_agent'])) {
$this->config['ssh']['add_to_agent'] = $this->config['api']['add_to_ssh_agent'];
}
if (isset($this->config['api']['auto_load_ssh_cert'])) {
$this->config['ssh']['auto_load_cert'] = $this->config['api']['auto_load_ssh_cert'];
}
if (isset($this->config['api']['ssh_domain_wildcards'])) {
$this->config['ssh']['domain_wildcards'] = $this->config['api']['ssh_domain_wildcards'];
}
}

private function applyUrlDefaults()
Expand Down
4 changes: 2 additions & 2 deletions src/Service/Ssh.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ protected function getSshOptions()
$options['IdentityFile'] = [
$this->sshConfig->formatFilePath($sshCert->privateKeyFilename()),
];
foreach ($this->sshConfig->getUserDefaultSshIdentityFiles() as $identityFile) {
$options['IdentityFile'][] = $this->sshConfig->formatFilePath($identityFile);
if ($this->certifier->useCertificateOnly()) {
$options['IdentitiesOnly'] = 'yes';
}
}
}
Expand Down
55 changes: 33 additions & 22 deletions src/Service/SshConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,38 +87,50 @@ public function configureSessionSsh()
$this->fs->remove($legacy);
}

$domainWildcards = $this->config->getWithDefault('api.ssh_domain_wildcards', []);
$domainWildcards = $this->config->getWithDefault('ssh.domain_wildcards', []);
if (!$domainWildcards) {
return false;
}

$lines = [];

if ($certificate = $this->certifier->getExistingCertificate()) {
$certificate = $this->certifier->getExistingCertificate();
$onlyCertificate = $certificate !== null && $this->certifier->useCertificateOnly();
if ($certificate) {
$executable = $this->config->get('application.executable');
$refreshCommand = sprintf('%s ssh-cert:load --refresh-only --yes --quiet', $executable);
if (!OsUtil::isWindows()) {
$refreshCommand .= ' 2>/dev/null';
}
// Use Match solely to run the refresh command.
$lines[] = '# Auto-refresh the SSH certificate:';
$lines[] = '# Auto-refresh the SSH certificate.';
$lines[] = sprintf('Match host "%s" exec "%s"', \implode(',', $domainWildcards), $refreshCommand);

$lines[] = '';
$lines[] = '# Cancel the Match, so that the following configuration will apply regardless of';
$lines[] = "# the command's execution.";
$lines[] = 'Host ' . implode(' ', $domainWildcards);

// Indentation in the SSH config is for readability (it has no other effect).
$lines[] = '';
$lines[] = '# Include the certificate and its key:';
$lines[] = '# Include the certificate and its key.';
$lines[] = sprintf('CertificateFile %s', $this->formatFilePath($certificate->certificateFilename()));
$lines[] = sprintf('IdentityFile %s', $this->formatFilePath($certificate->privateKeyFilename()));
if ($onlyCertificate) {
$lines[] = 'IdentitiesOnly yes';
}
} else {
$lines[] = 'Host ' . implode(' ', $domainWildcards);
}

$sessionIdentityFile = $this->sshKey->selectIdentity();
if ($sessionIdentityFile !== null) {
$lines[] = '';
$lines[] = '# This SSH key was detected as corresponding to the session:';
$lines[] = sprintf('IdentityFile %s', $this->formatFilePath($sessionIdentityFile));
$sessionIdentityFile = null;
if (!$onlyCertificate) {
$sessionIdentityFile = $this->sshKey->selectIdentity();
if ($sessionIdentityFile !== null) {
$lines[] = '';
$lines[] = '# This SSH key was detected as corresponding to the session.';
$lines[] = sprintf('IdentityFile %s', $this->formatFilePath($sessionIdentityFile));
$lines[] = '';
}
}

$sessionSpecificFilename = $this->getSessionSshDir() . DIRECTORY_SEPARATOR . 'config';
Expand All @@ -131,7 +143,7 @@ public function configureSessionSsh()
}

// Add default files if there is no preferred session identity file.
if ($sessionIdentityFile === null && ($defaultFiles = $this->getUserDefaultSshIdentityFiles())) {
if ($sessionIdentityFile === null && !$onlyCertificate && ($defaultFiles = $this->getUserDefaultSshIdentityFiles())) {
$lines[] = '';
$lines[] = '# Include SSH "default" identity files:';
foreach ($defaultFiles as $identityFile) {
Expand All @@ -147,15 +159,12 @@ public function configureSessionSsh()
'# It is updated automatically when certain CLI commands are run.',
];

$wildcards = $this->config->getWithDefault('api.ssh_domain_wildcards', []);
if (count($wildcards)) {
$includerLines[] = 'Host ' . implode(' ', $wildcards);
$includerLines[] = ' Include ' . $sessionSpecificFilename;
$this->writeSshIncludeFile(
$includerFilename,
$includerLines
);
}
$includerLines[] = 'Host ' . implode(' ', $domainWildcards);
$includerLines[] = ' Include ' . $sessionSpecificFilename;
$this->writeSshIncludeFile(
$includerFilename,
$includerLines
);

return true;
}
Expand Down Expand Up @@ -238,7 +247,7 @@ public function addUserSshConfig(QuestionHelper $questionHelper)

$filename = $this->getUserSshConfigFilename();

$wildcards = $this->config->getWithDefault('api.ssh_domain_wildcards', []);
$wildcards = $this->config->getWithDefault('ssh.domain_wildcards', []);
if (!$wildcards) {
return false;
}
Expand All @@ -252,7 +261,9 @@ public function addUserSshConfig(QuestionHelper $questionHelper)
$manualMessage = 'To configure SSH, add the following lines to: <comment>' . $filename . '</comment>'
. "\n" . $suggestedConfig;

$writeUserSshConfig = $this->config->getWithDefault('api.write_user_ssh_config', null);
$writeUserSshConfig = $this->config->has('api.write_user_ssh_config')
? $this->config->get('api.write_user_ssh_config')
: $this->config->getWithDefault('ssh.write_user_config', null);
if ($writeUserSshConfig === false) {
$this->stdErr->writeln($manualMessage);
return true;
Expand Down
2 changes: 1 addition & 1 deletion src/Service/SshDiagnostics.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public function sshHostIsInternal($uri)
return false;
}
// Check against the wildcard list.
foreach ($this->config->getWithDefault('api.ssh_domain_wildcards', []) as $wildcard) {
foreach ($this->config->getWithDefault('ssh.domain_wildcards', []) as $wildcard) {
if (\strpos($host, \str_replace('*.', '', $wildcard)) !== false) {
return true;
}
Expand Down
16 changes: 13 additions & 3 deletions src/SshCert/Certifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,17 @@ public function __construct(Api $api, Config $config, Shell $shell, Filesystem $
*/
public function isAutoLoadEnabled()
{
return !self::$disableAutoLoad && $this->config->getWithDefault('api.auto_load_ssh_cert', false);
return !self::$disableAutoLoad && $this->config->getWithDefault('ssh.auto_load_cert', false);
}

/**
* Whether to use the certificate only, if one is available.
*
* @return bool
*/
public function useCertificateOnly()
{
return $this->config->get('ssh.cert_only');
}

/**
Expand Down Expand Up @@ -99,7 +109,7 @@ private function doGenerateCertificate()
$this->fs->mkdir($dir, 0700);

// Remove the old certificate and key from the SSH agent.
if ($this->config->getWithDefault('api.add_to_ssh_agent', false)) {
if ($this->config->getWithDefault('ssh.add_to_agent', false)) {
$this->shell->execute(['ssh-add', '-d', $dir . DIRECTORY_SEPARATOR . self::PRIVATE_KEY_FILENAME], null, false, !$this->stdErr->isVeryVerbose());
}

Expand Down Expand Up @@ -129,7 +139,7 @@ private function doGenerateCertificate()
// Add the key to the SSH agent, if possible, silently.
// In verbose mode the full command will be printed, so the user can
// re-run it to check error details.
if ($this->config->getWithDefault('api.add_to_ssh_agent', false)) {
if ($this->config->getWithDefault('ssh.add_to_agent', false)) {
$lifetime = ($certificate->metadata()->getValidBefore() - time()) ?: 3600;
$this->shell->execute(['ssh-add', '-t', $lifetime, $sshPair['private']], null, false, !$this->stdErr->isVerbose());
}
Expand Down