diff --git a/app/Services/Dns/HttpHetznerDnsClient.php b/app/Services/Dns/HttpHetznerDnsClient.php index c9d7807..a7c2267 100644 --- a/app/Services/Dns/HttpHetznerDnsClient.php +++ b/app/Services/Dns/HttpHetznerDnsClient.php @@ -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); } diff --git a/app/Services/Monitoring/HttpMonitoringClient.php b/app/Services/Monitoring/HttpMonitoringClient.php index 13ccb92..7cdb47c 100644 --- a/app/Services/Monitoring/HttpMonitoringClient.php +++ b/app/Services/Monitoring/HttpMonitoringClient.php @@ -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(); } } diff --git a/tests/Feature/Admin/SecretVaultTest.php b/tests/Feature/Admin/SecretVaultTest.php index c91cd7b..079d010 100644 --- a/tests/Feature/Admin/SecretVaultTest.php +++ b/tests/Feature/Admin/SecretVaultTest.php @@ -1,6 +1,8 @@ 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