146 lines
5.4 KiB
PHP
146 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Http\Middleware\RestrictConsoleNetwork;
|
|
use App\Support\Settings;
|
|
use Illuminate\Console\Command;
|
|
|
|
/**
|
|
* The way back in when the console has locked its owner out.
|
|
*
|
|
* The allowlist is managed in the console — which is fine until the address you
|
|
* manage it from changes, and then the page that would fix the problem is the
|
|
* page the problem blocks. Every gate needs a door that does not depend on
|
|
* itself, and on a server that door is a shell.
|
|
*/
|
|
class ConsoleAccess extends Command
|
|
{
|
|
protected $signature = 'clupilot:console-access
|
|
{action=show : show|allow|deny|open|close|caddy}
|
|
{value? : an address or CIDR, for allow and deny}';
|
|
|
|
protected $description = 'Show or change who may reach the operator console';
|
|
|
|
public function handle(): int
|
|
{
|
|
$action = (string) $this->argument('action');
|
|
$value = trim((string) $this->argument('value'));
|
|
|
|
return match ($action) {
|
|
'show' => $this->show(),
|
|
'allow' => $this->allow($value),
|
|
'deny' => $this->deny($value),
|
|
'open' => $this->setRestricted(false),
|
|
'close' => $this->setRestricted(true),
|
|
'caddy' => $this->caddy(),
|
|
default => $this->refuse("Unknown action: {$action}"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The allowlist as a Caddy matcher, for the reverse proxy to import.
|
|
*
|
|
* The proxy has its own allowlist, hard-coded, and it runs FIRST — so
|
|
* everything the owner adds in the console has no effect whatsoever, and
|
|
* the console's own access page is a decoration. Worse, when the owner's
|
|
* address changes they are turned away by the proxy before the application
|
|
* they could have fixed it in is ever reached.
|
|
*
|
|
* Emitting the matcher from the same list the console manages makes the
|
|
* console the single authority. The agent on the host writes this out and
|
|
* reloads the proxy.
|
|
*/
|
|
private function caddy(): int
|
|
{
|
|
// Always the allowlist — never 0.0.0.0/0, not even when the console's
|
|
// own restriction is switched off.
|
|
//
|
|
// The owner's rule is absolute: the console is reachable over the
|
|
// management VPN, or from an address they have listed, and from nowhere
|
|
// else. Letting the console's "open" switch also open the PROXY would
|
|
// put the console on the public internet with one click, which is
|
|
// exactly the state that rule exists to prevent. Switching it off
|
|
// relaxes the application's own check; the proxy keeps its list.
|
|
$ranges = RestrictConsoleNetwork::allowedRanges();
|
|
|
|
// Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and
|
|
// the console would become unreachable from anywhere at all — including
|
|
// from the place someone would fix it. Loopback always survives, so a
|
|
// shell on the box is always a way back.
|
|
if ($ranges === []) {
|
|
$ranges = ['127.0.0.1', '::1'];
|
|
}
|
|
|
|
$this->line('# Generated from the console allowlist — do not edit by hand.');
|
|
$this->line('# Regenerated by deploy/update-agent.sh; edit it in the console.');
|
|
$this->line('@allowed remote_ip '.implode(' ', $ranges));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function show(): int
|
|
{
|
|
$this->line(' restricted : '.(RestrictConsoleNetwork::isRestricted() ? 'yes' : 'no — anyone reaching the hostname gets in'));
|
|
$this->line(' always : '.implode(', ', (array) config('admin_access.trusted_ranges', [])).' (VPN, not removable)');
|
|
|
|
$own = (array) Settings::get('console.allowed_ips', []);
|
|
$this->line(' additional : '.($own === [] ? '(none)' : implode(', ', $own)));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function allow(string $value): int
|
|
{
|
|
if ($value === '') {
|
|
return $this->refuse('Give an address or range: clupilot:console-access allow 203.0.113.7');
|
|
}
|
|
|
|
// An entry that matches nothing would be stored, reported as success,
|
|
// and leave whoever is recovering still locked out.
|
|
if (! RestrictConsoleNetwork::isNetwork($value)) {
|
|
return $this->refuse("Not an address or a range: {$value}");
|
|
}
|
|
|
|
$list = (array) Settings::get('console.allowed_ips', []);
|
|
|
|
if (! in_array($value, $list, true)) {
|
|
$list[] = $value;
|
|
Settings::set('console.allowed_ips', array_values($list));
|
|
}
|
|
|
|
$this->info("{$value} may now reach the console.");
|
|
|
|
return $this->show();
|
|
}
|
|
|
|
private function deny(string $value): int
|
|
{
|
|
Settings::set('console.allowed_ips', array_values(array_filter(
|
|
(array) Settings::get('console.allowed_ips', []),
|
|
fn ($entry) => $entry !== $value,
|
|
)));
|
|
|
|
$this->info("{$value} removed.");
|
|
|
|
return $this->show();
|
|
}
|
|
|
|
private function setRestricted(bool $on): int
|
|
{
|
|
// No lock-out check here on purpose: this command IS the recovery path,
|
|
// and it is only reachable by someone who already has the server.
|
|
Settings::set('console.network_restricted', $on);
|
|
$this->info($on ? 'Console restricted to the VPN and the listed addresses.' : 'Restriction lifted.');
|
|
|
|
return $this->show();
|
|
}
|
|
|
|
private function refuse(string $message): int
|
|
{
|
|
$this->error($message);
|
|
|
|
return self::FAILURE;
|
|
}
|
|
}
|