Dress the placeholder page, and record why the obvious VPN fix cannot work
tests / pest (push) Successful in 8m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details

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 <noreply@anthropic.com>
feat/portal-design tested-20260727-0741-9971598
Claude 2026-07-27 09:19:30 +02:00
parent a58faf3f85
commit 99715989aa
6 changed files with 138 additions and 39 deletions

View File

@ -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',
'',
]);

View File

@ -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',
];

View File

@ -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',
];

View File

@ -1,42 +1,91 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Belt and braces with the X-Robots-Tag header: some crawlers read only one. --}}
<meta name="robots" content="noindex, nofollow, noarchive">
<title>{{ config('app.name') }}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
{{-- 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. --}}
<style>
:root { color-scheme: light dark; --bg: #f6f7f9; --fg: #0f172a; --muted: #64748b; --accent: #f97316; --card: #ffffff; --line: #e2e8f0; }
@media (prefers-color-scheme: dark) { :root { --bg: #0b0f19; --fg: #e2e8f0; --muted: #94a3b8; --card: #111827; --line: #1f2937; } }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 4rem 1.5rem; text-align: center;
background: var(--bg); color: var(--fg);
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 30rem; }
.mark { width: 2.5rem; height: 2.5rem; margin: 0 auto; border-radius: 0.75rem; background: var(--accent); }
h1 { margin: 1.75rem 0 0; font-size: 1.5rem; line-height: 1.25; letter-spacing: -0.01em; }
p { margin: 0.75rem 0 0; font-size: 0.9rem; line-height: 1.65; color: var(--muted); }
.badge { display: inline-flex; align-items: center; gap: 0.5rem; margin-top: 1.25rem; padding: 0.25rem 0.75rem;
border: 1px solid var(--line); border-radius: 9999px; background: var(--card);
font-size: 0.72rem; font-weight: 600; color: var(--accent); }
.dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; background: var(--accent); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) { .dot { animation: none; } }
.foot { margin-top: 2rem; font-size: 0.75rem; color: var(--muted); }
</style>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Belt and braces with the X-Robots-Tag header: some crawlers read only one. --}}
<meta name="robots" content="noindex, nofollow, noarchive">
<title>CluPilot Cloud</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
{{--
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
<style>
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
:root{
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31;
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4;
--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;
--accent-text:#f0a06a;}
}
*{margin:0;padding:0;box-sizing:border-box}
body{
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
background:var(--paper);color:var(--ink-soft);line-height:1.6;
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
-webkit-font-smoothing:antialiased;
}
main{max-width:520px;width:100%}
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative}
/* Registration marks, as on the rest of the site. */
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1}
.plate::before{top:-6px;left:-6px}
.plate::after{bottom:-6px;right:-6px}
.head{
display:flex;justify-content:space-between;align-items:center;gap:14px;
padding:13px 20px;border-bottom:1px solid var(--rule);
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);
}
.head b{color:var(--accent-text);font-weight:400;display:inline-flex;align-items:center;gap:7px}
.dot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:pulse 1.8s ease-in-out infinite}
@keyframes pulse{50%{opacity:.35}}
@media (prefers-reduced-motion: reduce){.dot{animation:none}}
.body{padding:30px 20px 28px}
.brand{font-family:"Plex Serif",Georgia,serif;font-weight:600;font-size:1.1rem;letter-spacing:-.02em;color:var(--ink)}
.brand i{font-style:normal;color:var(--accent-text)}
h1{margin-top:16px;font-family:"Plex Serif",Georgia,serif;font-weight:600;
font-size:clamp(1.5rem,4vw,2rem);letter-spacing:-.025em;line-height:1.15;color:var(--ink)}
p{margin-top:12px;color:var(--muted);font-size:.95rem}
.foot{margin-top:18px;padding-top:16px;border-top:1px solid var(--rule);font-size:.86rem}
.foot a{color:var(--accent-text);font-weight:500;text-decoration:none}
.foot a:hover{text-decoration:underline}
</style>
@endverbatim
</head>
<body>
<main>
<div class="mark"></div>
<h1>{{ __('coming_soon.title') }}</h1>
<p>{{ __('coming_soon.body') }}</p>
<p class="foot">{{ __('coming_soon.contact') }}</p>
</main>
<main>
<div class="plate">
<div class="head">
<span>{{ __('coming_soon.label') }}</span>
<b><span class="dot" aria-hidden="true"></span>{{ __('coming_soon.state') }}</b>
</div>
<div class="body">
<span class="brand">Clu<i>Pilot</i> Cloud</span>
<h1>{{ __('coming_soon.title') }}</h1>
<p>{{ __('coming_soon.body') }}</p>
<p class="foot">{{ __('coming_soon.contact') }}
<a href="mailto:office&#64;clupilot.com">office&#64;clupilot.com</a>
</p>
</div>
</div>
</main>
</body>
</html>

View File

@ -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{

View File

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