From 33594f2dbae9a8784ec5ebd21a087cc30f364375 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 24 Mar 2024 21:58:48 +0100 Subject: [PATCH] headless console --- app/Facades/SSH.php | 2 +- app/Helpers/SSH.php | 56 ++++++-------- app/Http/Controllers/ConsoleController.php | 43 +++++++++++ app/Http/Middleware/HandleSSHErrors.php | 3 +- app/Support/Testing/SSHFake.php | 2 +- .../views/components/console-view.blade.php | 2 +- .../heroicons/o-command-line.blade.php | 14 ++++ resources/views/console/index.blade.php | 76 +++++++++++++++++++ resources/views/layouts/sidebar.blade.php | 12 +++ routes/server.php | 5 ++ 10 files changed, 180 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/ConsoleController.php create mode 100644 resources/views/components/heroicons/o-command-line.blade.php create mode 100644 resources/views/console/index.blade.php diff --git a/app/Facades/SSH.php b/app/Facades/SSH.php index b5ef0b6..66eecd3 100644 --- a/app/Facades/SSH.php +++ b/app/Facades/SSH.php @@ -12,7 +12,7 @@ * @method static init(Server $server, string $asUser = null) * @method static setLog(string $logType, int $siteId = null) * @method static connect() - * @method static string exec(string $command, string $log = '', int $siteId = null) + * @method static string exec(string $command, string $log = '', int $siteId = null, ?bool $stream = false) * @method static string assertExecuted(array|string $commands) * @method static string assertExecutedContains(string $command) * @method static disconnect() diff --git a/app/Helpers/SSH.php b/app/Helpers/SSH.php index d2d91b4..3536f3e 100755 --- a/app/Helpers/SSH.php +++ b/app/Helpers/SSH.php @@ -96,7 +96,7 @@ public function connect(bool $sftp = false): void * @throws SSHCommandError * @throws SSHConnectionError */ - public function exec(string|array $commands, string $log = '', ?int $siteId = null): string + public function exec(string $command, string $log = '', ?int $siteId = null, ?bool $stream = false): string { if ($log) { $this->setLog($log, $siteId); @@ -112,18 +112,34 @@ public function exec(string|array $commands, string $log = '', ?int $siteId = nu throw new SSHConnectionError($e->getMessage()); } - if (! is_array($commands)) { - $commands = [$commands]; - } - try { - $result = ''; - foreach ($commands as $command) { - $result .= $this->executeCommand($command); + if ($this->asUser) { + $command = 'sudo su - '.$this->asUser.' -c '.'"'.addslashes($command).'"'; } - return $result; + $this->connection->setTimeout(0); + if ($stream) { + $this->connection->exec($command, function ($output) { + $this->log?->write($output); + echo $output; + ob_flush(); + flush(); + }); + + return ''; + } else { + $output = $this->connection->exec($command); + + $this->log?->write($output); + + if (Str::contains($output, 'VITO_SSH_ERROR')) { + throw new Exception('SSH command failed with an error'); + } + + return $output; + } } catch (Throwable $e) { + throw $e; throw new SSHCommandError($e->getMessage()); } } @@ -141,28 +157,6 @@ public function upload(string $local, string $remote): void $this->connection->put($remote, $local, SFTP::SOURCE_LOCAL_FILE); } - /** - * @throws Exception - */ - protected function executeCommand(string $command): string - { - if ($this->asUser) { - $command = 'sudo su - '.$this->asUser.' -c '.'"'.addslashes($command).'"'; - } - - $this->connection->setTimeout(0); - - $output = $this->connection->exec($command); - - $this->log?->write($output); - - if (Str::contains($output, 'VITO_SSH_ERROR')) { - throw new Exception('SSH command failed with an error'); - } - - return $output; - } - /** * @throws Exception */ diff --git a/app/Http/Controllers/ConsoleController.php b/app/Http/Controllers/ConsoleController.php new file mode 100644 index 0000000..51639f1 --- /dev/null +++ b/app/Http/Controllers/ConsoleController.php @@ -0,0 +1,43 @@ + $server, + ]); + } + + public function run(Server $server, Request $request) + { + $this->validate($request, [ + 'user' => [ + 'required', + Rule::in(['root', $server->ssh_user]), + ], + 'command' => 'required|string', + ]); + + return response()->stream( + function () use ($server, $request) { + $ssh = $server->ssh($request->user); + $log = 'console-'.time(); + $ssh->exec(command: $request->command, log: $log, stream: true); + }, + 200, + [ + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + 'Content-Type' => 'text/event-stream', + ] + ); + } +} diff --git a/app/Http/Middleware/HandleSSHErrors.php b/app/Http/Middleware/HandleSSHErrors.php index 02b97b8..03df15a 100644 --- a/app/Http/Middleware/HandleSSHErrors.php +++ b/app/Http/Middleware/HandleSSHErrors.php @@ -7,13 +7,14 @@ use App\Facades\Toast; use Closure; use Illuminate\Http\Request; +use Illuminate\Http\Response; class HandleSSHErrors { public function handle(Request $request, Closure $next) { $res = $next($request); - if ($res->exception) { + if ($res instanceof Response && $res->exception) { if ($res->exception instanceof SSHConnectionError || $res->exception instanceof SSHCommandError) { Toast::error($res->exception->getMessage()); diff --git a/app/Support/Testing/SSHFake.php b/app/Support/Testing/SSHFake.php index def89d1..f17417a 100644 --- a/app/Support/Testing/SSHFake.php +++ b/app/Support/Testing/SSHFake.php @@ -34,7 +34,7 @@ public function connect(bool $sftp = false): void } } - public function exec(string|array $commands, string $log = '', ?int $siteId = null): string + public function exec(string|array $commands, string $log = '', ?int $siteId = null, ?bool $stream = false): string { if ($log) { $this->setLog($log, $siteId); diff --git a/resources/views/components/console-view.blade.php b/resources/views/components/console-view.blade.php index ab36b91..539e4d7 100644 --- a/resources/views/components/console-view.blade.php +++ b/resources/views/components/console-view.blade.php @@ -1,5 +1,5 @@
merge(["class" => "h-[500px] w-full overflow-auto whitespace-pre-line rounded-md border border-gray-200 bg-black p-5 text-gray-50 dark:border-gray-700"]) }} > {{ $slot }}
diff --git a/resources/views/components/heroicons/o-command-line.blade.php b/resources/views/components/heroicons/o-command-line.blade.php new file mode 100644 index 0000000..e035d11 --- /dev/null +++ b/resources/views/components/heroicons/o-command-line.blade.php @@ -0,0 +1,14 @@ + + + diff --git a/resources/views/console/index.blade.php b/resources/views/console/index.blade.php new file mode 100644 index 0000000..18d8a87 --- /dev/null +++ b/resources/views/console/index.blade.php @@ -0,0 +1,76 @@ + + {{ $server->name }} - Console + +
$server]) }}', + async run() { + this.output = 'Running...\n' + const fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + }, + body: JSON.stringify({ + user: this.user, + command: this.command, + }), + } + + const response = await fetch(this.runUrl, fetchOptions) + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') + + while (true) { + const { value, done } = await reader.read() + if (done) break + + const textChunk = decoder.decode(value, { stream: true }) + + this.output += textChunk + + document.getElementById('console-output').scrollTop = + document.getElementById('console-output').scrollHeight + } + this.output += '\nDone!' + }, + }" + > + + Headless Console + + Here you can run ssh commands on your server and see the result right away. +
+ Note that + this is a headless console, it doesn't keep the current path. it will always run from the home path of + the selected user. +
+
+ +
+ +
+
+
+ + + + + + Run + +
+
+
diff --git a/resources/views/layouts/sidebar.blade.php b/resources/views/layouts/sidebar.blade.php index 65200c2..3868e22 100644 --- a/resources/views/layouts/sidebar.blade.php +++ b/resources/views/layouts/sidebar.blade.php @@ -116,6 +116,18 @@ class="fixed left-0 top-0 z-40 h-screen w-64 -translate-x-full border-r border-g +
  • + + + + {{ __("Console") }} + + +
  • +
  • name('servers.services.restart'); Route::get('/{server}/services/{service}/enable', [ServiceController::class, 'enable'])->name('servers.services.enable'); Route::get('/{server}/services/{service}/disable', [ServiceController::class, 'disable'])->name('servers.services.disable'); + + // console + Route::get('/{server}/console', [ConsoleController::class, 'index'])->name('servers.console'); + Route::post('/{server}/console', [ConsoleController::class, 'run'])->name('servers.console.run'); }); // settings