Files
jabali-panel/app/Filament/Jabali/Pages/SshKeys.php
2026-01-28 04:19:30 +02:00

447 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class SshKeys extends Page implements HasActions, HasForms, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-key';
protected static ?int $navigationSort = 7;
protected string $view = 'filament.jabali.pages.ssh-keys';
public static function getNavigationLabel(): string
{
return __('SSH & SFTP');
}
public function getTitle(): string
{
return __('SSH & SFTP Access');
}
public array $sshKeys = [];
public string $sshHost = '';
public string $sshPort = '22';
public string $sshUsername = '';
public string $sshCommand = '';
public ?array $generatedKey = null;
public bool $shellEnabled = false;
public function mount(): void
{
$this->loadSshKeys();
$this->loadShellStatus();
$this->sshHost = request()->getHost();
$this->sshPort = '22';
$this->sshUsername = $this->getUsername();
$this->sshCommand = 'ssh '.$this->sshUsername.'@'.$this->sshHost;
}
protected function loadShellStatus(): void
{
try {
$result = $this->getAgent()->send('ssh.shell_status', ['username' => $this->getUsername()]);
$this->shellEnabled = $result['shell_enabled'] ?? false;
} catch (\Exception $e) {
$this->shellEnabled = false;
}
}
public function toggleShellAccess(): void
{
try {
$command = $this->shellEnabled ? 'ssh.disable_shell' : 'ssh.enable_shell';
$result = $this->getAgent()->send($command, ['username' => $this->getUsername()]);
if ($result['success'] ?? false) {
$this->shellEnabled = ! $this->shellEnabled;
Notification::make()
->title($this->shellEnabled ? __('SSH Shell Enabled') : __('SSH Shell Disabled'))
->body($this->shellEnabled
? __('You now have jailed shell access with wp-cli support.')
: __('Shell access disabled. You can still use SFTP for file transfers.'))
->success()
->send();
} else {
throw new \Exception($result['error'] ?? __('Failed to toggle shell access'));
}
} catch (\Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
}
protected function getUsername(): string
{
$user = Auth::user();
return $user->system_username ?? $user->username ?? $user->email;
}
protected function getAgent()
{
return app(\App\Services\Agent\AgentClient::class);
}
protected function loadSshKeys(): void
{
try {
$result = $this->getAgent()->send('ssh.list_keys', ['username' => $this->getUsername()]);
$this->sshKeys = $result['keys'] ?? [];
} catch (\Exception $e) {
$this->sshKeys = [];
}
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->sshKeys)
->columns([
TextColumn::make('name')
->label(__('Key Name'))
->icon('heroicon-o-key')
->iconColor('success')
->weight(FontWeight::Medium)
->searchable(),
TextColumn::make('fingerprint')
->label(__('Fingerprint'))
->fontFamily('mono')
->size('sm')
->color('gray')
->limit(40),
TextColumn::make('added_at')
->label(__('Added'))
->date('M d, Y')
->sortable(),
])
->recordActions([
Action::make('delete')
->label(__('Delete'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalHeading(__('Delete SSH Key'))
->modalDescription(__('Are you sure you want to delete this SSH key? You will no longer be able to use it to connect to the server.'))
->modalIcon('heroicon-o-trash')
->modalIconColor('danger')
->modalSubmitActionLabel(__('Delete Key'))
->action(function (array $record): void {
try {
$result = $this->getAgent()->send('ssh.delete_key', [
'username' => $this->getUsername(),
'key_id' => $record['id'],
]);
if ($result['success'] ?? false) {
Notification::make()
->title(__('SSH Key Deleted'))
->success()
->send();
$this->loadSshKeys();
$this->resetTable();
} else {
throw new \Exception($result['error'] ?? __('Failed to delete key'));
}
} catch (\Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
}),
])
->emptyStateHeading(__('No SSH keys added yet'))
->emptyStateDescription(__('Click "Generate SSH Key" to create a new key pair, or "Add Existing Key" to add your own public key'))
->emptyStateIcon('heroicon-o-key')
->striped();
}
public function getTableRecordKey(Model|array $record): string
{
return is_array($record) ? ($record['id'] ?? $record['name']) : $record->getKey();
}
protected function getHeaderActions(): array
{
return [
Action::make('generateKey')
->label(__('Generate SSH Key'))
->icon('heroicon-o-sparkles')
->color('success')
->modalHeading(__('Generate SSH Key'))
->modalDescription(__('Generate a new SSH key pair for secure server access'))
->modalIcon('heroicon-o-sparkles')
->modalIconColor('success')
->modalSubmitActionLabel(__('Generate Key'))
->form([
TextInput::make('name')
->label(__('Key Name'))
->placeholder(__('My Generated Key'))
->required()
->maxLength(50)
->helperText(__('A descriptive name to identify this key')),
Select::make('type')
->label(__('Key Type'))
->options([
'ed25519' => __('ED25519 (Recommended)'),
'rsa' => __('RSA 4096-bit'),
])
->default('ed25519')
->required()
->helperText(__('ED25519 is faster and more secure')),
TextInput::make('passphrase')
->label(__('Passphrase (Optional)'))
->password()
->helperText(__('Leave empty for no passphrase')),
])
->action(function (array $data): void {
try {
$result = $this->generateSshKey($data['name'], $data['type'], $data['passphrase'] ?? '');
if ($result) {
$this->generatedKey = $result;
$this->loadSshKeys();
Notification::make()
->title(__('SSH Key Generated!'))
->body(__('Download your private key below. The public key has been added to your account.'))
->success()
->persistent()
->send();
}
} catch (\Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
}),
Action::make('addKey')
->label(__('Add Existing Key'))
->icon('heroicon-o-plus')
->color('primary')
->modalHeading(__('Add Existing SSH Key'))
->modalDescription(__('Add your existing public key to enable SSH access'))
->modalIcon('heroicon-o-key')
->modalIconColor('primary')
->modalSubmitActionLabel(__('Add Key'))
->form([
TextInput::make('name')
->label(__('Key Name'))
->placeholder(__('My Laptop'))
->required()
->maxLength(50)
->helperText(__('A descriptive name to identify this key')),
Textarea::make('public_key')
->label(__('Public Key'))
->placeholder(__('ssh-rsa AAAAB3... or ssh-ed25519 AAAAC3...'))
->required()
->rows(4)
->helperText(__('Paste your public key (usually from ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub)')),
])
->action(function (array $data): void {
try {
$result = $this->getAgent()->send('ssh.add_key', [
'username' => $this->getUsername(),
'name' => $data['name'],
'public_key' => trim($data['public_key']),
]);
if ($result['success'] ?? false) {
Notification::make()
->title(__('SSH Key Added'))
->success()
->send();
$this->loadSshKeys();
} else {
throw new \Exception($result['error'] ?? __('Failed to add key'));
}
} catch (\Exception $e) {
Notification::make()
->title(__('Error'))
->body($e->getMessage())
->danger()
->send();
}
}),
];
}
protected function generateSshKey(string $name, string $type, string $passphrase = ''): array
{
$result = $this->getAgent()->send('ssh.generate_key', [
'name' => $name,
'type' => $type,
'passphrase' => $passphrase,
]);
if (! ($result['success'] ?? false)) {
throw new \Exception($result['error'] ?? __('Failed to generate SSH key'));
}
// Add public key to user authorized_keys
$addResult = $this->getAgent()->send('ssh.add_key', [
'username' => $this->getUsername(),
'name' => $name,
'public_key' => trim($result['public_key']),
]);
if (! ($addResult['success'] ?? false)) {
throw new \Exception($addResult['error'] ?? __('Failed to add key to server'));
}
return [
'name' => $result['name'],
'type' => $result['type'],
'private_key' => $result['private_key'],
'public_key' => $result['public_key'],
'ppk_key' => $result['ppk_key'] ?? null,
'fingerprint' => $result['fingerprint'] ?? '',
];
}
public function clearGeneratedKey(): void
{
$this->generatedKey = null;
}
public function downloadPrivateKey(): \Symfony\Component\HttpFoundation\StreamedResponse
{
if (! $this->generatedKey) {
abort(404);
}
$key = $this->generatedKey['private_key'];
$name = preg_replace('/[^a-zA-Z0-9_-]/', '_', $this->generatedKey['name']);
return response()->streamDownload(function () use ($key) {
echo $key;
}, "id_{$this->generatedKey['type']}_{$name}", [
'Content-Type' => 'application/x-pem-file',
]);
}
public function downloadPpkKey(): \Symfony\Component\HttpFoundation\StreamedResponse
{
if (! $this->generatedKey || ! $this->generatedKey['ppk_key']) {
abort(404);
}
$key = $this->generatedKey['ppk_key'];
$name = preg_replace('/[^a-zA-Z0-9_-]/', '_', $this->generatedKey['name']);
return response()->streamDownload(function () use ($key) {
echo $key;
}, "{$name}.ppk", [
'Content-Type' => 'application/x-putty-private-key',
]);
}
public function downloadFileZillaConfig(): \Symfony\Component\HttpFoundation\StreamedResponse
{
$xml = $this->generateFileZillaXml();
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'jabali-sftp.xml', [
'Content-Type' => 'application/xml',
]);
}
public function downloadWinScpConfig(): \Symfony\Component\HttpFoundation\StreamedResponse
{
$ini = $this->generateWinScpIni();
return response()->streamDownload(function () use ($ini) {
echo $ini;
}, 'jabali-sftp.ini', [
'Content-Type' => 'text/plain',
]);
}
protected function generateFileZillaXml(): string
{
$host = htmlspecialchars($this->sshHost);
$port = htmlspecialchars($this->sshPort);
$user = htmlspecialchars($this->sshUsername);
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<FileZilla3 version="3.66.4" platform="*">
<Servers>
<Server>
<Host>{$host}</Host>
<Port>{$port}</Port>
<Protocol>1</Protocol>
<Type>0</Type>
<User>{$user}</User>
<Logontype>1</Logontype>
<EncodingType>Auto</EncodingType>
<BypassProxy>0</BypassProxy>
<Name>Jabali - {$host}</Name>
<RemoteDir>/home/{$user}</RemoteDir>
<SyncBrowsing>0</SyncBrowsing>
<DirectoryComparison>0</DirectoryComparison>
</Server>
</Servers>
</FileZilla3>
XML;
}
protected function generateWinScpIni(): string
{
$host = $this->sshHost;
$port = $this->sshPort;
$user = $this->sshUsername;
$sessionName = "Jabali - {$host}";
return <<<INI
[Sessions\\{$sessionName}]
HostName={$host}
PortNumber={$port}
UserName={$user}
FSProtocol=2
RemoteDirectory=/home/{$user}
UpdateDirectories=1
INI;
}
}