CluPilotCloud/app/Services/Traefik/SshTraefikWriter.php

97 lines
3.5 KiB
PHP

<?php
namespace App\Services\Traefik;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\RemoteShell;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Http;
/**
* Writes the Traefik file-provider YAML onto the host that actually serves the
* traffic (DNS points at it), via SSH — not the worker's local filesystem — so
* that host's Traefik picks up the route. Checks HTTP-01 cert issuance by
* probing the fqdn. Not unit-tested (live I/O).
*/
class SshTraefikWriter implements TraefikWriter
{
public function __construct(private RemoteShell $shell) {}
public function write(string $trafficHost, string $subdomain, array $hostnames, string $backend): void
{
// The file is rewritten wholesale rather than appended to, so the
// hostnames handed in are exactly the hostnames served afterwards —
// that is what makes withdrawing a domain a write rather than a
// separate deletion somebody has to remember.
$yaml = $this->render($subdomain, $hostnames, $backend);
$this->shell->connectWithKey($trafficHost, 'root', $this->privateKey());
$this->shell->putFile($this->path($subdomain), $yaml); // putFile throws on failure
}
public function remove(string $trafficHost, string $subdomain): void
{
$this->shell->connectWithKey($trafficHost, 'root', $this->privateKey());
$this->shell->run('rm -f '.escapeshellarg($this->path($subdomain)));
}
/**
* The identity CluPilot deploys to every host: the one stored in the
* console if there is one, else CLUPILOT_SSH_PRIVATE_KEY(_PATH). Read
* HERE, at the point of use — see SecretVault's docblock (rule 3).
*/
private function privateKey(): string
{
return (string) app(SecretVault::class)->get('ssh.private_key');
}
public function certReachable(string $fqdn): bool
{
// While DNS propagates / Traefik warms up, the client throws
// (ConnectionException, TLS) — that's "not ready yet", not a hard error,
// so the step keeps polling instead of burning the retry budget.
try {
return Http::timeout(5)->connectTimeout(5)->get("https://{$fqdn}/status.php")->successful();
} catch (\Throwable) {
return false;
}
}
private function path(string $subdomain): string
{
return rtrim(ProvisioningSettings::traefikDynamicPath(), '/')."/{$subdomain}.yml";
}
/**
* One router, one rule, every hostname in it.
*
* Traefik's `Host()` matcher takes several names in one call, and a single
* rule keeps the certResolver, the service and the entryPoint stated once —
* a second router would have to repeat all three and could drift from the
* first.
*
* @param array<int, string> $hostnames
*/
private function render(string $subdomain, array $hostnames, string $backend): string
{
$names = implode(', ', array_map(fn (string $host) => "`{$host}`", $hostnames));
return implode("\n", [
'http:',
' routers:',
" {$subdomain}:",
" rule: \"Host({$names})\"",
" service: \"{$subdomain}\"",
' entryPoints: ["websecure"]',
' tls:',
' certResolver: "letsencrypt"',
' services:',
" {$subdomain}:",
' loadBalancer:',
' servers:',
" - url: \"http://{$backend}:80\"",
'',
]);
}
}