Read the DNS and monitoring tokens from the vault, not config

Two of the Zugangsdaten page's three entries were decorative: whatever an
operator typed was encrypted and stored, but HttpHetznerDnsClient and
HttpMonitoringClient still read config() directly, so the stored value never
reached an outgoing request. Both now resolve through SecretVault::get() at
the point of use, matching how the Stripe client already worked.
feat/granted-plans
nexxo 2026-07-29 00:34:54 +02:00
parent 49a3ca2e33
commit a457bea87e
3 changed files with 53 additions and 6 deletions

View File

@ -2,19 +2,26 @@
namespace App\Services\Dns;
use App\Services\Secrets\SecretVault;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Hetzner DNS API client. Zone + token from config/provisioning.php.
* Not unit-tested (live I/O).
* Hetzner DNS API client. Zone from config/provisioning.php; the token comes
* from the vault (console-stored, falling back to HETZNER_DNS_TOKEN).
* Not unit-tested against the real service (live I/O).
*/
class HttpHetznerDnsClient implements HetznerDnsClient
{
private function http(): PendingRequest
{
return Http::withHeaders(['Auth-API-Token' => (string) config('provisioning.dns.token')])
// Read HERE, at the point of use, rather than in a constructor or
// overlaid onto config at boot — see SecretVault's own docblock (rule
// 3): an overlay would add a query to every request, and a
// long-running queue worker would keep whatever token was true when
// it started.
return Http::withHeaders(['Auth-API-Token' => (string) app(SecretVault::class)->get('dns.token')])
->baseUrl('https://dns.hetzner.com/api/v1')
->timeout(15);
}

View File

@ -2,12 +2,17 @@
namespace App\Services\Monitoring;
use App\Services\Secrets\SecretVault;
use Illuminate\Support\Facades\Http;
/**
* Registers uptime targets with the configured monitoring service. If none is
* configured, returns a deterministic id (no-op) so provisioning still records a
* stable breadcrumb. Not unit-tested (live I/O).
*
* The bridge token comes from the vault (console-stored, falling back to
* MONITORING_API_TOKEN) read here, at the point of use, never overlaid onto
* config at boot. See SecretVault's docblock (rule 3).
*/
class HttpMonitoringClient implements MonitoringClient
{
@ -19,7 +24,7 @@ class HttpMonitoringClient implements MonitoringClient
}
$base = rtrim($endpoint, '/');
$token = (string) config('services.monitoring.token');
$token = (string) app(SecretVault::class)->get('monitoring.token');
// Idempotent: reuse an existing monitor for this URL rather than creating
// a duplicate when a crash retries after a prior successful POST.
@ -51,7 +56,7 @@ class HttpMonitoringClient implements MonitoringClient
}
try {
$monitor = Http::withToken((string) config('services.monitoring.token'))
$monitor = Http::withToken((string) app(SecretVault::class)->get('monitoring.token'))
->get(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw()->json('monitor', []);
} catch (\Throwable) {
return null; // the monitor is unreachable, which says nothing about the instance
@ -64,7 +69,7 @@ class HttpMonitoringClient implements MonitoringClient
{
$endpoint = (string) config('services.monitoring.url');
if (filled($endpoint)) {
Http::withToken((string) config('services.monitoring.token'))
Http::withToken((string) app(SecretVault::class)->get('monitoring.token'))
->delete(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw();
}
}

View File

@ -1,6 +1,8 @@
<?php
use App\Models\Operator;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Secrets\SecretVault;
use App\Services\Stripe\HttpStripeClient;
use Illuminate\Support\Facades\DB;
@ -97,6 +99,39 @@ it('hands the Stripe client the stored key rather than the environment one', fun
);
});
it('hands the Hetzner DNS client the stored token rather than the environment one', function () {
// Mutation boundary (Part A): before this, HttpHetznerDnsClient read
// config('provisioning.dns.token') directly — a value stored here was
// encrypted, kept, and never once reached the outgoing request.
config()->set('provisioning.dns.token', 'env-token-should-not-be-used');
app(SecretVault::class)->put('dns.token', 'dns-token-from-console', Operator::factory()->create());
Http::fake([
'dns.hetzner.com/api/v1/zones*' => Http::response(['zones' => [['id' => 'zone1', 'name' => config('provisioning.dns.zone')]]]),
'dns.hetzner.com/api/v1/records*' => Http::response(['record' => ['id' => 'rec1']]),
]);
app(HttpHetznerDnsClient::class)->upsertRecord('fsn-01.'.config('provisioning.dns.zone'), 'A', '10.0.0.1');
Http::assertSent(fn ($request) => $request->hasHeader('Auth-API-Token', 'dns-token-from-console'));
Http::assertNotSent(fn ($request) => $request->hasHeader('Auth-API-Token', 'env-token-should-not-be-used'));
});
it('hands the monitoring client the stored token rather than the environment one', function () {
config()->set('services.monitoring.url', 'https://kuma-bridge.test');
config()->set('services.monitoring.token', 'env-token-should-not-be-used');
app(SecretVault::class)->put('monitoring.token', 'mon-token-from-console', Operator::factory()->create());
Http::fake([
'kuma-bridge.test/monitors*' => Http::response(['monitors' => [], 'monitor' => ['id' => 5]]),
]);
app(HttpMonitoringClient::class)->registerTarget('probe', 'https://probe.example');
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer mon-token-from-console'));
Http::assertNotSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer env-token-should-not-be-used'));
});
it('accepts the key in the format the documentation tells you to generate', function () {
// `head -c 32 /dev/urandom | base64` produces raw base64 with no prefix.
// Requiring the prefix made every read and write fail on exactly the