445 lines
19 KiB
PHP
445 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Livewire\Concerns\ConfirmsPassword;
|
|
use App\Services\Deployment\UpdateChannel;
|
|
use App\Services\Env\EnvFileEditor;
|
|
use App\Services\Env\InvalidEnvContentException;
|
|
use App\Services\Secrets\SecretVault;
|
|
use App\Support\ProvisioningSettings;
|
|
use App\Support\Settings;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Connected services, one page, grouped by what they are for — Zahlungen,
|
|
* DNS, Monitoring, VPN/WireGuard, SSH-Identität — not by which of the two
|
|
* mechanisms below happens to hold a given value.
|
|
*
|
|
* Replaces Admin\Secrets and Admin\Infrastructure, which is what they were:
|
|
* one console area split across two pages by storage mechanism, reported as
|
|
* exactly the wrong axis to split on. An operator setting up an integration
|
|
* should not have to know whether a given field happens to be encrypted.
|
|
*
|
|
* Both mechanisms are unchanged underneath — this is a presentation change,
|
|
* not a data migration:
|
|
*
|
|
* - SecretVault: a curated, encrypted key/value store for credentials that
|
|
* actually stop the business when they leak or expire. Gated by
|
|
* `secrets.manage` (Owner only) AND a recently confirmed password — the
|
|
* realistic threat is an unlocked machine, not a stranger. A stored value
|
|
* is write-only: the page only ever shows an outline of it.
|
|
* - App\Support\Settings: plain deployment configuration. Gated by
|
|
* `hosts.manage` (Owner + Admin) alone — none of it opens anything by
|
|
* itself. Shows its value in full, because there is nothing to protect.
|
|
*
|
|
* mount() is reachable with EITHER capability, because the two halves are no
|
|
* longer two pages an operator's role happens to see one, both, or neither
|
|
* of — they are sections of the SAME page now. Each section, and each save
|
|
* action, still checks its OWN capability (guardSecrets()/guardInfra())
|
|
* server-side; an Admin who can reach this page for the DNS zone still gets
|
|
* 403 the moment they try to touch a vault entry.
|
|
*
|
|
* The .env editor (Part B) shares the secrets lock: it needs `secrets.manage`
|
|
* and the same confirmed password, because it is the one place on this page
|
|
* that can reach every credential the vault deliberately keeps write-only —
|
|
* see EnvFileEditor for the safeguards that make that survivable.
|
|
*
|
|
* TABBED, like Admin\Settings and for the same reason: this was one narrow
|
|
* column of six cards, and an operator connecting DNS scrolled past Stripe to
|
|
* get to it. The split is by what a section is FOR — outside services we buy
|
|
* from, our own machines, and the raw file underneath — which is the same axis
|
|
* the page was rebuilt around when it stopped being split by storage mechanism.
|
|
*
|
|
* The open tab is in the query string, so a reload or a bookmark lands where
|
|
* the operator was. The .env tab in particular is worth linking to directly,
|
|
* and the unlock survives the switch because the confirmation is a session
|
|
* fact, not a property of a tab.
|
|
*/
|
|
#[Layout('layouts.admin')]
|
|
class Integrations extends Component
|
|
{
|
|
/**
|
|
* The tabs, in order. The list IS the schema: it validates the query
|
|
* string, builds the bar and decides what renders.
|
|
*
|
|
* `services` is what we buy from outside (Stripe, Hetzner DNS, the
|
|
* monitoring bridge), `platform` is how we reach our own machines (the
|
|
* WireGuard hub, the SSH identity), `env` is the file underneath all of it.
|
|
*/
|
|
public const TABS = ['services', 'platform', 'env'];
|
|
|
|
#[Url(except: 'services')]
|
|
public string $tab = 'services';
|
|
|
|
use ConfirmsPassword {
|
|
forgetPasswordConfirmation as private lockAgain;
|
|
}
|
|
|
|
// Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
|
|
// and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
|
|
public array $entered = [];
|
|
|
|
public ?array $check = null;
|
|
|
|
// Plain settings — App\Support\ProvisioningSettings' full list.
|
|
public string $dnsZone = '';
|
|
|
|
public string $wgEndpoint = '';
|
|
|
|
public string $wgHubPubkey = '';
|
|
|
|
public string $traefikDynamicPath = '';
|
|
|
|
public string $sshPublicKey = '';
|
|
|
|
public string $monitoringUrl = '';
|
|
|
|
// .env editor.
|
|
public string $envContent = '';
|
|
|
|
/**
|
|
* Loaded lazily, on the first render after unlocking — never in mount(),
|
|
* so a merely-reachable page never pulls the whole credential file into
|
|
* component state for an operator who has not confirmed anything yet.
|
|
*/
|
|
public bool $envLoaded = false;
|
|
|
|
/**
|
|
* True from the moment saveEnv() successfully asks the agent to restart
|
|
* the workers, until render() observes the request is no longer pending.
|
|
*
|
|
* Drives the small inline "wird neu gestartet" indicator on this page —
|
|
* not the full-screen maintenance overlay in layouts/admin.blade.php,
|
|
* which is for an actual deployment. A worker restart is four containers
|
|
* coming back in a few seconds, this page (and the console around it)
|
|
* never goes offline for it, and `app` — the container answering this
|
|
* very poll — is never among them. wire:poll on this page's own
|
|
* component is therefore enough to watch it finish; nothing here needs
|
|
* the client-side watcher an in-flight deployment does.
|
|
*/
|
|
public bool $envRestartWatching = false;
|
|
|
|
protected function confirmationGuard(): string
|
|
{
|
|
return 'operator';
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
|
|
|
|
// A tab name out of the URL is a string a stranger typed.
|
|
if (! in_array($this->tab, self::TABS, true)) {
|
|
$this->tab = self::TABS[0];
|
|
}
|
|
|
|
// The raw file is Owner-only. Landing on the first tab says that
|
|
// without a blank page; the editor itself checks again anyway.
|
|
if ($this->tab === 'env' && ! Gate::allows('secrets.manage')) {
|
|
$this->tab = self::TABS[0];
|
|
}
|
|
|
|
if (Gate::allows('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();
|
|
}
|
|
}
|
|
|
|
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
|
|
|
|
public function saveInfra(): void
|
|
{
|
|
$this->guardInfra();
|
|
|
|
$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: __('integrations.settings_saved'));
|
|
}
|
|
|
|
// ---- Vault entries — SecretVault, secrets.manage + confirmed password. ----
|
|
|
|
public function save(string $key): void
|
|
{
|
|
$this->guardSecrets();
|
|
|
|
$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;
|
|
}
|
|
|
|
$this->entered[$field] = '';
|
|
$this->check = null;
|
|
$this->dispatch('notify', message: __('secrets.saved'));
|
|
}
|
|
|
|
/** ConfirmSaveSecret dispatches this back (R23) — see that class. */
|
|
#[On('secret-save-confirmed')]
|
|
public function onSaveConfirmed(string $key): void
|
|
{
|
|
$this->save($key);
|
|
}
|
|
|
|
public function forget(string $key): void
|
|
{
|
|
$this->guardSecrets();
|
|
|
|
app(SecretVault::class)->forget($key);
|
|
$this->check = null;
|
|
$this->dispatch('notify', message: __('secrets.removed'));
|
|
}
|
|
|
|
/** ConfirmForgetSecret dispatches this back (R23) — see that class. */
|
|
#[On('secret-forget-confirmed')]
|
|
public function onForgetConfirmed(string $key): void
|
|
{
|
|
$this->forget($key);
|
|
}
|
|
|
|
public function test(string $key): void
|
|
{
|
|
$this->guardSecrets();
|
|
|
|
$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 (a dot means nesting to Livewire). */
|
|
public static function field(string $key): string
|
|
{
|
|
return str_replace('.', '_', $key);
|
|
}
|
|
|
|
// ---- The .env editor — Part B. secrets.manage + confirmed password. ----
|
|
|
|
/**
|
|
* Validate, back up, write — then do the two things saving this file used
|
|
* to leave as homework: clear the config cache (this container can do
|
|
* that itself, no host privileges needed) and ask the host-side agent to
|
|
* restart queue, queue-provisioning, scheduler and reverb (UpdateChannel
|
|
* — it cannot be done from in here; see that class for why).
|
|
*
|
|
* EnvFileEditor does the write itself and the actual refusing; this only
|
|
* translates its one exception into a form error instead of a 500.
|
|
*/
|
|
public function saveEnv(): void
|
|
{
|
|
$this->guardSecrets();
|
|
|
|
try {
|
|
$backup = app(EnvFileEditor::class)->write($this->envContent);
|
|
} catch (InvalidEnvContentException $e) {
|
|
$this->addError('envContent', $e->invalidLine !== null
|
|
? __('integrations.env_invalid', ['line' => $e->invalidLine])
|
|
: __('integrations.env_empty'));
|
|
|
|
return;
|
|
}
|
|
|
|
// Unconditional, and deliberately not gated on
|
|
// app()->configurationIsCached() first: Illuminate's own ConfigClear
|
|
// command is already exactly that guard — it deletes the cache file
|
|
// if one exists and does nothing if there is none, so adding a check
|
|
// here would only save a single is_file() call in the common case at
|
|
// the cost of a second source of truth to keep in sync with it. This
|
|
// dev machine has no cache right now (checked: no
|
|
// bootstrap/cache/config.php), but deploy/update.sh and
|
|
// deploy/install-agent.sh both run `config:cache` as standard
|
|
// practice, so a real installation usually does — and while one is
|
|
// active, Laravel skips loading .env on every request AT ALL
|
|
// (LoadEnvironmentVariables bootstraps straight from the cached
|
|
// values instead). Left uncleared there, the value just written would
|
|
// stay invisible — not merely to the four workers below, to this
|
|
// application too — until the cache was rebuilt by hand.
|
|
Artisan::call('config:clear');
|
|
|
|
$channel = app(UpdateChannel::class);
|
|
$agentAlive = $channel->state()['agent_seen'];
|
|
$by = Auth::guard('operator')->user()?->email ?? 'console';
|
|
|
|
// '' means there was no previous file to protect — EnvFileEditor's
|
|
// own docblock explains why that is not an error.
|
|
$backupName = $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file');
|
|
|
|
if (! $agentAlive) {
|
|
// Honest, not silent: the values ARE saved, but nothing is going
|
|
// to pick them up on its own. Pretending otherwise here is worse
|
|
// than the manual-restart card this replaces — see
|
|
// admin_settings.update_no_agent for the same shape on updates.
|
|
$this->envRestartWatching = false;
|
|
$this->dispatch('notify', message: __('integrations.env_saved_no_agent', ['backup' => $backupName]));
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $channel->requestRestart($by)) {
|
|
// The single request slot is already taken by something else
|
|
// (a check, an update, or another restart just asked for) — rare,
|
|
// but real, and not something to paper over with a message that
|
|
// says "restarting" when nothing was actually queued.
|
|
$this->envRestartWatching = false;
|
|
$this->dispatch('notify', message: __('integrations.env_saved_restart_busy', ['backup' => $backupName]));
|
|
|
|
return;
|
|
}
|
|
|
|
$this->envRestartWatching = true;
|
|
$this->dispatch('notify', message: __('integrations.env_saved_restarting', ['backup' => $backupName]));
|
|
}
|
|
|
|
/** ConfirmSaveEnv dispatches this back (R23) — see that class. */
|
|
#[On('env-save-confirmed')]
|
|
public function onEnvSaveConfirmed(): void
|
|
{
|
|
$this->saveEnv();
|
|
}
|
|
|
|
/**
|
|
* Overrides ConfirmsPassword's own method (aliased above to lockAgain) to
|
|
* also drop the Stripe check result — the render()-side check just below
|
|
* handles $envContent/$envLoaded, and handles it for BOTH ways a session
|
|
* can end up locked: this explicit click, and the confirmation window
|
|
* simply expiring with nobody clicking anything. A clear here alone would
|
|
* only ever cover the first.
|
|
*/
|
|
public function forgetPasswordConfirmation(): void
|
|
{
|
|
$this->lockAgain();
|
|
|
|
$this->check = null;
|
|
}
|
|
|
|
private function guardInfra(): void
|
|
{
|
|
$this->authorize('hosts.manage');
|
|
}
|
|
|
|
/** Capability AND a recently confirmed password, on every vault/.env action. */
|
|
private function guardSecrets(): void
|
|
{
|
|
$this->authorize('secrets.manage');
|
|
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$vault = app(SecretVault::class);
|
|
$canSecrets = Gate::allows('secrets.manage');
|
|
$canInfra = Gate::allows('hosts.manage');
|
|
$unlocked = $this->passwordRecentlyConfirmed();
|
|
|
|
// Loaded here rather than in mount(): a page that is merely reachable
|
|
// must not already hold the whole credential file in component state
|
|
// for an operator who has not confirmed a password this session.
|
|
if ($canSecrets && $unlocked && ! $this->envLoaded) {
|
|
$this->envContent = app(EnvFileEditor::class)->read();
|
|
$this->envLoaded = true;
|
|
}
|
|
|
|
// The mirror case, and the one forgetPasswordConfirmation() alone
|
|
// does not cover: the confirmation window can also expire on its
|
|
// own, with nobody clicking "Wieder sperren" — $unlocked simply goes
|
|
// false on whatever the next render happens to be. Without this, the
|
|
// textarea disappears from the page but $envContent — the ENTIRE
|
|
// credential file — stays sitting in the component snapshot, reachable
|
|
// to anyone with the browser this was left open on (Codex review,
|
|
// P1). Checked on every render, not only the explicit lock action.
|
|
if (! $unlocked && $this->envLoaded) {
|
|
$this->envContent = '';
|
|
$this->envLoaded = false;
|
|
}
|
|
|
|
$restart = app(UpdateChannel::class)->state();
|
|
|
|
// The transition the small inline indicator exists to show: a restart
|
|
// THIS component asked for is no longer pending. Read off
|
|
// 'requested_at' rather than 'restarting': the latter is gated on
|
|
// agent_seen (see UpdateChannel::state()), so if the agent went
|
|
// quiet in the few seconds this was being waited on, 'restarting'
|
|
// would already read false EVEN THOUGH THE REQUEST FILE IS STILL
|
|
// SITTING THERE, unread — and this would announce "restarted"
|
|
// for a restart that never ran. 'requested_at' answers the only
|
|
// question that matters here — is a request still on disk — with no
|
|
// opinion about the agent either way. Fires once — the instant it
|
|
// does, envRestartWatching drops so the next poll (or the one after,
|
|
// on a slower host with no on-demand wake) does not dispatch it
|
|
// again. wire:poll on the .env section is what keeps calling
|
|
// render() while this is being waited on; see $envRestartWatching's
|
|
// own docblock for why that is enough and the full-page deployment
|
|
// overlay is not needed here.
|
|
if ($this->envRestartWatching && $restart['requested_at'] === null) {
|
|
$this->envRestartWatching = false;
|
|
$this->dispatch('notify', message: __('integrations.env_restart_done'));
|
|
}
|
|
|
|
return view('livewire.admin.integrations', [
|
|
// Only the tabs this operator can open. A tab that renders nothing
|
|
// is worse than one that is not there.
|
|
'tabs' => array_values(array_filter(
|
|
self::TABS,
|
|
fn (string $tab) => $tab !== 'env' || $canSecrets,
|
|
)),
|
|
'canSecrets' => $canSecrets,
|
|
'canInfra' => $canInfra,
|
|
'unlocked' => $unlocked,
|
|
'usable' => $vault->isUsable(),
|
|
'restart' => $restart,
|
|
'entries' => collect(SecretVault::REGISTRY)
|
|
->map(fn (array $meta, string $key) => [
|
|
'key' => $key,
|
|
'field' => self::field($key),
|
|
'label' => __($meta['label']),
|
|
'envKey' => $meta['env_key'],
|
|
'testable' => isset($meta['check']),
|
|
'source' => $vault->source($key),
|
|
'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.
|
|
'multiline' => $key === 'ssh.private_key',
|
|
])
|
|
->keyBy('key'),
|
|
]);
|
|
}
|
|
}
|