fix(engine-b): write Traefik route to the serving host over SSH, not locally

TraefikWriter now targets the host that serves the traffic (DNS points at it):
SshTraefikWriter SSHes in and writes the file-provider YAML there. Interface
takes the traffic host + guest backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 12:55:24 +02:00
parent 483a79a822
commit 157496f0c5
4 changed files with 41 additions and 31 deletions

View File

@ -41,11 +41,12 @@ class ConfigureDnsAndTls extends CustomerStep
$this->recordResource($run, $host, 'dns_record_id', $recordId); $this->recordResource($run, $host, 'dns_record_id', $recordId);
} }
// Traefik file-provider route → the guest VM (fall back to the host only // Traefik file-provider route, written on the serving host (DNS points at
// if the guest address wasn't captured). // it) and pointing at the guest VM.
if (! $instance->route_written) { if (! $instance->route_written) {
$target = $instance->guest_ip ?: ($host->wg_ip ?? $host->public_ip); $trafficHost = $host->wg_ip ?? $host->public_ip;
$this->traefik->write($instance->subdomain, $target); $backend = $instance->guest_ip ?: $host->public_ip;
$this->traefik->write($trafficHost, $instance->subdomain, $backend);
$instance->update(['route_written' => true]); $instance->update(['route_written' => true]);
} }

View File

@ -4,19 +4,23 @@ namespace App\Services\Traefik;
class FakeTraefikWriter implements TraefikWriter class FakeTraefikWriter implements TraefikWriter
{ {
/** @var array<string, string> subdomain => target */ /** @var array<string, string> subdomain => backend */
public array $routes = []; public array $routes = [];
/** @var array<string, string> subdomain => traffic host */
public array $hosts = [];
public bool $certReady = true; public bool $certReady = true;
public function write(string $subdomain, string $targetHost): void public function write(string $trafficHost, string $subdomain, string $backend): void
{ {
$this->routes[$subdomain] = $targetHost; $this->routes[$subdomain] = $backend;
$this->hosts[$subdomain] = $trafficHost;
} }
public function remove(string $subdomain): void public function remove(string $trafficHost, string $subdomain): void
{ {
unset($this->routes[$subdomain]); unset($this->routes[$subdomain], $this->hosts[$subdomain]);
} }
public function certReachable(string $fqdn): bool public function certReachable(string $fqdn): bool

View File

@ -2,35 +2,32 @@
namespace App\Services\Traefik; namespace App\Services\Traefik;
use App\Services\Ssh\RemoteShell;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use RuntimeException;
/** /**
* Writes Traefik file-provider YAML into the configured dynamic directory (where * Writes the Traefik file-provider YAML onto the host that actually serves the
* Traefik runs) and checks HTTP-01 cert issuance by probing the fqdn over HTTPS. * traffic (DNS points at it), via SSH not the worker's local filesystem so
* The concrete write path (host SSH / mounted volume) is a deployment watch-item. * that host's Traefik picks up the route. Checks HTTP-01 cert issuance by
* Not unit-tested (live I/O). * probing the fqdn. Not unit-tested (live I/O).
*/ */
class SshTraefikWriter implements TraefikWriter class SshTraefikWriter implements TraefikWriter
{ {
public function write(string $subdomain, string $targetHost): void public function __construct(private RemoteShell $shell) {}
public function write(string $trafficHost, string $subdomain, string $backend): void
{ {
$zone = (string) config('provisioning.dns.zone'); $zone = (string) config('provisioning.dns.zone');
$fqdn = "{$subdomain}.{$zone}"; $yaml = $this->render($subdomain, "{$subdomain}.{$zone}", $backend);
$yaml = $this->render($subdomain, $fqdn, $targetHost);
$path = rtrim((string) config('provisioning.traefik.dynamic_path'), '/')."/{$subdomain}.yml"; $this->shell->connectWithKey($trafficHost, 'root', (string) config('provisioning.ssh.private_key'));
if (file_put_contents($path, $yaml) === false) { $this->shell->putFile($this->path($subdomain), $yaml); // putFile throws on failure
throw new RuntimeException("Failed to write Traefik config: {$path}");
}
} }
public function remove(string $subdomain): void public function remove(string $trafficHost, string $subdomain): void
{ {
$path = rtrim((string) config('provisioning.traefik.dynamic_path'), '/')."/{$subdomain}.yml"; $this->shell->connectWithKey($trafficHost, 'root', (string) config('provisioning.ssh.private_key'));
if (is_file($path)) { $this->shell->run('rm -f '.escapeshellarg($this->path($subdomain)));
unlink($path);
}
} }
public function certReachable(string $fqdn): bool public function certReachable(string $fqdn): bool
@ -45,7 +42,12 @@ class SshTraefikWriter implements TraefikWriter
} }
} }
private function render(string $subdomain, string $fqdn, string $targetHost): string private function path(string $subdomain): string
{
return rtrim((string) config('provisioning.traefik.dynamic_path'), '/')."/{$subdomain}.yml";
}
private function render(string $subdomain, string $fqdn, string $backend): string
{ {
return implode("\n", [ return implode("\n", [
'http:', 'http:',
@ -60,7 +62,7 @@ class SshTraefikWriter implements TraefikWriter
" {$subdomain}:", " {$subdomain}:",
' loadBalancer:', ' loadBalancer:',
' servers:', ' servers:',
" - url: \"http://{$targetHost}:80\"", " - url: \"http://{$backend}:80\"",
'', '',
]); ]);
} }

View File

@ -4,10 +4,13 @@ namespace App\Services\Traefik;
interface TraefikWriter interface TraefikWriter
{ {
/** Write a file-provider router for subdomain → target host. */ /**
public function write(string $subdomain, string $targetHost): void; * Write a file-provider router on the Traefik host ($trafficHost) that routes
* the subdomain to the guest ($backend).
*/
public function write(string $trafficHost, string $subdomain, string $backend): void;
public function remove(string $subdomain): void; public function remove(string $trafficHost, string $subdomain): void;
/** True once the ACME (HTTP-01) certificate for the fqdn is served. */ /** True once the ACME (HTTP-01) certificate for the fqdn is served. */
public function certReachable(string $fqdn): bool; public function certReachable(string $fqdn): bool;