From 99715989aa21bfe0f45b003fc18c8a49044c899b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 09:19:30 +0200 Subject: [PATCH] Dress the placeholder page, and record why the obvious VPN fix cannot work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page shown while the site is hidden was still in the old style — a coloured square and system fonts — on the two hostnames a visitor is most likely to try. It now uses the site's own plate, typeface and registration marks, still entirely self-contained because it is shown precisely when the asset build may not exist. Both it and the error pages lighten the accent in dark mode: the tone is chosen for contrast against paper, and on the ink plate the small text is the first thing to stop being readable. The VPN investigation ended somewhere useful, and not where it started. The blank page on the phone is the reverse proxy refusing the request — `respond 404` sends no body. It refuses because the phone is not on the tunnel: the client config lists only the management subnet in AllowedIPs, so a request to the console's public hostname goes out over the mobile network and arrives from a carrier address. It also explains "last contact: never", since WireGuard only performs a handshake when it has traffic to send, and nothing is ever routed into the tunnel. The obvious fix — add the server's public address to AllowedIPs — is written down here as a comment and a test rather than as code, because it cannot work. The WireGuard endpoint is that same address, so routing it into the tunnel routes the handshake packets into the tunnel they are trying to establish. The result is a loop and a connection that never comes up: the same blank page, now with no way in at all. Reaching the console over the VPN needs an address INSIDE the subnet — the proxy answering on the hub address, and a name that resolves to it. That is a deployment change, not something a config line can express. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Vpn.php | 28 +++++- lang/de/coming_soon.php | 4 +- lang/en/coming_soon.php | 4 +- resources/views/coming-soon.blade.php | 119 +++++++++++++++++------- resources/views/errors/layout.blade.php | 6 +- tests/Feature/Admin/VpnTest.php | 16 ++++ 6 files changed, 138 insertions(+), 39 deletions(-) diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php index 802e5e3..ee37b99 100644 --- a/app/Livewire/Admin/Vpn.php +++ b/app/Livewire/Admin/Vpn.php @@ -318,6 +318,32 @@ class Vpn extends Component } } + /** + * What the client sends through the tunnel: the management subnet, and only + * that. + * + * It is tempting to add the server's own public address here, because the + * console lives at a public hostname and without that route the phone sends + * the request out over the mobile network instead — it arrives from a + * carrier address, the proxy refuses it, and the operator sees a blank page + * while the VPN app says "connected". + * + * That fix cannot work. The WireGuard endpoint is the SAME address. Routing + * it into the tunnel routes the handshake packets into the tunnel too, and + * a tunnel cannot carry the packets that establish it — the result is a + * loop and a connection that never comes up at all. Strictly worse: the + * same symptom, now with no way in. + * + * The console has to be reachable at an address that is INSIDE the subnet + * for the VPN to be the way in. That is a deployment change (the proxy + * answering on the hub address, and a name that resolves to it), not + * something this config line can express. + */ + private static function allowedIps(string $endpoint): string + { + return (string) config('provisioning.wireguard.subnet', '10.66.0.0/24'); + } + private function clientConfig(Keypair $keypair, string $ip): string { $hub = app(WireguardHub::class); @@ -330,7 +356,7 @@ class Vpn extends Component '[Peer]', 'PublicKey = '.$hub->publicKey(), 'Endpoint = '.$hub->endpoint(), - 'AllowedIPs = '.config('provisioning.wireguard.subnet', '10.66.0.0/24'), + 'AllowedIPs = '.self::allowedIps($hub->endpoint()), 'PersistentKeepalive = 25', '', ]); diff --git a/lang/de/coming_soon.php b/lang/de/coming_soon.php index de64057..7bc21c1 100644 --- a/lang/de/coming_soon.php +++ b/lang/de/coming_soon.php @@ -3,5 +3,7 @@ return [ 'title' => 'Wir bauen gerade.', 'body' => 'CluPilot ist noch nicht öffentlich. Managed Nextcloud aus deutschen und finnischen Rechenzentren — betreut, gesichert, aktuell gehalten.', - 'contact' => 'Fragen? info@clupilot.com', + 'contact' => 'Fragen beantworten wir gern:', + 'label' => 'Betriebszustand', + 'state' => 'In Vorbereitung', ]; diff --git a/lang/en/coming_soon.php b/lang/en/coming_soon.php index 05a1b43..ca10487 100644 --- a/lang/en/coming_soon.php +++ b/lang/en/coming_soon.php @@ -3,5 +3,7 @@ return [ 'title' => 'We are still building.', 'body' => 'CluPilot is not public yet. Managed Nextcloud from German and Finnish datacentres — operated, backed up, kept current.', - 'contact' => 'Questions? info@clupilot.com', + 'contact' => 'Questions are welcome:', + 'label' => 'Service status', + 'state' => 'Being prepared', ]; diff --git a/resources/views/coming-soon.blade.php b/resources/views/coming-soon.blade.php index 9ac0f6a..5bc5924 100644 --- a/resources/views/coming-soon.blade.php +++ b/resources/views/coming-soon.blade.php @@ -1,42 +1,91 @@ - - - {{-- Belt and braces with the X-Robots-Tag header: some crawlers read only one. --}} - - {{ config('app.name') }} - - {{-- Self-contained on purpose: this page is shown while the application is - mid-deploy or freshly installed, which is exactly when the Vite - manifest may not exist. Pulling it in through @vite would throw an - exception instead of rendering the reassurance the visitor came for. --}} - + + +{{-- Belt and braces with the X-Robots-Tag header: some crawlers read only one. --}} + +CluPilot Cloud + + +{{-- + Self-contained on purpose: this is shown while the application is mid-deploy + or freshly installed, which is exactly when the Vite manifest may not exist. + Pulling it in through @vite would throw an exception instead of rendering + the reassurance the visitor came for. + + The fonts are the site's own static files. If they are missing the page + degrades to a system serif and still reads correctly. +--}} +@verbatim + +@endverbatim -
-
-

{{ __('coming_soon.title') }}

-

{{ __('coming_soon.body') }}

-

{{ __('coming_soon.contact') }}

-
+
+
+
+ {{ __('coming_soon.label') }} + {{ __('coming_soon.state') }} +
+
+ CluPilot Cloud +

{{ __('coming_soon.title') }}

+

{{ __('coming_soon.body') }}

+

{{ __('coming_soon.contact') }} + office@clupilot.com +

+
+
+
diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php index ca24079..3b9426a 100644 --- a/resources/views/errors/layout.blade.php +++ b/resources/views/errors/layout.blade.php @@ -29,8 +29,12 @@ --accent:#f97316; --accent-text:#b8500a; } @media (prefers-color-scheme: dark){ + /* The accent has to lighten too: #b8500a is chosen for contrast against + paper, and on the ink plate it is the small text that stops being + readable first. */ :root{--paper:#17140f; --card:#211d16; --ink:#fffefb; --ink-soft:#ded7c9; - --muted:#93897a; --rule:#39332a; --rule-mid:#4a4238;} + --muted:#93897a; --rule:#39332a; --rule-mid:#4a4238; + --accent-text:#f0a06a;} } *{margin:0;padding:0;box-sizing:border-box} body{ diff --git a/tests/Feature/Admin/VpnTest.php b/tests/Feature/Admin/VpnTest.php index 42b5a31..2807661 100644 --- a/tests/Feature/Admin/VpnTest.php +++ b/tests/Feature/Admin/VpnTest.php @@ -879,3 +879,19 @@ it('re-issues even when the config vault is unavailable', function () { expect($peer->fresh()->config_secret)->toBeNull(); // shown once instead }); + +it('does not route the WireGuard endpoint through its own tunnel', function () { + // The obvious fix for "the VPN does not reach the console" is to add the + // server's public address to AllowedIPs. It cannot work: the endpoint is + // that same address, so the handshake packets would be routed into the + // tunnel they are trying to establish. Same blank page, now with no way in + // at all. + config()->set('provisioning.wireguard.subnet', '10.66.0.0/24'); + + $method = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'allowedIps'); + $method->setAccessible(true); + + foreach (['203.0.113.10:51820', '[2001:db8::10]:51820', 'vpn.example.test:51820'] as $endpoint) { + expect($method->invoke(null, $endpoint))->toBe('10.66.0.0/24', $endpoint); + } +});