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); } }