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; } }