diff --git a/app/Http/Middleware/RestrictConsoleNetwork.php b/app/Http/Middleware/RestrictConsoleNetwork.php new file mode 100644 index 0000000..e7570bb --- /dev/null +++ b/app/Http/Middleware/RestrictConsoleNetwork.php @@ -0,0 +1,108 @@ +is('admin', 'admin/*')) { + return $next($request); + } + + if (! self::isRestricted()) { + return $next($request); + } + + if (! self::allows((string) $request->ip())) { + abort(404); + } + + return $next($request); + } + + /** Whether the owner has switched the restriction on at all. */ + public static function isRestricted(): bool + { + return Settings::bool('console.network_restricted', false); + } + + /** + * Every network that may reach the console. + * + * The VPN is always in it and is not removable — it is the one path that + * exists independently of whatever the owner has typed into the list, and + * without it a bad entry would leave nobody able to fix the entry. + * + * @return array + */ + public static function allowedRanges(): array + { + $vpn = (array) config('admin_access.trusted_ranges', []); + $own = (array) Settings::get('console.allowed_ips', []); + + return array_values(array_unique(array_filter(array_merge( + $vpn, + array_map('trim', array_map('strval', $own)), + )))); + } + + public static function allows(string $ip): bool + { + return self::covers($ip, self::allowedRanges()); + } + + /** + * Would this address still get in, given that list? + * + * Split out so the "you are about to lock yourself out" decision can be + * made — and tested — without a request: it is the one check whose failure + * mode is that nobody can reach the page that would fix it. + * + * @param array $extra the owner's list AFTER the change + */ + public static function wouldStillAllow(string $ip, array $extra): bool + { + $vpn = (array) config('admin_access.trusted_ranges', []); + + return self::covers($ip, array_merge($vpn, $extra)); + } + + /** @param array $ranges */ + private static function covers(string $ip, array $ranges): bool + { + $ranges = array_values(array_filter($ranges)); + + return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 967c26c..3013611 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -235,6 +235,109 @@ class Settings extends Component return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count(); } + /** A new entry for the console allowlist: a single address or a CIDR range. */ + public string $consoleIp = ''; + + /** + * Add a network that may reach the console without the VPN. + * + * Validated as an address or CIDR before it is stored: a typo that silently + * matches nothing is how someone locks themselves out while believing they + * have not. + */ + public function addConsoleIp(): void + { + $this->authorize('site.manage'); + + $value = trim($this->consoleIp); + + if (! $this->looksLikeNetwork($value)) { + $this->addError('consoleIp', __('admin_settings.console_ip_invalid')); + + return; + } + + $list = (array) AppSettings::get('console.allowed_ips', []); + + if (! in_array($value, $list, true)) { + $list[] = $value; + AppSettings::set('console.allowed_ips', array_values($list)); + } + + $this->consoleIp = ''; + $this->dispatch('notify', message: __('admin_settings.console_ip_added', ['ip' => $value])); + } + + /** + * Remove one — unless it is the reason you can see this page. + * + * Refusing here rather than warning afterwards: by the time the page + * reloads, the request that would show the warning has already been + * rejected. The VPN is never in this list, so an operator on the VPN can + * always clear it out. + */ + public function removeConsoleIp(string $value): void + { + $this->authorize('site.manage'); + + $list = array_values(array_filter( + (array) AppSettings::get('console.allowed_ips', []), + fn ($entry) => $entry !== $value, + )); + + $ip = (string) request()->ip(); + + if (\App\Http\Middleware\RestrictConsoleNetwork::isRestricted() + && ! \App\Http\Middleware\RestrictConsoleNetwork::wouldStillAllow($ip, $list)) { + $this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip])); + + return; + } + + AppSettings::set('console.allowed_ips', $list); + $this->dispatch('notify', message: __('admin_settings.console_ip_removed', ['ip' => $value])); + } + + /** + * Switch the restriction on or off. + * + * Turning it ON is refused unless the address doing the switching is + * already covered — otherwise the click that secures the console is also + * the click that locks its owner out of it. + */ + public function toggleConsoleRestriction(): void + { + $this->authorize('site.manage'); + + $on = ! \App\Http\Middleware\RestrictConsoleNetwork::isRestricted(); + + if ($on && ! \App\Http\Middleware\RestrictConsoleNetwork::allows((string) request()->ip())) { + $this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()])); + + return; + } + + AppSettings::set('console.network_restricted', $on); + $this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked')); + } + + private function looksLikeNetwork(string $value): bool + { + [$address, $prefix] = array_pad(explode('/', $value, 2), 2, null); + + if (filter_var($address, FILTER_VALIDATE_IP) === false) { + return false; + } + + if ($prefix === null) { + return true; + } + + $max = str_contains($address, ':') ? 128 : 32; + + return ctype_digit($prefix) && (int) $prefix >= 0 && (int) $prefix <= $max; + } + public function render() { $staff = User::query() @@ -258,6 +361,12 @@ class Settings extends Component (string) request()->ip(), (array) config('admin_access.trusted_ranges', []), ), + // Who may reach the console, and from where the viewer is asking — + // shown so the consequence of a change is visible before it is made. + 'consoleRestricted' => \App\Http\Middleware\RestrictConsoleNetwork::isRestricted(), + 'consoleIps' => (array) AppSettings::get('console.allowed_ips', []), + 'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []), + 'viewerIp' => (string) request()->ip(), 'staff' => $staff, 'roles' => User::OPERATOR_ROLES, 'canManageStaff' => auth()->user()->can('staff.manage'), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4f34806..e40acba 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,6 +20,7 @@ use App\Services\Wireguard\WireguardHub; use App\Http\Middleware\EnsureAdmin; use App\Http\Middleware\EnsureCustomerActive; use App\Http\Middleware\RestrictAdminHost; +use App\Http\Middleware\RestrictConsoleNetwork; use App\Mail\MaintenanceCancelledMail; use App\Models\MaintenanceNotification; use App\Services\Maintenance\MaintenanceNotifier; @@ -67,6 +68,7 @@ class AppServiceProvider extends ServiceProvider // actions run. Livewire::addPersistentMiddleware([ RestrictAdminHost::class, + RestrictConsoleNetwork::class, EnsureAdmin::class, EnsureCustomerActive::class, ]); diff --git a/bootstrap/app.php b/bootstrap/app.php index 5199197..e776c6a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,11 @@ return Application::configure(basePath: dirname(__DIR__)) // that an operator console is hosted here. Self-scoped to /admin*. $middleware->prependToGroup('web', \App\Http\Middleware\RestrictAdminHost::class); + // And WHO may reach it, by network address — the VPN plus whatever the + // owner has listed in the console. The host guard above cannot answer + // that question: a Host header is chosen by the caller. + $middleware->prependToGroup('web', \App\Http\Middleware\RestrictConsoleNetwork::class); + // Runs after the host guard: the console must stay reachable even while // the public site is switched off. $middleware->appendToGroup('web', \App\Http\Middleware\PublicSiteGate::class); diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index ebc0e5b..70dead6 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -51,4 +51,28 @@ return [ 'last_owner' => 'Der letzte Inhaber kann nicht geändert oder entfernt werden.', 'not_staff' => 'Nur bestehende Mitarbeiter können bearbeitet werden.', 'is_customer' => 'Diese E-Mail gehört bereits zu einem Kundenkonto.', + + // Console network access — who may reach /admin at all. + 'console_title' => 'Zugang zur Konsole', + 'console_locked_badge' => 'Eingeschränkt', + 'console_open_badge' => 'Offen', + 'console_locked_body' => 'Nur das VPN und die unten gelisteten Adressen erreichen die Konsole. Alle anderen bekommen 404 — sie erfahren nicht einmal, dass es sie gibt.', + 'console_open_body' => 'Die Konsole ist von überall erreichbar, wo ihr Hostname aufgerufen wird. Solange das so ist, schützt sie allein das Passwort.', + 'console_your_ip' => 'Sie sehen diese Seite gerade von :ip', + 'console_lock' => 'Einschränken', + 'console_unlock' => 'Freigeben', + 'console_always' => 'Immer erlaubt', + 'console_vpn_note' => 'Management-VPN, nicht entfernbar', + 'console_extra' => 'Zusätzlich erlaubt', + 'console_none' => 'Noch keine weitere Adresse — ohne VPN kommt dann niemand herein.', + 'console_add' => 'Hinzufügen', + 'console_remove' => 'Entfernen', + 'console_ip_hint' => 'Einzelne Adresse oder Bereich, z. B. 203.0.113.7 oder 203.0.113.0/24.', + 'console_ip_invalid' => 'Das ist keine gültige IP-Adresse und kein gültiger Bereich.', + 'console_ip_added' => ':ip darf die Konsole jetzt erreichen.', + 'console_ip_removed' => ':ip wurde entfernt.', + 'console_ip_last' => 'Nicht entfernt: Sie greifen gerade selbst über :ip zu und würden sich aussperren.', + 'console_locked' => 'Konsole eingeschränkt — nur noch VPN und gelistete Adressen.', + 'console_unlocked' => 'Einschränkung aufgehoben.', + 'console_lock_refused' => 'Nicht eingeschränkt: :ip steht auf keiner Liste, Sie hätten sich damit ausgesperrt.', ]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index c32fde0..1ec01b2 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -51,4 +51,28 @@ return [ 'last_owner' => 'The last Owner cannot be changed or removed.', 'not_staff' => 'Only existing staff can be edited.', 'is_customer' => 'This email already belongs to a customer account.', + + // Console network access — who may reach /admin at all. + 'console_title' => 'Console access', + 'console_locked_badge' => 'Restricted', + 'console_open_badge' => 'Open', + 'console_locked_body' => 'Only the VPN and the addresses listed below reach the console. Everyone else gets a 404 — they do not even learn it exists.', + 'console_open_body' => 'The console answers wherever its hostname is used. While that is so, only the password protects it.', + 'console_your_ip' => 'You are viewing this from :ip', + 'console_lock' => 'Restrict', + 'console_unlock' => 'Open up', + 'console_always' => 'Always allowed', + 'console_vpn_note' => 'Management VPN, not removable', + 'console_extra' => 'Additionally allowed', + 'console_none' => 'No further address yet — without the VPN, nobody gets in.', + 'console_add' => 'Add', + 'console_remove' => 'Remove', + 'console_ip_hint' => 'A single address or a range, e.g. 203.0.113.7 or 203.0.113.0/24.', + 'console_ip_invalid' => 'That is not a valid IP address or range.', + 'console_ip_added' => ':ip may now reach the console.', + 'console_ip_removed' => ':ip removed.', + 'console_ip_last' => 'Not removed: you are connecting from :ip yourself and would lock yourself out.', + 'console_locked' => 'Console restricted — VPN and listed addresses only.', + 'console_unlocked' => 'Restriction lifted.', + 'console_lock_refused' => 'Not restricted: :ip is on no list, so this would have locked you out.', ]; diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index b482ae8..fc718f0 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -35,6 +35,65 @@ @endif + {{-- Who may reach this console --}} + @if ($canManageSite) +
+
+
+
+

{{ __('admin_settings.console_title') }}

+ + + {{ $consoleRestricted ? __('admin_settings.console_locked_badge') : __('admin_settings.console_open_badge') }} + +
+

+ {{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }} +

+

+ {{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }} +

+
+ + + {{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }} + +
+ +
+

{{ __('admin_settings.console_always') }}

+ @foreach ($consoleVpnRanges as $range) +
+ {{ $range }} + {{ __('admin_settings.console_vpn_note') }} +
+ @endforeach + +

{{ __('admin_settings.console_extra') }}

+ @forelse ($consoleIps as $ip) +
+ {{ $ip }} + + {{ __('admin_settings.console_remove') }} + +
+ @empty +

{{ __('admin_settings.console_none') }}

+ @endforelse + +
+
+ +
+ {{ __('admin_settings.console_add') }} +
+
+
+ @endif +

{{ __('admin_settings.account_title') }}

diff --git a/tests/Feature/Admin/ConsoleNetworkTest.php b/tests/Feature/Admin/ConsoleNetworkTest.php new file mode 100644 index 0000000..9150155 --- /dev/null +++ b/tests/Feature/Admin/ConsoleNetworkTest.php @@ -0,0 +1,112 @@ +server->set('REMOTE_ADDR', $ip); +} + +beforeEach(function () { + config()->set('admin_access.hosts', []); + config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']); + Settings::set('console.network_restricted', false); + Settings::set('console.allowed_ips', []); +}); + +it('changes nothing until the owner switches it on', function () { + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) + ->get('/admin') + ->assertOk(); +}); + +it('lets the management VPN in and turns everyone else away', function () { + Settings::set('console.network_restricted', true); + + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '10.66.0.5']) + ->get('/admin') + ->assertOk(); + + // 404, never 403: a stranger must not learn a console lives here. + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) + ->get('/admin') + ->assertNotFound(); +}); + +it('lets in an address the owner has listed, without the VPN', function () { + Settings::set('console.network_restricted', true); + Settings::set('console.allowed_ips', ['203.0.113.0/24']); + + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) + ->get('/admin') + ->assertOk(); + + // A neighbouring range is still nobody. + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '198.51.100.4']) + ->get('/admin') + ->assertNotFound(); +}); + +it('knows when a change would lock the owner out', function () { + // The one check whose failure mode is that nobody can reach the page that + // would fix it — so it is decided without a request, and tested that way. + expect(RestrictConsoleNetwork::wouldStillAllow('10.66.0.5', []))->toBeTrue() + ->and(RestrictConsoleNetwork::wouldStillAllow('198.51.100.4', []))->toBeFalse() + ->and(RestrictConsoleNetwork::wouldStillAllow('198.51.100.4', ['198.51.100.0/24']))->toBeTrue() + // Removing the entry you are sitting behind leaves you outside. + ->and(RestrictConsoleNetwork::wouldStillAllow('203.0.113.9', []))->toBeFalse(); +}); + +it('rejects an entry that is not an address or a range', function () { + // A typo that silently matches nothing is how someone locks themselves out + // while believing they have not. + foreach (['nonsense', '203.0.113.', '203.0.113.9/99', ''] as $bad) { + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->set('consoleIp', $bad) + ->call('addConsoleIp') + ->assertHasErrors('consoleIp'); + } + + expect(Settings::get('console.allowed_ips'))->toBe([]); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->set('consoleIp', '203.0.113.0/24') + ->call('addConsoleIp') + ->assertHasNoErrors(); + + expect(Settings::get('console.allowed_ips'))->toBe(['203.0.113.0/24']); +}); + +it('needs the capability, not merely an operator account', function () { + Livewire::actingAs(operator('Support')) + ->test(AdminSettings::class) + ->call('addConsoleIp') + ->assertForbidden(); +}); + +it('is re-checked on every Livewire action, not only on the page', function () { + // The page is not where the actions run. + $persistent = app(\Livewire\Mechanisms\PersistentMiddleware\PersistentMiddleware::class) + ->getPersistentMiddleware(); + + expect($persistent)->toContain(RestrictConsoleNetwork::class); +});