diff --git a/VERSION b/VERSION index 0c00f61..17e63e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.10 +1.3.11 diff --git a/app/Console/Commands/VerifyCustomDomains.php b/app/Console/Commands/VerifyCustomDomains.php new file mode 100644 index 0000000..1346b20 --- /dev/null +++ b/app/Console/Commands/VerifyCustomDomains.php @@ -0,0 +1,79 @@ +whereNotNull('custom_domain')->whereNotNull('domain_token'); + + if ($uuid = $this->option('instance')) { + $query->where('uuid', $uuid); + } + + $checked = 0; + $withdrawn = 0; + + foreach ($query->cursor() as $instance) { + $checked++; + $present = $verifier->proofPresent($instance); + + if ($present) { + $instance->forceFill([ + 'domain_verified_at' => $instance->domain_verified_at ?? now(), + 'domain_checked_at' => now(), + 'domain_error' => null, + 'domain_failures' => 0, + ])->save(); + + continue; + } + + $failures = $instance->domain_failures + 1; + + // Withdrawal clears verified_at, which is the single flag every + // consumer reads. Nothing else is deleted: the domain and its token + // stay on the row, so the customer sees what is wrong and can put + // the record back rather than starting over. + $withdraw = $instance->domain_verified_at !== null + && $failures >= DomainVerifier::FAILURES_BEFORE_WITHDRAWAL; + + $instance->forceFill([ + 'domain_checked_at' => now(), + 'domain_error' => 'missing_txt', + 'domain_failures' => $failures, + 'domain_verified_at' => $withdraw ? null : $instance->domain_verified_at, + ])->save(); + + if ($withdraw) { + $withdrawn++; + $this->warn("Withdrew {$instance->custom_domain}: proof missing on {$failures} consecutive checks."); + } + } + + $this->info("Checked {$checked} domain(s), withdrew {$withdrawn}."); + + return self::SUCCESS; + } +} diff --git a/app/Livewire/Admin/Instances.php b/app/Livewire/Admin/Instances.php index 79cf01e..2f7fe6e 100644 --- a/app/Livewire/Admin/Instances.php +++ b/app/Livewire/Admin/Instances.php @@ -32,7 +32,9 @@ class Instances extends Component return view('livewire.admin.instances', [ 'instances' => $instances, 'rows' => $instances->getCollection()->map(fn (Instance $i) => [ - 'address' => $i->custom_domain ?: $i->subdomain, + // The operator list shows what is actually served — an unverified + // custom domain is a plan, not an address. + 'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain, 'customer' => $i->customer?->name ?? '—', 'host' => $i->host?->name ?? '—', 'vmid' => $i->vmid ?? '—', diff --git a/app/Livewire/Cloud.php b/app/Livewire/Cloud.php index 9c13579..9eb8c52 100644 --- a/app/Livewire/Cloud.php +++ b/app/Livewire/Cloud.php @@ -39,8 +39,13 @@ class Cloud extends Component $growth = array_map(fn ($v) => (int) round($v / $curveMax * $quota), $growth); } $used = $growth[count($growth) - 1]; - $domain = $shown?->custom_domain - ?: ($shown?->subdomain ? $shown->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') : 'cloud.example.com'); + // address() rather than `custom_domain ?: subdomain`: a domain whose + // DNS proof has not been read is not this instance's address, and + // printing it here would tell a customer to visit a hostname that does + // not resolve to them. + $domain = $shown?->subdomain + ? $shown->address(\App\Support\ProvisioningSettings::dnsZone()) + : 'cloud.example.com'; return view('livewire.cloud', [ 'instance' => [ diff --git a/app/Livewire/CustomDomain.php b/app/Livewire/CustomDomain.php new file mode 100644 index 0000000..c494745 --- /dev/null +++ b/app/Livewire/CustomDomain.php @@ -0,0 +1,158 @@ +domain = (string) ($this->instance()?->custom_domain ?? ''); + } + + private function instance(): ?Instance + { + // The shared resolver, not `auth()->user()->customer`: accounts created + // before the explicit link exists are matched by address, and this page + // must not be the one place that forgets that. + return $this->customer()?->instances() + ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) + ->latest('id') + ->first(); + } + + /** + * Save the domain and mint a fresh token. + * + * A NEW token every time the domain changes. Reusing the old one would let + * a customer who once verified example.com claim any other domain later + * without touching its DNS — the record for the first domain would still be + * sitting there, and the value is all that is compared. + */ + public function save(DomainVerifier $verifier): void + { + $this->validate(); + + $instance = $this->instance(); + + if ($instance === null) { + return; + } + + $domain = Str::lower(trim($this->domain)); + $domain = preg_replace('#^https?://#', '', $domain); + $domain = rtrim((string) $domain, '/.'); + + // A hostname, not a URL and not a path. Loose on purpose — the DNS + // lookup is the real test, and a pattern strict enough to be a + // specification would reject somebody's perfectly valid domain. + if ($domain !== '' && ! preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/', $domain)) { + $this->addError('domain', __('domain.invalid')); + + return; + } + + // Clearing the field takes the domain away entirely, including its + // verification — the instance falls back to its platform address. + if ($domain === '') { + $instance->forceFill([ + 'custom_domain' => null, 'domain_token' => null, + 'domain_verified_at' => null, 'domain_checked_at' => null, + 'domain_error' => null, 'domain_failures' => 0, + ])->save(); + + $this->domain = ''; + $this->dispatch('notify', message: __('domain.removed')); + + return; + } + + if ($domain !== $instance->custom_domain) { + $instance->forceFill([ + 'custom_domain' => $domain, + 'domain_token' => $verifier->newToken(), + 'domain_verified_at' => null, + 'domain_checked_at' => null, + 'domain_error' => null, + 'domain_failures' => 0, + ])->save(); + } + + $this->domain = $domain; + $this->dispatch('notify', message: __('domain.saved')); + } + + /** + * Look now, rather than waiting for tonight. + * + * The nightly run is what keeps a domain honest; this button is only so + * that somebody who has just created the record does not have to wait until + * the morning to find out whether they typed it correctly. + */ + public function checkNow(DomainVerifier $verifier): void + { + $instance = $this->instance(); + + if ($instance === null || blank($instance->custom_domain)) { + return; + } + + $present = $verifier->proofPresent($instance); + + $instance->forceFill([ + 'domain_checked_at' => now(), + 'domain_verified_at' => $present ? ($instance->domain_verified_at ?? now()) : null, + 'domain_error' => $present ? null : 'missing_txt', + 'domain_failures' => $present ? 0 : $instance->domain_failures + 1, + ])->save(); + + $this->justChecked = true; + $this->dispatch('notify', message: __($present ? 'domain.verified' : 'domain.not_found')); + } + + public function render() + { + $instance = $this->instance(); + $verifier = app(DomainVerifier::class); + + return view('livewire.custom-domain', [ + 'instance' => $instance, + 'platformAddress' => $instance?->subdomain + ? $instance->subdomain.'.'.ProvisioningSettings::dnsZone() + : null, + 'recordName' => $instance && filled($instance->custom_domain) + ? $verifier->recordName($instance->custom_domain) + : null, + 'recordValue' => $instance && filled($instance->domain_token) + ? $verifier->recordValue($instance) + : null, + ]); + } +} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 7dec72a..f86b67c 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -195,10 +195,11 @@ class Dashboard extends Component return null; } - return $instance->custom_domain - ?: ($instance->subdomain - ? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') - : null); + // See Instance::address(): the customer's own domain counts only once + // its DNS proof has been read. + return $instance->subdomain + ? $instance->address(\App\Support\ProvisioningSettings::dnsZone()) + : null; } /** diff --git a/app/Models/Instance.php b/app/Models/Instance.php index 032035c..1f4c1ac 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -18,6 +18,7 @@ class Instance extends Model 'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'traffic_addons', 'disk_gb', 'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at', 'route_written', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at', + 'domain_token', 'domain_verified_at', 'domain_checked_at', 'domain_error', 'domain_failures', ]; protected $hidden = ['nc_admin_ref', 'admin_password']; @@ -32,6 +33,9 @@ class Instance extends Model // nc_admin_ref and Host::api_token_ref, for the same reason. 'admin_password' => 'encrypted', 'credentials_acknowledged_at' => 'datetime', + 'domain_verified_at' => 'datetime', + 'domain_checked_at' => 'datetime', + 'domain_failures' => 'integer', 'route_written' => 'boolean', 'cert_ok' => 'boolean', 'vmid' => 'integer', @@ -62,6 +66,34 @@ class Instance extends Model $query->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')); } + /** + * Is the customer's own domain proven to be theirs? + * + * The one flag. A domain that has never been verified — or that lost its + * DNS proof and was withdrawn — is not this instance's address, whatever is + * typed in the column. + */ + public function domainIsVerified(): bool + { + return filled($this->custom_domain) && $this->domain_verified_at !== null; + } + + /** + * The address this instance is actually served at. + * + * Every caller goes through here rather than reading `custom_domain ?: + * subdomain` for itself. That expression was the hole: it served, certified + * and trusted whatever hostname was in the column, so anybody who pointed + * any domain at the proxy got somebody else's Nextcloud under their own + * name — with a certificate to match. + */ + public function address(string $zone): string + { + return $this->domainIsVerified() + ? (string) $this->custom_domain + : $this->subdomain.'.'.$zone; + } + public function customer(): BelongsTo { return $this->belongsTo(Customer::class); diff --git a/app/Provisioning/Jobs/IssueInstanceAdminAccess.php b/app/Provisioning/Jobs/IssueInstanceAdminAccess.php index edb91f5..89fec2f 100644 --- a/app/Provisioning/Jobs/IssueInstanceAdminAccess.php +++ b/app/Provisioning/Jobs/IssueInstanceAdminAccess.php @@ -101,7 +101,10 @@ class IssueInstanceAdminAccess implements ShouldQueue ]); ConfigHandoff::put(json_encode([ - 'url' => 'https://'.($instance->custom_domain ?: $instance->subdomain.'.'.ProvisioningSettings::dnsZone()), + // Through address(), which serves the customer's own domain only + // once its DNS proof has been read. A link to an unverified + // domain would not resolve to this instance anyway. + 'url' => 'https://'.$instance->address(ProvisioningSettings::dnsZone()), 'username' => $username, 'password' => $password, ]), $this->handoffToken); diff --git a/app/Provisioning/Steps/Customer/ConfigureNextcloud.php b/app/Provisioning/Steps/Customer/ConfigureNextcloud.php index 65edae7..bda02ae 100644 --- a/app/Provisioning/Steps/Customer/ConfigureNextcloud.php +++ b/app/Provisioning/Steps/Customer/ConfigureNextcloud.php @@ -31,7 +31,11 @@ class ConfigureNextcloud extends CustomerStep // Idempotent occ calls (setting the same values is a no-op). $this->guest($pve, $run, $occ.'config:system:set trusted_domains 1 --value='.escapeshellarg($fqdn)); - if (filled($instance->custom_domain)) { + // Only a VERIFIED domain goes into trusted_domains. Nextcloud will + // answer for anything listed here, so an unproven hostname added at + // provisioning time would serve this customer's files to whoever + // pointed that name at the proxy. + if ($instance->domainIsVerified()) { $this->guest($pve, $run, $occ.'config:system:set trusted_domains 2 --value='.escapeshellarg($instance->custom_domain)); } $this->guest($pve, $run, $occ.'background:cron'); diff --git a/app/Services/Domains/DomainVerifier.php b/app/Services/Domains/DomainVerifier.php new file mode 100644 index 0000000..1698f87 --- /dev/null +++ b/app/Services/Domains/DomainVerifier.php @@ -0,0 +1,107 @@ +> */ + 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> $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; + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index c7e10dc..bf474f4 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -21,6 +21,7 @@ final class Navigation ['label' => null, 'items' => [ ['dashboard', 'gauge', 'overview', null], ['cloud', 'cloud', 'cloud', null], + ['domain', 'globe', 'domain', null], ['users', 'users', 'users', null], ['backups', 'database', 'backups', null], ]], diff --git a/database/migrations/2026_07_29_220000_add_domain_verification_to_instances.php b/database/migrations/2026_07_29_220000_add_domain_verification_to_instances.php new file mode 100644 index 0000000..6dac1fb --- /dev/null +++ b/database/migrations/2026_07_29_220000_add_domain_verification_to_instances.php @@ -0,0 +1,57 @@ +string('domain_token', 40)->nullable()->after('custom_domain'); + + // Null until the record has been seen. This is the flag the proxy, + // the certificate and Nextcloud's trusted_domains all key off: + // unverified means the domain does not exist as far as we are + // concerned, and the proxy answers nothing at all. + $table->timestamp('domain_verified_at')->nullable()->after('domain_token'); + + // The last time the record was actually read, successful or not, + // and what went wrong. Separate from verified_at so a customer can + // see "checked ten minutes ago, still missing" rather than silence. + $table->timestamp('domain_checked_at')->nullable()->after('domain_verified_at'); + $table->string('domain_error', 32)->nullable()->after('domain_checked_at'); + + // How long it has been failing. One failed lookup is a nameserver + // having a bad minute; days of them is a domain that has moved on. + $table->unsignedSmallInteger('domain_failures')->default(0)->after('domain_error'); + }); + } + + public function down(): void + { + Schema::table('instances', function (Blueprint $table) { + $table->dropColumn([ + 'domain_token', 'domain_verified_at', 'domain_checked_at', + 'domain_error', 'domain_failures', + ]); + }); + } +}; diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 25eed8e..0150739 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -124,6 +124,7 @@ return [ 'nav' => [ 'overview' => 'Übersicht', 'cloud' => 'Meine Cloud', + 'domain' => 'Eigene Domain', 'users' => 'Benutzer', 'backups' => 'Backups', 'invoices' => 'Rechnungen', diff --git a/lang/de/domain.php b/lang/de/domain.php new file mode 100644 index 0000000..b9bcf3e --- /dev/null +++ b/lang/de/domain.php @@ -0,0 +1,41 @@ + 'Eigene Domain', + 'subtitle' => 'Ihre Cloud unter Ihrer eigenen Adresse — sobald bewiesen ist, dass die Domain Ihnen gehört.', + + 'field' => 'Domain', + 'placeholder' => 'cloud.ihre-firma.at', + 'save' => 'Speichern', + 'saved' => 'Domain gespeichert. Jetzt den Nachweis eintragen.', + 'removed' => 'Domain entfernt. Ihre Cloud ist wieder unter der Plattformadresse erreichbar.', + 'invalid' => 'Das sieht nicht nach einem Hostnamen aus. Beispiel: cloud.ihre-firma.at', + 'current_address' => 'Aktuelle Adresse', + 'platform_address' => 'Plattformadresse', + + 'step_a' => 'Schritt 1 — Domain auf uns zeigen lassen', + 'step_a_body' => 'Legen Sie bei Ihrem Domain-Anbieter einen A-Eintrag auf die IP-Adresse an, die wir Ihnen genannt haben. Das können wir für Sie nicht prüfen — hinter manchen Anbietern ist die Adresse von außen nicht sichtbar.', + + 'step_b' => 'Schritt 2 — Besitz nachweisen', + 'step_b_body' => 'Legen Sie zusätzlich diesen TXT-Eintrag an. Er beweist, dass die Domain Ihnen gehört. Ohne ihn liefern wir unter dieser Adresse nichts aus.', + 'record_name' => 'Name', + 'record_type' => 'Typ', + 'record_value' => 'Wert', + + 'check_now' => 'Jetzt prüfen', + 'verified' => 'Nachweis gefunden — die Domain ist freigeschaltet.', + 'not_found' => 'Der TXT-Eintrag ist noch nicht sichtbar. DNS-Änderungen brauchen oft ein paar Minuten.', + + 'state' => [ + 'verified' => 'Freigeschaltet', + 'pending' => 'Warten auf Nachweis', + 'none' => 'Keine eigene Domain', + ], + + 'checked_at' => 'Zuletzt geprüft: :when', + 'never_checked' => 'Noch nicht geprüft.', + + // The part that is easy to get wrong and expensive to explain afterwards. + 'keep_record' => 'Bitte lassen Sie den TXT-Eintrag dauerhaft stehen. Wir prüfen ihn jede Nacht erneut; fehlt er an drei Nächten hintereinander, schalten wir die Domain wieder ab und Ihre Cloud ist unter der Plattformadresse erreichbar.', + 'failures' => 'Nachweis fehlt seit :count Prüfung(en).', +]; diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 092dc77..55185b4 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -124,6 +124,7 @@ return [ 'nav' => [ 'overview' => 'Overview', 'cloud' => 'My cloud', + 'domain' => 'Your domain', 'users' => 'Users', 'backups' => 'Backups', 'invoices' => 'Invoices', diff --git a/lang/en/domain.php b/lang/en/domain.php new file mode 100644 index 0000000..b1fd9b4 --- /dev/null +++ b/lang/en/domain.php @@ -0,0 +1,40 @@ + 'Your own domain', + 'subtitle' => 'Your cloud on your own address — once it is proven that the domain is yours.', + + 'field' => 'Domain', + 'placeholder' => 'cloud.your-company.com', + 'save' => 'Save', + 'saved' => 'Domain saved. Now add the proof record.', + 'removed' => 'Domain removed. Your cloud is reachable at the platform address again.', + 'invalid' => 'That does not look like a hostname. Example: cloud.your-company.com', + 'current_address' => 'Current address', + 'platform_address' => 'Platform address', + + 'step_a' => 'Step 1 — point the domain at us', + 'step_a_body' => 'Create an A record at your domain provider pointing to the IP address we gave you. We cannot check this for you — behind some providers the address is not visible from outside.', + + 'step_b' => 'Step 2 — prove ownership', + 'step_b_body' => 'Also create this TXT record. It proves the domain is yours. Without it we serve nothing at this address.', + 'record_name' => 'Name', + 'record_type' => 'Type', + 'record_value' => 'Value', + + 'check_now' => 'Check now', + 'verified' => 'Proof found — the domain is live.', + 'not_found' => 'The TXT record is not visible yet. DNS changes often take a few minutes.', + + 'state' => [ + 'verified' => 'Live', + 'pending' => 'Waiting for proof', + 'none' => 'No custom domain', + ], + + 'checked_at' => 'Last checked: :when', + 'never_checked' => 'Not checked yet.', + + 'keep_record' => 'Please leave the TXT record in place. We re-read it every night; if it is missing on three consecutive nights we withdraw the domain and your cloud is reachable at the platform address.', + 'failures' => 'Proof missing for :count check(s).', +]; diff --git a/resources/js/app.js b/resources/js/app.js index eea91e9..b499a09 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -196,6 +196,24 @@ document.addEventListener('alpine:init', () => { // already ended by the time we can ask again. renderedCommit: commit, wasRunning: running, + // Has the SERVER ever reported this run, as opposed to us opening the + // overlay optimistically when the button was pressed? + // + // This is the gap the whole thing turned on. The agent consumes the + // request file BEFORE it resolves the release — deliberately, because + // update.sh may kill the shell and a request left in place would loop — + // and only writes `state: running` once it has decided to go ahead. In + // between, the request is gone and the status does not say running yet, + // so the endpoint honestly answers "nothing is running". The watcher + // read that as "the run has finished" and reloaded the page: the + // overlay appeared on the click, vanished a poll later, and the 503 + // arrived after it. Reported twice, exactly that way. + // + // The run has ENDED only once the server has both confirmed it and then + // stopped reporting it. Before the confirmation, silence means the agent + // has not got there yet. + serverConfirmed: running, + unconfirmedPolls: 0, // Already translated server-side ("Schritt: …", "Läuft seit …") — the // client only ever displays these, never assembles them, so German // text stays out of JavaScript entirely. @@ -236,16 +254,32 @@ document.addEventListener('alpine:init', () => { return; } + if (state.running) { + this.serverConfirmed = true; + this.unconfirmedPolls = 0; + } + // The run ended without the build changing — a check that found // nothing, or a failure. Either way the card has something new // to say. - if (this.wasRunning && !state.running) { + if (this.wasRunning && this.serverConfirmed && !state.running) { window.location.reload(); return; } - this.wasRunning = state.running; + // ...but an overlay that waits forever is its own bug. The + // agent refuses a request with nothing to install, and says so + // by writing nothing at all — so give it a bounded number of + // polls before concluding that nobody picked it up. + if (this.wasRunning && !this.serverConfirmed && ++this.unconfirmedPolls > 20) { + window.location.reload(); + + return; + } + + // Stay open while we are waiting for the agent to confirm. + this.wasRunning = state.running || (this.wasRunning && !this.serverConfirmed); this.step = state.step ?? null; this.runningSince = state.running_since ?? null; this.log = state.log ?? null; diff --git a/resources/views/livewire/custom-domain.blade.php b/resources/views/livewire/custom-domain.blade.php new file mode 100644 index 0000000..ed74ac7 --- /dev/null +++ b/resources/views/livewire/custom-domain.blade.php @@ -0,0 +1,92 @@ +
+
+

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

+

{{ __('domain.subtitle') }}

+
+ + @if ($instance === null) +

{{ __('domain.state.none') }}

+ @else + {{-- What is served right now. First, because it is the question + somebody opens this page with. --}} + +
+
+

{{ __('domain.current_address') }}

+

{{ $instance->address(\App\Support\ProvisioningSettings::dnsZone()) }}

+
+
+ @if ($instance->domainIsVerified()) + {{ __('domain.state.verified') }} + @elseif (filled($instance->custom_domain)) + {{ __('domain.state.pending') }} + @else + {{ __('domain.state.none') }} + @endif +
+
+ @if ($platformAddress && $instance->domainIsVerified()) +

+ {{ __('domain.platform_address') }}: {{ $platformAddress }} +

+ @endif +
+ + +
+
+ + + @error('domain')

{{ $message }}

@enderror +
+ {{ __('domain.save') }} +
+
+ + @if (filled($instance->custom_domain)) + +

{{ __('domain.step_a') }}

+

{{ __('domain.step_a_body') }}

+ +

{{ __('domain.step_b') }}

+

{{ __('domain.step_b_body') }}

+ +
+ @foreach ([ + __('domain.record_name') => $recordName, + __('domain.record_type') => 'TXT', + __('domain.record_value') => $recordValue, + ] as $term => $value) +
+
{{ $term }}
+
{{ $value }}
+
+ @endforeach +
+ +
+ + {{ __('domain.check_now') }} + +

+ @if ($instance->domain_checked_at) + {{ __('domain.checked_at', ['when' => $instance->domain_checked_at->local()->isoFormat('DD.MM. HH:mm')]) }} + @if (! $instance->domainIsVerified() && $instance->domain_failures > 0) + · {{ __('domain.failures', ['count' => $instance->domain_failures]) }} + @endif + @else + {{ __('domain.never_checked') }} + @endif +

+
+ + {{-- Said plainly and where it cannot be missed. A customer who + tidies the record away six months from now takes their own + cloud off its address, and "you were told at the time" is + no use to either side. --}} + {{ __('domain.keep_record') }} +
+ @endif + @endif +
diff --git a/routes/console.php b/routes/console.php index 4fffc70..5cb6b99 100644 --- a/routes/console.php +++ b/routes/console.php @@ -73,3 +73,14 @@ Schedule::command('clupilot:prune-exports') Schedule::command('clupilot:sample-status') ->everyFiveMinutes() ->withoutOverlapping(); + +// Re-read the DNS proof for every custom domain. +// +// Not a one-off: a token checked once can be taken straight back out, and a +// domain that later lapses and is registered by somebody else keeps resolving +// to the same instance. Nightly, and at an hour where withdrawing a domain +// inconveniences nobody — a withdrawal takes a working Nextcloud off its own +// address, which is not a thing to do at eleven in the morning. +Schedule::command('clupilot:verify-domains') + ->dailyAt('03:40') + ->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index 31d608c..18e8f3a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -241,6 +241,7 @@ $portal = function () { Route::middleware(['auth', 'verified', 'customer.active'])->group(function () { Route::get('/dashboard', Dashboard::class)->name('dashboard'); Route::get('/cloud', Cloud::class)->name('cloud'); + Route::get('/domain', \App\Livewire\CustomDomain::class)->name('domain'); Route::get('/users', Users::class)->name('users'); Route::get('/backups', Backups::class)->name('backups'); Route::get('/invoices', Invoices::class)->name('invoices'); diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index 7d21105..b95c8dc 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -860,3 +860,36 @@ it('does not cover the console because a button was pressed over nothing', funct $component->call('requestUpdate')->assertDispatched('update-started'); $component->call('requestUpdate')->assertNotDispatched('update-started'); }); + +it('does not read the agent’s start-up gap as the end of the run', function () { + // The reported loop, in the one place it can be reproduced: the agent + // consumes the request file before it resolves the release, and writes + // `state: running` only once it has decided to go ahead. In between, the + // endpoint honestly answers "nothing is running" — and the watcher read + // that as "finished" and reloaded, so the overlay opened on the click, + // vanished a poll later, and the 503 arrived after it. + $watcher = Illuminate\Support\Facades\File::get(resource_path('js/app.js')); + + // The overlay may only close once the SERVER has confirmed a run and then + // stopped reporting it. + expect($watcher)->toContain('this.wasRunning && this.serverConfirmed && !state.running') + // And it may not wait forever: the agent refuses a request with nothing + // to install by writing nothing at all. + ->and($watcher)->toContain('this.unconfirmedPolls > 20'); +}); + +it('reproduces the gap the console used to reload on', function () { + // The state the endpoint reports between the two agent steps: the request + // has been consumed, the status still says idle. `running` is false here + // and that is correct — the fix is on the reader, not on this value. + writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); + + app(UpdateChannel::class)->request('owner@example.com'); + expect(app(UpdateChannel::class)->state()['running'])->toBeTrue(); + + // The agent takes the request… + Illuminate\Support\Facades\File::delete(storage_path('app/deploy/update-request.json')); + + // …and has not written its status yet. + expect(app(UpdateChannel::class)->state()['running'])->toBeFalse(); +}); diff --git a/tests/Feature/CustomDomainTest.php b/tests/Feature/CustomDomainTest.php new file mode 100644 index 0000000..8331c74 --- /dev/null +++ b/tests/Feature/CustomDomainTest.php @@ -0,0 +1,196 @@ +bind(DomainVerifier::class, fn () => new DomainVerifier( + fn (string $name) => $records[$name] ?? [], + )); +} + +function customerInstance(array $attributes = []): Instance +{ + // customers.user_id, not users.customer_id — the link runs the other way. + $user = User::factory()->create(['email_verified_at' => now()]); + $customer = Customer::factory()->create(['user_id' => $user->id, 'email' => $user->email]); + + return Instance::factory()->create([ + 'customer_id' => $customer->id, + 'status' => 'active', + 'subdomain' => 'berger', + ...$attributes, + ]); +} + +it('does not serve a domain nobody has proven', function () { + // The hole, stated as a test: a hostname in the column is a request, not + // an address. + $instance = customerInstance(['custom_domain' => 'cloud.fremde-firma.at', 'domain_token' => 'abc']); + + expect($instance->domainIsVerified())->toBeFalse() + ->and($instance->address('clupilot.com'))->toBe('berger.clupilot.com'); +}); + +it('serves it once the proof is in the DNS', function () { + $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']); + + withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]); + + expect(app(DomainVerifier::class)->proofPresent($instance))->toBeTrue(); + + $this->artisan('clupilot:verify-domains')->assertSuccessful(); + + $instance->refresh(); + + expect($instance->domainIsVerified())->toBeTrue() + ->and($instance->address('clupilot.com'))->toBe('cloud.berger.at'); +}); + +it('finds the proof among a domain’s other TXT records', function () { + // A name commonly carries several: SPF, other providers' verifications. + // DNS returns them in no particular order, so only checking the first + // would fail on a domain that is already in use. + $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']); + + withResolver(['_clupilot-challenge.cloud.berger.at' => [ + ['txt' => 'v=spf1 -all'], + ['txt' => 'google-site-verification=xyz'], + ['txt' => 'cp-verify=tok123'], + ]]); + + expect(app(DomainVerifier::class)->proofPresent($instance))->toBeTrue(); +}); + +it('is not satisfied by somebody else’s token', function () { + $instance = customerInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'mine']); + + withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=someone-elses']]]); + + expect(app(DomainVerifier::class)->proofPresent($instance))->toBeFalse(); +}); + +it('withdraws a domain whose proof was taken away — but not on the first miss', function () { + // The reason the check repeats at all: put the token in, pass, take it + // straight back out. And the reason it does not act immediately: one failed + // lookup is a nameserver having a bad minute, and withdrawing takes a + // working Nextcloud off its own address. + $instance = customerInstance([ + 'custom_domain' => 'cloud.berger.at', + 'domain_token' => 'tok123', + 'domain_verified_at' => now()->subDays(30), + ]); + + withResolver([]); // the record is gone + + $this->artisan('clupilot:verify-domains'); + expect($instance->fresh()->domainIsVerified())->toBeTrue() + ->and($instance->fresh()->domain_failures)->toBe(1); + + $this->artisan('clupilot:verify-domains'); + expect($instance->fresh()->domainIsVerified())->toBeTrue(); + + $this->artisan('clupilot:verify-domains'); + expect($instance->fresh()->domainIsVerified())->toBeFalse() + // The domain and its token stay on the row: the customer has to see + // what is wrong and put the record back, not start over. + ->and($instance->fresh()->custom_domain)->toBe('cloud.berger.at') + ->and($instance->fresh()->domain_token)->toBe('tok123'); +}); + +it('forgives a miss once the record comes back', function () { + $instance = customerInstance([ + 'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123', + 'domain_verified_at' => now()->subDay(), 'domain_failures' => 2, + ]); + + withResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]); + + $this->artisan('clupilot:verify-domains'); + + expect($instance->fresh()->domain_failures)->toBe(0) + ->and($instance->fresh()->domainIsVerified())->toBeTrue(); +}); + +it('mints a new token when the domain changes', function () { + // Otherwise a customer who once verified example.com could claim any other + // domain later without touching its DNS: the old record is still sitting + // there and only the value is compared. + $instance = customerInstance([ + 'custom_domain' => 'alt.berger.at', 'domain_token' => 'old-token', + 'domain_verified_at' => now(), + ]); + + Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id)) + ->test(CustomDomain::class) + ->set('domain', 'neu.berger.at') + ->call('save') + ->assertHasNoErrors(); + + $instance->refresh(); + + expect($instance->custom_domain)->toBe('neu.berger.at') + ->and($instance->domain_token)->not->toBe('old-token') + // And it is not live until the new one is proven. + ->and($instance->domainIsVerified())->toBeFalse(); +}); + +it('lets a customer take the domain away again', function () { + // It was suggested this should be one-way. It should not: moving an address + // is a proxy entry, a certificate and a line in trusted_domains. + $instance = customerInstance([ + 'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(), + ]); + + Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id)) + ->test(CustomDomain::class) + ->set('domain', '') + ->call('save'); + + $instance->refresh(); + + expect($instance->custom_domain)->toBeNull() + ->and($instance->domain_verified_at)->toBeNull() + ->and($instance->address('clupilot.com'))->toBe('berger.clupilot.com'); +}); + +it('takes a pasted URL and stores a hostname', function () { + $instance = customerInstance(); + + Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id)) + ->test(CustomDomain::class) + ->set('domain', 'https://Cloud.Berger.at/') + ->call('save'); + + expect($instance->fresh()->custom_domain)->toBe('cloud.berger.at'); +}); + +it('refuses something that is not a hostname', function () { + $instance = customerInstance(); + + Livewire::actingAs(User::query()->findOrFail($instance->customer->user_id)) + ->test(CustomDomain::class) + ->set('domain', 'nicht mal annähernd') + ->call('save') + ->assertHasErrors('domain'); + + expect($instance->fresh()->custom_domain)->toBeNull(); +});