diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6a81b..14fec5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Alle Einträge verwenden ISO-8601-Timestamps in UTC. Mehrere Änderungen desselben Tages werden in einem gemeinsamen Eintrag zusammengefasst. +## 2026-09-22T00:00:00+00:00 – Kundendomains und Traefik-Routing + +- Optionale kundeneigene FQDNs können bei der Instanzbuchung erfasst und eindeutig gespeichert werden. +- Bei Erstellung und Redeploy werden Traefik-Docker-Labels für HTTPS, ACME-Zertifikate und Foundry-Port 30000 gesetzt. +- Instanzen mit Kundendomain werden automatisch dem Docker-Netzwerk `traefik-public` zugeordnet. +- Die Migration `004_instance_custom_domain.sql` ergänzt Domain-Spalte und Eindeutigkeitsindex. + ## 2026-09-20T13:48:02+00:00 – Initial Release ### Anwendung diff --git a/dashboard.php b/dashboard.php index 9211972..1e7ed14 100644 --- a/dashboard.php +++ b/dashboard.php @@ -2,6 +2,7 @@ declare(strict_types=1); require_once __DIR__ . '/includes/bootstrap.php'; +require_once __DIR__ . '/includes/traefik.php'; pnpaas_session_start(); if (empty($_SESSION['pnpaas_user_id'])) { @@ -67,6 +68,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $db = pnpaas_db(); if ($action === 'request_instance') { $name = trim((string)($_POST['name'] ?? '')); + $customDomain = strtolower(trim((string)($_POST['custom_domain'] ?? ''))); $planKey = strtolower(trim((string)($_POST['plan'] ?? ''))); $billingPeriod = (string)($_POST['billing_period'] ?? ''); $paymentMethod = (string)($_POST['payment_method'] ?? 'bank_transfer'); @@ -78,11 +80,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $name) ?? 'foundry'); $containerName = 'pnpaas-' . trim($slug, '-') . '-' . bin2hex(random_bytes(3)); + pnpaas_traefik_labels($customDomain !== '' ? $customDomain : null, $containerName); $port = (int)$db->query('SELECT COALESCE(MAX(port), 29999) + 1 FROM instances')->fetchColumn(); if ($port > 60000) throw new RuntimeException('Es sind derzeit keine Ports verfügbar.'); $p = $plans[$planKey]; - $statement = $db->prepare('INSERT INTO instances (owner_user_id, name, plan, billing_period, price_gross_cents, payment_status, payment_method, container_name, port, volume_size_mb, memory_limit_mb, cpu_limit, status) VALUES (:owner, :name, :plan, :billing_period, :price_gross_cents, "pending", :method, :container, :port, :storage, :memory, :cpu, "pending_payment")'); - $statement->execute(['owner' => $userId, 'name' => $name, 'plan' => $planKey, 'billing_period' => $billingPeriod, 'price_gross_cents' => $p[$billingPeriod . '_cents'], 'method' => $paymentMethod, 'container' => $containerName, 'port' => $port, 'storage' => $p['storage'], 'memory' => $p['memory'], 'cpu' => $p['cpu']]); + $statement = $db->prepare('INSERT INTO instances (owner_user_id, name, custom_domain, plan, billing_period, price_gross_cents, payment_status, payment_method, container_name, port, volume_size_mb, memory_limit_mb, cpu_limit, status) VALUES (:owner, :name, :custom_domain, :plan, :billing_period, :price_gross_cents, "pending", :method, :container, :port, :storage, :memory, :cpu, "pending_payment")'); + $statement->execute(['owner' => $userId, 'name' => $name, 'custom_domain' => $customDomain !== '' ? $customDomain : null, 'plan' => $planKey, 'billing_period' => $billingPeriod, 'price_gross_cents' => $p[$billingPeriod . '_cents'], 'method' => $paymentMethod, 'container' => $containerName, 'port' => $port, 'storage' => $p['storage'], 'memory' => $p['memory'], 'cpu' => $p['cpu']]); $message = 'Die Instanzanfrage wurde erfasst und wartet auf die Zahlungsfreigabe.'; } elseif ($action === 'approve_instance' && $isAdmin) { $instanceId = (int)($_POST['instance_id'] ?? 0); @@ -91,12 +94,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $instance = $query->fetch(); if (!$instance) throw new RuntimeException('Diese Instanzanfrage ist nicht mehr freigabefähig.'); $db->prepare('UPDATE instances SET payment_status = "paid", paid_at = CURRENT_TIMESTAMP, status = "provisioning", error_message = NULL WHERE id = :id')->execute(['id' => $instanceId]); - $endpoint = pnpaas_portainer_endpoint_id(); $dataPath = '/opt/foundry/instances/' . $instance['container_name']; + $labels = pnpaas_traefik_labels($instance['custom_domain'] ?? null, (string)$instance['container_name']); $payload = [ 'Image' => 'pnpaas/foundryvtt:14.368', 'Env' => ['FOUNDRY_VERSION=14.368'], 'ExposedPorts' => ['30000/tcp' => new stdClass()], + 'Labels' => $labels, 'HostConfig' => [ 'Binds' => [$dataPath . ':/data'], 'PortBindings' => ['30000/tcp' => [['HostPort' => (string)$instance['port']]]], @@ -105,6 +109,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'RestartPolicy' => ['Name' => 'unless-stopped'], ], ]; + if ($labels !== []) $payload['NetworkingConfig'] = ['EndpointsConfig' => ['traefik-public' => new stdClass()]]; $created = pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/create?name=' . rawurlencode($instance['container_name']), 'POST', $payload); $containerId = (string)($created['Id'] ?? ''); if ($containerId === '') throw new RuntimeException('Portainer hat keine Container-ID geliefert.'); @@ -133,10 +138,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $db->prepare('UPDATE instances SET status = "provisioning", error_message = NULL WHERE id = :id')->execute(['id' => $instanceId]); pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/' . $containerId . '?force=true', 'DELETE', null); $dataPath = '/opt/foundry/instances/' . $instance['container_name']; + $labels = pnpaas_traefik_labels($instance['custom_domain'] ?? null, (string)$instance['container_name']); $payload = [ 'Image' => 'pnpaas/foundryvtt:14.368', 'Env' => ['FOUNDRY_VERSION=14.368'], 'ExposedPorts' => ['30000/tcp' => new stdClass()], + 'Labels' => $labels, 'HostConfig' => [ 'Binds' => [$dataPath . ':/data'], 'PortBindings' => ['30000/tcp' => [['HostPort' => (string)$instance['port']]]], @@ -145,6 +152,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'RestartPolicy' => ['Name' => 'unless-stopped'], ], ]; + if ($labels !== []) $payload['NetworkingConfig'] = ['EndpointsConfig' => ['traefik-public' => new stdClass()]]; $created = pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/create?name=' . rawurlencode($instance['container_name']), 'POST', $payload); $newContainerId = (string)($created['Id'] ?? ''); if ($newContainerId === '') throw new RuntimeException('Portainer hat keine neue Container-ID geliefert.'); @@ -303,7 +311,7 @@ function dashboard_bytes(int $bytes): string {
Neue Buchung
Provisionierung
| Name | Benutzer | Tarif | Preis | Zahlung | Status | Adresse | Aktion |
|---|---|---|---|---|---|---|---|
| = dashboard_escape((string)$instance['name']) ?> | = dashboard_escape((string)$instance['username']) ?> | = strtoupper(dashboard_escape((string)$instance['plan'])) ?> | = dashboard_escape(dashboard_money((int)($instance['price_gross_cents'] ?? 0))) ?> (= (string)($instance['billing_period'] ?? 'monthly') === 'yearly' ? 'jährlich' : 'monatlich' ?>) | = dashboard_escape((string)$instance['payment_status']) ?> | = dashboard_escape(dashboard_status((string)$instance['status'])) ?> = dashboard_escape((string)$instance['error_message']) ?> | := dashboard_escape((string)$instance['port']) ?>– | – |
| Name | Benutzer | Tarif | Preis | Zahlung | Status | Adresse | Aktion |
|---|---|---|---|---|---|---|---|
| = dashboard_escape((string)$instance['name']) ?> | = dashboard_escape((string)$instance['username']) ?> | = strtoupper(dashboard_escape((string)$instance['plan'])) ?> | = dashboard_escape(dashboard_money((int)($instance['price_gross_cents'] ?? 0))) ?> (= (string)($instance['billing_period'] ?? 'monthly') === 'yearly' ? 'jährlich' : 'monatlich' ?>) | = dashboard_escape((string)$instance['payment_status']) ?> | = dashboard_escape(dashboard_status((string)$instance['status'])) ?> = dashboard_escape((string)$instance['error_message']) ?> | = !empty($instance['custom_domain']) ? dashboard_escape((string)$instance['custom_domain']) : ':' . dashboard_escape((string)$instance['port']) ?>– | – |