Answer on every name for the website, and send them all to one
SITE_HOST takes a comma-separated list now, the first name canonical. It serves the site; every other name in the list redirects there permanently, path and query intact. An apex domain and its www are two names for one thing, and answering on both without picking one splits search rankings and cookies between them. The redirect is a host-bound catch-all rather than middleware. Middleware that only some routes carry is the same half-measure this file already made once, and a catch-all registered without a host would swallow every path in the application — which is what the third test is there to stop. The test harness needed fixing too, and the fix is the finding. Router::dispatch on a standalone router does not rebind the container's request, so a route reading one — through the helper OR through an injected parameter, both of which resolve from the container — gets the test's request rather than the one being answered. The redirect silently lost its query string in the test while working in production. dispatchOn() binds it where the framework would. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
e6d2e6dc33
commit
77bd30ca56
|
|
@ -215,6 +215,9 @@ APP_HOST=
|
||||||
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
|
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
|
||||||
# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt
|
# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt
|
||||||
# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall,
|
# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall,
|
||||||
|
# Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus,
|
||||||
|
# alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten):
|
||||||
|
# SITE_HOST=www.clupilot.com,clupilot.com
|
||||||
# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite,
|
# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite,
|
||||||
# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum')
|
# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum')
|
||||||
# auch aus einer Mail heraus eine www-Adresse.
|
# auch aus einer Mail heraus eine www-Adresse.
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Support\ProvisioningSettings;
|
|
||||||
use App\Support\Settings;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deployment configuration that is not secret but still has to be settable —
|
|
||||||
* which DNS zone customer instances live under, where the WireGuard hub is
|
|
||||||
* reachable, where Traefik's dynamic config lives, the public half of the SSH
|
|
||||||
* identity, and the monitoring bridge's URL.
|
|
||||||
*
|
|
||||||
* Split from Admin\Secrets on purpose: these values are visible and editable
|
|
||||||
* without a password re-confirmation, because none of them opens anything by
|
|
||||||
* itself — the credentials that pair with some of them (the SSH private key,
|
|
||||||
* the monitoring bridge token) live in the vault instead, gated the way a
|
|
||||||
* live payment key is.
|
|
||||||
*
|
|
||||||
* Every field here is genuinely read from Settings by its consumer
|
|
||||||
* (App\Support\ProvisioningSettings) — see that class for the full list and
|
|
||||||
* for the two values (CLUPILOT_WG_SUBNET, CLUPILOT_WG_HUB_IP) deliberately
|
|
||||||
* left out because the compose file reads them too.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Infrastructure extends Component
|
|
||||||
{
|
|
||||||
public string $dnsZone = '';
|
|
||||||
|
|
||||||
public string $wgEndpoint = '';
|
|
||||||
|
|
||||||
public string $wgHubPubkey = '';
|
|
||||||
|
|
||||||
public string $traefikDynamicPath = '';
|
|
||||||
|
|
||||||
public string $sshPublicKey = '';
|
|
||||||
|
|
||||||
public string $monitoringUrl = '';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorize('hosts.manage');
|
|
||||||
|
|
||||||
$this->dnsZone = ProvisioningSettings::dnsZone();
|
|
||||||
$this->wgEndpoint = ProvisioningSettings::wgEndpoint();
|
|
||||||
$this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey();
|
|
||||||
$this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath();
|
|
||||||
$this->sshPublicKey = ProvisioningSettings::sshPublicKey();
|
|
||||||
$this->monitoringUrl = ProvisioningSettings::monitoringUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(): void
|
|
||||||
{
|
|
||||||
$this->authorize('hosts.manage');
|
|
||||||
|
|
||||||
$data = $this->validate([
|
|
||||||
'dnsZone' => ['nullable', 'string', 'max:255'],
|
|
||||||
'wgEndpoint' => ['nullable', 'string', 'max:255'],
|
|
||||||
'wgHubPubkey' => ['nullable', 'string', 'max:255'],
|
|
||||||
'traefikDynamicPath' => ['nullable', 'string', 'max:255'],
|
|
||||||
'sshPublicKey' => ['nullable', 'string', 'max:1000'],
|
|
||||||
'monitoringUrl' => ['nullable', 'url', 'max:255'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone']));
|
|
||||||
Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint']));
|
|
||||||
Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey']));
|
|
||||||
Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath']));
|
|
||||||
Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey']));
|
|
||||||
Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl']));
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('infrastructure.saved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.admin.infrastructure');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Livewire\Concerns\ConfirmsPassword;
|
|
||||||
use App\Services\Secrets\SecretVault;
|
|
||||||
use App\Services\Stripe\StripeCheck;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Credentials, changeable from the console instead of over SSH.
|
|
||||||
*
|
|
||||||
* Two gates, not one. The capability decides who may open the page at all; the
|
|
||||||
* password decides whether this SESSION may see or change anything. The second
|
|
||||||
* exists because the realistic threat is not a stranger — it is an unlocked
|
|
||||||
* machine, and a signed-in session is exactly what that gives away.
|
|
||||||
*
|
|
||||||
* Both are enforced on every action, server-side. A Livewire action is
|
|
||||||
* reachable by anyone who can post to /livewire/update, and the buttons not
|
|
||||||
* being on screen has never stopped anybody.
|
|
||||||
*
|
|
||||||
* The value being entered lives in a public property only while it is being
|
|
||||||
* typed, and is cleared the moment it is stored — a Livewire property travels
|
|
||||||
* to the browser and back in the component snapshot.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Secrets extends Component
|
|
||||||
{
|
|
||||||
use ConfirmsPassword;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The new value being entered, keyed by a DOTLESS form key.
|
|
||||||
*
|
|
||||||
* Livewire reads a dot in a property path as nesting, so binding to
|
|
||||||
* `entered.stripe.secret` writes `entered['stripe']['secret']` and the
|
|
||||||
* value never arrives where the save looks for it. The registry keys keep
|
|
||||||
* their dots; the form does not.
|
|
||||||
*/
|
|
||||||
public array $entered = [];
|
|
||||||
|
|
||||||
/** Result of the last connection test, for display only. */
|
|
||||||
public ?array $check = null;
|
|
||||||
|
|
||||||
protected function confirmationGuard(): string
|
|
||||||
{
|
|
||||||
return 'operator';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorize('secrets.manage');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(string $key): void
|
|
||||||
{
|
|
||||||
$this->guard();
|
|
||||||
|
|
||||||
$field = self::field($key);
|
|
||||||
$value = trim((string) ($this->entered[$field] ?? ''));
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
$this->addError('entered.'.$field, __('secrets.empty'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user());
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->addError('entered.'.$field, $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Out of the component state as soon as it is stored.
|
|
||||||
$this->entered[$field] = '';
|
|
||||||
$this->check = null;
|
|
||||||
$this->dispatch('notify', message: __('secrets.saved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The save button opens ConfirmSaveSecret instead of calling save()
|
|
||||||
* directly (R23) — that modal cannot see `entered` itself, so its confirm
|
|
||||||
* button dispatches back here rather than mutating anything on its own.
|
|
||||||
*/
|
|
||||||
#[On('secret-save-confirmed')]
|
|
||||||
public function onSaveConfirmed(string $key): void
|
|
||||||
{
|
|
||||||
$this->save($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forget(string $key): void
|
|
||||||
{
|
|
||||||
$this->guard();
|
|
||||||
|
|
||||||
app(SecretVault::class)->forget($key);
|
|
||||||
$this->check = null;
|
|
||||||
$this->dispatch('notify', message: __('secrets.removed'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** See onSaveConfirmed() — same reasoning, for ConfirmForgetSecret. */
|
|
||||||
#[On('secret-forget-confirmed')]
|
|
||||||
public function onForgetConfirmed(string $key): void
|
|
||||||
{
|
|
||||||
$this->forget($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try the key that is in force — or the one being typed, before storing it.
|
|
||||||
*
|
|
||||||
* Checking the candidate first is the point: a key that is saved and wrong
|
|
||||||
* fails later, somewhere else, usually in front of a customer.
|
|
||||||
*/
|
|
||||||
public function test(string $key): void
|
|
||||||
{
|
|
||||||
$this->guard();
|
|
||||||
|
|
||||||
// The checker named by THIS entry, not a fixed one. When the area held a
|
|
||||||
// single Stripe key a hard-coded StripeCheck was the same thing; with
|
|
||||||
// four entries it would have reported on Stripe while the operator was
|
|
||||||
// looking at the DNS token.
|
|
||||||
$checker = SecretVault::REGISTRY[$key]['check'] ?? null;
|
|
||||||
abort_if($checker === null, 404);
|
|
||||||
|
|
||||||
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
|
|
||||||
$this->check = app($checker)->run($candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The dotless form key for a registry key. */
|
|
||||||
public static function field(string $key): string
|
|
||||||
{
|
|
||||||
return str_replace('.', '_', $key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Capability AND a recent password, on every single action. */
|
|
||||||
private function guard(): void
|
|
||||||
{
|
|
||||||
$this->authorize('secrets.manage');
|
|
||||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$vault = app(SecretVault::class);
|
|
||||||
$unlocked = $this->passwordRecentlyConfirmed();
|
|
||||||
|
|
||||||
return view('livewire.admin.secrets', [
|
|
||||||
'unlocked' => $unlocked,
|
|
||||||
'usable' => $vault->isUsable(),
|
|
||||||
'entries' => collect(SecretVault::REGISTRY)
|
|
||||||
->map(fn (array $meta, string $key) => [
|
|
||||||
'key' => $key,
|
|
||||||
'field' => self::field($key),
|
|
||||||
'label' => __($meta['label']),
|
|
||||||
// Only where a checker exists. A test button that cannot
|
|
||||||
// actually test is a promise the page does not keep.
|
|
||||||
'testable' => isset($meta['check']),
|
|
||||||
'source' => $vault->source($key),
|
|
||||||
// Only ever an outline, and only once unlocked.
|
|
||||||
'outline' => $unlocked ? $vault->outline($key) : null,
|
|
||||||
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
|
|
||||||
// The SSH identity is a multi-line PEM key: a single-line
|
|
||||||
// password field would mangle it on paste. Everything else
|
|
||||||
// here is a one-line token.
|
|
||||||
'multiline' => $key === 'ssh.private_key',
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -62,24 +62,32 @@ return [
|
||||||
'app_host' => strtolower(trim((string) env('APP_HOST', ''))),
|
'app_host' => strtolower(trim((string) env('APP_HOST', ''))),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
| Hostname the public website lives on, e.g. www.clupilot.com.
|
| Hostnames the public website lives on, e.g. www.clupilot.com,clupilot.com.
|
||||||
|
|
|
||||||
|
| Comma-separated, and the FIRST is canonical: it serves the site, and every
|
||||||
|
| other name in the list redirects to it permanently, path and all. An apex
|
||||||
|
| domain and its www are two names for one site, and answering on both
|
||||||
|
| without picking one splits the search rankings and the cookies between
|
||||||
|
| them.
|
||||||
|
|
|
|
||||||
| Empty means the landing page answers on every host, which is what it did
|
| Empty means the landing page answers on every host, which is what it did
|
||||||
| — including on the portal's own hostname. Somebody who typed
|
| — including on the portal's own hostname. Somebody who typed
|
||||||
| app.clupilot.com got the shop window: marketing copy, a pricing table and
|
| app.clupilot.com got the shop window: marketing copy, a pricing table and
|
||||||
| a "sign up" call to action, at the address where their servers are.
|
| a "sign up" call to action, at the address where their servers are.
|
||||||
|
|
|
|
||||||
| Set, the website — landing page, robots.txt and the legal pages — is bound
|
| Set, the website — landing page, robots.txt and the legal pages — exists
|
||||||
| to this host and exists nowhere else. Every other host answers "/" with
|
| on these names and nowhere else. Every other host answers "/" with the
|
||||||
| the product instead: the dashboard for somebody signed in, the sign-in
|
| product instead. The console keeps its own "/": its routes are registered
|
||||||
| page for everybody else. The console keeps its own "/" — its routes are
|
| first and win.
|
||||||
| registered first and win.
|
|
||||||
|
|
|
|
||||||
| Binding the legal pages is also what makes route('legal.impressum')
|
| Binding the legal pages is also what makes route('legal.impressum')
|
||||||
| produce a www URL from inside a queued mail, where there is no request to
|
| produce a canonical URL from inside a queued mail, where there is no
|
||||||
| take a hostname from.
|
| request to take a hostname from.
|
||||||
*/
|
*/
|
||||||
'site_host' => strtolower(trim((string) env('SITE_HOST', ''))),
|
'site_hosts' => array_values(array_filter(array_map(
|
||||||
|
fn ($host) => strtolower(trim($host)),
|
||||||
|
explode(',', (string) env('SITE_HOST', ''))
|
||||||
|
))),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
| Key for VPN configs stored at rest (32 bytes, base64). Separate from
|
| Key for VPN configs stored at rest (32 bytes, base64). Separate from
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => 'Infrastruktur',
|
|
||||||
'subtitle' => 'Bereitstellungs-Einstellungen — sichtbar und hier änderbar, ohne Zugriff auf den Server. Die zugehörigen Zugangsdaten liegen unter Zugangsdaten.',
|
|
||||||
|
|
||||||
'dns_title' => 'DNS & Traefik',
|
|
||||||
'dns_body' => 'Die Zone, unter der Kundeninstanzen und Hosts erreichbar sind, und wo Traefik seine dynamische Konfiguration erwartet.',
|
|
||||||
'dns_zone' => 'DNS-Zone',
|
|
||||||
'dns_zone_hint' => 'Zum Beispiel clupilot.com — Kundeninstanzen erhalten <subdomain>.<Zone>.',
|
|
||||||
'traefik_path' => 'Traefik: Pfad für dynamische Konfiguration',
|
|
||||||
'traefik_path_hint' => 'Verzeichnis auf dem Traffic-Host, in das Routen geschrieben werden.',
|
|
||||||
|
|
||||||
'wg_title' => 'WireGuard-Hub',
|
|
||||||
'wg_body' => 'Wie ein neuer Host CluPilot als VPN-Hub erreicht. Der private Hub-Schlüssel bleibt in der Serverdatei — nur der öffentliche Teil und die Adresse stehen hier.',
|
|
||||||
'wg_endpoint' => 'Hub-Endpunkt',
|
|
||||||
'wg_endpoint_hint' => 'Öffentlich erreichbare Adresse und Port, z. B. vpn.clupilot.com:51820.',
|
|
||||||
'wg_hub_pubkey' => 'Hub-Public-Key',
|
|
||||||
'wg_hub_pubkey_hint' => 'Ausgabe von wg pubkey < /etc/wireguard/privatekey auf diesem Server.',
|
|
||||||
|
|
||||||
'ssh_title' => 'SSH-Identität',
|
|
||||||
'ssh_body' => 'Der öffentliche Teil des Schlüssels, der auf jedem neuen Host hinterlegt wird. Der private Teil ist geheim und liegt unter Zugangsdaten.',
|
|
||||||
'ssh_public_key' => 'SSH-Schlüssel (öffentlich)',
|
|
||||||
'ssh_public_key_hint' => 'Wird bei der Host-Aufnahme in authorized_keys eingetragen.',
|
|
||||||
|
|
||||||
'monitoring_title' => 'Monitoring',
|
|
||||||
'monitoring_body' => 'Wo die Kuma-Bridge erreichbar ist. Kuma selbst (KUMA_URL/KUMA_USERNAME/KUMA_PASSWORD/KUMA_TOTP) bleibt in der Serverdatei — das ist die Bridge, ein eigener Container, der sie beim Start liest, nicht diese Anwendung.',
|
|
||||||
'monitoring_url' => 'Monitoring-Bridge-URL',
|
|
||||||
'monitoring_url_hint' => 'Zum Beispiel http://kuma-bridge:8080. Leer lässt Monitoring aus.',
|
|
||||||
|
|
||||||
'save' => 'Speichern',
|
|
||||||
'saved' => 'Gespeichert. Der neue Wert gilt ab sofort.',
|
|
||||||
];
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => 'Infrastructure',
|
|
||||||
'subtitle' => 'Deployment settings — visible and changeable here, without server access. The matching credentials live under Credentials.',
|
|
||||||
|
|
||||||
'dns_title' => 'DNS & Traefik',
|
|
||||||
'dns_body' => 'The zone customer instances and hosts live under, and where Traefik expects its dynamic configuration.',
|
|
||||||
'dns_zone' => 'DNS zone',
|
|
||||||
'dns_zone_hint' => 'For example clupilot.com — customer instances get <subdomain>.<zone>.',
|
|
||||||
'traefik_path' => 'Traefik dynamic config path',
|
|
||||||
'traefik_path_hint' => 'Directory on the traffic host that routes get written into.',
|
|
||||||
|
|
||||||
'wg_title' => 'WireGuard hub',
|
|
||||||
'wg_body' => 'How a new host reaches CluPilot as the VPN hub. The private hub key stays in the server file — only the public half and the address live here.',
|
|
||||||
'wg_endpoint' => 'Hub endpoint',
|
|
||||||
'wg_endpoint_hint' => 'Publicly reachable address and port, e.g. vpn.clupilot.com:51820.',
|
|
||||||
'wg_hub_pubkey' => 'Hub public key',
|
|
||||||
'wg_hub_pubkey_hint' => 'Output of wg pubkey < /etc/wireguard/privatekey on this server.',
|
|
||||||
|
|
||||||
'ssh_title' => 'SSH identity',
|
|
||||||
'ssh_body' => 'The public half of the key deployed to every fresh host. The private half is secret and lives under Credentials.',
|
|
||||||
'ssh_public_key' => 'SSH key (public)',
|
|
||||||
'ssh_public_key_hint' => 'Written into authorized_keys during host onboarding.',
|
|
||||||
|
|
||||||
'monitoring_title' => 'Monitoring',
|
|
||||||
'monitoring_body' => 'Where the Kuma bridge is reachable. Kuma itself (KUMA_URL/KUMA_USERNAME/KUMA_PASSWORD/KUMA_TOTP) stays in the server file — that is the bridge, a separate container that reads them at startup, not this application.',
|
|
||||||
'monitoring_url' => 'Monitoring bridge URL',
|
|
||||||
'monitoring_url_hint' => 'For example http://kuma-bridge:8080. Leave blank to leave monitoring off.',
|
|
||||||
|
|
||||||
'save' => 'Save',
|
|
||||||
'saved' => 'Saved. The new value applies immediately.',
|
|
||||||
];
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
<div class="mx-auto max-w-3xl space-y-6">
|
|
||||||
<div class="animate-rise">
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('infrastructure.title') }}</h1>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.subtitle') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- One page, one save: every field here is a plain deployment setting —
|
|
||||||
no password re-confirmation, no per-field confirm modal (R23 governs
|
|
||||||
destructive actions; saving a URL is not one), unlike Zugangsdaten. --}}
|
|
||||||
<form wire:submit="save" class="space-y-6">
|
|
||||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('infrastructure.dns_title') }}</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.dns_body') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<x-ui.input name="dnsZone" wire:model="dnsZone" :label="__('infrastructure.dns_zone')" :hint="__('infrastructure.dns_zone_hint')" />
|
|
||||||
<x-ui.input name="traefikDynamicPath" wire:model="traefikDynamicPath" :label="__('infrastructure.traefik_path')" :hint="__('infrastructure.traefik_path_hint')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('infrastructure.wg_title') }}</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.wg_body') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<x-ui.input name="wgEndpoint" wire:model="wgEndpoint" :label="__('infrastructure.wg_endpoint')" :hint="__('infrastructure.wg_endpoint_hint')" placeholder="vpn.clupilot.com:51820" />
|
|
||||||
<x-ui.input name="wgHubPubkey" wire:model="wgHubPubkey" :label="__('infrastructure.wg_hub_pubkey')" :hint="__('infrastructure.wg_hub_pubkey_hint')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('infrastructure.ssh_title') }}</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.ssh_body') }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="sshPublicKey" class="block text-sm font-medium text-body">{{ __('infrastructure.ssh_public_key') }}</label>
|
|
||||||
<textarea id="sshPublicKey" name="sshPublicKey" rows="3" autocomplete="off" spellcheck="false"
|
|
||||||
wire:model="sshPublicKey"
|
|
||||||
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"
|
|
||||||
placeholder="ssh-ed25519 AAAA... clupilot"></textarea>
|
|
||||||
<p class="mt-1.5 text-xs text-muted">{{ __('infrastructure.ssh_public_key_hint') }}</p>
|
|
||||||
@error('sshPublicKey')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('infrastructure.monitoring_title') }}</h2>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('infrastructure.monitoring_body') }}</p>
|
|
||||||
</div>
|
|
||||||
<x-ui.input name="monitoringUrl" type="url" wire:model="monitoringUrl" :label="__('infrastructure.monitoring_url')" :hint="__('infrastructure.monitoring_url_hint')" placeholder="http://kuma-bridge:8080" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save">
|
|
||||||
{{ __('infrastructure.save') }}
|
|
||||||
</x-ui.button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
<div class="mx-auto max-w-3xl space-y-6">
|
|
||||||
<div class="animate-rise">
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('secrets.title') }}</h1>
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('secrets.subtitle') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (! $usable)
|
|
||||||
<x-ui.alert variant="warning">{{ __('secrets.no_key') }}</x-ui.alert>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! $unlocked)
|
|
||||||
{{-- The second gate. Being signed in is not enough to read a payment
|
|
||||||
key: the realistic threat is an unlocked machine, and a session is
|
|
||||||
exactly what that hands over. --}}
|
|
||||||
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('secrets.locked_title') }}</h2>
|
|
||||||
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('secrets.locked_body') }}</p>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap items-start gap-2">
|
|
||||||
<div class="min-w-56 flex-1">
|
|
||||||
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
|
|
||||||
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
|
|
||||||
</div>
|
|
||||||
<x-ui.button type="submit" variant="primary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
|
|
||||||
{{ __('secrets.unlock') }}
|
|
||||||
</x-ui.button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@else
|
|
||||||
<div class="flex items-center justify-between rounded-lg border border-accent-border bg-accent-subtle px-4 py-2.5 animate-rise">
|
|
||||||
<p class="text-sm text-accent-text">{{ __('secrets.unlocked_note') }}</p>
|
|
||||||
<button type="button" wire:click="forgetPasswordConfirmation"
|
|
||||||
class="text-xs font-semibold text-accent-text hover:underline">{{ __('secrets.lock_again') }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@foreach ($entries as $entry)
|
|
||||||
<div wire:key="secret-{{ $entry['key'] }}" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-ink">{{ $entry['label'] }}</h2>
|
|
||||||
<p class="mt-1 font-mono text-xs text-muted">{{ $entry['key'] }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
|
||||||
{{ $entry['source'] === 'stored' ? 'border-success-border bg-success-bg text-success'
|
|
||||||
: ($entry['source'] === 'environment' ? 'border-info-border bg-info-bg text-info'
|
|
||||||
: 'border-warning-border bg-warning-bg text-warning') }}">
|
|
||||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
|
||||||
{{ __('secrets.source_'.$entry['source']) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($entry['outline'])
|
|
||||||
<dl class="rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm">
|
|
||||||
<div class="flex justify-between gap-4">
|
|
||||||
<dt class="text-muted">{{ __('secrets.stored_value') }}</dt>
|
|
||||||
<dd class="font-mono text-body">{{ $entry['outline'] }}</dd>
|
|
||||||
</div>
|
|
||||||
@if ($entry['updated_at'])
|
|
||||||
<div class="mt-1 flex justify-between gap-4">
|
|
||||||
<dt class="text-muted">{{ __('secrets.changed') }}</dt>
|
|
||||||
<dd class="text-body">{{ \Illuminate\Support\Carbon::parse($entry['updated_at'])->diffForHumans() }}</dd>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</dl>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div>
|
|
||||||
@if ($entry['multiline'])
|
|
||||||
{{-- A multi-line PEM key: a single-line password field
|
|
||||||
would mangle it on paste. Not masked — a textarea
|
|
||||||
cannot mask its content in any browser, and every
|
|
||||||
other tool that accepts an SSH key (GitHub
|
|
||||||
included) shows it in the clear while typing for
|
|
||||||
the same reason. --}}
|
|
||||||
<label for="entered-{{ $entry['field'] }}" class="block text-sm font-medium text-body">{{ __('secrets.new_value') }}</label>
|
|
||||||
<textarea id="entered-{{ $entry['field'] }}" name="entered.{{ $entry['field'] }}" rows="6" autocomplete="off" spellcheck="false"
|
|
||||||
wire:model="entered.{{ $entry['field'] }}"
|
|
||||||
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 font-mono text-xs text-ink placeholder:text-faint transition"
|
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
|
||||||
<p class="mt-1.5 text-xs text-muted">{{ __('secrets.ssh_private_key_hint') }}</p>
|
|
||||||
@error('entered.'.$entry['field'])<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
|
||||||
@else
|
|
||||||
<x-ui.input name="entered.{{ $entry['field'] }}" type="password" autocomplete="off"
|
|
||||||
:label="__('secrets.new_value')" :hint="__('secrets.new_value_hint')"
|
|
||||||
wire:model="entered.{{ $entry['field'] }}" />
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{{-- Test first: a key that is saved and wrong fails later,
|
|
||||||
somewhere else, usually in front of a customer. Only
|
|
||||||
where this entry actually has a checker. --}}
|
|
||||||
@if ($entry['testable'])
|
|
||||||
<x-ui.button variant="secondary" wire:click="test('{{ $entry['key'] }}')"
|
|
||||||
wire:loading.attr="disabled" wire:target="test('{{ $entry['key'] }}')">
|
|
||||||
{{ __('secrets.test') }}
|
|
||||||
</x-ui.button>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-ui.button variant="primary"
|
|
||||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-save-secret', arguments: { key: '{{ $entry['key'] }}' } })">
|
|
||||||
{{ __('secrets.save') }}
|
|
||||||
</x-ui.button>
|
|
||||||
|
|
||||||
@if ($entry['source'] === 'stored')
|
|
||||||
<x-ui.button variant="ghost"
|
|
||||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-forget-secret', arguments: { key: '{{ $entry['key'] }}' } })">
|
|
||||||
{{ __('secrets.forget') }}
|
|
||||||
</x-ui.button>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
|
|
||||||
@if ($check !== null)
|
|
||||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
|
||||||
<h2 class="font-semibold text-ink">{{ __('secrets.check_title') }}</h2>
|
|
||||||
|
|
||||||
@if (! $check['ok'])
|
|
||||||
<x-ui.alert variant="danger" class="mt-3">{{ __('secrets.check_'.$check['reason']) }}</x-ui.alert>
|
|
||||||
@else
|
|
||||||
<dl class="mt-3 space-y-1 text-sm">
|
|
||||||
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_account') }}:</dt>
|
|
||||||
<dd class="font-mono text-body">{{ $check['account'] }}{{ $check['business'] ? ' · '.$check['business'] : '' }}</dd></div>
|
|
||||||
<div class="flex gap-2"><dt class="text-muted">{{ __('secrets.check_mode') }}:</dt>
|
|
||||||
<dd class="{{ $check['live'] ? 'font-medium text-warning' : 'text-body' }}">
|
|
||||||
{{ $check['live'] ? __('secrets.mode_live') : __('secrets.mode_test') }}{{ $check['restricted'] ? ' · '.__('secrets.mode_restricted') : '' }}
|
|
||||||
</dd></div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{{-- The part nobody can see from outside: a key can be
|
|
||||||
perfectly valid while the endpoint listens for the
|
|
||||||
wrong events, and nothing fails until a payment goes
|
|
||||||
unrecorded. --}}
|
|
||||||
<h3 class="mt-5 text-sm font-semibold text-ink">{{ __('secrets.check_webhooks') }}</h3>
|
|
||||||
@if ($check['webhooks'] === null)
|
|
||||||
<p class="mt-1 text-sm text-muted">{{ __('secrets.check_webhooks_unknown') }}</p>
|
|
||||||
@elseif ($check['webhooks'] === [])
|
|
||||||
<x-ui.alert variant="warning" class="mt-2">{{ __('secrets.check_webhooks_none') }}</x-ui.alert>
|
|
||||||
@else
|
|
||||||
<ul class="mt-2 space-y-3">
|
|
||||||
@foreach ($check['webhooks'] as $hook)
|
|
||||||
<li class="rounded-lg border border-line bg-surface-2 px-4 py-3">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<span class="font-mono text-xs text-body">{{ $hook['url'] }}</span>
|
|
||||||
<span class="text-xs {{ $hook['status'] === 'enabled' ? 'text-success' : 'text-warning' }}">{{ $hook['status'] }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
|
||||||
@foreach ($hook['events'] as $event)
|
|
||||||
<span class="rounded border border-line px-1.5 py-0.5 font-mono text-[11px] text-muted">{{ $event }}</span>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-ui.alert variant="info">{{ __('secrets.webhook_secret_note') }}</x-ui.alert>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
@ -170,7 +170,9 @@ if ($statusHost !== '') {
|
||||||
| was given), /up, and Livewire's own /livewire/update, which both the console
|
| was given), /up, and Livewire's own /livewire/update, which both the console
|
||||||
| and the portal post component actions to.
|
| and the portal post component actions to.
|
||||||
*/
|
*/
|
||||||
$siteHost = (string) config('admin_access.site_host');
|
$siteHosts = (array) config('admin_access.site_hosts', []);
|
||||||
|
$siteHost = $siteHosts[0] ?? ''; // canonical: the one that serves
|
||||||
|
$siteAliases = array_slice($siteHosts, 1); // every other name redirects to it
|
||||||
$appHost = (string) config('admin_access.app_host');
|
$appHost = (string) config('admin_access.app_host');
|
||||||
|
|
||||||
$publicSite = function () {
|
$publicSite = function () {
|
||||||
|
|
@ -263,6 +265,31 @@ if ($appHost !== '') {
|
||||||
if ($siteHost !== '') {
|
if ($siteHost !== '') {
|
||||||
Route::domain($siteHost)->group($publicSite);
|
Route::domain($siteHost)->group($publicSite);
|
||||||
|
|
||||||
|
// Every other name for the same site — an apex beside its www — answers
|
||||||
|
// with a permanent redirect to the canonical one, path and query intact.
|
||||||
|
// Serving both without picking one splits search rankings and cookies
|
||||||
|
// between two names for one thing.
|
||||||
|
//
|
||||||
|
// Registered as a catch-all on those hosts alone, so it cannot swallow a
|
||||||
|
// path on any other host. It is deliberately not a middleware: a redirect
|
||||||
|
// that only some routes get is the half-measure this file already made
|
||||||
|
// once.
|
||||||
|
foreach ($siteAliases as $index => $alias) {
|
||||||
|
Route::domain($alias)
|
||||||
|
// The injected request, not the request() helper: this closure also
|
||||||
|
// runs in tests that dispatch through a router of their own, where
|
||||||
|
// the helper resolves whatever happens to be bound in the container
|
||||||
|
// rather than the request being answered.
|
||||||
|
->get('/{path?}', function (Illuminate\Http\Request $request, ?string $path = null) use ($siteHost) {
|
||||||
|
$target = 'https://'.$siteHost.'/'.ltrim((string) $path, '/');
|
||||||
|
$query = $request->getQueryString();
|
||||||
|
|
||||||
|
return redirect()->away($query ? $target.'?'.$query : $target, 301);
|
||||||
|
})
|
||||||
|
->where('path', '.*')
|
||||||
|
->name("site.alias{$index}");
|
||||||
|
}
|
||||||
|
|
||||||
// "/" on a host that is neither: the portal's own hostname, or a bare IP.
|
// "/" on a host that is neither: the portal's own hostname, or a bare IP.
|
||||||
// Never a 404 — "/" is what somebody types from memory, and turning that
|
// Never a 404 — "/" is what somebody types from memory, and turning that
|
||||||
// into an error page to make a point about hostnames helps nobody.
|
// into an error page to make a point about hostnames helps nobody.
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Livewire\Admin\Infrastructure;
|
|
||||||
use App\Support\ProvisioningSettings;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deployment configuration that is not secret but still has to be settable —
|
|
||||||
* see App\Support\ProvisioningSettings for the full list of what is wired
|
|
||||||
* here and what is deliberately left in .env instead.
|
|
||||||
*/
|
|
||||||
it('is not reachable without hosts.manage', function () {
|
|
||||||
Livewire::actingAs(operator('Support'), 'operator')
|
|
||||||
->test(Infrastructure::class)
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads the .env-derived value when nothing has been saved from the console yet', function () {
|
|
||||||
config()->set('provisioning.dns.zone', 'env-zone.example');
|
|
||||||
|
|
||||||
Livewire::actingAs(operator('Owner'), 'operator')
|
|
||||||
->test(Infrastructure::class)
|
|
||||||
->assertSet('dnsZone', 'env-zone.example');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('saves every field, and the consumer reads the saved value back', function () {
|
|
||||||
Livewire::actingAs(operator('Owner'), 'operator')
|
|
||||||
->test(Infrastructure::class)
|
|
||||||
->set('dnsZone', 'console-zone.example')
|
|
||||||
->set('wgEndpoint', 'vpn.example:51820')
|
|
||||||
->set('wgHubPubkey', 'hubpubkey==')
|
|
||||||
->set('traefikDynamicPath', '/etc/traefik/dynamic')
|
|
||||||
->set('sshPublicKey', 'ssh-ed25519 AAAAC3 clupilot')
|
|
||||||
->set('monitoringUrl', 'http://kuma-bridge:8080')
|
|
||||||
->call('save')
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
// Not just that Settings holds the value — that the class every real
|
|
||||||
// consumer (HttpHetznerDnsClient, LocalWireguardHub, SshTraefikWriter,
|
|
||||||
// EstablishSshTrust, HttpMonitoringClient) actually calls returns it.
|
|
||||||
expect(ProvisioningSettings::dnsZone())->toBe('console-zone.example')
|
|
||||||
->and(ProvisioningSettings::wgEndpoint())->toBe('vpn.example:51820')
|
|
||||||
->and(ProvisioningSettings::wgHubPublicKey())->toBe('hubpubkey==')
|
|
||||||
->and(ProvisioningSettings::traefikDynamicPath())->toBe('/etc/traefik/dynamic')
|
|
||||||
->and(ProvisioningSettings::sshPublicKey())->toBe('ssh-ed25519 AAAAC3 clupilot')
|
|
||||||
->and(ProvisioningSettings::monitoringUrl())->toBe('http://kuma-bridge:8080');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a monitoring URL that is not a URL', function () {
|
|
||||||
Livewire::actingAs(operator('Owner'), 'operator')
|
|
||||||
->test(Infrastructure::class)
|
|
||||||
->set('monitoringUrl', 'not a url')
|
|
||||||
->call('save')
|
|
||||||
->assertHasErrors('monitoringUrl');
|
|
||||||
});
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Livewire\Admin\ConfirmForgetSecret;
|
|
||||||
use App\Livewire\Admin\ConfirmSaveSecret;
|
|
||||||
use App\Livewire\Admin\Secrets;
|
|
||||||
use App\Livewire\Admin\Secrets as SecretsPage;
|
|
||||||
use App\Services\Secrets\SecretVault;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The credentials page has two gates, and the second one is the point.
|
|
||||||
*
|
|
||||||
* The capability decides who may open it. The password decides whether THIS
|
|
||||||
* session may see or change anything — because the realistic threat is not a
|
|
||||||
* stranger but an unlocked machine, and a session is exactly what that gives
|
|
||||||
* away.
|
|
||||||
*/
|
|
||||||
beforeEach(function () {
|
|
||||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the no-key banner for a malformed SECRETS_KEY, not only a missing one', function () {
|
|
||||||
// Codex R15#3, P2: render() feeds 'usable' => $vault->isUsable() to the
|
|
||||||
// view, and the banner is the one place an operator actually sees that
|
|
||||||
// verdict. Before the fix, a malformed (nonempty, wrong-length) key made
|
|
||||||
// isUsable() answer true, so this banner stayed hidden — the page said
|
|
||||||
// credentials could be stored here when they could not. Asserted before
|
|
||||||
// the lock gate specifically because @if (! $usable) sits above @if (!
|
|
||||||
// $unlocked) in the view; a signed-in-but-locked session must still see
|
|
||||||
// it.
|
|
||||||
config()->set('admin_access.secrets_key', 'zu-kurz');
|
|
||||||
|
|
||||||
Livewire::actingAs(operator('Owner'), 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->assertSee(__('secrets.no_key'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is not reachable without the capability', function () {
|
|
||||||
// Every operator has console.view. That must not mean "can read the
|
|
||||||
// payment key".
|
|
||||||
Livewire::actingAs(operator('Admin'), 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows nothing until the password is confirmed', function () {
|
|
||||||
$owner = operator('Owner');
|
|
||||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_ABCDEFGH9999', $owner);
|
|
||||||
|
|
||||||
$page = Livewire::actingAs($owner, 'operator')->test(SecretsPage::class);
|
|
||||||
|
|
||||||
$page->assertSee(__('secrets.locked_title'))
|
|
||||||
// Not even the outline before unlocking.
|
|
||||||
->assertDontSee('9999')
|
|
||||||
->assertViewHas('unlocked', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses every action while locked, not merely hiding the buttons', function () {
|
|
||||||
// A Livewire action is reachable by anyone who can post to /livewire/update.
|
|
||||||
$owner = operator('Owner');
|
|
||||||
|
|
||||||
foreach ([['save', 'stripe.secret'], ['forget', 'stripe.secret'], ['test', 'stripe.secret']] as [$action, $arg]) {
|
|
||||||
Livewire::actingAs($owner, 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->call($action, $arg)
|
|
||||||
->assertForbidden();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unlocks with the password, then stores and clears the entered value', function () {
|
|
||||||
$owner = operator('Owner');
|
|
||||||
|
|
||||||
$page = Livewire::actingAs($owner, 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->set('confirmablePassword', 'password')
|
|
||||||
->call('confirmPassword')
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertViewHas('unlocked', true);
|
|
||||||
|
|
||||||
$page->set('entered.stripe_secret', 'sk_live_NEWKEY1234')
|
|
||||||
->call('save', 'stripe.secret')
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_NEWKEY1234')
|
|
||||||
// Out of the component state as soon as it is stored: a Livewire
|
|
||||||
// property travels to the browser and back in the snapshot.
|
|
||||||
->and($page->get('entered')['stripe_secret'])->toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never puts a stored secret into the component snapshot', function () {
|
|
||||||
$owner = operator('Owner');
|
|
||||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_TOPSECRET42', $owner);
|
|
||||||
|
|
||||||
$page = Livewire::actingAs($owner, 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->set('confirmablePassword', 'password')
|
|
||||||
->call('confirmPassword');
|
|
||||||
|
|
||||||
expect(json_encode($page->snapshot))->not->toContain('sk_live_TOPSECRET42');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can be locked again without signing out', function () {
|
|
||||||
$owner = operator('Owner');
|
|
||||||
|
|
||||||
Livewire::actingAs($owner, 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->set('confirmablePassword', 'password')
|
|
||||||
->call('confirmPassword')
|
|
||||||
->assertViewHas('unlocked', true)
|
|
||||||
->call('forgetPasswordConfirmation')
|
|
||||||
->assertViewHas('unlocked', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// R23: confirmation moved from wire:confirm to ConfirmSaveSecret /
|
|
||||||
// ConfirmForgetSecret. Neither modal can see Secrets' own (deferred)
|
|
||||||
// `entered` state, so they dispatch back to the page instead of mutating
|
|
||||||
// anything themselves — save()/forget() keep their existing guard().
|
|
||||||
|
|
||||||
it('does not open the save/forget confirmation without the capability', function () {
|
|
||||||
Livewire::actingAs(operator('Admin'), 'operator')
|
|
||||||
->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
|
|
||||||
->assertForbidden();
|
|
||||||
|
|
||||||
Livewire::actingAs(operator('Admin'), 'operator')
|
|
||||||
->test(ConfirmForgetSecret::class, ['key' => 'stripe.secret'])
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('confirms a save through the modal without storing anything itself', function () {
|
|
||||||
$owner = operator('Owner');
|
|
||||||
|
|
||||||
Livewire::actingAs($owner, 'operator')
|
|
||||||
->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
|
|
||||||
->assertSee(__('secrets.item.stripe_secret'))
|
|
||||||
->call('confirm')
|
|
||||||
->assertDispatched('secret-save-confirmed', key: 'stripe.secret');
|
|
||||||
|
|
||||||
// The modal only asked; nothing is stored until the page itself saves.
|
|
||||||
expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('saves the typed value once the page receives the confirmed event', function () {
|
|
||||||
// Exercises the forwarding listener directly — the same round trip a
|
|
||||||
// browser produces by dispatching openModal then confirm (checked
|
|
||||||
// manually against admin.dev.clupilot.com, since a global browser event
|
|
||||||
// reaching a sibling component's #[On] listener is not something a
|
|
||||||
// single-component Livewire::test() can simulate).
|
|
||||||
$owner = operator('Owner');
|
|
||||||
|
|
||||||
Livewire::actingAs($owner, 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->set('confirmablePassword', 'password')
|
|
||||||
->call('confirmPassword')
|
|
||||||
->set('entered.stripe_secret', 'sk_live_VIACONFIRM1')
|
|
||||||
->call('onSaveConfirmed', 'stripe.secret')
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_VIACONFIRM1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forgets the stored value once the page receives the confirmed event', function () {
|
|
||||||
$owner = operator('Owner');
|
|
||||||
app(SecretVault::class)->put('stripe.secret', 'sk_live_TOFORGET999', $owner);
|
|
||||||
|
|
||||||
Livewire::actingAs($owner, 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->set('confirmablePassword', 'password')
|
|
||||||
->call('confirmPassword')
|
|
||||||
->call('onForgetConfirmed', 'stripe.secret');
|
|
||||||
|
|
||||||
expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores the SSH identity with its internal newlines intact', function () {
|
|
||||||
// The one multi-line entry on the page (a PEM key) — save() only trims
|
|
||||||
// the ENDS, so an internal newline must survive the round trip exactly.
|
|
||||||
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
|
||||||
$owner = operator('Owner');
|
|
||||||
$pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nabcdef\nghijkl\n-----END OPENSSH PRIVATE KEY-----";
|
|
||||||
|
|
||||||
Livewire::actingAs($owner, 'operator')
|
|
||||||
->test(SecretsPage::class)
|
|
||||||
->set('confirmablePassword', 'password')
|
|
||||||
->call('confirmPassword')
|
|
||||||
->set('entered.ssh_private_key', $pem)
|
|
||||||
->call('save', 'ssh.private_key')
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
expect(app(SecretVault::class)->get('ssh.private_key'))->toBe($pem);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds the form to a key Livewire can actually write to', function () {
|
|
||||||
// A dot in a Livewire property path means nesting, so a registry key with a
|
|
||||||
// dot in it would be written to entered['stripe']['secret'] and the value
|
|
||||||
// would never reach the save.
|
|
||||||
expect(Secrets::field('stripe.secret'))->toBe('stripe_secret')
|
|
||||||
->and(str_contains(Secrets::field('stripe.secret'), '.'))->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
@ -23,9 +23,9 @@ use Illuminate\Support\Facades\Route;
|
||||||
* These re-register them against a fresh router — the idiom WelcomeTest already
|
* These re-register them against a fresh router — the idiom WelcomeTest already
|
||||||
* uses for the status host.
|
* uses for the status host.
|
||||||
*/
|
*/
|
||||||
function routerWithHosts(?string $siteHost, ?string $appHost = null): Router
|
function routerWithHosts(string|array|null $siteHosts, ?string $appHost = null): Router
|
||||||
{
|
{
|
||||||
config()->set('admin_access.site_host', $siteHost ?? '');
|
config()->set('admin_access.site_hosts', array_values(array_filter((array) $siteHosts)));
|
||||||
config()->set('admin_access.app_host', $appHost ?? '');
|
config()->set('admin_access.app_host', $appHost ?? '');
|
||||||
|
|
||||||
$router = new Router(app('events'), app());
|
$router = new Router(app('events'), app());
|
||||||
|
|
@ -36,6 +36,24 @@ function routerWithHosts(?string $siteHost, ?string $appHost = null): Router
|
||||||
return $router;
|
return $router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch through the fresh router, with the request bound where the framework
|
||||||
|
* would bind it.
|
||||||
|
*
|
||||||
|
* Router::dispatch() on a standalone router does not rebind the container's
|
||||||
|
* request, so a route that reads one — through the helper OR through an
|
||||||
|
* injected parameter, both of which resolve from the container — would be
|
||||||
|
* handed the test's own request instead of the one being answered. In a real
|
||||||
|
* request the two are the same object; here they are not, and a redirect would
|
||||||
|
* silently lose its query string in the test while working in production.
|
||||||
|
*/
|
||||||
|
function dispatchOn(Router $router, Request $request)
|
||||||
|
{
|
||||||
|
app()->instance('request', $request);
|
||||||
|
|
||||||
|
return $router->dispatch($request);
|
||||||
|
}
|
||||||
|
|
||||||
/** Does this host answer this path at all? */
|
/** Does this host answer this path at all? */
|
||||||
function answers(Router $router, string $host, string $path): bool
|
function answers(Router $router, string $host, string $path): bool
|
||||||
{
|
{
|
||||||
|
|
@ -75,7 +93,7 @@ it('answers "/" on the portal host with the product, never a 404', function () {
|
||||||
// to make a point about hostnames helps nobody.
|
// to make a point about hostnames helps nobody.
|
||||||
$router = routerWithHosts('www.example.test', 'app.example.test');
|
$router = routerWithHosts('www.example.test', 'app.example.test');
|
||||||
|
|
||||||
$response = $router->dispatch(Request::create('http://app.example.test/', 'GET'));
|
$response = dispatchOn($router, Request::create('http://app.example.test/', 'GET'));
|
||||||
|
|
||||||
expect($response->getStatusCode())->toBe(302)
|
expect($response->getStatusCode())->toBe(302)
|
||||||
->and($response->headers->get('Location'))->toContain('/login');
|
->and($response->headers->get('Location'))->toContain('/login');
|
||||||
|
|
@ -85,7 +103,7 @@ it('sends a signed-in customer on the portal host to their dashboard', function
|
||||||
$this->actingAs(User::factory()->create());
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
$router = routerWithHosts('www.example.test', 'app.example.test');
|
$router = routerWithHosts('www.example.test', 'app.example.test');
|
||||||
$response = $router->dispatch(Request::create('http://app.example.test/', 'GET'));
|
$response = dispatchOn($router, Request::create('http://app.example.test/', 'GET'));
|
||||||
|
|
||||||
expect($response->getStatusCode())->toBe(302)
|
expect($response->getStatusCode())->toBe(302)
|
||||||
->and($response->headers->get('Location'))->toContain('/dashboard');
|
->and($response->headers->get('Location'))->toContain('/dashboard');
|
||||||
|
|
@ -120,3 +138,33 @@ it('binds nothing at all while no hostnames are configured', function () {
|
||||||
expect(answers($router, 'anything.example.test', $path))->toBeTrue("everything should answer {$path}");
|
expect(answers($router, 'anything.example.test', $path))->toBeTrue("everything should answer {$path}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends every other name for the website to the canonical one', function () {
|
||||||
|
// An apex domain and its www are two names for one site. Answering on both
|
||||||
|
// without picking one splits search rankings and cookies between them.
|
||||||
|
$router = routerWithHosts(['www.example.test', 'example.test'], 'app.example.test');
|
||||||
|
|
||||||
|
$response = dispatchOn($router, Request::create('http://example.test/legal/impressum', 'GET'));
|
||||||
|
|
||||||
|
expect($response->getStatusCode())->toBe(301)
|
||||||
|
->and($response->headers->get('Location'))->toBe('https://www.example.test/legal/impressum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the path and the query when it redirects to the canonical name', function () {
|
||||||
|
// A bookmark or an ad link carries both, and dropping them turns a
|
||||||
|
// redirect into a dead end at the front page.
|
||||||
|
$router = routerWithHosts(['www.example.test', 'example.test'], 'app.example.test');
|
||||||
|
|
||||||
|
$response = dispatchOn($router, Request::create('http://example.test/legal/agb?ref=mail', 'GET'));
|
||||||
|
|
||||||
|
expect($response->headers->get('Location'))->toBe('https://www.example.test/legal/agb?ref=mail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let the alias redirect swallow any other host', function () {
|
||||||
|
// It is a catch-all, and a catch-all registered without a host would eat
|
||||||
|
// every path in the application.
|
||||||
|
$router = routerWithHosts(['www.example.test', 'example.test'], 'app.example.test');
|
||||||
|
|
||||||
|
expect(answers($router, 'app.example.test', '/dashboard'))->toBeTrue()
|
||||||
|
->and(answers($router, 'www.example.test', '/legal/impressum'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue