From 60d2325f22cc222b9aaefbe4f0ec91b9709d5b96 Mon Sep 17 00:00:00 2001 From: Sam Jordan Date: Tue, 10 Aug 2021 21:44:18 +0100 Subject: [PATCH] Adds ssm:copy command to allow for scp over ssm --- app/Commands/AwsSsmConnect.php | 6 +- app/Commands/AwsSsmCopy.php | 161 +++++++++++++++++++++++++++ app/Commands/Console/InputOption.php | 2 +- app/Helpers/Aws/Ec2.php | 2 +- 4 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 app/Commands/AwsSsmCopy.php diff --git a/app/Commands/AwsSsmConnect.php b/app/Commands/AwsSsmConnect.php index 25a4a8b..7b25659 100644 --- a/app/Commands/AwsSsmConnect.php +++ b/app/Commands/AwsSsmConnect.php @@ -137,7 +137,7 @@ protected function sendReRunHelper($rebuildOptions): void { $this->info("You can run this command again without having to go through options using this:"); $this->info(' '); - $this->comment("netsells aws:ssm:connect " . implode(' ', $rebuildOptions)); + $this->comment("netsells " . $this->signature . " " . implode(' ', $rebuildOptions)); $this->info(' '); } @@ -189,7 +189,7 @@ protected function askForInstanceId() return $this->menu("Choose an instance to connect to...", $instances->toArray())->open(); } - private function generateTempSshKey() + protected function generateTempSshKey() { $requiredBinaries = ['aws', 'ssh', 'ssh-keygen']; @@ -227,7 +227,7 @@ private function generateTempSshKey() return trim(file_get_contents($pubKeyName)); } - private function generateRemoteCommand($username, $key) + protected function generateRemoteCommand($username, $key) { // Borrowed from https://github.com/elpy1/ssh-over-ssm/blob/master/ssh-ssm.sh#L10 return trim(<<setDefinition(array_merge([ + new InputOption('instance-id', null, InputOption::VALUE_OPTIONAL, 'The instance ID to connect to'), + new InputOption('username', null, InputOption::VALUE_OPTIONAL, 'The username connect with'), + new InputOption('direction', null, InputOption::VALUE_OPTIONAL, 'Direction (Up/Down)'), + new InputOption('local-path', null, InputOption::VALUE_OPTIONAL, 'The path of the other server (local or remote). Can be a file or folder'), + new InputOption('remote-path', null, InputOption::VALUE_OPTIONAL, 'The path of the server. Can be a file or folder'), + new InputOption('other-server', null, InputOption::VALUE_OPTIONAL, 'Left blank, the other server is the local machine. Otherwise, specify user@host'), + ], $this->helpers->aws()->commonConsoleOptions())); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $requiredBinaries = ['aws', 'ssh']; + + if ($this->helpers->checks()->checkAndReportMissingBinaries($requiredBinaries)) { + return 1; + } + + $rebuildOptions = []; + + $instanceId = $this->option('instance-id') ?: $this->askForInstanceId(); + $username = $this->option('username') ?: $this->askForUsername(); + + if (!$instanceId) { + $this->error('No instance ID provided.'); + return 1; + } + + $rebuildOptions = $this->appendResolvedArgument($rebuildOptions, 'username', $username); + $rebuildOptions = $this->appendResolvedArgument($rebuildOptions, 'instance-id', $instanceId); + $rebuildOptions = $this->appendResolvedArgument($rebuildOptions, 'aws-profile'); + $rebuildOptions = $this->appendResolvedArgument($rebuildOptions, 'aws-region'); + + $key = $this->generateTempSshKey(); + $command = $this->generateRemoteCommand($username, $key); + + $this->info("Sending a temporary SSH key to the server...", OutputInterface::VERBOSITY_VERBOSE); + if (!$this->helpers->aws()->ssm()->sendRemoteCommand($instanceId, $command)) { + $this->error('Failed to send SSH key to server'); + return 1; + } + + $sessionCommand = $this->helpers->aws()->ssm()->startSessionProcess($instanceId); + $sessionCommandString = implode(' ', $sessionCommand->getArguments()); + + $options = [ + '-o', 'IdentityFile ~/.ssh/netsells-cli-ssm-ssh-tmp', + '-o', 'IdentitiesOnly yes', + '-o', 'GSSAPIAuthentication no', + '-o', 'PasswordAuthentication no', + '-o', "ProxyCommand {$sessionCommandString}", + ]; + + $direction = trim($this->option('direction')) ?: $this->askForDirection(); + + if (!$this->validateDirection($direction)) { + $this->error('Invalid direction'); + } + + $rebuildOptions = $this->appendResolvedArgument($rebuildOptions, 'direction', $direction); + + $otherServer = trim($this->option('other-server')) ?: $this->askForOtherServer(); + + $localPath = trim($this->option('local-path')) ?: $this->askForLocalPath($otherServer); + $remotePath = trim($this->option('remote-path')) ?: $this->askForRemotePath(); + + $rebuildOptions = $this->appendResolvedArgument($rebuildOptions, 'local-path', $localPath); + $rebuildOptions = $this->appendResolvedArgument($rebuildOptions, 'remote-path', $remotePath); + $rebuildOptions = $this->appendResolvedArgument($rebuildOptions, 'other-server', $otherServer); + + $this->sendReRunHelper($rebuildOptions); + + $localOption = ($otherServer !== '__localhost__' ? "{$otherServer}:" : null) . $localPath; + $remoteOption = sprintf("%s@%s", $username, $instanceId) . ":{$remotePath}"; + + $directionOptions = ($direction == 'up') ? [$localOption, $remoteOption] : [$remoteOption, $localOption]; + + try { + $this->helpers->process()->withCommand(array_merge( + [ + 'scp', + ], + $options, + $directionOptions, + )) + ->withTimeout(null) + ->withProcessModifications(function ($process) { + $process->setTty(Process::isTtySupported()); + $process->setIdleTimeout(null); + }) + ->run(); + } catch (ProcessFailed $e) { + $this->info(' '); + $this->error("SCP command exited with an exit code of " . $e->getCode()); + } + } + + protected function askForDirection() + { + return $this->menu("Which direction are you sending the files?", [ + 'up' => 'Upstream', + 'down' => 'Downstream', + ])->open(); + } + + protected function validateDirection($direction): bool + { + return in_array($direction, ['up', 'down']); + } + + protected function askForLocalPath($otherServer) + { + if ($otherServer == '__localhost__') { + return $this->ask("What is the path of the file/folder on your computer?"); + } + + return $this->ask("What is the path of the file/folder on the other server? ({$otherServer})"); + } + + protected function askForRemotePath() + { + return $this->ask("What is the path of the file/folder on the remote server?"); + } + + protected function askForOtherServer() + { + return $this->ask("What is the path of the other server? Should be in the format username@hostname. Leave blank for this computer", '__localhost__'); + } +} diff --git a/app/Commands/Console/InputOption.php b/app/Commands/Console/InputOption.php index b2f7299..22e99af 100644 --- a/app/Commands/Console/InputOption.php +++ b/app/Commands/Console/InputOption.php @@ -23,7 +23,7 @@ public function getDefault() $constantName = sprintf("%s::%s", NetsellsFile::class, $this->netsellsFilePrefix . $keyName); if (defined($constantName)) { - return (new NetsellsFile())->get(constant($constantName)); + return (new NetsellsFile())->get(constant($constantName), parent::getDefault()); } return parent::getDefault(); diff --git a/app/Helpers/Aws/Ec2.php b/app/Helpers/Aws/Ec2.php index 669bd66..7e3b540 100644 --- a/app/Helpers/Aws/Ec2.php +++ b/app/Helpers/Aws/Ec2.php @@ -31,7 +31,7 @@ public function listInstances($query): ?Collection $processOutput = $this->aws->newProcess($commandOptions) ->run(); } catch (ProcessFailed $e) { - $this->command->error("Unable to list ec2 instances"); + $this->aws->getCommand()->error("Unable to list ec2 instances"); return null; }