diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php index 17fd0e4..fff5c14 100644 --- a/app/Livewire/Admin/Customers.php +++ b/app/Livewire/Admin/Customers.php @@ -53,6 +53,8 @@ class Customers extends Component 'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—', 'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale), 'instance' => $instance->subdomain ?? '—', + // Only an instance that exists can hand out an admin login. + 'instance_uuid' => $instance->uuid ?? null, 'closed' => $c->closed_at !== null || $c->status === 'closed', 'suspended' => $c->status === 'suspended', 'status' => match (true) { diff --git a/app/Livewire/Admin/InstanceAdminAccess.php b/app/Livewire/Admin/InstanceAdminAccess.php new file mode 100644 index 0000000..97053c1 --- /dev/null +++ b/app/Livewire/Admin/InstanceAdminAccess.php @@ -0,0 +1,95 @@ +authorize('instances.adminlogin'); + + $instance = Instance::query()->where('uuid', $uuid)->firstOrFail(); + $this->uuid = $uuid; + $this->subdomain = $instance->subdomain; + } + + public function hydrate(): void + { + $this->authorize('instances.adminlogin'); + } + + public function request(): void + { + $this->authorize('instances.adminlogin'); + $this->resetErrorBag('password'); + + $key = 'instance-admin:'.auth()->id(); + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->addError('password', __('instances.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)])); + + return; + } + + if (! Hash::check($this->password, auth()->user()->password)) { + RateLimiter::hit($key, 300); + $this->addError('password', __('instances.wrong_password')); + + return; + } + + RateLimiter::clear($key); + $this->reset('password'); + + // The reset runs on the provisioning worker — it owns the tunnel. The + // token is where it will leave the result. + $this->token = Str::random(40); + $this->waiting = true; + + IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, (int) auth()->id()); + } + + public function render() + { + $payload = null; + if ($this->token !== null) { + $raw = ConfigHandoff::get($this->token); + $payload = $raw === null ? null : json_decode($raw, true); + } + + return view('livewire.admin.instance-admin-access', [ + 'credentials' => isset($payload['password']) ? $payload : null, + 'failed' => isset($payload['error']) ? $payload['error'] : null, + ]); + } +} diff --git a/app/Provisioning/Jobs/IssueInstanceAdminAccess.php b/app/Provisioning/Jobs/IssueInstanceAdminAccess.php new file mode 100644 index 0000000..efc0c10 --- /dev/null +++ b/app/Provisioning/Jobs/IssueInstanceAdminAccess.php @@ -0,0 +1,86 @@ +onConnection('provisioning'); + $this->onQueue('provisioning'); + } + + public function handle(ProxmoxClient $proxmox): void + { + $instance = Instance::query()->with('host')->where('uuid', $this->instanceUuid)->first(); + + if ($instance === null || $instance->host === null || blank($instance->nc_admin_ref)) { + ConfigHandoff::put(json_encode(['error' => 'unavailable']), $this->handoffToken); + + return; + } + + $username = $instance->nc_admin_ref; + $password = Str::password(24, symbols: false); + + $result = $proxmox->forHost($instance->host)->guestExec( + $instance->host->node ?? 'pve', + (int) $instance->vmid, + 'cd /opt/nextcloud && OC_PASS='.escapeshellarg($password). + ' docker compose exec -T -e OC_PASS app php occ user:resetpassword --password-from-env '. + escapeshellarg($username), + ); + + if ((int) ($result['exitcode'] ?? 1) !== 0) { + Log::warning('instance admin access failed', [ + 'instance' => $instance->uuid, 'user' => $this->requestedBy, + ]); + ConfigHandoff::put(json_encode(['error' => 'failed']), $this->handoffToken); + + return; + } + + // Auditable on its own: who asked, for which instance, when. The + // credentials themselves are never logged. + Log::info('instance admin access issued', [ + 'instance' => $instance->uuid, 'user' => $this->requestedBy, + ]); + + ConfigHandoff::put(json_encode([ + 'url' => 'https://'.($instance->custom_domain ?: $instance->subdomain.'.'.config('provisioning.dns.zone')), + 'username' => $username, + 'password' => $password, + ]), $this->handoffToken); + } +} diff --git a/app/Services/Wireguard/ConfigHandoff.php b/app/Services/Wireguard/ConfigHandoff.php index 6129d81..e630d97 100644 --- a/app/Services/Wireguard/ConfigHandoff.php +++ b/app/Services/Wireguard/ConfigHandoff.php @@ -23,9 +23,10 @@ final class ConfigHandoff private const TTL_SECONDS = 600; - public static function put(#[SensitiveParameter] string $plaintext): string + /** @param string|null $token reuse a handle the caller already handed out */ + public static function put(#[SensitiveParameter] string $plaintext, ?string $token = null): string { - $token = Str::random(40); + $token ??= Str::random(40); Cache::put(self::PREFIX.$token, $plaintext, self::TTL_SECONDS); return $token; diff --git a/database/migrations/2026_07_26_000000_add_instance_admin_capability.php b/database/migrations/2026_07_26_000000_add_instance_admin_capability.php new file mode 100644 index 0000000..e746a62 --- /dev/null +++ b/database/migrations/2026_07_26_000000_add_instance_admin_capability.php @@ -0,0 +1,34 @@ +forgetCachedPermissions(); + + Permission::findOrCreate('instances.adminlogin', 'web'); + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'web')->givePermissionTo('instances.adminlogin'); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::query()->where('name', 'instances.adminlogin')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/instances.php b/lang/de/instances.php new file mode 100644 index 0000000..723c6d0 --- /dev/null +++ b/lang/de/instances.php @@ -0,0 +1,19 @@ + 'Administrator-Zugang zu :name', + 'admin_body' => 'Setzt das Passwort unseres verwalteten Administrator-Kontos in dieser Nextcloud neu und zeigt es einmalig an. Konten des Kunden bleiben unberührt. Zur Bestätigung Ihr eigenes Passwort.', + 'admin_confirm' => 'Zugang erzeugen', + 'admin_working' => 'Passwort wird in der Instanz gesetzt …', + 'admin_ready' => 'Zugangsdaten erzeugt.', + 'admin_url' => 'Adresse', + 'admin_user' => 'Benutzer', + 'admin_password' => 'Passwort', + 'admin_once' => 'Nur jetzt sichtbar. Beim nächsten Mal wird wieder ein neues Passwort gesetzt.', + 'admin_failed' => 'Die Instanz hat nicht geantwortet. Läuft die VM und ist der Tunnel aktiv?', + 'your_password' => 'Ihr Passwort', + 'wrong_password' => 'Passwort stimmt nicht.', + 'too_many_attempts' => 'Zu viele Versuche. Bitte in :seconds Sekunden erneut versuchen.', + 'cancel' => 'Abbrechen', + 'admin_action' => 'Als Administrator anmelden', +]; diff --git a/lang/en/instances.php b/lang/en/instances.php new file mode 100644 index 0000000..98610cd --- /dev/null +++ b/lang/en/instances.php @@ -0,0 +1,19 @@ + 'Administrator access to :name', + 'admin_body' => 'Resets the password of our managed administrator account inside this Nextcloud and shows it once. The customer\'s own accounts are untouched. Confirm with your own password.', + 'admin_confirm' => 'Issue access', + 'admin_working' => 'Setting the password on the instance …', + 'admin_ready' => 'Credentials issued.', + 'admin_url' => 'Address', + 'admin_user' => 'User', + 'admin_password' => 'Password', + 'admin_once' => 'Visible only now. Next time a new password is set again.', + 'admin_failed' => 'The instance did not answer. Is the VM running and the tunnel up?', + 'your_password' => 'Your password', + 'wrong_password' => 'That password is not correct.', + 'too_many_attempts' => 'Too many attempts. Try again in :seconds seconds.', + 'cancel' => 'Cancel', + 'admin_action' => 'Sign in as administrator', +]; diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php index 56c1c98..91e4551 100644 --- a/resources/views/livewire/admin/customers.blade.php +++ b/resources/views/livewire/admin/customers.blade.php @@ -36,6 +36,17 @@ {{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }} @endunless + {{-- Impersonation borrows the customer's PORTAL session; this + is administrator access to their Nextcloud itself. Two + different things, so two buttons. --}} + @if ($r['instance_uuid'] && auth()->user()?->can('instances.adminlogin')) + + @endif