108 lines
3.7 KiB
PHP
108 lines
3.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Domains;
|
|
|
|
use App\Models\Instance;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Reads the proof a customer published in their own DNS.
|
|
*
|
|
* The whole mechanism is one TXT record at a fixed name under the customer's
|
|
* domain. Only somebody who controls the domain can create it, which is exactly
|
|
* the question being asked.
|
|
*
|
|
* Deliberately NOT a one-off. A token that is checked once can be removed the
|
|
* minute afterwards, and a domain that later lapses and is re-registered by
|
|
* somebody else keeps pointing at the same instance. The record has to stay,
|
|
* and something has to go and look — see VerifyCustomDomains, which does it
|
|
* nightly.
|
|
*/
|
|
class DomainVerifier
|
|
{
|
|
/** Where the record goes. Prefixed so it cannot collide with anything. */
|
|
public const RECORD_PREFIX = '_clupilot-challenge';
|
|
|
|
/** What the record has to contain. */
|
|
public const VALUE_PREFIX = 'cp-verify=';
|
|
|
|
/**
|
|
* How many consecutive failures a verified domain survives.
|
|
*
|
|
* One failed lookup is a nameserver having a bad minute, not a customer
|
|
* taking their domain away; withdrawing the domain on the first miss would
|
|
* take a working Nextcloud offline over a hiccup. Three nights is long
|
|
* enough to be a decision and short enough to matter.
|
|
*/
|
|
public const FAILURES_BEFORE_WITHDRAWAL = 3;
|
|
|
|
/** @var callable(string): array<int, array<string, mixed>> */
|
|
private $resolve;
|
|
|
|
/**
|
|
* The resolver is injectable so the tests can answer without touching the
|
|
* network — a suite that depends on real DNS fails on a train.
|
|
*
|
|
* @param null|callable(string): array<int, array<string, mixed>> $resolve
|
|
*/
|
|
public function __construct(?callable $resolve = null)
|
|
{
|
|
$this->resolve = $resolve ?? static fn (string $name) => dns_get_record($name, DNS_TXT) ?: [];
|
|
}
|
|
|
|
/** The record name a customer has to create. */
|
|
public function recordName(string $domain): string
|
|
{
|
|
return self::RECORD_PREFIX.'.'.ltrim($domain, '.');
|
|
}
|
|
|
|
/** The value it has to hold. */
|
|
public function recordValue(Instance $instance): string
|
|
{
|
|
return self::VALUE_PREFIX.$instance->domain_token;
|
|
}
|
|
|
|
/**
|
|
* A fresh token. Called when a domain is set, and again whenever it
|
|
* changes — a new domain must not be provable with the previous one's
|
|
* token, or a customer who once verified example.com could claim any
|
|
* domain afterwards without touching its DNS.
|
|
*/
|
|
public function newToken(): string
|
|
{
|
|
return Str::lower(Str::random(32));
|
|
}
|
|
|
|
/**
|
|
* Is the proof in place right now?
|
|
*
|
|
* Every TXT value at the name is considered, not just the first: a domain
|
|
* may carry several TXT records at one name (SPF, other verifications), and
|
|
* DNS returns them in no particular order.
|
|
*/
|
|
public function proofPresent(Instance $instance): bool
|
|
{
|
|
if (blank($instance->custom_domain) || blank($instance->domain_token)) {
|
|
return false;
|
|
}
|
|
|
|
$expected = $this->recordValue($instance);
|
|
|
|
foreach (($this->resolve)($this->recordName($instance->custom_domain)) as $record) {
|
|
// dns_get_record splits long values into `entries`; `txt` is the
|
|
// joined form. Both are checked because which one is populated
|
|
// depends on the value's length, and a token is short enough to
|
|
// land in either.
|
|
$values = array_merge([(string) ($record['txt'] ?? '')], (array) ($record['entries'] ?? []));
|
|
|
|
foreach ($values as $value) {
|
|
if (trim($value) === $expected) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|