93 lines
3.3 KiB
PHP
93 lines
3.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Vpn;
|
|
|
|
use App\Support\ProvisioningSettings;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Whether the WireGuard hub endpoint an onboarding host is told to dial is
|
|
* even CAPABLE of being reached from outside this network.
|
|
*
|
|
* Deliberately makes no network call. A UDP port cannot be knocked on
|
|
* cleanly from the outside — there is no handshake to observe without a real
|
|
* peer, and this class runs on every readiness page load, not on demand. What
|
|
* IS decidable without touching the wire: whether the address configured is
|
|
* one a host on the internet could even attempt to reach. `10.10.90.185` and
|
|
* `203.0.113.11` (RFC 5737) both look "set" and are unreachable from any host
|
|
* outside this operator's own network or a documentation example generator —
|
|
* exactly the two shapes seen in this installation's configuration on
|
|
* 2026-07-30.
|
|
*/
|
|
final class WireguardEndpointCheck
|
|
{
|
|
/**
|
|
* RFC 5737 "TEST-NET" ranges: documentation and example addresses, never
|
|
* routed on the real internet. PHP's own FILTER_FLAG_NO_RES_RANGE does
|
|
* NOT reject these (verified: 203.0.113.11 passes filter_var with that
|
|
* flag) — exactly the range a seed value or a copy-pasted example lands
|
|
* in, which is precisely the failure mode this check exists to catch.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
private const DOCUMENTATION_RANGES = [
|
|
'192.0.2.0/24', // TEST-NET-1
|
|
'198.51.100.0/24', // TEST-NET-2
|
|
'203.0.113.0/24', // TEST-NET-3
|
|
];
|
|
|
|
/** @return array<string, mixed> */
|
|
public function run(?string $endpoint = null): array
|
|
{
|
|
$endpoint ??= ProvisioningSettings::wgEndpoint();
|
|
|
|
if (blank($endpoint)) {
|
|
return ['ok' => false, 'reason' => 'missing'];
|
|
}
|
|
|
|
if (! str_contains($endpoint, ':')) {
|
|
return ['ok' => false, 'reason' => 'no_port'];
|
|
}
|
|
|
|
$host = Str::beforeLast($endpoint, ':');
|
|
|
|
// No valid IP means: a name. It can resolve publicly, and guessing
|
|
// here would be worse than letting it through — the check states what
|
|
// it knows, not what it suspects.
|
|
if (! filter_var($host, FILTER_VALIDATE_IP)) {
|
|
return ['ok' => true, 'reason' => 'hostname', 'address' => $host];
|
|
}
|
|
|
|
// NO_PRIV_RANGE covers RFC 1918, NO_RES_RANGE covers loopback and
|
|
// link-local among others — exactly the private address that stood
|
|
// at one host on 2026-07-30.
|
|
$public = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
|
|
|
if ($public === false || $this->isDocumentationAddress($host)) {
|
|
return ['ok' => false, 'reason' => 'not_public', 'address' => $host];
|
|
}
|
|
|
|
return ['ok' => true, 'reason' => 'public', 'address' => $host];
|
|
}
|
|
|
|
private function isDocumentationAddress(string $ip): bool
|
|
{
|
|
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
return false;
|
|
}
|
|
|
|
$long = ip2long($ip);
|
|
|
|
foreach (self::DOCUMENTATION_RANGES as $cidr) {
|
|
[$subnet, $bits] = explode('/', $cidr);
|
|
$mask = -1 << (32 - (int) $bits);
|
|
|
|
if (($long & $mask) === (ip2long($subnet) & $mask)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|