CluPilotCloud/app/Services/Domains/DomainVerifier.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;
}
}