Add customer domain Traefik routing

This commit is contained in:
Hermes Agent
2026-09-22 12:18:22 +00:00
parent d36a72f421
commit 4476af5aab
5 changed files with 54 additions and 5 deletions
+13 -5
View File
@@ -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 {
<?php if ($message !== null): ?><div class="alert alert-success" role="status"><?= dashboard_escape($message) ?></div><?php endif; ?>
<?php if ($error !== null): ?><div class="alert alert-error" role="alert"><?= dashboard_escape($error) ?></div><?php endif; ?>
<section class="data-section"><div class="section-heading"><div><p class="eyebrow">Neue Buchung</p><h2>Instanz anfragen</h2></div><span class="status-badge">S · M · L</span></div>
<form method="post" class="login-form"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="request_instance"><label for="name">Instanzname</label><input id="name" name="name" required maxlength="120" placeholder="Meine Spielrunde"><label for="billing_period">Abrechnung</label><select id="billing_period" name="billing_period"><option value="monthly">Monatlich</option><option value="yearly">Jährlich (Rabatt)</option></select><label for="plan">Tarif</label><select id="plan" name="plan"><option value="s">S – 2,99 € monatlich / 29,99 € jährlich · 512 MB RAM · 1 CPU · 5 GB</option><option value="m">M – 3,99 € monatlich / 39,99 € jährlich · 2 GB RAM · 2 CPU · 10 GB</option><option value="l">L – 4,99 € monatlich / 49,99 € jährlich · 4 GB RAM · 4 CPU · 25 GB</option></select><label for="payment_method">Zahlungsart</label><select id="payment_method" name="payment_method"><option value="paypal">PayPal</option><option value="bank_transfer">Überweisung</option></select><button type="submit">Instanz anfragen</button></form></section>
<form method="post" class="login-form"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="request_instance"><label for="name">Instanzname</label><input id="name" name="name" required maxlength="120" placeholder="Meine Spielrunde"><label for="custom_domain">Kundeneigene Domain (optional)</label><input id="custom_domain" name="custom_domain" type="text" maxlength="253" placeholder="foundry.example.de" autocomplete="url"><label for="billing_period">Abrechnung</label><select id="billing_period" name="billing_period"><option value="monthly">Monatlich</option><option value="yearly">Jährlich (Rabatt)</option></select><label for="plan">Tarif</label><select id="plan" name="plan"><option value="s">S – 2,99 € monatlich / 29,99 € jährlich · 512 MB RAM · 1 CPU · 5 GB</option><option value="m">M – 3,99 € monatlich / 39,99 € jährlich · 2 GB RAM · 2 CPU · 10 GB</option><option value="l">L – 4,99 € monatlich / 49,99 € jährlich · 4 GB RAM · 4 CPU · 25 GB</option></select><label for="payment_method">Zahlungsart</label><select id="payment_method" name="payment_method"><option value="paypal">PayPal</option><option value="bank_transfer">Überweisung</option></select><button type="submit">Instanz anfragen</button></form></section>
<section class="data-section"><div class="section-heading"><div><p class="eyebrow">Provisionierung</p><h2>Instanzen</h2></div><?php if ($isAdmin): ?><a href="dashboard.php">← Realms</a><?php endif; ?></div>
<?php if ($instances === []): ?><div class="empty-state">Noch keine Instanzen vorhanden.</div><?php else: ?><div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th><?php if ($isAdmin): ?><th>Benutzer</th><?php endif; ?><th>Tarif</th><th>Preis</th><th>Zahlung</th><th>Status</th><th>Adresse</th><th>Aktion</th></tr></thead><tbody><?php foreach ($instances as $instance): ?><tr><td data-label="Name"><strong><?= dashboard_escape((string)$instance['name']) ?></strong></td><?php if ($isAdmin): ?><td data-label="Benutzer"><?= dashboard_escape((string)$instance['username']) ?></td><?php endif; ?><td data-label="Tarif"><?= strtoupper(dashboard_escape((string)$instance['plan'])) ?></td><td data-label="Preis"><?= dashboard_escape(dashboard_money((int)($instance['price_gross_cents'] ?? 0))) ?> (<?= (string)($instance['billing_period'] ?? 'monthly') === 'yearly' ? 'jährlich' : 'monatlich' ?>)</td><td data-label="Zahlung"><?= dashboard_escape((string)$instance['payment_status']) ?></td><td data-label="Status"><span class="container-state <?= dashboard_escape((string)$instance['status']) ?>"><?= dashboard_escape(dashboard_status((string)$instance['status'])) ?></span><?php if (!empty($instance['error_message'])): ?><br><small><?= dashboard_escape((string)$instance['error_message']) ?></small><?php endif; ?></td><td data-label="Adresse"><?php if ($instance['status'] === 'running'): ?>:<?= dashboard_escape((string)$instance['port']) ?><?php else: ?>–<?php endif; ?></td><td data-label="Aktion"><?php if (in_array((string)$instance['status'], ['pending_payment', 'error'], true) && $isAdmin && ($instance['status'] === 'pending_payment' || $instance['payment_status'] === 'paid')): ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="approve_instance"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit"><?= $instance['status'] === 'error' ? 'Erneut provisionieren' : 'Zahlung freigeben' ?></button></form><?php elseif (in_array($instance['status'], ['running', 'stopped'], true)): ?><div class="instance-actions"><form method="post" onsubmit="return confirm('Instanz wirklich neu bereitstellen? Die Daten in /data bleiben erhalten.');"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="redeploy"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Redeploy</button></form><?php if ($instance['status'] === 'running'): ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="stop"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Stoppen</button></form><?php else: ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="start"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Starten</button></form><?php endif; ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="restart"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Neustarten</button></form><?php if ($isAdmin): ?><form method="post" onsubmit="return confirm('Instanz wirklich löschen? Das Datenverzeichnis bleibt erhalten.');"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="delete"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Löschen</button></form><?php endif; ?></div><?php else: ?>–<?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?></section>
<?php if ($instances === []): ?><div class="empty-state">Noch keine Instanzen vorhanden.</div><?php else: ?><div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th><?php if ($isAdmin): ?><th>Benutzer</th><?php endif; ?><th>Tarif</th><th>Preis</th><th>Zahlung</th><th>Status</th><th>Adresse</th><th>Aktion</th></tr></thead><tbody><?php foreach ($instances as $instance): ?><tr><td data-label="Name"><strong><?= dashboard_escape((string)$instance['name']) ?></strong></td><?php if ($isAdmin): ?><td data-label="Benutzer"><?= dashboard_escape((string)$instance['username']) ?></td><?php endif; ?><td data-label="Tarif"><?= strtoupper(dashboard_escape((string)$instance['plan'])) ?></td><td data-label="Preis"><?= dashboard_escape(dashboard_money((int)($instance['price_gross_cents'] ?? 0))) ?> (<?= (string)($instance['billing_period'] ?? 'monthly') === 'yearly' ? 'jährlich' : 'monatlich' ?>)</td><td data-label="Zahlung"><?= dashboard_escape((string)$instance['payment_status']) ?></td><td data-label="Status"><span class="container-state <?= dashboard_escape((string)$instance['status']) ?>"><?= dashboard_escape(dashboard_status((string)$instance['status'])) ?></span><?php if (!empty($instance['error_message'])): ?><br><small><?= dashboard_escape((string)$instance['error_message']) ?></small><?php endif; ?></td><td data-label="Adresse"><?php if ($instance['status'] === 'running'): ?><?= !empty($instance['custom_domain']) ? dashboard_escape((string)$instance['custom_domain']) : ':' . dashboard_escape((string)$instance['port']) ?><?php else: ?>–<?php endif; ?></td><td data-label="Aktion"><?php if (in_array((string)$instance['status'], ['pending_payment', 'error'], true) && $isAdmin && ($instance['status'] === 'pending_payment' || $instance['payment_status'] === 'paid')): ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="approve_instance"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit"><?= $instance['status'] === 'error' ? 'Erneut provisionieren' : 'Zahlung freigeben' ?></button></form><?php elseif (in_array($instance['status'], ['running', 'stopped'], true)): ?><div class="instance-actions"><form method="post" onsubmit="return confirm('Instanz wirklich neu bereitstellen? Die Daten in /data bleiben erhalten.');"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="redeploy"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Redeploy</button></form><?php if ($instance['status'] === 'running'): ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="stop"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Stoppen</button></form><?php else: ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="start"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Starten</button></form><?php endif; ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="restart"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Neustarten</button></form><?php if ($isAdmin): ?><form method="post" onsubmit="return confirm('Instanz wirklich löschen? Das Datenverzeichnis bleibt erhalten.');"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="delete"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Löschen</button></form><?php endif; ?></div><?php else: ?>–<?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?></section>
<?php endif; ?></main></body></html>