Compare commits
2 Commits
085b110e7f
...
3a4324fb6f
| Author | SHA1 | Date |
|---|---|---|
|
|
3a4324fb6f | |
|
|
712803edd6 |
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 product’s 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 user’s', 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');
|
||||
});
|
||||
|
|
@ -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 domain’s certificate, but still fails over the platform’s', 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 customer’s 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);
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue