Compare commits

...

2 Commits

Author SHA1 Message Date
nexxo 3a4324fb6f Give people a way back in, and put the URL in English
tests / pest (push) Failing after 7m54s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
── The hole ────────────────────────────────────────────────────────────────
There was no password reset. Fortify's feature was commented out, so there
was no link on the sign-in form, no page and no route — a customer who forgot
their password was locked out of their own cloud until somebody opened a
shell. On a product whose selling point is that you can ring somebody, that
is a support call a week and an embarrassing one.

Two pages of ours under Fortify's route names (R1/R2), because with views off
Fortify registers only the POST endpoints. The mail is a Mailable in this
product's design rather than the framework's MailMessage: somebody who has
just been locked out is exactly the person a phishing mail is aimed at, and a
message that looks nothing like the rest of our post is one they cannot check
— ours carries the footer that names our domains.

Three decisions worth stating, each with a test:

The answer is identical whether the address is known or not. "No account with
that address" turns the form into a way of finding out who is a customer.

The reset kills every other session. Whoever knew the old password may still
be signed in somewhere, and a reset that leaves them there has fixed nothing.

It does NOT sign the visitor in. The link arrived by email, and a mailbox
somebody else can read would otherwise be a session somebody else gets.

── /sicherheit → /security ─────────────────────────────────────────────────
R13: paths are English. Mine was not. The old path stays as a permanent
redirect because it has already gone out in mail footers.

── One wordmark ────────────────────────────────────────────────────────────
"Sometimes it says Cloud and sometimes it does not" — correct. It was on the
footer, the placeholder and the maintenance screen, and absent from the
header, the sidebar and the sign-in plate. The product is called CluPilot
Cloud; that is the name on the invoices and in every mail, so it is now the
name everywhere. The console sidebar takes the small size so the lockup, the
ADMIN badge and the close button still share one line (R18).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:50:29 +02:00
nexxo 712803edd6 Serve the custom domain, not just announce it
A verified custom domain was reported as the customer's address by
Instance::address(), by the portal and by the credentials mail while
nothing on the platform routed it: the Traefik router's rule was
hard-coded to {subdomain}.{zone}. The address a customer was handed
answered nothing, and a withdrawn domain stayed in Nextcloud's
trusted_domains forever, because the only thing that ever wrote either
was the initial provisioning run.

- TraefikWriter::write() takes a LIST of hostnames under one stable
  router name — the platform address always, the verified domain as
  well when there is one. One file per instance, so a withdrawal is a
  rewrite rather than a second thing somebody has to remember.
- ConfigureDnsAndTls records the custom domain's certificate instead of
  waiting on it: it depends on an A record in the customer's own zone,
  which may never appear, and must never fail a run. The platform
  address keeps its 840s deadline. instances.domain_cert_ok tells
  "proven" apart from "answering", and the portal now says which.
- ConfigureNextcloud deletes trusted_domains 2 when there is no verified
  domain, so a withdrawn one stops being trusted.
- New `address` pipeline (those two steps) plus ReapplyInstanceAddress,
  which starts one against the order and refuses to start a second while
  any run is in flight. The route is rewritten when the hostname list
  differs from what the router carries — not on route_written, which
  would short-circuit exactly the case a re-apply exists for.
- Triggered where the address changes: verification flipping either way
  in clupilot:verify-domains, the customer's own domain page, and
  CustomDomainAccess::deactivate() on a package downgrade.
- A maintenance run no longer condemns its subject: an address run that
  failed used to mark the order failed and release the live instance
  with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:44:52 +02:00
42 changed files with 1463 additions and 33 deletions

View File

@ -1 +1 @@
1.3.18
1.3.19

View File

@ -0,0 +1,138 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Make an instance's address true again the router, the certificate and the
* hostname Nextcloud answers to without touching anything else about it.
*
* A custom domain used to be announced and never served: the portal, the
* credentials mail and Instance::address() all reported it the moment its TXT
* proof appeared, while the only thing that ever wrote a route was the initial
* provisioning run, and it wrote the platform subdomain. This is the missing
* half the moment a proven domain becomes an address, and the moment a
* withdrawn one stops being one.
*
* Called from the three places the address can change: the nightly proof check
* when verification flips, the customer's own domain page, and a package change
* that takes the right to a domain away. Deliberately not called from anywhere
* that merely LOOKS at the domain a re-apply is remote work on a live
* machine, so it happens on a change and not on a schedule.
*/
class ReapplyInstanceAddress
{
/**
* Start an address run for this instance, or return null when there is
* nothing to start.
*
* Null is the ordinary answer, not a failure: an instance still being
* built, one whose machine no longer exists, or one that already has a run
* in flight all have their address applied by that run instead.
*/
public function __invoke(?Instance $instance): ?ProvisioningRun
{
if ($instance === null || ! $this->isReapplyable($instance)) {
return null;
}
// The lock closes the window between asking whether a run exists and
// creating one. Two requests can reach this in the same instant — the
// customer's own "check now" and the nightly command are the obvious
// pair — and two runs against one machine would write the same router
// twice and race each other's occ calls. Nobody waits for the lock: a
// re-apply that lost the race has nothing to add, because the run that
// won reads the same domain state it would have.
$lock = Cache::lock('instance-address:'.$instance->uuid, 30);
if (! $lock->get()) {
return null;
}
try {
return $this->start($instance);
} finally {
$lock->release();
}
}
private function start(Instance $instance): ?ProvisioningRun
{
$order = $instance->order;
// A run already under way applies whatever the domain state says when
// it gets to the step, which is this run's state or newer. Checked
// against the ORDER, because that is the subject both pipelines share:
// starting an address run beside an unfinished customer run would have
// two runs writing one router and one Nextcloud config.
if ($this->hasRunInFlight($order)) {
return null;
}
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'address',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Everything the two steps read. instance_id is how CustomerStep
// finds the machine; node and vmid are how it reaches inside it.
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Re-applying an instance address.', [
'instance' => $instance->uuid,
'domain' => $instance->custom_domain,
'verified' => $instance->domainIsVerified(),
'run' => $run->uuid,
]);
return $run;
}
/**
* Is there a machine here whose address can be re-applied at all?
*
* A reservation with no VM, a failed build and an instance whose order has
* gone all fail the same way: the steps would reach for a guest agent that
* is not there and burn the run's retries doing it. An instance that is
* still `provisioning` is excluded for a different reason its own run
* will apply the address on its way past, and it holds the same lock this
* would.
*/
private function isReapplyable(Instance $instance): bool
{
return $instance->order !== null
&& $instance->host !== null
&& $instance->vmid !== null
&& in_array($instance->status, ['active', 'cancellation_scheduled'], true);
}
private function hasRunInFlight(Order $order): bool
{
return ProvisioningRun::query()
->where('subject_type', Order::class)
->where('subject_id', $order->id)
->whereIn('status', [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
ProvisioningRun::STATUS_PAUSED,
])
->exists();
}
}

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Actions\ReapplyInstanceAddress;
use App\Models\Instance;
use App\Services\Domains\DomainVerifier;
use Illuminate\Console\Command;
@ -24,7 +25,7 @@ class VerifyCustomDomains extends Command
protected $description = 'Re-read the DNS proof for every custom domain and withdraw the ones that lost it';
public function handle(DomainVerifier $verifier): int
public function handle(DomainVerifier $verifier, ReapplyInstanceAddress $reapply): int
{
$query = Instance::query()->whereNotNull('custom_domain')->whereNotNull('domain_token');
@ -39,6 +40,12 @@ class VerifyCustomDomains extends Command
$checked++;
$present = $verifier->proofPresent($instance);
// What was true before this check, so the CHANGE can be acted on
// rather than the state. Re-applying an address is remote work on a
// live machine; doing it nightly for every domain that is simply
// still fine would be a hundred pointless runs a night.
$wasVerified = $instance->domainIsVerified();
if ($present) {
$instance->forceFill([
'domain_verified_at' => $instance->domain_verified_at ?? now(),
@ -47,6 +54,13 @@ class VerifyCustomDomains extends Command
'domain_failures' => 0,
])->save();
// A domain that has just become provable is a domain nothing is
// serving yet: the router was written without it and Nextcloud
// does not trust it. This is where it becomes an address.
if (! $wasVerified) {
$reapply($instance);
}
continue;
}
@ -64,10 +78,18 @@ class VerifyCustomDomains extends Command
'domain_error' => 'missing_txt',
'domain_failures' => $failures,
'domain_verified_at' => $withdraw ? null : $instance->domain_verified_at,
'domain_cert_ok' => $withdraw ? false : $instance->domain_cert_ok,
])->save();
if ($withdraw) {
$withdrawn++;
// The other half of the flip, and the one that actually takes
// the domain away: the router stops carrying it and Nextcloud
// stops trusting it. Clearing verified_at alone only stops us
// TELLING people about it.
$reapply($instance);
$this->warn("Withdrew {$instance->custom_domain}: proof missing on {$failures} consecutive checks.");
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* "I cannot get in any more."
*
* There was no way out of that at all: Fortify's password-reset feature was
* switched off, so there was no link, no page and no route. A customer who
* forgot their password was locked out of their own cloud until somebody
* opened a shell on a product whose selling point is that you can ring
* somebody, that is a support call a week and an embarrassing one.
*
* The answer is deliberately identical whether the address is known or not.
* "No account with that address" turns this form into a way of finding out who
* is a customer, which is the first thing anybody targeting a business does.
*/
#[Layout('layouts.portal')]
class ForgotPassword extends Component
{
#[Validate('required|email|max:255')]
public string $email = '';
public bool $sent = false;
public function send(): void
{
$this->validate();
// Throttled by Laravel's broker (once a minute per address). The
// response does not say which of the two happened: a slower answer for
// a known address would leak the same thing the message would.
Password::broker()->sendResetLink(['email' => $this->email]);
$this->sent = true;
}
public function render()
{
return view('livewire.auth.forgot-password');
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Livewire\Auth;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The other end of the link.
*
* The token is checked by Laravel's broker, not here: it holds the hash, the
* expiry and the single-use deletion, and re-implementing any of that is how a
* reset link ends up working twice.
*
* The user is NOT signed in afterwards. A reset link travels by email, and a
* mailbox somebody else can read would otherwise be a session somebody else
* gets. They type the new password once more on the sign-in page which also
* proves the reset did what they think it did.
*/
#[Layout('layouts.portal')]
class ResetPassword extends Component
{
public string $token = '';
#[Validate('required|email|max:255')]
public string $email = '';
#[Validate('required|string|min:12|confirmed')]
public string $password = '';
public string $password_confirmation = '';
public bool $done = false;
public function mount(string $token): void
{
$this->token = $token;
$this->email = (string) request()->query('email', '');
}
/**
* NOT called reset(): Livewire\Component::reset() clears properties, and
* a component that redeclares it will not load at all.
*/
public function save(): void
{
$this->validate();
$status = Password::broker()->reset(
[
'email' => $this->email,
'password' => $this->password,
'password_confirmation' => $this->password_confirmation,
'token' => $this->token,
],
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password),
// Every existing session dies with it. Whoever knew the old
// password may still be signed in somewhere, and a reset
// that leaves them there has fixed nothing.
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
},
);
if ($status !== Password::PASSWORD_RESET) {
$this->addError('email', __($status));
return;
}
$this->done = true;
}
public function render()
{
return view('livewire.auth.reset-password');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Actions\ReapplyInstanceAddress;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Instance;
use App\Services\Billing\CustomDomainAccess;
@ -88,15 +89,24 @@ class CustomDomain extends Component
return;
}
// Whether anything is being SERVED under the old domain right now. A
// change here can only ever take a verified domain away — the new one
// starts unproven — so this is the one question that decides whether
// the proxy and Nextcloud have to be told.
$wasVerified = $instance->domainIsVerified();
// 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_verified_at' => null, 'domain_cert_ok' => false,
'domain_checked_at' => null,
'domain_error' => null, 'domain_failures' => 0,
])->save();
$this->stopOrStartServing($instance, $wasVerified);
$this->domain = '';
$this->dispatch('notify', message: __('domain.removed'));
@ -108,10 +118,13 @@ class CustomDomain extends Component
'custom_domain' => $domain,
'domain_token' => $verifier->newToken(),
'domain_verified_at' => null,
'domain_cert_ok' => false,
'domain_checked_at' => null,
'domain_error' => null,
'domain_failures' => 0,
])->save();
$this->stopOrStartServing($instance, $wasVerified);
}
$this->domain = $domain;
@ -133,19 +146,48 @@ class CustomDomain extends Component
return;
}
$wasVerified = $instance->domainIsVerified();
$wasServed = $instance->domainIsServed();
$present = $verifier->proofPresent($instance);
$instance->forceFill([
'domain_checked_at' => now(),
'domain_verified_at' => $present ? ($instance->domain_verified_at ?? now()) : null,
'domain_cert_ok' => $present ? $instance->domain_cert_ok : false,
'domain_error' => $present ? null : 'missing_txt',
'domain_failures' => $present ? 0 : $instance->domain_failures + 1,
])->save();
// This button flips verification both ways, so it is a place the
// address changes and has to be re-applied like any other. It also
// re-applies a domain that is proven but still answering nothing —
// which is what somebody is doing here when they add the A record
// after the TXT one and press it again, and it is the only way they
// have of asking us to look now rather than at the next change.
if ($present !== $wasVerified || ($present && ! $wasServed)) {
app(ReapplyInstanceAddress::class)($instance);
}
$this->justChecked = true;
$this->dispatch('notify', message: __($present ? 'domain.verified' : 'domain.not_found'));
}
/**
* Tell the proxy and Nextcloud that the address has moved.
*
* Only when something WAS being served: an unproven domain never reached a
* router or a trusted_domains entry, so replacing one with another changes
* nothing that has to be un-done, and starting a run for it would be remote
* work on a live machine for no reason at all.
*/
private function stopOrStartServing(Instance $instance, bool $wasVerified): void
{
if ($wasVerified) {
app(ReapplyInstanceAddress::class)($instance);
}
}
public function render()
{
$instance = $this->instance();

View File

@ -29,8 +29,13 @@ class CustomerProvisioning extends Component
return null;
}
// The BUILD, not every run against the order. An address re-apply runs
// on the same subject and would light this card up with "Ihre Cloud
// wird bereitgestellt" over a router file being rewritten — on a cloud
// that has been running for months.
return ProvisioningRun::query()
->where('subject_type', Order::class)
->where('pipeline', 'customer')
->whereIn('subject_id', $customer->orders()->select('id'))
->latest('id')
->first();

View File

@ -0,0 +1,47 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Models\User;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* "Set a new password."
*
* In this product's design rather than the framework's default MailMessage,
* for the same reason as the verification mail: a customer who has just been
* locked out is exactly the person a phishing mail is aimed at, and a message
* that looks nothing like the rest of our post is one they cannot check.
*
* It therefore carries the standard footer, which names our domains.
*/
class ResetPasswordMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
public function __construct(public User $user, public string $url, public int $minutes)
{
$this->mailer('cp_'.MailPurpose::SYSTEM);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('reset_password.subject'));
}
public function content(): Content
{
return new Content(view: 'mail.reset-password', with: [
'name' => $this->user->name,
'url' => $this->url,
'minutes' => $this->minutes,
]);
}
}

View File

@ -50,6 +50,11 @@ class Host extends Model implements ProvisioningSubject
$this->update(['status' => 'error']);
}
public function provisioningPipeline(): string
{
return 'host';
}
public function instances(): HasMany
{
return $this->hasMany(Instance::class);

View File

@ -3,6 +3,8 @@
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Database\Factories\InstanceFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -11,14 +13,14 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
class Instance extends Model
{
/** @use HasFactory<\Database\Factories\InstanceFactory> */
/** @use HasFactory<InstanceFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'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',
'route_written', 'routed_hostnames', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at',
'domain_token', 'domain_verified_at', 'domain_cert_ok', 'domain_checked_at', 'domain_error', 'domain_failures',
];
protected $hidden = ['nc_admin_ref', 'admin_password'];
@ -36,7 +38,9 @@ class Instance extends Model
'domain_verified_at' => 'datetime',
'domain_checked_at' => 'datetime',
'domain_failures' => 'integer',
'domain_cert_ok' => 'boolean',
'route_written' => 'boolean',
'routed_hostnames' => 'array',
'cert_ok' => 'boolean',
'vmid' => 'integer',
'traffic_addons' => 'integer',
@ -59,7 +63,7 @@ class Instance extends Model
* placement would call a host comfortable while orders were being refused
* on it.
*
* @param \Illuminate\Database\Eloquent\Builder<self> $query
* @param Builder<self> $query
*/
public function scopeOccupyingHost($query): void
{
@ -78,6 +82,21 @@ class Instance extends Model
return filled($this->custom_domain) && $this->domain_verified_at !== null;
}
/**
* Is the customer's own domain not merely proven, but actually answering?
*
* Verification says the domain is theirs. It says nothing about whether
* they have pointed it at us that is an A record in their zone, which we
* cannot create and (behind a CDN) cannot even read. So there is a real
* state between "proven" and "working", it can last forever, and the portal
* has to be able to say which one the customer is in rather than printing
* an address that answers nothing.
*/
public function domainIsServed(): bool
{
return $this->domainIsVerified() && (bool) $this->domain_cert_ok;
}
/**
* The address this instance is actually served at.
*

View File

@ -133,4 +133,9 @@ class Order extends Model implements ProvisioningSubject
$this->update(['status' => 'failed']);
$this->instance()->update(['status' => 'failed']);
}
public function provisioningPipeline(): string
{
return 'customer';
}
}

View File

@ -20,6 +20,23 @@ class User extends Authenticatable implements MustVerifyEmail
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable;
/**
* The reset mail in this product's design, not the framework's.
*
* Somebody who has just been locked out is exactly the person a phishing
* mail is aimed at. A message that looks nothing like the rest of our post
* is one they cannot check and ours carries the footer naming our
* domains, which is the whole point.
*/
public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void
{
\Illuminate\Support\Facades\Mail::to($this->email)->send(new \App\Mail\ResetPasswordMail(
$this,
route('password.reset', ['token' => $token, 'email' => $this->email]),
(int) config('auth.passwords.users.expire', 60),
));
}
/**
* The confirmation mail, in this product's design rather than Laravel's.
*

View File

@ -9,4 +9,17 @@ namespace App\Provisioning\Contracts;
interface ProvisioningSubject
{
public function onProvisioningFailed(): void;
/**
* The pipeline that BUILDS this subject, as opposed to one that maintains
* it afterwards.
*
* The failure hook writes the whole thing off an order is marked failed
* and its instance released with it which is the right answer when the
* machine was never finished and entirely the wrong one when a live
* customer's address re-apply could not reach their VM for ten minutes. So
* the runner asks which pipeline this subject exists to be built by, and
* only that one is allowed to condemn it.
*/
public function provisioningPipeline(): string;
}

View File

@ -167,9 +167,14 @@ class RunRunner
$run->save();
$this->record($run, $stepKey, 'failed', $reason);
// Let the subject react (e.g. a Host moves to the 'error' status).
// Let the subject react (e.g. a Host moves to the 'error' status) — but
// only when the run that failed is the one that builds it. A
// maintenance pipeline (`address`) shares the same subject, and letting
// it fire this hook would mark a paid, running customer's order failed
// and release their live instance because a router file could not be
// written. See ProvisioningSubject::provisioningPipeline().
$subject = $run->subject;
if ($subject instanceof ProvisioningSubject) {
if ($subject instanceof ProvisioningSubject && $run->pipeline === $subject->provisioningPipeline()) {
$subject->onProvisioningFailed();
}
}

View File

@ -2,6 +2,7 @@
namespace App\Provisioning\Steps\Customer;
use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Dns\HetznerDnsClient;
@ -10,6 +11,18 @@ use App\Support\ProvisioningSettings;
class ConfigureDnsAndTls extends CustomerStep
{
/**
* How long the step keeps looking for the CUSTOM domain's certificate
* before it moves on without it.
*
* Long enough that the ordinary case lands inside the run: the customer has
* already pointed their A record at us, Traefik sees the first request to
* the new hostname and finishes HTTP-01 in seconds. Short enough that it is
* never a wait the domain may equally be pointed at us tomorrow, or
* never, and the run has no business hanging on that.
*/
private const CUSTOM_DOMAIN_GRACE = 120;
public function __construct(
private HetznerDnsClient $dns,
private TraefikWriter $traefik,
@ -32,8 +45,20 @@ class ConfigureDnsAndTls extends CustomerStep
$host = $instance->host;
$fqdn = $instance->subdomain.'.'.ProvisioningSettings::dnsZone();
// The customer's own domain is served ONLY once it is proven to be
// theirs. Everything downstream reads this one flag, so an unverified
// hostname sitting in the column reaches neither the router nor a
// certificate — see Instance::domainIsVerified().
$customDomain = $instance->domainIsVerified() ? (string) $instance->custom_domain : null;
// The platform address first: it is ours, it always works, and it is
// what the instance falls back to the moment a custom domain goes away.
$hostnames = array_values(array_filter([$fqdn, $customDomain]));
// DNS — provider upsert is idempotent; the local row uses firstOrCreate on
// the record id and is written BEFORE the breadcrumb (the short-circuit guard).
// Only OUR zone: the custom domain's A record lives in the customer's
// zone, which we have no access to and never will.
if (! $this->hasResource($run, 'dns_record_id')) {
$recordId = $this->dns->upsertRecord($fqdn, 'A', $host->public_ip);
$instance->dnsRecords()->firstOrCreate(
@ -45,11 +70,19 @@ class ConfigureDnsAndTls extends CustomerStep
// Traefik file-provider route, written on the serving host (DNS points at
// it) and pointing at the guest VM.
if (! $instance->route_written) {
//
// Guarded on WHAT the router carries, not merely on whether one was ever
// written. `route_written` alone would short-circuit exactly the case
// this step is re-run for — an address that has changed — and the
// customer's domain would be announced but never routed, or stay routed
// after it was withdrawn. Comparing the hostname list keeps the retry
// cheap (a second attempt at the same address writes nothing) without
// making a re-apply a no-op.
if (! $instance->route_written || $instance->routed_hostnames !== $hostnames) {
$trafficHost = $host->wg_ip ?? $host->public_ip;
$backend = $instance->guest_ip ?: $host->public_ip;
$this->traefik->write($trafficHost, $instance->subdomain, $backend);
$instance->update(['route_written' => true]);
$this->traefik->write($trafficHost, $instance->subdomain, $hostnames, $backend);
$instance->update(['route_written' => true, 'routed_hostnames' => $hostnames]);
}
// TLS via HTTP-01 — poll until the certificate is served.
@ -63,6 +96,58 @@ class ConfigureDnsAndTls extends CustomerStep
}
}
return $this->settleCustomDomainCertificate($run, $instance, $customDomain);
}
/**
* The custom domain's certificate: recorded, never required.
*
* The platform address above is ours no certificate there means something
* is broken on our side and the run fails so somebody looks. This one is the
* opposite: it can only be issued once the customer has pointed their own A
* record at us, and whether they have is not ours to decide, not ours to
* check (behind a CDN the address is invisible from outside) and possibly
* never going to happen. Failing a run over it would mean a customer who
* typed a domain and went to lunch could not get their cloud built.
*
* So the outcome is written to `domain_cert_ok` and the run advances either
* way. The portal reads that flag to tell "proven" apart from "answering",
* because it is the portal that prints this address as the customer's.
*/
private function settleCustomDomainCertificate(ProvisioningRun $run, Instance $instance, ?string $customDomain): StepResult
{
if ($customDomain === null) {
// No verified domain: whatever was true before is not true now.
if ($instance->domain_cert_ok) {
$instance->update(['domain_cert_ok' => false]);
}
return StepResult::advance();
}
if ($this->traefik->certReachable($customDomain)) {
$instance->update(['domain_cert_ok' => true]);
return StepResult::advance();
}
// A short look, not a wait — see CUSTOM_DOMAIN_GRACE.
if ($run->started_at !== null && ! $run->started_at->copy()->addSeconds(self::CUSTOM_DOMAIN_GRACE)->isPast()) {
return StepResult::poll(15, 'waiting for the custom domain certificate');
}
$instance->update(['domain_cert_ok' => false]);
// Said out loud rather than swallowed: from here the domain is proven,
// routed, and answering nothing, and that is a state an operator
// reading this run should be able to see without going looking.
$run->events()->create([
'step' => $this->key(),
'attempt' => $run->attempt,
'outcome' => 'info',
'message' => "Eigene Domain {$customDomain}: noch kein Zertifikat — zeigt der A-Eintrag des Kunden schon auf uns?",
]);
return StepResult::advance();
}
}

View File

@ -35,8 +35,19 @@ class ConfigureNextcloud extends CustomerStep
// 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.
//
// And the other direction has to happen too. Writing the entry when
// there is a domain and doing nothing when there is not leaves a
// withdrawn domain trusted forever — the proof is gone, the router no
// longer carries it, and Nextcloud would still answer to it the moment
// anything reached it under that name. `config:system:delete` is the
// occ command that removes the entry rather than blanking it, and it
// exits 0 when the key is already absent, so the step stays idempotent
// for the overwhelmingly common case of an instance that never had one.
if ($instance->domainIsVerified()) {
$this->guest($pve, $run, $occ.'config:system:set trusted_domains 2 --value='.escapeshellarg($instance->custom_domain));
} else {
$this->guest($pve, $run, $occ.'config:system:delete trusted_domains 2');
}
$this->guest($pve, $run, $occ.'background:cron');
$this->guest($pve, $run, $occ.'config:system:set default_phone_region --value=DE');

View File

@ -3,6 +3,7 @@
namespace App\Services\Billing;
use App\Actions\BookAddon;
use App\Actions\ReapplyInstanceAddress;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\PlanFamily;
@ -288,6 +289,13 @@ final class CustomDomainAccess
* Neither the verification nor the serving logic is reimplemented here:
* Instance::address() already falls back to the subdomain the moment the
* domain is gone, and that is the whole of the fallback in this codebase.
*
* Clearing the row is only half of it, though. A domain that was verified
* is a domain the proxy is routing and Nextcloud is trusting, and neither
* of those reads this table so the address is re-applied, which is what
* actually stops the customer's old domain from answering. Without it a
* downgrade would take the domain off every screen while the machine
* carried on serving it.
*/
public function deactivate(?Instance $instance): bool
{
@ -295,15 +303,22 @@ final class CustomDomainAccess
return false;
}
$wasVerified = $instance->domainIsVerified();
$instance->forceFill([
'custom_domain' => null,
'domain_token' => null,
'domain_verified_at' => null,
'domain_cert_ok' => false,
'domain_checked_at' => null,
'domain_error' => null,
'domain_failures' => 0,
])->save();
if ($wasVerified) {
app(ReapplyInstanceAddress::class)($instance);
}
return true;
}

View File

@ -7,24 +7,52 @@ class FakeTraefikWriter implements TraefikWriter
/** @var array<string, string> subdomain => backend */
public array $routes = [];
/** @var array<string, array<int, string>> subdomain => hostnames the router serves */
public array $hostnames = [];
/** @var array<string, string> subdomain => traffic host */
public array $hosts = [];
/** How many times a router file was written, so a test can prove a rewrite happened. */
public int $writes = 0;
public bool $certReady = true;
public function write(string $trafficHost, string $subdomain, string $backend): void
/**
* Hostnames that answer nothing however often they are probed a
* customer's own domain whose A record does not point at us. Consulted
* before $certReady, so a test can have the platform address certified
* while the custom domain is not.
*
* @var array<int, string>
*/
public array $certUnreachable = [];
public function write(string $trafficHost, string $subdomain, array $hostnames, string $backend): void
{
$this->routes[$subdomain] = $backend;
$this->hostnames[$subdomain] = array_values($hostnames);
$this->hosts[$subdomain] = $trafficHost;
$this->writes++;
}
public function remove(string $trafficHost, string $subdomain): void
{
unset($this->routes[$subdomain], $this->hosts[$subdomain]);
unset($this->routes[$subdomain], $this->hostnames[$subdomain], $this->hosts[$subdomain]);
}
public function certReachable(string $fqdn): bool
{
if (in_array($fqdn, $this->certUnreachable, true)) {
return false;
}
return $this->certReady;
}
/** Does the router for this instance serve that hostname right now? */
public function serves(string $subdomain, string $hostname): bool
{
return in_array($hostname, $this->hostnames[$subdomain] ?? [], true);
}
}

View File

@ -17,10 +17,13 @@ class SshTraefikWriter implements TraefikWriter
{
public function __construct(private RemoteShell $shell) {}
public function write(string $trafficHost, string $subdomain, string $backend): void
public function write(string $trafficHost, string $subdomain, array $hostnames, string $backend): void
{
$zone = ProvisioningSettings::dnsZone();
$yaml = $this->render($subdomain, "{$subdomain}.{$zone}", $backend);
// The file is rewritten wholesale rather than appended to, so the
// hostnames handed in are exactly the hostnames served afterwards —
// that is what makes withdrawing a domain a write rather than a
// separate deletion somebody has to remember.
$yaml = $this->render($subdomain, $hostnames, $backend);
$this->shell->connectWithKey($trafficHost, 'root', $this->privateKey());
$this->shell->putFile($this->path($subdomain), $yaml); // putFile throws on failure
@ -59,13 +62,25 @@ class SshTraefikWriter implements TraefikWriter
return rtrim(ProvisioningSettings::traefikDynamicPath(), '/')."/{$subdomain}.yml";
}
private function render(string $subdomain, string $fqdn, string $backend): string
/**
* One router, one rule, every hostname in it.
*
* Traefik's `Host()` matcher takes several names in one call, and a single
* rule keeps the certResolver, the service and the entryPoint stated once
* a second router would have to repeat all three and could drift from the
* first.
*
* @param array<int, string> $hostnames
*/
private function render(string $subdomain, array $hostnames, string $backend): string
{
$names = implode(', ', array_map(fn (string $host) => "`{$host}`", $hostnames));
return implode("\n", [
'http:',
' routers:',
" {$subdomain}:",
" rule: \"Host(`{$fqdn}`)\"",
" rule: \"Host({$names})\"",
" service: \"{$subdomain}\"",
' entryPoints: ["websecure"]',
' tls:',

View File

@ -5,10 +5,19 @@ namespace App\Services\Traefik;
interface TraefikWriter
{
/**
* Write a file-provider router on the Traefik host ($trafficHost) that routes
* the subdomain to the guest ($backend).
* Write a file-provider router on the Traefik host ($trafficHost) that
* routes every hostname in $hostnames to the guest ($backend).
*
* A LIST, not one name: an instance is reachable under its platform
* address always, and under the customer's verified domain as well when
* there is one. They belong in ONE router under one name the subdomain,
* which is stable and is what remove() deletes because two routers for
* one backend are two things to keep in step, and the second one is what
* gets forgotten the day the domain is withdrawn.
*
* @param array<int, string> $hostnames platform address first
*/
public function write(string $trafficHost, string $subdomain, string $backend): void;
public function write(string $trafficHost, string $subdomain, array $hostnames, string $backend): void;
public function remove(string $trafficHost, string $subdomain): void;

View File

@ -170,7 +170,12 @@ return [
// Registration is exposed via an app route (routes/web.php) with a
// registration-scoped throttle instead of Fortify's unthrottled route.
// Features::registration(),
// Features::resetPasswords(),
// Was off, which meant there was no forgot-password link, no page
// and no route: a customer who forgot their password was locked out
// of their own cloud until somebody opened a shell. The POST
// endpoints come from Fortify; the two GET pages are ours (R1/R2),
// registered in routes/web.php under Fortify's own route names.
Features::resetPasswords(),
// Double opt-in. An account whose address was never confirmed is an
// account that cannot be billed, cannot be told its server is down,
// and may not be its owner's address at all — anybody can type

View File

@ -61,6 +61,24 @@ return [
Customer\RunAcceptanceChecks::class,
Customer\CompleteProvisioning::class,
],
/*
| Making an existing instance's address real again, without rebuilding
| anything. Exactly the two steps an address consists of: the router,
| the DNS record and the certificate, then the hostname Nextcloud
| itself will answer to.
|
| Same subject as `customer` (the Order), because that is what
| CustomerStep::order()/instance() resolve a run against any other
| subject would need every step in it rewritten. Started by
| App\Actions\ReapplyInstanceAddress whenever the address changes:
| a domain proven, a domain withdrawn, a package that no longer
| carries one.
*/
'address' => [
Customer\ConfigureDnsAndTls::class,
Customer\ConfigureNextcloud::class,
],
],
// The one currency the catalogue is priced in. Plan prices carry no currency

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* What the proxy actually serves, as opposed to what the customer was promised.
*
* A verified custom domain was announced everywhere Instance::address(), the
* portal, the credentials mail while nothing routed it: the Traefik router's
* rule was hard-coded to the platform subdomain, so the address the customer
* was given answered nothing. These two columns are the difference between the
* promise and the fact.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instances', function (Blueprint $table) {
// The hostnames the router file on the traffic host currently
// carries. Written by ConfigureDnsAndTls after the file lands, and
// compared against the wanted list on the next run — that
// comparison is what lets an address re-apply rewrite the route
// while an ordinary retry still short-circuits. `route_written`
// alone cannot answer it: it says a file exists, not what is in it.
$table->json('routed_hostnames')->nullable()->after('route_written');
// Whether the CUSTOM domain's certificate is being served, kept
// apart from `cert_ok` (the platform address) on purpose. The
// platform address is ours: if its certificate does not appear, the
// run has failed and somebody has to look. The custom domain's
// certificate depends on the customer pointing their DNS at us,
// which may not have happened and may never happen — so it is
// recorded, never waited on, and never a reason to fail anything.
$table->boolean('domain_cert_ok')->default(false)->after('domain_verified_at');
});
}
public function down(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn(['routed_hostnames', 'domain_cert_ok']);
});
}
};

View File

@ -62,4 +62,20 @@ return [
'phishing_note' => 'Sie sind gerade auf :host. Wir fragen Sie nie per E-Mail oder Telefon nach Ihrem Passwort.',
'phishing_link' => 'Echte Adressen erkennen',
'forgot_link' => "Passwort vergessen?",
'forgot_title' => "Passwort vergessen",
'forgot_subtitle' => "Wir schicken Ihnen einen Link, mit dem Sie ein neues vergeben.",
'forgot_send' => "Link schicken",
'forgot_sent_title' => "Schauen Sie in Ihr Postfach",
'forgot_sent_body' => "Wenn es bei uns ein Konto zu :email gibt, ist der Link unterwegs. Er gilt eine Stunde und lässt sich einmal verwenden.",
'forgot_sent_hint' => "Nichts angekommen? Sehen Sie im Spam-Ordner nach. Wir sagen aus Sicherheitsgründen nicht, ob es zu dieser Adresse ein Konto gibt.",
'back_to_login' => "Zurück zur Anmeldung",
'reset_title' => "Neues Passwort vergeben",
'reset_subtitle' => "Danach melden Sie sich damit an.",
'reset_new' => "Neues Passwort",
'reset_repeat' => "Neues Passwort wiederholen",
'reset_save' => "Passwort speichern",
'reset_done_title' => "Passwort geändert",
'reset_done_body' => "Alle anderen offenen Anmeldungen wurden beendet. Melden Sie sich jetzt mit dem neuen Passwort an — absichtlich nicht automatisch: der Link kam per E-Mail, und wer Ihr Postfach lesen kann, bekäme sonst gleich die Sitzung dazu.",
];

View File

@ -28,10 +28,16 @@ return [
'state' => [
'verified' => 'Freigeschaltet',
// Nachgewiesen ist nicht dasselbe wie erreichbar: der TXT-Eintrag
// gehört uns, der A-Eintrag dem Kunden. Diese Seite hat die Adresse
// bisher als fertig angezeigt, obwohl darunter nichts geantwortet hat.
'not_served' => 'Nachgewiesen, noch nicht erreichbar',
'pending' => 'Warten auf Nachweis',
'none' => 'Keine eigene Domain',
],
'not_served_hint' => 'Der Besitz ist nachgewiesen und die Domain ist bei uns eingerichtet — unter dieser Adresse antwortet aber noch nichts. Meistens fehlt dann noch der A-Eintrag aus Schritt 1, oder er ist noch nicht überall bekannt. Bis dahin ist Ihre Cloud unverändert unter der Plattformadresse erreichbar.',
'checked_at' => 'Zuletzt geprüft: :when',
'never_checked' => 'Noch nicht geprüft.',

View File

@ -0,0 +1,13 @@
<?php
return [
'subject' => 'Neues Passwort für Ihren CluPilot-Zugang',
'preheader' => 'Der Link gilt :minutes Minuten.',
'heading' => 'Neues Passwort setzen',
'greeting' => 'Guten Tag :name,',
'intro' => 'Sie haben ein neues Passwort für Ihren CluPilot-Zugang angefordert. Über den Knopf unten vergeben Sie es.',
'action' => 'Neues Passwort setzen',
'expiry' => 'Der Link gilt :minutes und lässt sich nur einmal verwenden.',
'fallback' => 'Falls der Knopf nicht funktioniert, kopieren Sie diese Adresse in Ihren Browser:',
'not_you' => 'Sie haben das nicht angefordert? Dann ignorieren Sie diese Nachricht — ohne den Link ändert sich nichts an Ihrem Zugang. Wir fragen Sie nie per E-Mail nach Ihrem Passwort.',
];

View File

@ -62,4 +62,20 @@ return [
'phishing_note' => 'You are currently on :host. We never ask for your password by email or on the phone.',
'phishing_link' => 'How to recognise our addresses',
'forgot_link' => "Forgotten your password?",
'forgot_title' => "Forgotten password",
'forgot_subtitle' => "We will send you a link to set a new one.",
'forgot_send' => "Send the link",
'forgot_sent_title' => "Check your inbox",
'forgot_sent_body' => "If we have an account for :email, the link is on its way. It is valid for an hour and can be used once.",
'forgot_sent_hint' => "Nothing arrived? Check your spam folder. For security we do not say whether an account exists for that address.",
'back_to_login' => "Back to sign-in",
'reset_title' => "Set a new password",
'reset_subtitle' => "Then sign in with it.",
'reset_new' => "New password",
'reset_repeat' => "Repeat the new password",
'reset_save' => "Save the password",
'reset_done_title' => "Password changed",
'reset_done_body' => "Every other open session has been ended. Sign in now with the new password — deliberately not automatically: the link arrived by email, and whoever can read your mailbox would otherwise get the session with it.",
];

View File

@ -28,10 +28,13 @@ return [
'state' => [
'verified' => 'Live',
'not_served' => 'Proven, not reachable yet',
'pending' => 'Waiting for proof',
'none' => 'No custom domain',
],
'not_served_hint' => 'Ownership is proven and the domain is set up on our side — but nothing answers at this address yet. Usually the A record from step 1 is still missing, or it has not propagated everywhere. Until then your cloud stays reachable at the platform address.',
'checked_at' => 'Last checked: :when',
'never_checked' => 'Not checked yet.',

View File

@ -0,0 +1,13 @@
<?php
return [
'subject' => 'A new password for your CluPilot account',
'preheader' => 'The link is valid for :minutes minutes.',
'heading' => 'Set a new password',
'greeting' => 'Hello :name,',
'intro' => 'You asked for a new password for your CluPilot account. Use the button below to set one.',
'action' => 'Set a new password',
'expiry' => 'The link is valid for :minutes and can be used once.',
'fallback' => 'If the button does not work, copy this address into your browser:',
'not_you' => 'You did not ask for this? Then ignore this message — without the link nothing about your account changes. We never ask for your password by email.',
];

View File

@ -73,7 +73,7 @@
<div class="mx-auto max-w-[1120px] px-5 py-14 sm:px-6">
<div class="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
<div class="lg:col-span-2">
<x-ui.brand size="md" suffix />
<x-ui.brand size="md" />
<p class="mt-4 max-w-[38ch] text-sm leading-relaxed text-muted">
Eine eigene, isolierte Cloud für Ihr Unternehmen eingerichtet, gesichert und
überwacht von uns, mit Serverstandort in der EU.

View File

@ -22,7 +22,7 @@
>
<div class="flex shrink-0 items-center justify-between gap-2 px-2.5 pb-5">
<a href="{{ $console ? \App\Support\AdminArea::home() : route('dashboard') }}" class="flex items-center gap-2.5">
<x-ui.brand size="md" />
<x-ui.brand :size="$console ? 'sm' : 'md'" />
@if ($console)
<span class="rounded-sm bg-accent-subtle px-1.5 py-0.5 font-mono text-[9.5px] font-semibold uppercase tracking-[0.1em] text-accent-text">{{ __('admin.badge') }}</span>
@endif

View File

@ -1,6 +1,5 @@
@props([
'size' => 'md', // sm | md | lg
'suffix' => false, // append "Cloud" in the muted tone
'tone' => 'ink', // ink | white — for the dark sign-in plate
])
{{--
@ -16,6 +15,12 @@
of them, and because a two-tone wordmark makes the accent a permanent
fixture rather than something used sparingly.
"Cloud" is no longer optional either. It was on the footer, the placeholder
and the maintenance screen and absent from the header, the sidebar and the
sign-in plate "sometimes it says Cloud and sometimes it does not", which
is exactly right. The product is called CluPilot Cloud; that is the name on
the invoices and in every mail, so it is the name everywhere.
The mark and the word are the only two children of the flex row, and the
word is a single element. `gap` applies between EVERY child of a flex
container and a bare text node is a child written the other way this
@ -33,5 +38,5 @@
view with a parse error. The newline also supplies the space before
"Cloud". --}}
<span class="whitespace-nowrap {{ $words[$size] ?? $words['md'] }} font-bold tracking-[-0.025em] {{ $tone === 'white' ? 'text-white' : 'text-ink' }}">CluPilot
@if ($suffix)<span class="font-semibold {{ $tone === 'white' ? 'text-white/70' : 'text-muted' }}">Cloud</span>@endif</span>
<span class="font-semibold {{ $tone === 'white' ? 'text-white/70' : 'text-muted' }}">Cloud</span></span>
</span>

View File

@ -0,0 +1,44 @@
<div class="flex min-h-screen bg-bg">
<x-auth.brand
:headline="__('auth.brand_headline')"
:sub="__('auth.brand_sub')"
:facts="[
__('auth.fact_location_term') => __('auth.fact_location'),
__('auth.fact_backup_term') => __('auth.fact_backup'),
__('auth.fact_restore_term') => __('auth.fact_restore'),
__('auth.fact_support_term') => __('auth.fact_support'),
]" />
<main class="flex flex-1 items-center justify-center px-6 py-12">
<div class="w-full max-w-sm animate-rise">
<x-ui.brand size="md" class="mb-9 lg:hidden" />
@if ($sent)
{{-- The same page whether the address is known or not. Saying
"no account with that address" would turn this form into a
way of finding out who is a customer. --}}
<div class="grid size-11 place-items-center rounded-lg bg-accent-subtle">
<x-ui.icon name="mail" class="size-5 text-accent-text" />
</div>
<h1 class="mt-5 text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.forgot_sent_title') }}</h1>
<p class="mt-3 text-sm leading-relaxed text-muted">{{ __('auth.forgot_sent_body', ['email' => $email]) }}</p>
<p class="mt-4 text-sm leading-relaxed text-muted">{{ __('auth.forgot_sent_hint') }}</p>
<x-ui.button href="{{ route('login') }}" variant="secondary" class="mt-8 w-full">{{ __('auth.back_to_login') }}</x-ui.button>
@else
<h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.forgot_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('auth.forgot_subtitle') }}</p>
<form wire:submit="send" class="mt-8 space-y-5">
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="email" autofocus wire:model="email" />
<x-ui.button type="submit" variant="primary" size="md" class="w-full"
wire:loading.attr="disabled" wire:target="send">{{ __('auth.forgot_send') }}</x-ui.button>
</form>
<p class="mt-6 text-sm text-muted">
<a href="{{ route('login') }}" class="font-medium text-accent-text underline-offset-4 hover:underline">{{ __('auth.back_to_login') }}</a>
</p>
@endif
</div>
</main>
</div>

View File

@ -25,7 +25,10 @@
@csrf
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="email" autofocus required value="{{ old('email') }}" />
<x-ui.input name="password" type="password" :label="__('auth.password_label')" autocomplete="current-password" required />
<x-ui.checkbox name="remember" :label="__('auth.remember')" value="1" />
<div class="flex flex-wrap items-center justify-between gap-2">
<x-ui.checkbox name="remember" :label="__('auth.remember')" value="1" />
<a href="{{ route('password.request') }}" class="text-sm text-muted underline-offset-4 hover:text-accent-text hover:underline">{{ __('auth.forgot_link') }}</a>
</div>
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
</form>

View File

@ -0,0 +1,41 @@
<div class="flex min-h-screen bg-bg">
<x-auth.brand
:headline="__('auth.brand_headline')"
:sub="__('auth.brand_sub')"
:facts="[
__('auth.fact_location_term') => __('auth.fact_location'),
__('auth.fact_backup_term') => __('auth.fact_backup'),
__('auth.fact_restore_term') => __('auth.fact_restore'),
__('auth.fact_support_term') => __('auth.fact_support'),
]" />
<main class="flex flex-1 items-center justify-center px-6 py-12">
<div class="w-full max-w-sm animate-rise">
<x-ui.brand size="md" class="mb-9 lg:hidden" />
@if ($done)
<div class="grid size-11 place-items-center rounded-lg bg-success-bg">
<x-ui.icon name="check" class="size-5 text-success" />
</div>
<h1 class="mt-5 text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.reset_done_title') }}</h1>
{{-- Not signed in automatically: a reset link travels by email,
and a mailbox somebody else can read would otherwise be a
session somebody else gets. --}}
<p class="mt-3 text-sm leading-relaxed text-muted">{{ __('auth.reset_done_body') }}</p>
<x-ui.button href="{{ route('login') }}" variant="primary" class="mt-8 w-full">{{ __('auth.sign_in') }}</x-ui.button>
@else
<h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.reset_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('auth.reset_subtitle') }}</p>
<form wire:submit="save" class="mt-8 space-y-5">
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="username" wire:model="email" />
<x-ui.input name="password" type="password" :label="__('auth.reset_new')" autocomplete="new-password" wire:model="password" />
<x-ui.input name="password_confirmation" type="password" :label="__('auth.reset_repeat')" autocomplete="new-password" wire:model="password_confirmation" />
<x-ui.button type="submit" variant="primary" size="md" class="w-full"
wire:loading.attr="disabled" wire:target="save">{{ __('auth.reset_save') }}</x-ui.button>
</form>
@endif
</div>
</main>
</div>

View File

@ -16,8 +16,14 @@
<p class="mt-1 font-mono text-md text-ink">{{ $instance->address(\App\Support\ProvisioningSettings::dnsZone()) }}</p>
</div>
<div class="ml-auto">
@if ($instance->domainIsVerified())
{{-- Three states, not two. A domain can be proven and still
answer nothing, because pointing it at us is an A record
in the customer's own zone and this card was printing
that address as if it worked. --}}
@if ($instance->domainIsServed())
<x-ui.badge status="active">{{ __('domain.state.verified') }}</x-ui.badge>
@elseif ($instance->domainIsVerified())
<x-ui.badge status="warning">{{ __('domain.state.not_served') }}</x-ui.badge>
@elseif (filled($instance->custom_domain))
<x-ui.badge status="warning">{{ __('domain.state.pending') }}</x-ui.badge>
@else
@ -30,6 +36,9 @@
{{ __('domain.platform_address') }}: <span class="font-mono text-body">{{ $platformAddress }}</span>
</p>
@endif
@if ($instance->domainIsVerified() && ! $instance->domainIsServed())
<x-ui.alert variant="warning" class="mt-4">{{ __('domain.not_served_hint') }}</x-ui.alert>
@endif
</x-ui.card>
<x-ui.card :title="__('domain.field')" class="animate-rise [animation-delay:120ms]">

View File

@ -0,0 +1,39 @@
<x-mail.layout
:heading="__('reset_password.heading')"
:preheader="__('reset_password.preheader', ['minutes' => $minutes])"
:greeting="$name ? __('reset_password.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 40px 24px 40px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('reset_password.intro') }}</p>
</td></tr>
<tr><td style="padding:0 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
<a href="{{ $url }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('reset_password.action') }}</a>
</td></tr>
</table>
<p style="margin:14px 0 0 0;font-size:13px;line-height:20px;color:#6e6e7a;">{!! __('reset_password.expiry', ['minutes' => '<strong style="color:#43434e;font-weight:600;">'.$minutes.' Minuten</strong>']) !!}</p>
</td></tr>
{{-- The link as text as well, for the same reason as on the verification mail:
corporate mail gateways rewrite button links and some of them fetch the
target first to scan it, which on a single-use link spends it before the
recipient ever clicks. --}}
<tr><td style="padding:28px 40px 0 40px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
<tr><td style="padding:14px 16px;">
<p style="margin:0 0 6px 0;font-size:12px;line-height:16px;color:#6e6e7a;">{{ __('reset_password.fallback') }}</p>
<p style="margin:0;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:18px;color:#b8500a;word-break:break-all;">{{ $url }}</p>
</td></tr>
</table>
</td></tr>
{{-- The sentence that matters most on this particular mail. Somebody who did
NOT ask for it has just learned that a stranger knows their address, and
the right advice is "do nothing" not "contact us immediately", which is
what a phishing copy would say. --}}
<tr><td style="padding:24px 40px 0 40px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('reset_password.not_you') }}</p>
</td></tr>

View File

@ -183,7 +183,12 @@ $publicSite = function () {
// Reachable WITHOUT an account, deliberately: somebody who has just typed
// their password into a copy of our sign-in form is not signed in anywhere,
// and the page they need cannot be behind the thing they lost.
Route::get('/sicherheit', fn () => view('security'))->name('security');
Route::get('/security', fn () => view('security'))->name('security');
// The German path this shipped under for two releases. R13 says paths are
// English and this one was not — mine. Kept as a permanent redirect
// because it has already gone out in mail footers.
Route::get('/sicherheit', fn () => redirect()->route('security', status: 301));
Route::get('/robots.txt', function () {
$body = App\Support\Settings::bool('site.public', true)
@ -220,6 +225,13 @@ $portal = function () {
Route::post('/register', [\Laravel\Fortify\Http\Controllers\RegisteredUserController::class, 'store'])
->middleware('throttle:registration')
->name('register.store');
// Fortify registers the two POST endpoints; with views off it
// registers no GET routes at all, so the pages are ours under its
// names — every framework redirect and the reset mail resolve
// `password.request` and `password.reset`.
Route::get('/forgot-password', \App\Livewire\Auth\ForgotPassword::class)->name('password.request');
Route::get('/reset-password/{token}', \App\Livewire\Auth\ResetPassword::class)->name('password.reset');
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
});

View File

@ -0,0 +1,131 @@
<?php
use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\ResetPassword;
use App\Mail\ResetPasswordMail;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire;
/**
* Getting back in after forgetting the password.
*
* There was no way to. Fortify's resetPasswords feature was switched off, so
* there was no link on the sign-in form, no page and no route a customer who
* forgot their password was locked out of their own cloud until somebody
* opened a shell. On a product whose selling point is that you can ring
* somebody, that is a support call a week and an embarrassing one.
*/
it('offers the way back on the sign-in form', function () {
$this->get(route('login'))
->assertOk()
->assertSee(__('auth.forgot_link'))
->assertSee(route('password.request'), false);
});
it('sends a link in this products own design', function () {
Mail::fake();
$user = User::factory()->create(['email' => 'kunde@example.test']);
Livewire::test(ForgotPassword::class)
->set('email', $user->email)
->call('send')
->assertHasNoErrors();
// Not the framework's default MailMessage: somebody who has just been
// locked out is exactly the person a phishing mail is aimed at, and a
// message that looks nothing like the rest of our post is one they cannot
// check against the others.
Mail::assertQueued(ResetPasswordMail::class, fn ($mail) => $mail->hasTo($user->email));
});
it('answers the same whether the address is known or not', function () {
// Otherwise the form is a way of finding out who is a customer, which is
// the first thing anybody targeting a business does.
Mail::fake();
$known = Livewire::test(ForgotPassword::class)->set('email', User::factory()->create()->email)->call('send');
$unknown = Livewire::test(ForgotPassword::class)->set('email', 'niemand@example.test')->call('send');
$known->assertSet('sent', true)->assertHasNoErrors();
$unknown->assertSet('sent', true)->assertHasNoErrors();
Mail::assertQueuedCount(1);
});
it('sets the new password and kills every other session', function () {
$user = User::factory()->create(['password' => Hash::make('das-alte-passwort')]);
$before = $user->remember_token;
$token = Password::broker()->createToken($user);
Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasNoErrors()
->assertSet('done', true);
$user->refresh();
expect(Hash::check('ein-langes-neues-passwort', $user->password))->toBeTrue()
// Whoever knew the old password may still be signed in somewhere. A
// reset that leaves them there has fixed nothing.
->and($user->remember_token)->not->toBe($before);
});
it('does not sign the visitor in', function () {
// The link arrived by email. A mailbox somebody else can read would
// otherwise be a session somebody else gets.
$user = User::factory()->create();
$token = Password::broker()->createToken($user);
Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save');
expect(auth()->check())->toBeFalse();
});
it('refuses a token that is not this users', function () {
$mine = User::factory()->create();
$theirs = User::factory()->create();
Livewire::test(ResetPassword::class, ['token' => Password::broker()->createToken($theirs)])
->set('email', $mine->email)
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasErrors('email')
->assertSet('done', false);
});
it('spends the token, so a forwarded link cannot be used twice', function () {
$user = User::factory()->create();
$token = Password::broker()->createToken($user);
$reset = fn () => Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save');
$reset()->assertSet('done', true);
$reset()->assertSet('done', false)->assertHasErrors('email');
});
it('insists on a password long enough to be worth resetting to', function () {
$user = User::factory()->create();
Livewire::test(ResetPassword::class, ['token' => Password::broker()->createToken($user)])
->set('email', $user->email)
->set('password', 'kurz')
->set('password_confirmation', 'kurz')
->call('save')
->assertHasErrors('password');
});

View File

@ -0,0 +1,372 @@
<?php
use App\Actions\ReapplyInstanceAddress;
use App\Livewire\CustomDomain;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\User;
use App\Provisioning\Steps\Customer\ConfigureDnsAndTls;
use App\Provisioning\Steps\Customer\ConfigureNextcloud;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Domains\DomainVerifier;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* Serving the domain, as opposed to announcing it.
*
* A verified custom domain was reported as the customer's address by
* Instance::address(), by the portal and by the credentials mail, while nothing
* on the platform ever routed it: the Traefik router's rule was hard-coded to
* `{subdomain}.{zone}`, so the address the customer had just been given
* answered nothing at all. The other direction was worse a withdrawn domain
* stayed in the router and in Nextcloud's trusted_domains forever, because the
* only thing that ever wrote either was the initial provisioning run.
*
* These tests are about the two halves of that: an address that has to become
* real, and one that has to stop being real.
*/
/** An instance that is finished, running, and reachable at its platform address. */
function servedInstance(array $attributes = [], string $subdomain = 'berger'): array
{
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'business']);
$instance = Instance::factory()->create(array_merge([
'order_id' => $order->id,
'customer_id' => $order->customer_id,
'host_id' => $host->id,
'vmid' => 101,
'guest_ip' => '10.20.0.7',
'subdomain' => $subdomain,
'status' => 'active',
// The state a re-apply actually finds: built, routed, certified.
'route_written' => true,
'routed_hostnames' => [platformAddress($subdomain)],
'cert_ok' => true,
], $attributes));
return compact('host', 'order', 'instance');
}
/** A run of the `address` pipeline against that instance's order. */
function addressRun(array $fixture): ProvisioningRun
{
return ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $fixture['order']->id,
'pipeline' => 'address',
'context' => [
'instance_id' => $fixture['instance']->id,
'host_id' => $fixture['host']->id,
'node' => 'pve',
'vmid' => 101,
'subdomain' => $fixture['instance']->subdomain,
],
]);
}
/** No real DNS: a suite that needs a nameserver fails on a train. */
function bindDomainResolver(array $records): void
{
app()->bind(DomainVerifier::class, fn () => new DomainVerifier(
fn (string $name) => $records[$name] ?? [],
));
}
function platformAddress(string $subdomain = 'berger'): string
{
return $subdomain.'.'.ProvisioningSettings::dnsZone();
}
/** The portal account that owns the fixture's instance. */
function signIntoPortal(array $fixture): User
{
$user = User::factory()->create(['email_verified_at' => now()]);
// customers.user_id, not users.customer_id — the link runs the other way.
$fixture['order']->customer->update(['user_id' => $user->id, 'email' => $user->email]);
return $user;
}
it('puts a verified custom domain in the router beside the platform address, and an unverified one nowhere', function () {
$s = fakeServices();
// Unverified: the hostname is in the column, so everything that reads the
// column would announce it — but it is a request, not an address.
$unproven = servedInstance(['custom_domain' => 'cloud.fremde-firma.at', 'domain_token' => 'tok'], 'fremde');
expect(app(ConfigureDnsAndTls::class)->execute(addressRun($unproven))->type)->toBe('advance')
->and($s['traefik']->writes)->toBe(0) // nothing changed, so nothing was written
->and($unproven['instance']->fresh()->routed_hostnames)->toBe([platformAddress('fremde')]);
// Proven: one router, both names, the platform address first — it is what
// the instance falls back to the moment the domain goes away.
$proven = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => now(),
]);
expect(app(ConfigureDnsAndTls::class)->execute(addressRun($proven))->type)->toBe('advance')
->and($s['traefik']->hostnames['berger'])->toBe([platformAddress(), 'cloud.berger.at'])
->and($proven['instance']->fresh()->routed_hostnames)->toBe([platformAddress(), 'cloud.berger.at'])
->and($proven['instance']->fresh()->domain_cert_ok)->toBeTrue();
});
it('writes one router file for both names rather than a second one', function () {
// Two routers for one backend are two things to keep in step, and the
// second is the one nobody remembers on the day the domain is withdrawn.
$s = fakeServices();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
]);
app(ConfigureDnsAndTls::class)->execute(addressRun($fixture));
expect($s['traefik']->writes)->toBe(1)
->and(array_keys($s['traefik']->hostnames))->toBe(['berger']);
});
it('stops serving a withdrawn domain and takes it out of trusted_domains', function () {
$s = fakeServices();
// Verified yesterday, served ever since, and withdrawn this morning: the
// proof is gone but the router and Nextcloud have never been told.
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => null,
'domain_cert_ok' => true,
'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
]);
$run = addressRun($fixture);
expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('advance')
->and(app(ConfigureNextcloud::class)->execute($run)->type)->toBe('advance');
expect($s['traefik']->hostnames['berger'])->toBe([platformAddress()])
->and($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse()
->and($fixture['instance']->fresh()->routed_hostnames)->toBe([platformAddress()])
->and($fixture['instance']->fresh()->domain_cert_ok)->toBeFalse()
// Deleted, not blanked: an entry left in trusted_domains means Nextcloud
// still answers to that name whenever anything reaches it.
->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue()
->and($s['pve']->guestRan('config:system:set trusted_domains 2'))->toBeFalse();
});
it('trusts a verified domain and leaves the delete alone', function () {
$s = fakeServices();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
]);
app(ConfigureNextcloud::class)->execute(addressRun($fixture));
expect($s['pve']->guestRan("config:system:set trusted_domains 2 --value='cloud.berger.at'"))->toBeTrue()
->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeFalse();
});
it('never fails a run over the custom domains certificate, but still fails over the platforms', function () {
$s = fakeServices();
// The customer has proven the domain is theirs and has NOT pointed it at
// us — which is an A record in their own zone, may take days, and may never
// happen at all. Their cloud still has to be delivered.
$s['traefik']->certUnreachable = ['cloud.berger.at'];
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => now(),
'cert_ok' => false,
]);
$run = addressRun($fixture);
$run->update(['started_at' => now()->subSeconds(300)]); // past the short look
expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('advance');
$instance = $fixture['instance']->fresh();
expect($instance->cert_ok)->toBeTrue() // ours works
->and($instance->domain_verified_at)->not->toBeNull()
->and($instance->domain_cert_ok)->toBeFalse() // theirs does not, and is recorded as such
->and($instance->domainIsVerified())->toBeTrue()
->and($instance->domainIsServed())->toBeFalse()
// Visible to an operator reading the run, not swallowed.
->and($run->events()->where('outcome', 'info')->where('step', 'configure_dns_and_tls')->exists())->toBeTrue();
// The platform address is ours: no certificate there is a broken run.
$s2 = fakeServices();
$s2['traefik']->certReady = false;
$second = servedInstance(['cert_ok' => false], 'zweite');
$failing = addressRun($second);
$failing->update(['started_at' => now()->subSeconds(900)]);
$result = app(ConfigureDnsAndTls::class)->execute($failing);
expect($result->type)->toBe('fail')
->and($result->reason)->toBe('cert_timeout');
});
it('looks again for a short while before giving up on the custom certificate', function () {
// Traefik finishes HTTP-01 seconds after the first request to a new
// hostname, so advancing on the very first probe would mark a domain that
// is about to work as not working.
$s = fakeServices();
$s['traefik']->certUnreachable = ['cloud.berger.at'];
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
]);
$run = addressRun($fixture);
$run->update(['started_at' => now()->subSeconds(5)]);
expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('poll');
});
it('starts exactly one re-apply when verification flips, and none when nothing changed', function () {
Queue::fake();
$fixture = servedInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']);
bindDomainResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]);
// The flip: proof appears, so the domain has to become an address.
$this->artisan('clupilot:verify-domains')->assertSuccessful();
$runs = ProvisioningRun::query()->where('pipeline', 'address');
expect($runs->count())->toBe(1)
->and($fixture['instance']->fresh()->domainIsVerified())->toBeTrue();
// Finished, so nothing is in flight to suppress a second one — the only
// thing stopping it must be that nothing changed.
$runs->first()->update(['status' => ProvisioningRun::STATUS_COMPLETED]);
$this->artisan('clupilot:verify-domains')->assertSuccessful();
expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
// And the other flip: the proof is taken away for the third time running.
bindDomainResolver([]);
$this->artisan('clupilot:verify-domains');
$this->artisan('clupilot:verify-domains');
expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
$this->artisan('clupilot:verify-domains');
expect($fixture['instance']->fresh()->domainIsVerified())->toBeFalse()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(2);
});
it('stops serving the domain when the package stops allowing one', function () {
$s = fakeServices();
Queue::fake();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => now(),
'domain_cert_ok' => true,
'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
]);
// What PlanChange::settleCustomDomain() reaches through CustomDomainAccess
// when a downgrade lands on a package that carries no own domain.
expect(app(CustomDomainAccess::class)->deactivate($fixture['instance']))->toBeTrue();
$run = ProvisioningRun::query()->where('pipeline', 'address')->latest('id')->first();
expect($run)->not->toBeNull();
app(ConfigureDnsAndTls::class)->execute($run);
app(ConfigureNextcloud::class)->execute($run);
expect($fixture['instance']->fresh()->custom_domain)->toBeNull()
->and($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse()
->and($s['traefik']->hostnames['berger'])->toBe([platformAddress()])
->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue();
});
it('does not start a second run while one is already going', function () {
Queue::fake();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
]);
$reapply = app(ReapplyInstanceAddress::class);
expect($reapply($fixture['instance']))->not->toBeNull()
->and($reapply($fixture['instance']))->toBeNull()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
// Nor beside the build itself: the customer run applies the address on its
// way past, and two runs writing one router is the thing being avoided.
ProvisioningRun::query()->where('pipeline', 'address')->update(['status' => ProvisioningRun::STATUS_COMPLETED]);
ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $fixture['order']->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_RUNNING,
]);
expect($reapply($fixture['instance']))->toBeNull()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});
it('stops serving the old domain the moment the customer changes or clears it', function () {
Queue::fake();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => now(),
'domain_cert_ok' => true,
'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
]);
$user = signIntoPortal($fixture);
// Moving to another domain leaves the old one routed and trusted until
// something says otherwise — the new one is unproven and serves nothing.
Livewire::actingAs($user)->test(CustomDomain::class)
->set('domain', 'neu.berger.at')
->call('save')
->assertHasNoErrors();
expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1)
->and($fixture['instance']->fresh()->domain_cert_ok)->toBeFalse();
ProvisioningRun::query()->where('pipeline', 'address')->update(['status' => ProvisioningRun::STATUS_COMPLETED]);
// Clearing an unproven domain changes nothing that is being served, so it
// is not worth a run on a live machine.
Livewire::actingAs($user)->test(CustomDomain::class)
->set('domain', '')
->call('save');
expect($fixture['instance']->fresh()->custom_domain)->toBeNull()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});
it('makes the domain an address as soon as the customers own check finds the proof', function () {
Queue::fake();
$fixture = servedInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']);
$user = signIntoPortal($fixture);
bindDomainResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]);
Livewire::actingAs($user)->test(CustomDomain::class)->call('checkNow');
expect($fixture['instance']->fresh()->domainIsVerified())->toBeTrue()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});
it('has nothing to re-apply for an instance with no machine', function () {
Queue::fake();
// A reservation that never became a VM, and a build that failed: the steps
// would reach for a guest agent that is not there and spend the run's
// retries finding out.
$reserved = Instance::factory()->create(['status' => 'reserving', 'vmid' => null]);
expect(app(ReapplyInstanceAddress::class)($reserved))->toBeNull()
->and(app(ReapplyInstanceAddress::class)(null))->toBeNull()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0);
});

View File

@ -56,11 +56,16 @@ it('serves the page to somebody who is not signed in', function () {
// Somebody who has just given their password to a copy of our sign-in form
// is signed in nowhere. The page they need cannot be behind the thing they
// lost.
$this->get('/sicherheit')
// The path is English (R13). /sicherheit shipped for two releases and
// still redirects, which is asserted below.
$this->get('/security')
->assertOk()
->assertSee('clupilot.com')
->assertSee('clupilot.cloud')
->assertSee(__('security.compromised_title'));
// The German path went out in mail footers before the rule was applied.
$this->get('/sicherheit')->assertRedirect('/security');
});
it('carries the domains in every mail footer', function () {

View File

@ -106,9 +106,11 @@ it('keeps started_at stable across polls so step deadlines can accumulate', func
});
it('marks a Host subject as error when the run fails', function () {
bindPipeline(['test' => [FakeFailStep::class]]);
// Named 'host' rather than 'test': the failure hook belongs to the pipeline
// that BUILDS the subject, and the runner now asks which one that is.
bindPipeline(['host' => [FakeFailStep::class]]);
$host = Host::factory()->create(['status' => 'onboarding']);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'test']);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
app(RunRunner::class)->advance($run);
@ -116,6 +118,21 @@ it('marks a Host subject as error when the run fails', function () {
->and($host->fresh()->status)->toBe('error');
});
it('does not condemn the subject when a maintenance run fails', function () {
// A run that maintains something already built shares its subject. If its
// failure fired the same hook, a router file that could not be written
// would put a live, paid-for host into 'error' — and, on the customer side,
// mark the order failed and release the running instance with it.
bindPipeline(['address' => [FakeFailStep::class]]);
$host = Host::factory()->create(['status' => 'active']);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'address']);
app(RunRunner::class)->advance($run);
expect($run->fresh()->status)->toBe('failed')
->and($host->fresh()->status)->toBe('active');
});
it('treats a thrown exception as a retry', function () {
bindPipeline(['test' => [FakeThrowStep::class]]);
$run = ProvisioningRun::factory()->create(['pipeline' => 'test', 'max_attempts' => 5]);