234 lines
7.4 KiB
PHP
234 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting\Traefik;
|
|
|
|
use App\Exceptions\Hosting\TraefikException;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
use Symfony\Component\Yaml\Exception\ParseException;
|
|
|
|
class TraefikGenerator
|
|
{
|
|
private const MANAGED_PREFIX = 'customer-';
|
|
|
|
public function configPath(): string
|
|
{
|
|
return config('hosting.traefik.dynamic_config_path');
|
|
}
|
|
|
|
public function addCustomerRoute(string $domain, string $ip): void
|
|
{
|
|
$domain = $this->sanitizeDomain($domain);
|
|
$ip = $this->sanitizeIp($ip);
|
|
|
|
$config = $this->loadConfig();
|
|
$routerKey = $this->routerKey($domain);
|
|
$serviceKey = $this->serviceKey($domain);
|
|
$port = (int) config('hosting.traefik.backend_port', 80);
|
|
$entrypoint = config('hosting.traefik.entrypoint', 'websecure');
|
|
$certResolver = config('hosting.traefik.cert_resolver', 'letsencrypt');
|
|
|
|
$config['http'] ??= [];
|
|
$config['http']['routers'] ??= [];
|
|
$config['http']['services'] ??= [];
|
|
|
|
$config['http']['routers'][$routerKey] = [
|
|
'rule' => 'Host(`'.$this->escapeHostRule($domain).'`)',
|
|
'entryPoints' => [$entrypoint],
|
|
'service' => $serviceKey,
|
|
'tls' => [
|
|
'certResolver' => $certResolver,
|
|
],
|
|
];
|
|
|
|
$config['http']['services'][$serviceKey] = [
|
|
'loadBalancer' => [
|
|
'servers' => [
|
|
['url' => "http://{$ip}:{$port}"],
|
|
],
|
|
],
|
|
];
|
|
|
|
$this->writeConfig($config);
|
|
Log::info('Traefik: route added', ['domain' => $domain, 'ip' => $ip]);
|
|
}
|
|
|
|
public function removeCustomerRoute(string $domain): void
|
|
{
|
|
$domain = $this->sanitizeDomain($domain);
|
|
$config = $this->loadConfig();
|
|
|
|
$routerKey = $this->routerKey($domain);
|
|
$serviceKey = $this->serviceKey($domain);
|
|
|
|
unset($config['http']['routers'][$routerKey], $config['http']['services'][$serviceKey]);
|
|
|
|
$this->writeConfig($config);
|
|
Log::info('Traefik: route removed', ['domain' => $domain]);
|
|
}
|
|
|
|
public function rebuildAllRoutes(iterable $customers): void
|
|
{
|
|
$config = $this->loadConfig();
|
|
$config['http'] ??= [];
|
|
$config['http']['routers'] = $this->preserveNonManaged($config['http']['routers'] ?? [], 'routers');
|
|
$config['http']['services'] = $this->preserveNonManaged($config['http']['services'] ?? [], 'services');
|
|
|
|
foreach ($customers as $customer) {
|
|
if (empty($customer->domain) || empty($customer->ip_address)) {
|
|
continue;
|
|
}
|
|
|
|
$domain = $this->sanitizeDomain($customer->domain);
|
|
$ip = $this->sanitizeIp($customer->ip_address);
|
|
$routerKey = $this->routerKey($domain);
|
|
$serviceKey = $this->serviceKey($domain);
|
|
$port = (int) config('hosting.traefik.backend_port', 80);
|
|
$entrypoint = config('hosting.traefik.entrypoint', 'websecure');
|
|
$certResolver = config('hosting.traefik.cert_resolver', 'letsencrypt');
|
|
|
|
$config['http']['routers'][$routerKey] = [
|
|
'rule' => 'Host(`'.$this->escapeHostRule($domain).'`)',
|
|
'entryPoints' => [$entrypoint],
|
|
'service' => $serviceKey,
|
|
'tls' => ['certResolver' => $certResolver],
|
|
];
|
|
|
|
$config['http']['services'][$serviceKey] = [
|
|
'loadBalancer' => [
|
|
'servers' => [['url' => "http://{$ip}:{$port}"]],
|
|
],
|
|
];
|
|
}
|
|
|
|
$this->writeConfig($config);
|
|
Log::info('Traefik: all customer routes rebuilt');
|
|
}
|
|
|
|
public function reload(): void
|
|
{
|
|
$command = config('hosting.traefik.reload_command');
|
|
|
|
if (empty($command)) {
|
|
Log::info('Traefik: no reload command configured, skipping reload');
|
|
|
|
return;
|
|
}
|
|
|
|
$result = Process::timeout(30)->run($command);
|
|
|
|
if (! $result->successful()) {
|
|
throw new TraefikException(
|
|
'Traefik reload failed: '.$result->errorOutput(),
|
|
step: 'traefik_reload',
|
|
);
|
|
}
|
|
|
|
Log::info('Traefik: reload triggered');
|
|
}
|
|
|
|
private function loadConfig(): array
|
|
{
|
|
$path = $this->configPath();
|
|
|
|
if (! File::exists($path)) {
|
|
File::ensureDirectoryExists(dirname($path));
|
|
|
|
return ['http' => ['routers' => [], 'services' => []]];
|
|
}
|
|
|
|
$contents = File::get($path);
|
|
|
|
if (trim($contents) === '') {
|
|
return ['http' => ['routers' => [], 'services' => []]];
|
|
}
|
|
|
|
try {
|
|
$parsed = Yaml::parse($contents, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE);
|
|
|
|
return is_array($parsed) ? $parsed : ['http' => ['routers' => [], 'services' => []]];
|
|
} catch (ParseException $e) {
|
|
throw new TraefikException(
|
|
'Failed to parse Traefik config: '.$e->getMessage(),
|
|
step: 'traefik_parse',
|
|
previous: $e,
|
|
);
|
|
}
|
|
}
|
|
|
|
private function writeConfig(array $config): void
|
|
{
|
|
$path = $this->configPath();
|
|
File::ensureDirectoryExists(dirname($path));
|
|
|
|
$yaml = Yaml::dump($config, 6, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
|
|
|
|
if (str_contains($yaml, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $yaml)) {
|
|
throw new TraefikException('Refusing to write invalid Traefik YAML.', step: 'traefik_write');
|
|
}
|
|
|
|
$tempPath = $path.'.'.uniqid('tmp', true);
|
|
|
|
if (File::put($tempPath, $yaml) === false) {
|
|
throw new TraefikException('Failed to write temporary Traefik config.', step: 'traefik_write');
|
|
}
|
|
|
|
if (! @rename($tempPath, $path)) {
|
|
File::delete($tempPath);
|
|
throw new TraefikException('Atomic rename of Traefik config failed.', step: 'traefik_write');
|
|
}
|
|
}
|
|
|
|
private function preserveNonManaged(array $items, string $type): array
|
|
{
|
|
return array_filter(
|
|
$items,
|
|
fn ($key) => ! str_starts_with((string) $key, self::MANAGED_PREFIX),
|
|
ARRAY_FILTER_USE_KEY,
|
|
);
|
|
}
|
|
|
|
private function routerKey(string $domain): string
|
|
{
|
|
return self::MANAGED_PREFIX.$this->slug($domain).'-router';
|
|
}
|
|
|
|
private function serviceKey(string $domain): string
|
|
{
|
|
return self::MANAGED_PREFIX.$this->slug($domain).'-service';
|
|
}
|
|
|
|
private function slug(string $domain): string
|
|
{
|
|
return Str::slug(str_replace('.', '-', $domain), '-');
|
|
}
|
|
|
|
private function sanitizeDomain(string $domain): string
|
|
{
|
|
$domain = strtolower(trim($domain));
|
|
|
|
if (! preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $domain)) {
|
|
throw new TraefikException("Invalid domain: {$domain}", step: 'traefik_validation');
|
|
}
|
|
|
|
return $domain;
|
|
}
|
|
|
|
private function sanitizeIp(string $ip): string
|
|
{
|
|
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
throw new TraefikException("Invalid IP: {$ip}", step: 'traefik_validation');
|
|
}
|
|
|
|
return $ip;
|
|
}
|
|
|
|
private function escapeHostRule(string $domain): string
|
|
{
|
|
return str_replace(['`', '\\'], '', $domain);
|
|
}
|
|
}
|