initial commit
This commit is contained in:
233
app/Services/Hosting/Traefik/TraefikGenerator.php
Normal file
233
app/Services/Hosting/Traefik/TraefikGenerator.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user