initial commit

This commit is contained in:
TheOnlyMace
2026-05-17 13:26:14 +02:00
commit 75299b723d
176 changed files with 20327 additions and 0 deletions

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