208 lines
8.5 KiB
PHP
208 lines
8.5 KiB
PHP
<?php
|
|
|
|
use App\Http\Middleware\RestrictConsoleNetwork;
|
|
use App\Livewire\Admin\Settings as AdminSettings;
|
|
use App\Models\User;
|
|
use App\Support\Settings;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* Who may reach the console, by network address.
|
|
*
|
|
* The hostname guard answers under which NAME the console responds, and the
|
|
* caller picks the Host header — so it can never answer who is asking. This
|
|
* can, and the owner keeps the list themselves.
|
|
*/
|
|
/** Livewire's test harness has no withServerVariables(); set it on the request. */
|
|
function fromIp(string $ip): void
|
|
{
|
|
request()->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'), 'operator')
|
|
->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'), 'operator')
|
|
->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'), 'operator')
|
|
->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'), 'operator')
|
|
->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
|
|
->get('/admin')
|
|
->assertOk();
|
|
|
|
// A neighbouring range is still nobody.
|
|
$this->actingAs(operator('Owner'), 'operator')
|
|
->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'), 'operator')
|
|
->test(AdminSettings::class)
|
|
->set('consoleIp', $bad)
|
|
->call('addConsoleIp')
|
|
->assertHasErrors('consoleIp');
|
|
}
|
|
|
|
expect(Settings::get('console.allowed_ips'))->toBe([]);
|
|
|
|
Livewire::actingAs(operator('Owner'), 'operator')
|
|
->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'), 'operator')
|
|
->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);
|
|
});
|
|
|
|
it('offers a way back in that does not depend on the console', function () {
|
|
// Every gate needs a door that does not depend on itself: the list is
|
|
// managed from the console, and the address you manage it from can change.
|
|
Settings::set('console.network_restricted', true);
|
|
Settings::set('console.allowed_ips', []);
|
|
|
|
$this->artisan('clupilot:console-access allow 203.0.113.42')->assertSuccessful();
|
|
expect(Settings::get('console.allowed_ips'))->toBe(['203.0.113.42'])
|
|
->and(RestrictConsoleNetwork::wouldStillAllow('203.0.113.42', ['203.0.113.42']))->toBeTrue();
|
|
|
|
$this->artisan('clupilot:console-access deny 203.0.113.42')->assertSuccessful();
|
|
expect(Settings::get('console.allowed_ips'))->toBe([]);
|
|
|
|
// And the restriction itself can be lifted from the shell — the only place
|
|
// that is reachable when the gate has shut on its own owner.
|
|
$this->artisan('clupilot:console-access open')->assertSuccessful();
|
|
expect(RestrictConsoleNetwork::isRestricted())->toBeFalse();
|
|
});
|
|
|
|
it('tells the reverse proxy the same thing it tells itself', function () {
|
|
// The proxy has its own allowlist and it runs FIRST. If the two disagree,
|
|
// everything the owner changes in the console is silently ineffective —
|
|
// and when their address changes they are turned away before reaching the
|
|
// page that would have fixed it.
|
|
Settings::set('console.network_restricted', true);
|
|
Settings::set('console.allowed_ips', ['203.0.113.7']);
|
|
|
|
// One assertion on the whole line: the ranges have to arrive together, in
|
|
// the matcher, not merely appear somewhere in the output.
|
|
$this->artisan('clupilot:console-access caddy')
|
|
->expectsOutputToContain('@allowed remote_ip 10.66.0.0/24 203.0.113.7')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
it('never opens the proxy to everyone, not even when the restriction is lifted', function () {
|
|
// The rule is absolute: the console is reachable over the VPN or from a
|
|
// listed address, and nowhere else. Letting the console's own switch also
|
|
// open the PROXY would put it on the public internet with one click.
|
|
Settings::set('console.network_restricted', false);
|
|
Settings::set('console.allowed_ips', ['203.0.113.7']);
|
|
|
|
$this->artisan('clupilot:console-access caddy')
|
|
->expectsOutputToContain('@allowed remote_ip 10.66.0.0/24 203.0.113.7')
|
|
->assertSuccessful();
|
|
|
|
$this->artisan('clupilot:console-access caddy')
|
|
->doesntExpectOutputToContain('0.0.0.0/0')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
it('never emits a matcher that lets nobody in', function () {
|
|
// An empty remote_ip matcher matches NOTHING in Caddy — the console would
|
|
// be unreachable from everywhere, including from where it gets fixed.
|
|
config()->set('admin_access.trusted_ranges', []);
|
|
Settings::set('console.network_restricted', true);
|
|
Settings::set('console.allowed_ips', []);
|
|
|
|
$this->artisan('clupilot:console-access caddy')
|
|
->expectsOutputToContain('127.0.0.1')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
it('lets an operator change their own password', function () {
|
|
// There was no way to do it at all: Fortify's updatePasswords feature was
|
|
// off, so an account created with a generated password kept it until
|
|
// someone opened a shell on the server.
|
|
$operator = operator('Owner');
|
|
|
|
Livewire::actingAs($operator, 'operator')
|
|
->test(AdminSettings::class)
|
|
->set('currentPassword', 'password')
|
|
->set('newPassword', 'ein-neues-langes-passwort')
|
|
->set('newPasswordConfirmation', 'ein-neues-langes-passwort')
|
|
->call('updateOwnPassword')
|
|
->assertHasNoErrors();
|
|
|
|
expect(Illuminate\Support\Facades\Hash::check('ein-neues-langes-passwort', $operator->refresh()->password))->toBeTrue();
|
|
});
|
|
|
|
it('refuses to change a password without the current one', function () {
|
|
// Otherwise an unlocked machine is enough to lock the owner out of their
|
|
// own account with two keystrokes.
|
|
$operator = operator('Owner');
|
|
|
|
Livewire::actingAs($operator, 'operator')
|
|
->test(AdminSettings::class)
|
|
->set('currentPassword', 'falsch')
|
|
->set('newPassword', 'ein-neues-langes-passwort')
|
|
->set('newPasswordConfirmation', 'ein-neues-langes-passwort')
|
|
->call('updateOwnPassword')
|
|
->assertHasErrors('currentPassword');
|
|
|
|
expect(Illuminate\Support\Facades\Hash::check('password', $operator->refresh()->password))->toBeTrue();
|
|
});
|