diff --git a/config-defaults.yaml b/config-defaults.yaml index 237cc7519..2e6f3b3b7 100644 --- a/config-defaults.yaml +++ b/config-defaults.yaml @@ -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 @@ -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 @@ -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. @@ -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: diff --git a/config.yaml b/config.yaml index 8d47293e4..ffa9ee8f7 100644 --- a/config.yaml +++ b/config.yaml @@ -49,8 +49,6 @@ 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 @@ -58,6 +56,9 @@ api: vendor_filter: 'platformsh' +ssh: + domain_wildcards: ['*.platform.sh'] + detection: git_remote_name: 'platform' git_domain: 'platform.sh' # matches git.eu-5.platform.sh, etc. diff --git a/src/Command/SshCert/SshCertLoadCommand.php b/src/Command/SshCert/SshCertLoadCommand.php index 29b629073..462220a4e 100644 --- a/src/Command/SshCert/SshCertLoadCommand.php +++ b/src/Command/SshCert/SshCertLoadCommand.php @@ -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." diff --git a/src/Command/WelcomeCommand.php b/src/Command/WelcomeCommand.php index 98ce5f0f6..97e7a2509 100644 --- a/src/Command/WelcomeCommand.php +++ b/src/Command/WelcomeCommand.php @@ -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()) { diff --git a/src/Service/Config.php b/src/Service/Config.php index 3b9a9765a..52ba283e7 100644 --- a/src/Service/Config.php +++ b/src/Service/Config.php @@ -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) { @@ -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. @@ -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() diff --git a/src/Service/Ssh.php b/src/Service/Ssh.php index 0b2fb2494..5469c92d3 100644 --- a/src/Service/Ssh.php +++ b/src/Service/Ssh.php @@ -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'; } } } diff --git a/src/Service/SshConfig.php b/src/Service/SshConfig.php index a55c6d03e..cba92250c 100644 --- a/src/Service/SshConfig.php +++ b/src/Service/SshConfig.php @@ -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'; @@ -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) { @@ -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; } @@ -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; } @@ -252,7 +261,9 @@ public function addUserSshConfig(QuestionHelper $questionHelper) $manualMessage = 'To configure SSH, add the following lines to: ' . $filename . '' . "\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; diff --git a/src/Service/SshDiagnostics.php b/src/Service/SshDiagnostics.php index b8076a5f9..e1bbd9414 100644 --- a/src/Service/SshDiagnostics.php +++ b/src/Service/SshDiagnostics.php @@ -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; } diff --git a/src/SshCert/Certifier.php b/src/SshCert/Certifier.php index ad6fa1da3..9d770e9d0 100644 --- a/src/SshCert/Certifier.php +++ b/src/SshCert/Certifier.php @@ -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'); } /** @@ -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()); } @@ -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()); }