*/ /** * What the owner may change here — curated, never "everything in config". * * The list is the whole point of the area. With one entry it was a page * that existed to hold a single Stripe key, which is not worth a password * gate; these are the credentials that actually stop the business when * they expire or leak, and none of them can otherwise be rotated without a * shell on the server. ssh.private_key is the most sensitive value here — * it opens every server this installation owns. * * mail.password lived here too, until the mailboxes table (see its own * migration) took over sending credentials — a single SMTP password could * not say that an invoice comes from billing@ while a maintenance notice * comes from no-reply@. * * `check` is optional and names a class that can verify the value before it * is stored. Where there is none, no test button is offered — a button that * silently checks something else is worse than no button. * * `env_key` is the literal .env variable name the raw editor on the console * page (App\Livewire\Admin\Integrations) marks as vault-managed — shown so * an operator editing that file directly can see why a changed line has no * visible effect (get() prefers a stored row over config() every time; see * the docblock above). Which console SECTION an entry belongs beside is not * metadata here — Integrations' own view picks each entry up by key, so the * grouping lives in one place, the template, rather than two. * * Deliberately NOT here: STRIPE_WEBHOOK_SECRET. It is read on every * incoming payment event, and a database problem would turn signature * verification into a silent failure. It stays in the server file. * * Also deliberately NOT here: KUMA_PASSWORD / KUMA_TOTP. They authenticate * docker/kuma-bridge/app.py — a separate Python container — to Uptime Kuma * at process boot; nothing in this Laravel app ever reads them, so storing * them here would recreate exactly the decorative-field problem this * registry exists to avoid. They stay in the server file. */ public const REGISTRY = [ 'stripe.secret' => [ 'config' => 'services.stripe.secret', 'label' => 'secrets.item.stripe_secret', 'check' => StripeCheck::class, 'env_key' => 'STRIPE_SECRET', ], 'dns.token' => [ 'config' => 'provisioning.dns.token', 'label' => 'secrets.item.dns_token', 'env_key' => 'HETZNER_DNS_TOKEN', ], 'monitoring.token' => [ 'config' => 'services.monitoring.token', 'label' => 'secrets.item.monitoring_token', 'env_key' => 'MONITORING_API_TOKEN', ], 'inbound_mail.password' => [ 'config' => 'services.inbound_mail.password', 'label' => 'secrets.item.inbound_mail_password', 'env_key' => 'INBOUND_MAIL_PASSWORD', ], 'ssh.private_key' => [ 'config' => 'provisioning.ssh.private_key', 'label' => 'secrets.item.ssh_private_key', // File-or-inline (see config/provisioning.php's $secretFrom) — the // editor names both, since either can be the one actually in force. 'env_key' => 'CLUPILOT_SSH_PRIVATE_KEY / CLUPILOT_SSH_PRIVATE_KEY_PATH', ], ]; public function has(string $key): bool { return $this->row($key) !== null; } /** * The stored value, or the configured one when nothing is stored. * * Falls back only when there is genuinely no row — never on a decryption * failure. Falling back then would quietly resume using an old key from the * environment while the console shows the new one. */ public function get(string $key): ?string { $this->assertKnown($key); $row = $this->row($key); if ($row === null) { $configured = config(self::REGISTRY[$key]['config']); return $configured === null ? null : (string) $configured; } try { return app(SecretCipher::class)->decrypt($row->value); } catch (DecryptException $e) { Log::error('A stored secret could not be decrypted', ['key' => $key]); throw new RuntimeException("Stored secret [{$key}] cannot be decrypted.", previous: $e); } } /** Where the value in force comes from: stored, environment, or nowhere. */ public function source(string $key): string { $this->assertKnown($key); return match (true) { $this->row($key) !== null => 'stored', filled(config(self::REGISTRY[$key]['config'])) => 'environment', default => 'none', }; } public function outline(string $key): ?string { return $this->row($key)?->outline; } public function updatedAt(string $key): ?string { return $this->row($key)?->updated_at; } public function put(string $key, string $value, Operator $by): void { $this->assertKnown($key); if (trim($value) === '') { throw new RuntimeException('Refusing to store an empty secret.'); } $attributes = [ 'value' => app(SecretCipher::class)->encrypt($value), 'outline' => $this->sketch($value), 'updated_by' => $by->id, 'updated_at' => now(), ]; // created_at only on the first write: rotating a key must not rewrite // when it was first set, which is the one piece of audit metadata that // answers "how long has this been here?". if ($this->row($key) === null) { $attributes['created_at'] = now(); } DB::table('app_secrets')->updateOrInsert(['key' => $key], $attributes); } /** Remove the stored value, falling back to whatever the environment says. */ public function forget(string $key): void { $this->assertKnown($key); DB::table('app_secrets')->where('key', $key)->delete(); } /** Is storing credentials possible at all on this installation? */ public function isUsable(): bool { return app(SecretCipher::class)->isUsable(); } private function row(string $key): ?object { return DB::table('app_secrets')->where('key', $key)->first(); } private function assertKnown(string $key): void { if (! array_key_exists($key, self::REGISTRY)) { throw new RuntimeException("Unknown secret [{$key}]."); } } /** Enough to tell two keys apart, useless to anyone reading over a shoulder. */ private function sketch(string $value): string { return Str::length($value) <= 8 ? str_repeat('•', Str::length($value)) : Str::substr($value, 0, 3).str_repeat('•', 6).Str::substr($value, -4); } }