-
-
-
+
+
+
-
-
-
-
-
Laden...
+
+
+
+
+
+
-
-
+
+
-
-
+
+
-EOHTML
+EOPHP
- # admin.js erstellen (JavaScript separat für bessere Wartbarkeit)
- cat > $INSTALL_DIR/admin.js << 'EOJS'
-// Initialize
-document.addEventListener('DOMContentLoaded', function() {
- loadDomains();
- updateStatistics();
-});
+ cat > "${INSTALL_DIR}/login.php" <<'EOPHP'
+ response.json())
- .then(data => {
- hideLoading();
- if (data.success) {
- displayDomains(data.domains);
- updateStatistics();
- } else {
- showToast('Fehler beim Laden der Domains', 'danger');
- }
- })
- .catch(error => {
- hideLoading();
- showToast('Netzwerkfehler: ' + error, 'danger');
- });
+if (auth_is_logged_in()) {
+ header('Location: index.php');
+ exit;
}
-// Display domains
-function displayDomains(domains) {
- const container = document.getElementById('domainsList');
- container.innerHTML = '';
-
- if (!domains || domains.length === 0) {
- container.innerHTML = '
Keine Domains konfiguriert
';
- return;
+$error = '';
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $user = trim($_POST['username'] ?? '');
+ $pass = (string)($_POST['password'] ?? '');
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
+
+ if (auth_rate_limited($ip)) {
+ $error = 'Zu viele Fehlversuche. Bitte 15 Minuten warten.';
+ } elseif (auth_verify($user, $pass)) {
+ auth_rate_reset($ip);
+ session_regenerate_id(true);
+ $_SESSION['logged_in'] = true;
+ $_SESSION['username'] = $user;
+ $_SESSION['login_at'] = time();
+ header('Location: index.php');
+ exit;
+ } else {
+ auth_rate_record($ip);
+ $error = 'Ungültige Anmeldedaten';
+ usleep(random_int(200000, 600000));
}
+}
+?>
+
+
+
+
+
+
Login — Caddy Admin
+
+
+
+
+
+
Caddy Admin Login
+
+
= htmlspecialchars($error, ENT_QUOTES) ?>
+
+
+
+
+
+
+EOPHP
- domains.forEach(domain => {
- const card = createDomainCard(domain);
- container.innerHTML += card;
- });
+ cat > "${INSTALL_DIR}/logout.php" <<'EOPHP'
+
-
-
-
${statusText}
-
${domain.domain}
-
- Ziel: ${domain.target_ip}:${domain.target_port}
- Protokoll: ${domain.protocol.toUpperCase()}
- SSL: ${domain.ssl_enabled ? '' : ''}
-
-
-
-
-
-
-
-
-
- `;
+ install -d -m 750 -o "$WEB_USER" -g "$WEB_GROUP" "${INSTALL_DIR}/lib"
+ cat > "${INSTALL_DIR}/lib/auth.php" <<'EOPHP'
+ 0,
+ 'path' => '/',
+ 'domain' => '',
+ 'secure' => $secure,
+ 'httponly' => true,
+ 'samesite' => 'Strict',
+ ]);
+ ini_set('session.use_strict_mode', '1');
+ ini_set('session.use_only_cookies', '1');
+ session_start();
+
+ if (!empty($_SESSION['login_at']) && (time() - $_SESSION['login_at']) > SESSION_LIFETIME) {
+ $_SESSION = [];
+ session_destroy();
+ session_start();
+ }
}
-// Save domain
-function saveDomain() {
- const form = document.getElementById('domainForm');
- const formData = new FormData(form);
- const data = Object.fromEntries(formData);
- data.enable_ssl = document.getElementById('enableSsl').checked;
-
- const action = data.id ? 'update' : 'add';
-
- showLoading();
- fetch(`api.php?action=${action}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(data)
- })
- .then(response => response.json())
- .then(result => {
- hideLoading();
- if (result.success) {
- showToast('Domain erfolgreich gespeichert', 'success');
- bootstrap.Modal.getInstance(document.getElementById('addDomainModal')).hide();
- loadDomains();
- form.reset();
- } else {
- showToast('Fehler: ' + result.message, 'danger');
- }
- })
- .catch(error => {
- hideLoading();
- showToast('Netzwerkfehler: ' + error, 'danger');
- });
+function auth_is_logged_in(): bool {
+ return !empty($_SESSION['logged_in']) && !empty($_SESSION['username']);
}
-// Edit domain
-function editDomain(id) {
- showLoading();
- fetch(`api.php?action=get&id=${id}`)
- .then(response => response.json())
- .then(data => {
- hideLoading();
- if (data.success) {
- const domain = data.domain;
- document.getElementById('domainId').value = domain.id;
- document.getElementById('domain').value = domain.domain;
- document.getElementById('targetIp').value = domain.target_ip;
- document.getElementById('targetPort').value = domain.target_port;
- document.getElementById('protocol').value = domain.protocol;
- document.getElementById('enableSsl').checked = domain.ssl_enabled;
- document.getElementById('additionalConfig').value = domain.additional_config || '';
- document.getElementById('modalTitle').textContent = 'Domain bearbeiten';
-
- const modal = new bootstrap.Modal(document.getElementById('addDomainModal'));
- modal.show();
- } else {
- showToast('Fehler beim Laden der Domain', 'danger');
- }
- })
- .catch(error => {
- hideLoading();
- showToast('Netzwerkfehler: ' + error, 'danger');
- });
+function auth_require_login(): void {
+ auth_init_session();
+ if (!auth_is_logged_in()) {
+ http_response_code(401);
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'message' => 'Nicht authentifiziert']);
+ exit;
+ }
}
-// Delete domain
-function deleteDomain(id) {
- if (!confirm('Möchten Sie diese Domain wirklich löschen?')) {
- return;
+function auth_csrf_token(): string {
+ if (empty($_SESSION['csrf'])) {
+ $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
-
- showLoading();
- fetch(`api.php?action=delete&id=${id}`, {
- method: 'DELETE'
- })
- .then(response => response.json())
- .then(result => {
- hideLoading();
- if (result.success) {
- showToast('Domain erfolgreich gelöscht', 'success');
- loadDomains();
- } else {
- showToast('Fehler: ' + result.message, 'danger');
- }
- })
- .catch(error => {
- hideLoading();
- showToast('Netzwerkfehler: ' + error, 'danger');
- });
+ return $_SESSION['csrf'];
}
-// Test connection
-function testConnection(id) {
- showLoading();
- fetch(`api.php?action=test&id=${id}`)
- .then(response => response.json())
- .then(result => {
- hideLoading();
- if (result.success) {
- if (result.reachable) {
- showToast('Verbindung erfolgreich!', 'success');
- } else {
- showToast('Ziel nicht erreichbar', 'warning');
- }
- } else {
- showToast('Test fehlgeschlagen: ' + result.message, 'danger');
- }
- })
- .catch(error => {
- hideLoading();
- showToast('Netzwerkfehler: ' + error, 'danger');
- });
+function auth_check_csrf(): void {
+ $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
+ if (!is_string($token) || empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) {
+ http_response_code(403);
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'message' => 'CSRF-Token ungültig']);
+ exit;
+ }
}
-// Reload Caddy configuration
-function reloadCaddy() {
- if (!confirm('Möchten Sie die Caddy-Konfiguration neu laden?')) {
- return;
+function auth_load(): array {
+ $raw = @file_get_contents(AUTH_FILE);
+ if ($raw === false) return [];
+ $data = json_decode($raw, true);
+ return is_array($data) ? $data : [];
+}
+
+function auth_verify(string $user, string $pass): bool {
+ $a = auth_load();
+ if (empty($a['username']) || empty($a['password_hash'])) return false;
+ if (!hash_equals((string)$a['username'], $user)) {
+ password_verify($pass, '$2y$12$' . str_repeat('a', 53));
+ return false;
}
-
- showLoading();
- fetch('api.php?action=reload', {
- method: 'POST'
- })
- .then(response => response.json())
- .then(result => {
- hideLoading();
- if (result.success) {
- showToast('Caddy erfolgreich neu geladen', 'success');
- } else {
- showToast('Fehler beim Neuladen: ' + result.message, 'danger');
- }
- })
- .catch(error => {
- hideLoading();
- showToast('Netzwerkfehler: ' + error, 'danger');
- });
+ return password_verify($pass, (string)$a['password_hash']);
}
-// Update statistics
-function updateStatistics() {
- fetch('api.php?action=stats')
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- document.getElementById('activeDomains').textContent = data.stats.total;
- document.getElementById('onlineServices').textContent = data.stats.online;
- document.getElementById('offlineServices').textContent = data.stats.offline;
- }
- })
- .catch(error => console.error('Stats error:', error));
+function auth_rate_load(): array {
+ $raw = @file_get_contents(RATE_LIMIT_FILE);
+ if ($raw === false) return [];
+ $data = json_decode($raw, true);
+ return is_array($data) ? $data : [];
}
-// Show loading spinner
-function showLoading() {
- document.querySelector('.loading-spinner').style.display = 'block';
+function auth_rate_save(array $data): void {
+ $fp = @fopen(RATE_LIMIT_FILE, 'c+');
+ if (!$fp) return;
+ flock($fp, LOCK_EX);
+ ftruncate($fp, 0);
+ rewind($fp);
+ fwrite($fp, json_encode($data));
+ fflush($fp);
+ flock($fp, LOCK_UN);
+ fclose($fp);
}
-// Hide loading spinner
-function hideLoading() {
- document.querySelector('.loading-spinner').style.display = 'none';
+function auth_rate_limited(string $ip): bool {
+ $data = auth_rate_load();
+ if (!isset($data[$ip])) return false;
+ $now = time();
+ $data[$ip] = array_filter($data[$ip], fn($t) => $t > $now - RATE_LIMIT_WIN);
+ return count($data[$ip]) >= RATE_LIMIT_MAX;
}
-// Show toast notification
-function showToast(message, type = 'info') {
- const toastId = 'toast-' + Date.now();
- const toastHTML = `
-
- `;
-
- document.querySelector('.toast-container').insertAdjacentHTML('beforeend', toastHTML);
- const toastElement = document.getElementById(toastId);
- const toast = new bootstrap.Toast(toastElement);
- toast.show();
-
- // Remove toast after it's hidden
- toastElement.addEventListener('hidden.bs.toast', () => {
- toastElement.remove();
- });
+function auth_rate_record(string $ip): void {
+ $data = auth_rate_load();
+ $now = time();
+ $data[$ip] ??= [];
+ $data[$ip] = array_filter($data[$ip], fn($t) => $t > $now - RATE_LIMIT_WIN);
+ $data[$ip][] = $now;
+ auth_rate_save($data);
}
-// Reset modal when closed
-document.getElementById('addDomainModal').addEventListener('hidden.bs.modal', function () {
- document.getElementById('domainForm').reset();
- document.getElementById('domainId').value = '';
- document.getElementById('modalTitle').textContent = 'Neue Domain hinzufügen';
-});
-EOJS
+function auth_rate_reset(string $ip): void {
+ $data = auth_rate_load();
+ unset($data[$ip]);
+ auth_rate_save($data);
+}
+EOPHP
- # api.php erstellen (mit angepassten Pfaden)
- cat > $INSTALL_DIR/api.php << 'EOPHP'
+ cat > "${INSTALL_DIR}/api.php" <<'EOPHP'
false, 'message' => 'Config nicht lesbar']);
+ exit;
+ }
+ $cfg = json_decode($raw, true);
+ if (!is_array($cfg)) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'message' => 'Config korrupt']);
+ exit;
+ }
+ return $cfg;
}
-define('DB_FILE', '/var/www/caddy-admin/domains.json');
-define('CADDYFILE_PATH', '/etc/caddy/Caddyfile');
-define('CADDY_API_URL', 'http://localhost:2019');
+function db_file(): string { return load_config()['install_dir'] . '/domains.json'; }
+
+function db_read(): array {
+ $f = db_file();
+ if (!file_exists($f)) return ['domains' => []];
+ $fp = @fopen($f, 'r');
+ if (!$fp) return ['domains' => []];
+ flock($fp, LOCK_SH);
+ $raw = stream_get_contents($fp);
+ flock($fp, LOCK_UN);
+ fclose($fp);
+ $data = json_decode($raw, true);
+ return is_array($data) && isset($data['domains']) ? $data : ['domains' => []];
+}
-if (!file_exists(DB_FILE)) {
- file_put_contents(DB_FILE, json_encode(['domains' => []]));
+function db_write(array $data): bool {
+ $f = db_file();
+ $fp = @fopen($f, 'c+');
+ if (!$fp) return false;
+ if (!flock($fp, LOCK_EX)) { fclose($fp); return false; }
+ ftruncate($fp, 0);
+ rewind($fp);
+ fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ fflush($fp);
+ flock($fp, LOCK_UN);
+ fclose($fp);
+ return true;
}
-$action = $_GET['action'] ?? '';
-$response = ['success' => false, 'message' => 'Invalid action'];
-
-switch ($action) {
- case 'list':
- $response = listDomains();
- break;
- case 'get':
- $id = $_GET['id'] ?? 0;
- $response = getDomain($id);
- break;
- case 'add':
- $data = json_decode(file_get_contents('php://input'), true);
- $response = addDomain($data);
- break;
- case 'update':
- $data = json_decode(file_get_contents('php://input'), true);
- $response = updateDomain($data);
- break;
- case 'delete':
- $id = $_GET['id'] ?? 0;
- $response = deleteDomain($id);
- break;
- case 'test':
- $id = $_GET['id'] ?? 0;
- $response = testConnection($id);
- break;
- case 'reload':
- $response = reloadCaddy();
- break;
- case 'stats':
- $response = getStatistics();
- break;
- case 'health':
- $response = ['success' => true, 'status' => 'healthy', 'timestamp' => time()];
- break;
- default:
- $response = ['success' => false, 'message' => 'Unknown action'];
+function validate_domain(string $d): string {
+ $d = strtolower(trim($d));
+ if ($d === 'localhost') return $d;
+ if (!preg_match('/^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $d)) {
+ throw new RuntimeException('Ungültige Domain');
+ }
+ return $d;
}
-echo json_encode($response);
+function validate_ip(string $ip): string {
+ if (!filter_var($ip, FILTER_VALIDATE_IP)) {
+ throw new RuntimeException('Ungültige IP-Adresse');
+ }
+ return $ip;
+}
-function listDomains() {
- $data = json_decode(file_get_contents(DB_FILE), true);
- return ['success' => true, 'domains' => array_values($data['domains'] ?? [])];
+function validate_port($p): int {
+ $p = (int)$p;
+ if ($p < 1 || $p > 65535) throw new RuntimeException('Ungültiger Port');
+ return $p;
}
-function getDomain($id) {
- $data = json_decode(file_get_contents(DB_FILE), true);
- foreach ($data['domains'] as $domain) {
- if ($domain['id'] == $id) {
- return ['success' => true, 'domain' => $domain];
- }
- }
- return ['success' => false, 'message' => 'Domain not found'];
+function validate_protocol(string $p): string {
+ if (!in_array($p, ['http', 'https'], true)) throw new RuntimeException('Ungültiges Protokoll');
+ return $p;
}
-function addDomain($input) {
- if (!validateDomainInput($input)) {
- return ['success' => false, 'message' => 'Invalid input data'];
- }
-
- $data = json_decode(file_get_contents(DB_FILE), true);
-
- foreach ($data['domains'] as $domain) {
- if ($domain['domain'] === $input['domain']) {
- return ['success' => false, 'message' => 'Domain already exists'];
- }
- }
-
- $maxId = 0;
- foreach ($data['domains'] as $domain) {
- if ($domain['id'] > $maxId) {
- $maxId = $domain['id'];
- }
- }
-
- $newDomain = [
- 'id' => $maxId + 1,
- 'domain' => $input['domain'],
- 'target_ip' => $input['target_ip'],
- 'target_port' => $input['target_port'],
- 'protocol' => $input['protocol'] ?? 'http',
- 'ssl_enabled' => $input['enable_ssl'] ?? false,
- 'additional_config' => $input['additional_config'] ?? '',
- 'status' => 'active',
- 'created_at' => date('Y-m-d H:i:s'),
- 'updated_at' => date('Y-m-d H:i:s')
- ];
-
- $data['domains'][] = $newDomain;
-
- if (file_put_contents(DB_FILE, json_encode($data, JSON_PRETTY_PRINT))) {
- generateCaddyfile($data['domains']);
- return ['success' => true, 'message' => 'Domain added successfully', 'domain' => $newDomain];
- }
-
- return ['success' => false, 'message' => 'Failed to save domain'];
+function safe_log_name(string $domain): string {
+ return preg_replace('/[^a-z0-9._-]/', '_', strtolower($domain));
}
-function updateDomain($input) {
- if (!isset($input['id']) || !validateDomainInput($input)) {
- return ['success' => false, 'message' => 'Invalid input data'];
+function load_php_sock(): string {
+ foreach (['/run/php/php-fpm.sock', '/run/php-fpm/www.sock'] as $p) {
+ if (file_exists($p)) return $p;
}
-
- $data = json_decode(file_get_contents(DB_FILE), true);
- $found = false;
-
- foreach ($data['domains'] as &$domain) {
- if ($domain['id'] == $input['id']) {
- $domain['domain'] = $input['domain'];
- $domain['target_ip'] = $input['target_ip'];
- $domain['target_port'] = $input['target_port'];
- $domain['protocol'] = $input['protocol'] ?? 'http';
- $domain['ssl_enabled'] = $input['enable_ssl'] ?? false;
- $domain['additional_config'] = $input['additional_config'] ?? '';
- $domain['updated_at'] = date('Y-m-d H:i:s');
- $found = true;
- break;
- }
- }
-
- if (!$found) {
- return ['success' => false, 'message' => 'Domain not found'];
+ foreach (glob('/run/php/php*-fpm.sock') ?: [] as $p) {
+ return $p;
}
-
- if (file_put_contents(DB_FILE, json_encode($data, JSON_PRETTY_PRINT))) {
- generateCaddyfile($data['domains']);
- return ['success' => true, 'message' => 'Domain updated successfully'];
- }
-
- return ['success' => false, 'message' => 'Failed to update domain'];
+ return '/run/php-fpm/www.sock';
}
-function deleteDomain($id) {
- $data = json_decode(file_get_contents(DB_FILE), true);
- $newDomains = [];
- $found = false;
-
- foreach ($data['domains'] as $domain) {
- if ($domain['id'] != $id) {
- $newDomains[] = $domain;
- } else {
- $found = true;
+function generate_caddyfile(array $domains): void {
+ $cfg = load_config();
+ $email = preg_replace('/[^A-Za-z0-9@._+\-]/', '', $cfg['admin_email']);
+ $out = "# Auto-generiert vom Caddy Admin Panel\n";
+ $out .= "# Manuelle Änderungen werden bei nächstem Reload überschrieben\n\n";
+ $out .= "{\n";
+ $out .= " admin localhost:2019\n";
+ $out .= " email {$email}\n";
+ $out .= "}\n\n";
+
+ $admin = $cfg['admin_domain'];
+ $port = (int)$cfg['admin_port'];
+ if (!empty($cfg['enable_ssl']) && $admin !== 'localhost') {
+ $out .= "{$admin} {\n";
+ } else {
+ $out .= ":{$port} {\n";
+ if ($admin === 'localhost') {
+ $out .= " tls internal\n";
}
}
-
- if (!$found) {
- return ['success' => false, 'message' => 'Domain not found'];
+ $out .= " root * " . $cfg['install_dir'] . "\n";
+ $out .= " php_fastcgi unix/" . load_php_sock() . "\n";
+ $out .= " file_server\n";
+ $out .= " @denyPrivate path /lib/* /domains.json /.* \n";
+ $out .= " respond @denyPrivate 403\n";
+ $out .= "}\n\n";
+
+ foreach ($domains as $d) {
+ $domain = validate_domain($d['domain']);
+ $proto = validate_protocol($d['protocol'] ?? 'http');
+ $ip = validate_ip($d['target_ip']);
+ $tport = validate_port($d['target_port'] ?? 0);
+ $logname = safe_log_name($domain);
+
+ $out .= "{$domain} {\n";
+ if ($domain === 'localhost') {
+ $out .= " tls internal\n";
+ }
+ $out .= " reverse_proxy {$proto}://{$ip}:{$tport} {\n";
+ $out .= " header_up Host {host}\n";
+ $out .= " header_up X-Real-IP {remote_host}\n";
+ $out .= " header_up X-Forwarded-For {remote_host}\n";
+ $out .= " header_up X-Forwarded-Proto {scheme}\n";
+ $out .= " }\n";
+ $out .= " log {\n";
+ $out .= " output file " . $cfg['log_dir'] . "/{$logname}.log\n";
+ $out .= " }\n";
+ $out .= "}\n\n";
}
-
- $data['domains'] = $newDomains;
-
- if (file_put_contents(DB_FILE, json_encode($data, JSON_PRETTY_PRINT))) {
- generateCaddyfile($data['domains']);
- return ['success' => true, 'message' => 'Domain deleted successfully'];
+
+ if (file_put_contents($cfg['stage_file'], $out) === false) {
+ throw new RuntimeException('Stage-Datei konnte nicht geschrieben werden');
}
-
- return ['success' => false, 'message' => 'Failed to delete domain'];
}
-function testConnection($id) {
- $domainData = getDomain($id);
-
- if (!$domainData['success']) {
- return $domainData;
- }
-
- $domain = $domainData['domain'];
- $url = $domain['protocol'] . '://' . $domain['target_ip'] . ':' . $domain['target_port'];
-
- $ch = curl_init($url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_TIMEOUT, 5);
- curl_setopt($ch, CURLOPT_NOBODY, true);
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
- curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
-
- $result = curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
-
- return ['success' => true, 'reachable' => ($httpCode > 0), 'http_code' => $httpCode, 'url_tested' => $url];
+function reload_caddy(): array {
+ $out = []; $rv = 0;
+ exec('sudo -n ' . escapeshellcmd(RELOAD_HELPER_BIN) . ' 2>&1', $out, $rv);
+ return ['success' => ($rv === 0), 'message' => implode("\n", $out)];
}
-function generateCaddyfile($domains) {
- global $ADMIN_EMAIL;
- $email = getenv('ADMIN_EMAIL') ?: 'admin@example.com';
-
- $caddyfile = "# Caddy Reverse Proxy Configuration\n";
- $caddyfile .= "# Generated by Caddy Admin Panel\n";
- $caddyfile .= "# " . date('Y-m-d H:i:s') . "\n\n";
- $caddyfile .= "{\n";
- $caddyfile .= " admin localhost:2019\n";
- $caddyfile .= " email " . $email . "\n";
- $caddyfile .= "}\n\n";
-
- foreach ($domains as $domain) {
- $caddyfile .= $domain['domain'] . " {\n";
-
- if ($domain['ssl_enabled']) {
- $caddyfile .= " tls internal\n";
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+$action = $_GET['action'] ?? '';
+
+if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
+ auth_check_csrf();
+}
+
+$writeActions = ['add', 'update', 'delete', 'reload'];
+if (in_array($action, $writeActions, true) && $method === 'GET') {
+ http_response_code(405);
+ echo json_encode(['success' => false, 'message' => 'Method Not Allowed']);
+ exit;
+}
+
+try {
+ switch ($action) {
+ case 'list':
+ echo json_encode(['success' => true, 'domains' => array_values(db_read()['domains'] ?? [])]);
+ break;
+ case 'get': {
+ $id = (int)($_GET['id'] ?? 0);
+ foreach (db_read()['domains'] as $d) {
+ if ((int)$d['id'] === $id) { echo json_encode(['success' => true, 'domain' => $d]); exit; }
+ }
+ http_response_code(404);
+ echo json_encode(['success' => false, 'message' => 'Nicht gefunden']);
+ break;
+ }
+ case 'add': {
+ $in = json_decode(file_get_contents('php://input'), true);
+ if (!is_array($in)) throw new RuntimeException('Ungültiger Body');
+ $domain = validate_domain($in['domain'] ?? '');
+ $proto = validate_protocol($in['protocol'] ?? 'http');
+ $ip = validate_ip($in['target_ip'] ?? '');
+ $port = validate_port($in['target_port'] ?? 0);
+ $ssl = !empty($in['enable_ssl']);
+
+ $data = db_read();
+ foreach ($data['domains'] as $d) {
+ if (strcasecmp($d['domain'], $domain) === 0) {
+ throw new RuntimeException('Domain existiert bereits');
+ }
+ }
+ $maxId = 0;
+ foreach ($data['domains'] as $d) { if ((int)$d['id'] > $maxId) $maxId = (int)$d['id']; }
+ $now = date('c');
+ $data['domains'][] = [
+ 'id' => $maxId + 1, 'domain' => $domain, 'target_ip' => $ip,
+ 'target_port' => $port, 'protocol' => $proto, 'ssl_enabled' => $ssl,
+ 'created_at' => $now, 'updated_at' => $now,
+ ];
+ if (!db_write($data)) throw new RuntimeException('Speichern fehlgeschlagen');
+ generate_caddyfile($data['domains']);
+ $r = reload_caddy();
+ if (!$r['success']) throw new RuntimeException('Caddy-Reload: ' . $r['message']);
+ echo json_encode(['success' => true]);
+ break;
}
-
- $target = $domain['protocol'] . '://' . $domain['target_ip'] . ':' . $domain['target_port'];
- $caddyfile .= " reverse_proxy " . $target . " {\n";
- $caddyfile .= " header_up Host {host}\n";
- $caddyfile .= " header_up X-Real-IP {remote}\n";
- $caddyfile .= " header_up X-Forwarded-For {remote}\n";
- $caddyfile .= " header_up X-Forwarded-Proto {scheme}\n";
- $caddyfile .= " }\n";
-
- if (!empty($domain['additional_config'])) {
- $caddyfile .= "\n # Custom configuration\n";
- $lines = explode("\n", $domain['additional_config']);
- foreach ($lines as $line) {
- $caddyfile .= " " . $line . "\n";
+ case 'update': {
+ $in = json_decode(file_get_contents('php://input'), true);
+ if (!is_array($in) || empty($in['id'])) throw new RuntimeException('Ungültiger Body');
+ $id = (int)$in['id'];
+ $domain = validate_domain($in['domain'] ?? '');
+ $proto = validate_protocol($in['protocol'] ?? 'http');
+ $ip = validate_ip($in['target_ip'] ?? '');
+ $port = validate_port($in['target_port'] ?? 0);
+ $ssl = !empty($in['enable_ssl']);
+
+ $data = db_read();
+ $found = false;
+ foreach ($data['domains'] as &$d) {
+ if ((int)$d['id'] === $id) {
+ $d['domain'] = $domain; $d['target_ip'] = $ip; $d['target_port'] = $port;
+ $d['protocol'] = $proto; $d['ssl_enabled'] = $ssl;
+ $d['updated_at'] = date('c');
+ $found = true; break;
+ }
}
+ unset($d);
+ if (!$found) throw new RuntimeException('Nicht gefunden');
+ if (!db_write($data)) throw new RuntimeException('Speichern fehlgeschlagen');
+ generate_caddyfile($data['domains']);
+ $r = reload_caddy();
+ if (!$r['success']) throw new RuntimeException('Caddy-Reload: ' . $r['message']);
+ echo json_encode(['success' => true]);
+ break;
}
-
- $caddyfile .= "\n log {\n";
- $caddyfile .= " output file /var/log/caddy/" . $domain['domain'] . ".log\n";
- $caddyfile .= " }\n";
- $caddyfile .= "}\n\n";
+ case 'delete': {
+ $id = (int)($_GET['id'] ?? 0);
+ $data = db_read();
+ $new = array_values(array_filter($data['domains'], fn($d) => (int)$d['id'] !== $id));
+ if (count($new) === count($data['domains'])) throw new RuntimeException('Nicht gefunden');
+ $data['domains'] = $new;
+ if (!db_write($data)) throw new RuntimeException('Speichern fehlgeschlagen');
+ generate_caddyfile($data['domains']);
+ $r = reload_caddy();
+ if (!$r['success']) throw new RuntimeException('Caddy-Reload: ' . $r['message']);
+ echo json_encode(['success' => true]);
+ break;
+ }
+ case 'test': {
+ $id = (int)($_GET['id'] ?? 0);
+ $found = null;
+ foreach (db_read()['domains'] as $d) { if ((int)$d['id'] === $id) { $found = $d; break; } }
+ if (!$found) throw new RuntimeException('Nicht gefunden');
+ $url = $found['protocol'] . '://' . $found['target_ip'] . ':' . $found['target_port'];
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_NOBODY => true,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ CURLOPT_FOLLOWLOCATION => false,
+ ]);
+ curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $err = curl_error($ch);
+ curl_close($ch);
+ echo json_encode([
+ 'success' => true,
+ 'reachable' => ($code > 0),
+ 'http_code' => $code,
+ 'error' => $err ?: null,
+ ]);
+ break;
+ }
+ case 'stats': {
+ $domains = db_read()['domains'];
+ $online = 0; $offline = 0;
+ foreach ($domains as $d) {
+ $url = $d['protocol'] . '://' . $d['target_ip'] . ':' . $d['target_port'];
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 2,
+ CURLOPT_NOBODY => true,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ ]);
+ curl_exec($ch);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ if ($code > 0) $online++; else $offline++;
+ }
+ echo json_encode(['success' => true, 'stats' => [
+ 'total' => count($domains), 'online' => $online, 'offline' => $offline,
+ ]]);
+ break;
+ }
+ case 'reload':
+ echo json_encode(reload_caddy());
+ break;
+ case 'health':
+ echo json_encode(['success' => true, 'status' => 'healthy', 'timestamp' => time()]);
+ break;
+ default:
+ http_response_code(400);
+ echo json_encode(['success' => false, 'message' => 'Unbekannte Aktion']);
}
-
- $tempFile = sys_get_temp_dir() . '/Caddyfile.' . time();
- file_put_contents($tempFile, $caddyfile);
- exec('sudo /usr/local/bin/update-caddyfile.sh ' . escapeshellarg($tempFile));
-
- return true;
+} catch (Throwable $e) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
+EOPHP
-function reloadCaddy() {
- exec('sudo systemctl reload caddy 2>&1', $output, $return_var);
-
- if ($return_var === 0) {
- return ['success' => true, 'message' => 'Caddy reloaded successfully'];
+ cat > "${INSTALL_DIR}/admin.js" <<'EOJS'
+const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
+
+document.addEventListener('DOMContentLoaded', () => {
+ loadDomains();
+ updateStatistics();
+});
+
+async function api(action, opts = {}) {
+ const init = {
+ method: opts.method || 'GET',
+ headers: { 'Accept': 'application/json' },
+ credentials: 'same-origin',
+ };
+ if (opts.method && opts.method !== 'GET') {
+ init.headers['X-CSRF-Token'] = CSRF;
}
-
- return ['success' => false, 'message' => 'Failed to reload Caddy'];
+ if (opts.body !== undefined) {
+ init.headers['Content-Type'] = 'application/json';
+ init.body = JSON.stringify(opts.body);
+ }
+ const url = `api.php?action=${encodeURIComponent(action)}` +
+ (opts.query ? '&' + new URLSearchParams(opts.query).toString() : '');
+ const res = await fetch(url, init);
+ if (res.status === 401) { window.location.href = 'login.php'; return; }
+ return res.json();
}
-function getStatistics() {
- $data = json_decode(file_get_contents(DB_FILE), true);
- $domains = $data['domains'] ?? [];
-
- $stats = ['total' => count($domains), 'online' => 0, 'offline' => 0];
-
- foreach ($domains as $domain) {
- $url = $domain['protocol'] . '://' . $domain['target_ip'] . ':' . $domain['target_port'];
-
- $ch = curl_init($url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_TIMEOUT, 2);
- curl_setopt($ch, CURLOPT_NOBODY, true);
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
- curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
-
- curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
-
- if ($httpCode > 0) {
- $stats['online']++;
- } else {
- $stats['offline']++;
- }
- }
-
- return ['success' => true, 'stats' => $stats];
+function loadDomains() {
+ showLoading();
+ api('list').then(data => {
+ hideLoading();
+ if (!data || !data.success) return showToast('Fehler beim Laden', 'danger');
+ displayDomains(data.domains);
+ updateStatistics();
+ }).catch(e => { hideLoading(); showToast('Netzwerkfehler: ' + e, 'danger'); });
}
-function validateDomainInput($input) {
- if (empty($input['domain']) || empty($input['target_ip']) || empty($input['target_port'])) {
- return false;
- }
-
- if (!preg_match('/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i', $input['domain'])) {
- return false;
- }
-
- if (!filter_var($input['target_ip'], FILTER_VALIDATE_IP)) {
- return false;
- }
-
- $port = intval($input['target_port']);
- if ($port < 1 || $port > 65535) {
- return false;
+function displayDomains(domains) {
+ const c = document.getElementById('domainsList');
+ c.innerHTML = '';
+ if (!domains || !domains.length) {
+ c.innerHTML = '
Keine Domains konfiguriert
';
+ return;
}
-
- return true;
+ domains.forEach(d => c.insertAdjacentHTML('beforeend', cardHTML(d)));
}
-?>
-EOPHP
- # Environment Variable für E-Mail setzen
- echo "ADMIN_EMAIL=$ADMIN_EMAIL" >> $INSTALL_DIR/.env
-
- echo -e "${GREEN}✓ Admin Panel Dateien erstellt${NC}"
+function cardHTML(d) {
+ const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ return `
+
+
+
+
${esc(d.domain)}
+
+ Ziel: ${esc(d.target_ip)}:${esc(d.target_port)}
+ Protokoll: ${esc(d.protocol.toUpperCase())}
+ SSL: ${d.ssl_enabled ? '' : ''}
+
+
+
+
+
+
+
+
+
`;
}
-# Webserver konfigurieren
-configure_webserver() {
- if $USE_NGINX; then
- configure_nginx
- else
- configure_apache
- fi
+function saveDomain() {
+ const f = document.getElementById('domainForm');
+ const fd = Object.fromEntries(new FormData(f));
+ fd.enable_ssl = document.getElementById('enableSsl').checked;
+ fd.target_port = parseInt(fd.target_port, 10);
+ const action = fd.id ? 'update' : 'add';
+ showLoading();
+ api(action, { method: 'POST', body: fd }).then(r => {
+ hideLoading();
+ if (r && r.success) {
+ showToast('Gespeichert', 'success');
+ bootstrap.Modal.getInstance(document.getElementById('addDomainModal'))?.hide();
+ f.reset();
+ loadDomains();
+ } else {
+ showToast('Fehler: ' + (r?.message || 'unbekannt'), 'danger');
+ }
+ }).catch(e => { hideLoading(); showToast('Netzwerkfehler: ' + e, 'danger'); });
}
-# Nginx konfigurieren
-configure_nginx() {
- echo -e "${YELLOW}→ Konfiguriere Nginx...${NC}"
-
- # Nginx Konfiguration erstellen
- cat > /etc/nginx/sites-available/caddy-admin << EONGINX
-server {
- listen $ADMIN_PORT;
- server_name $ADMIN_DOMAIN;
- root $INSTALL_DIR;
- index index.html index.php;
-
- location / {
- try_files \$uri \$uri/ =404;
- }
+function editDomain(id) {
+ showLoading();
+ api('get', { query: { id } }).then(r => {
+ hideLoading();
+ if (!r || !r.success) return showToast('Fehler beim Laden', 'danger');
+ const d = r.domain;
+ document.getElementById('domainId').value = d.id;
+ document.getElementById('domain').value = d.domain;
+ document.getElementById('targetIp').value = d.target_ip;
+ document.getElementById('targetPort').value = d.target_port;
+ document.getElementById('protocol').value = d.protocol;
+ document.getElementById('enableSsl').checked = !!d.ssl_enabled;
+ document.getElementById('modalTitle').textContent = 'Domain bearbeiten';
+ new bootstrap.Modal(document.getElementById('addDomainModal')).show();
+ }).catch(e => { hideLoading(); showToast('Netzwerkfehler: ' + e, 'danger'); });
+}
- location ~ \.php$ {
- include snippets/fastcgi-php.conf;
- fastcgi_pass unix:/var/run/php/php${PHP_VERSION}-fpm.sock;
- fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
- include fastcgi_params;
- }
+function deleteDomain(id) {
+ if (!confirm('Domain wirklich löschen?')) return;
+ showLoading();
+ api('delete', { method: 'DELETE', query: { id } }).then(r => {
+ hideLoading();
+ if (r && r.success) { showToast('Gelöscht', 'success'); loadDomains(); }
+ else showToast('Fehler: ' + (r?.message || 'unbekannt'), 'danger');
+ }).catch(e => { hideLoading(); showToast('Netzwerkfehler: ' + e, 'danger'); });
+}
- location ~ /\.ht {
- deny all;
- }
+function testConnection(id) {
+ showLoading();
+ api('test', { query: { id } }).then(r => {
+ hideLoading();
+ if (!r) return;
+ if (r.success && r.reachable) showToast(`Erreichbar (HTTP ${r.http_code})`, 'success');
+ else if (r.success) showToast('Nicht erreichbar', 'warning');
+ else showToast('Fehler: ' + r.message, 'danger');
+ }).catch(e => { hideLoading(); showToast('Netzwerkfehler: ' + e, 'danger'); });
+}
- access_log /var/log/nginx/caddy-admin-access.log;
- error_log /var/log/nginx/caddy-admin-error.log;
+function reloadCaddy() {
+ if (!confirm('Caddy-Konfiguration jetzt neu laden?')) return;
+ showLoading();
+ api('reload', { method: 'POST' }).then(r => {
+ hideLoading();
+ if (r && r.success) showToast('Caddy neu geladen', 'success');
+ else showToast('Fehler: ' + (r?.message || 'unbekannt'), 'danger');
+ }).catch(e => { hideLoading(); showToast('Netzwerkfehler: ' + e, 'danger'); });
}
-EONGINX
-
- # Site aktivieren
- ln -sf /etc/nginx/sites-available/caddy-admin /etc/nginx/sites-enabled/
-
- # Default site deaktivieren
- rm -f /etc/nginx/sites-enabled/default
-
- # Nginx neustarten
- systemctl restart nginx
-
- echo -e "${GREEN}✓ Nginx konfiguriert${NC}"
+
+function updateStatistics() {
+ api('stats').then(r => {
+ if (!r || !r.success) return;
+ document.getElementById('activeDomains').textContent = r.stats.total;
+ document.getElementById('onlineServices').textContent = r.stats.online;
+ document.getElementById('offlineServices').textContent = r.stats.offline;
+ }).catch(() => {});
}
-# Apache konfigurieren
-configure_apache() {
- echo -e "${YELLOW}→ Konfiguriere Apache...${NC}"
-
- # Apache Konfiguration erstellen
- if [[ "$OS_ID" == "ubuntu" ]] || [[ "$OS_ID" == "debian" ]]; then
- APACHE_SITES="/etc/apache2/sites-available"
- APACHE_CONF="/etc/apache2/apache2.conf"
-
- # Port hinzufügen
- if ! grep -q "Listen $ADMIN_PORT" /etc/apache2/ports.conf; then
- echo "Listen $ADMIN_PORT" >> /etc/apache2/ports.conf
- fi
-
- elif [[ "$OS_ID" == "centos" ]] || [[ "$OS_ID" == "rhel" ]] || [[ "$OS_ID" == "fedora" ]]; then
- APACHE_SITES="/etc/httpd/conf.d"
- APACHE_CONF="/etc/httpd/conf/httpd.conf"
-
- # Port hinzufügen
- if ! grep -q "Listen $ADMIN_PORT" $APACHE_CONF; then
- echo "Listen $ADMIN_PORT" >> $APACHE_CONF
- fi
+function showLoading() { document.querySelector('.loading-spinner').style.display = 'block'; }
+function hideLoading() { document.querySelector('.loading-spinner').style.display = 'none'; }
+
+function showToast(msg, type = 'info') {
+ const id = 't' + Date.now();
+ const html = `
`;
+ document.querySelector('.toast-container').insertAdjacentHTML('beforeend', html);
+ const el = document.getElementById(id);
+ new bootstrap.Toast(el).show();
+ el.addEventListener('hidden.bs.toast', () => el.remove());
+}
+
+document.getElementById('addDomainModal').addEventListener('hidden.bs.modal', () => {
+ document.getElementById('domainForm').reset();
+ document.getElementById('domainId').value = '';
+ document.getElementById('modalTitle').textContent = 'Neue Domain hinzufügen';
+});
+EOJS
+
+ if [[ ! -f "${INSTALL_DIR}/domains.json" ]]; then
+ echo '{"domains": []}' > "${INSTALL_DIR}/domains.json"
fi
-
- # Virtual Host erstellen
- cat > $APACHE_SITES/caddy-admin.conf << EOAPACHE
-
- ServerName $ADMIN_DOMAIN
- DocumentRoot $INSTALL_DIR
-
-
- Options Indexes FollowSymLinks
- AllowOverride All
- Require all granted
-
-
- ErrorLog \${APACHE_LOG_DIR}/caddy-admin-error.log
- CustomLog \${APACHE_LOG_DIR}/caddy-admin-access.log combined
-
-EOAPACHE
-
- # Site aktivieren und Apache neustarten
- if [[ "$OS_ID" == "ubuntu" ]] || [[ "$OS_ID" == "debian" ]]; then
- a2ensite caddy-admin > /dev/null 2>&1
- a2dissite 000-default > /dev/null 2>&1
- systemctl restart apache2
- else
- systemctl restart httpd
+ if [[ ! -f "$RATE_LIMIT_FILE" ]]; then
+ echo '{}' > "$RATE_LIMIT_FILE"
fi
-
- echo -e "${GREEN}✓ Apache konfiguriert${NC}"
-}
-# Caddy konfigurieren
-configure_caddy() {
- echo -e "${YELLOW}→ Konfiguriere Caddy...${NC}"
-
- # Basis Caddyfile erstellen
- cat > $CADDY_CONFIG_DIR/Caddyfile << EOCADDY
-{
- admin localhost:2019
- email $ADMIN_EMAIL
-}
+ chown -R "${WEB_USER}:${WEB_GROUP}" "$INSTALL_DIR"
+ find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
+ find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
+ chmod 660 "${INSTALL_DIR}/domains.json"
-# Admin Panel (falls SSL aktiviert)
-EOCADDY
+ chown -R "${WEB_USER}:${WEB_GROUP}" "$STAGE_DIR"
+ chmod 660 "$RATE_LIMIT_FILE" 2>/dev/null || true
- if $ENABLE_SSL && [[ "$ADMIN_DOMAIN" != "localhost" ]]; then
- cat >> $CADDY_CONFIG_DIR/Caddyfile << EOCADDY
-$ADMIN_DOMAIN {
- reverse_proxy localhost:$ADMIN_PORT
+ ok "Panel-Dateien geschrieben (Permissions restriktiv)"
}
-EOCADDY
+# ---- Initiales Caddyfile + Reload-Helper -----------------------------------
+
+write_caddyfile_initial() {
+ info "Schreibe initiales Caddyfile..."
+ {
+ echo "# Initiales Caddyfile (vom Installer)"
+ echo "{"
+ echo " admin localhost:2019"
+ echo " email ${ADMIN_EMAIL}"
+ echo "}"
+ echo
+ if $ENABLE_SSL && [[ "$ADMIN_DOMAIN" != "localhost" ]]; then
+ echo "${ADMIN_DOMAIN} {"
+ else
+ echo ":${ADMIN_PORT} {"
+ if [[ "$ADMIN_DOMAIN" == "localhost" ]]; then
+ echo " tls internal"
+ fi
+ fi
+ echo " root * ${INSTALL_DIR}"
+ echo " php_fastcgi unix/${PHP_FPM_SOCK}"
+ echo " file_server"
+ echo " @denyPrivate path /lib/* /domains.json /.*"
+ echo " respond @denyPrivate 403"
+ echo "}"
+ } > "$CADDY_LIVE_FILE"
+
+ chown root:caddy "$CADDY_LIVE_FILE" 2>/dev/null || chown root:root "$CADDY_LIVE_FILE"
+ chmod 644 "$CADDY_LIVE_FILE"
+
+ if ! caddy validate --config "$CADDY_LIVE_FILE" --adapter caddyfile >/dev/null 2>&1; then
+ warn "Caddyfile-Validierung fehlgeschlagen — bitte $CADDY_LIVE_FILE prüfen"
fi
-
- # Caddy neustarten
- systemctl restart caddy
-
- echo -e "${GREEN}✓ Caddy konfiguriert${NC}"
+ ok "Caddyfile geschrieben: $CADDY_LIVE_FILE"
}
-# Sudo-Berechtigungen konfigurieren
-configure_sudo() {
- echo -e "${YELLOW}→ Konfiguriere Sudo-Berechtigungen...${NC}"
-
- # Update-Script erstellen
- cat > /usr/local/bin/update-caddyfile.sh << 'EOSCRIPT'
+write_reload_helper() {
+ info "Schreibe Reload-Helper..."
+ cat > "$RELOAD_HELPER" <<'EOSH'
#!/bin/bash
-TEMP_FILE=$1
-CADDY_FILE="/etc/caddy/Caddyfile"
+# Reload-Helper für das Caddy Admin Panel.
+# Wird von www-data via sudo OHNE ARGUMENTE aufgerufen.
+# Liest fix /var/lib/caddy-admin/Caddyfile.staged — keine Argumente von außen.
+
+set -euo pipefail
+
+STAGE="/var/lib/caddy-admin/Caddyfile.staged"
+LIVE="/etc/caddy/Caddyfile"
BACKUP_DIR="/var/backups/caddy"
+MAX_BACKUPS_PER_DAY=10
-mkdir -p $BACKUP_DIR
-cp $CADDY_FILE "$BACKUP_DIR/Caddyfile.$(date +%Y%m%d_%H%M%S)"
-
-if caddy validate --config $TEMP_FILE 2>/dev/null; then
- cp $TEMP_FILE $CADDY_FILE
- systemctl reload caddy
- echo "Caddyfile updated successfully"
- exit 0
-else
- echo "Invalid Caddyfile"
- exit 1
+[[ -f "$STAGE" ]] || { echo "Kein gestagedes Caddyfile vorhanden: $STAGE" >&2; exit 1; }
+
+if ! /usr/bin/caddy validate --config "$STAGE" --adapter caddyfile >/dev/null 2>&1; then
+ /usr/bin/caddy validate --config "$STAGE" --adapter caddyfile >&2 || true
+ echo "Caddyfile-Validierung fehlgeschlagen, Reload abgebrochen" >&2
+ exit 2
fi
-EOSCRIPT
-
- chmod +x /usr/local/bin/update-caddyfile.sh
-
- # Sudoers Datei erstellen
- cat > /etc/sudoers.d/caddy-admin << EOSUDO
-# Caddy Admin Panel Permissions
-www-data ALL=(ALL) NOPASSWD: /usr/bin/caddy reload
-www-data ALL=(ALL) NOPASSWD: /usr/bin/caddy validate
-www-data ALL=(ALL) NOPASSWD: /usr/local/bin/update-caddyfile.sh
-www-data ALL=(ALL) NOPASSWD: /bin/systemctl reload caddy
-www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart caddy
-EOSUDO
- chmod 440 /etc/sudoers.d/caddy-admin
-
- echo -e "${GREEN}✓ Sudo-Berechtigungen konfiguriert${NC}"
-}
+mkdir -p "$BACKUP_DIR"
+TODAY="$(date +%Y%m%d)"
+COUNT=$(find "$BACKUP_DIR" -maxdepth 1 -name "Caddyfile.${TODAY}_*" -type f | wc -l)
+if [[ -f "$LIVE" && "$COUNT" -lt "$MAX_BACKUPS_PER_DAY" ]]; then
+ cp -p "$LIVE" "${BACKUP_DIR}/Caddyfile.${TODAY}_$(date +%H%M%S)"
+fi
-# Dateiberechtigungen setzen
-set_permissions() {
- echo -e "${YELLOW}→ Setze Dateiberechtigungen...${NC}"
-
- # Verzeichnisse erstellen
- mkdir -p $LOG_DIR
- mkdir -p $BACKUP_DIR
-
- # Berechtigungen setzen
- chown -R www-data:www-data $INSTALL_DIR
- chmod 755 $INSTALL_DIR
- chmod 644 $INSTALL_DIR/*
- chmod 755 $INSTALL_DIR/api.php
-
- # Log-Verzeichnis
- chown caddy:caddy $LOG_DIR
- chmod 755 $LOG_DIR
-
- # Backup-Verzeichnis
- chown www-data:www-data $BACKUP_DIR
- chmod 755 $BACKUP_DIR
-
- # JSON Datei erstellen
- touch $INSTALL_DIR/domains.json
- chown www-data:www-data $INSTALL_DIR/domains.json
- chmod 664 $INSTALL_DIR/domains.json
-
- echo -e "${GREEN}✓ Dateiberechtigungen gesetzt${NC}"
+install -m 644 -o root -g caddy "$STAGE" "$LIVE" 2>/dev/null \
+ || install -m 644 -o root -g root "$STAGE" "$LIVE"
+/bin/systemctl reload caddy
+echo "Caddy erfolgreich neu geladen"
+EOSH
+ chown root:root "$RELOAD_HELPER"
+ chmod 750 "$RELOAD_HELPER"
+ ok "Reload-Helper installiert: $RELOAD_HELPER"
}
-# Firewall konfigurieren
-configure_firewall() {
- if $ENABLE_FIREWALL; then
- echo -e "${YELLOW}→ Konfiguriere Firewall...${NC}"
-
- # UFW installieren falls nicht vorhanden
- if ! command -v ufw &> /dev/null; then
- $PKG_INSTALL ufw > /dev/null 2>&1
- fi
-
- # Firewall-Regeln
- ufw allow 22/tcp > /dev/null 2>&1 # SSH
- ufw allow 80/tcp > /dev/null 2>&1 # HTTP
- ufw allow 443/tcp > /dev/null 2>&1 # HTTPS
- ufw allow $ADMIN_PORT/tcp > /dev/null 2>&1 # Admin Panel
-
- # Firewall aktivieren
- ufw --force enable > /dev/null 2>&1
-
- echo -e "${GREEN}✓ Firewall konfiguriert${NC}"
+setup_sudoers() {
+ info "Konfiguriere Sudo (minimal, ohne Argumente)..."
+ cat > "${SUDOERS_FILE}.tmp" <
/dev/null 2>&1
-
- if $USE_NGINX; then
- $PKG_INSTALL python3-certbot-nginx > /dev/null 2>&1
- certbot --nginx -d $ADMIN_DOMAIN --non-interactive --agree-tos -m $ADMIN_EMAIL > /dev/null 2>&1
- else
- $PKG_INSTALL python3-certbot-apache > /dev/null 2>&1
- certbot --apache -d $ADMIN_DOMAIN --non-interactive --agree-tos -m $ADMIN_EMAIL > /dev/null 2>&1
- fi
- fi
-
- echo -e "${GREEN}✓ SSL-Zertifikat eingerichtet${NC}"
+# ---- Firewall + Backup -----------------------------------------------------
+
+setup_firewall() {
+ if ! $ENABLE_FIREWALL; then
+ ok "Firewall-Konfiguration übersprungen"
+ return
+ fi
+ info "Konfiguriere Firewall..."
+ if ! command -v ufw >/dev/null 2>&1; then
+ $PKG_INSTALL ufw >/dev/null
fi
+ ufw allow 22/tcp >/dev/null 2>&1 || true
+ ufw allow 80/tcp >/dev/null 2>&1 || true
+ ufw allow 443/tcp >/dev/null 2>&1 || true
+ ufw allow "${ADMIN_PORT}/tcp" >/dev/null 2>&1 || true
+ ufw --force enable >/dev/null 2>&1 || warn "ufw enable fehlgeschlagen"
+ ok "Firewall konfiguriert (22, 80, 443, ${ADMIN_PORT})"
}
-# Backup-Script erstellen
-create_backup_script() {
- echo -e "${YELLOW}→ Erstelle Backup-Script...${NC}"
-
- cat > /usr/local/bin/backup-caddy.sh << 'EOBACKUP'
+write_backup_script() {
+ info "Schreibe Backup-Skript..."
+ cat > /usr/local/bin/backup-caddy.sh </dev/null || true
fi
-
-# Alte Backups löschen (älter als 30 Tage)
-find $BACKUP_DIR -type f -mtime +30 -delete
-
-echo "Backup completed: $BACKUP_DIR"
+find "\$BACKUP_DIR" -type f -mtime +30 -delete
+echo "Backup completed: \$BACKUP_DIR"
EOBACKUP
-
- chmod +x /usr/local/bin/backup-caddy.sh
-
- # Cron-Job für tägliches Backup
+ chmod 750 /usr/local/bin/backup-caddy.sh
echo "0 2 * * * root /usr/local/bin/backup-caddy.sh" > /etc/cron.d/caddy-backup
-
- echo -e "${GREEN}✓ Backup-Script erstellt${NC}"
+ chmod 644 /etc/cron.d/caddy-backup
+ ok "Backup-Skript installiert (täglich 02:00)"
+}
+
+# ---- Services ---------------------------------------------------------------
+
+start_services() {
+ info "Starte Services..."
+ systemctl restart "$PHP_FPM_SERVICE"
+ systemctl restart caddy
+ ok "Services gestartet"
}
-# Service Status überprüfen
check_services() {
- echo ""
- echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}"
- echo -e "${YELLOW}Service Status:${NC}"
- echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}"
-
- # Caddy
- if systemctl is-active --quiet caddy; then
- echo -e " Caddy: ${GREEN}✓ Läuft${NC}"
- else
- echo -e " Caddy: ${RED}✗ Gestoppt${NC}"
- fi
-
- # Webserver
- if $USE_NGINX; then
- if systemctl is-active --quiet nginx; then
- echo -e " Nginx: ${GREEN}✓ Läuft${NC}"
- else
- echo -e " Nginx: ${RED}✗ Gestoppt${NC}"
- fi
- else
- if [[ "$OS_ID" == "ubuntu" ]] || [[ "$OS_ID" == "debian" ]]; then
- if systemctl is-active --quiet apache2; then
- echo -e " Apache: ${GREEN}✓ Läuft${NC}"
- else
- echo -e " Apache: ${RED}✗ Gestoppt${NC}"
- fi
+ hr
+ echo -e "${YELLOW}Service-Status:${NC}"
+ hr
+ for svc in caddy "$PHP_FPM_SERVICE"; do
+ if systemctl is-active --quiet "$svc"; then
+ echo -e " $svc: ${GREEN}✓ läuft${NC}"
else
- if systemctl is-active --quiet httpd; then
- echo -e " Apache: ${GREEN}✓ Läuft${NC}"
- else
- echo -e " Apache: ${RED}✗ Gestoppt${NC}"
- fi
+ echo -e " $svc: ${RED}✗ gestoppt${NC}"
fi
- fi
+ done
}
-# Installation abschließen
-finish_installation() {
- echo ""
- echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
- echo -e "${GREEN} Installation erfolgreich abgeschlossen! ${NC}"
- echo -e "${GREEN}══════════════════════════════════════════════════════════════${NC}"
- echo ""
- echo -e "${BLUE}Zugriff auf Admin Panel:${NC}"
-
+print_summary() {
+ echo
+ hr
+ echo -e "${GREEN}Installation erfolgreich abgeschlossen!${NC}"
+ hr
+ echo -e "${BLUE}Zugriff auf das Admin Panel:${NC}"
if [[ "$ADMIN_DOMAIN" == "localhost" ]]; then
- echo -e " URL: ${GREEN}http://$(hostname -I | awk '{print $1}'):$ADMIN_PORT${NC}"
+ local ip; ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
+ echo -e " URL: ${GREEN}https://${ip:-127.0.0.1}:${ADMIN_PORT}/${NC}"
+ echo -e " ${YELLOW}(self-signed Caddy-CA — beim ersten Zugriff im Browser akzeptieren)${NC}"
+ elif $ENABLE_SSL; then
+ echo -e " URL: ${GREEN}https://${ADMIN_DOMAIN}/${NC}"
+ echo -e " ${YELLOW}(DNS muss auf diese Maschine zeigen, sonst schlägt Let's Encrypt fehl)${NC}"
else
- if $ENABLE_SSL; then
- echo -e " URL: ${GREEN}https://$ADMIN_DOMAIN${NC}"
- else
- echo -e " URL: ${GREEN}http://$ADMIN_DOMAIN:$ADMIN_PORT${NC}"
- fi
+ echo -e " URL: ${GREEN}http://${ADMIN_DOMAIN}:${ADMIN_PORT}/${NC}"
fi
-
- echo ""
+ echo -e " User: ${BLUE}${ADMIN_USER}${NC}"
+ echo
echo -e "${BLUE}Wichtige Pfade:${NC}"
- echo -e " Web-Dateien: ${YELLOW}$INSTALL_DIR${NC}"
- echo -e " Caddy Config: ${YELLOW}$CADDY_CONFIG_DIR/Caddyfile${NC}"
- echo -e " Logs: ${YELLOW}$LOG_DIR${NC}"
- echo -e " Backups: ${YELLOW}$BACKUP_DIR${NC}"
-
- echo ""
- echo -e "${BLUE}Nützliche Befehle:${NC}"
- echo -e " Status prüfen: ${YELLOW}systemctl status caddy${NC}"
- echo -e " Logs anzeigen: ${YELLOW}journalctl -u caddy -f${NC}"
- echo -e " Backup erstellen: ${YELLOW}/usr/local/bin/backup-caddy.sh${NC}"
-
- echo ""
- echo -e "${YELLOW}Hinweis: Vergessen Sie nicht, die DNS-Einträge für Ihre Domains${NC}"
- echo -e "${YELLOW}auf die öffentliche IP dieses Servers zu zeigen!${NC}"
- echo ""
+ echo -e " Web-Dateien: ${YELLOW}${INSTALL_DIR}${NC}"
+ echo -e " Caddyfile: ${YELLOW}${CADDY_LIVE_FILE}${NC}"
+ echo -e " Auth/Config: ${YELLOW}${ADMIN_CONFIG_DIR}${NC}"
+ echo -e " Stage: ${YELLOW}${STAGE_DIR}${NC}"
+ echo -e " Logs: ${YELLOW}${LOG_DIR}${NC}"
+ echo -e " Backups: ${YELLOW}${BACKUP_DIR}${NC}"
+ echo
+ echo -e "${BLUE}Befehle:${NC}"
+ echo -e " Status: ${YELLOW}systemctl status caddy ${PHP_FPM_SERVICE}${NC}"
+ echo -e " Caddy-Logs: ${YELLOW}journalctl -u caddy -f${NC}"
+ echo -e " Backup: ${YELLOW}/usr/local/bin/backup-caddy.sh${NC}"
+ echo
+ echo -e "${YELLOW}Sicherheits-Hinweise:${NC}"
+ echo -e " • Verwende öffentliche Erreichbarkeit nur mit aktiviertem Let's-Encrypt-SSL."
+ echo -e " • Bei localhost ohne TLS niemals direkt im Internet exponieren."
+ echo -e " • Passwort-Reset: ${ADMIN_AUTH_FILE} neu schreiben (bcrypt-Hash, mode 0640)."
}
-# Main Installation
+# ---- Main -------------------------------------------------------------------
+
main() {
check_root
show_banner
detect_os
- get_user_input
-
- echo ""
- echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}"
+ prompt_config
+
+ hr
echo -e "${YELLOW}Starte Installation...${NC}"
- echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}"
-
+ hr
+
update_system
- install_base_packages
install_caddy
install_php
- install_webserver
- create_admin_panel_files
- configure_webserver
- configure_caddy
- configure_sudo
- set_permissions
- configure_firewall
- setup_ssl
- create_backup_script
+
+ setup_directories
+ write_panel_files
+ write_config_files
+ write_caddyfile_initial
+ write_reload_helper
+ setup_sudoers
+ write_backup_script
+ setup_firewall
+ start_services
+
check_services
- finish_installation
+ print_summary
}
-# Script starten
-main
+# Nur ausführen, wenn das Skript direkt aufgerufen wird — nicht beim Sourcen
+# (z.B. aus den Bats-Tests, die die Validierungs-Funktionen prüfen).
+if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then
+ main "$@"
+fi
diff --git a/tests/extract-php.sh b/tests/extract-php.sh
new file mode 100755
index 0000000..2b674ed
--- /dev/null
+++ b/tests/extract-php.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Extrahiert die in install.sh per Heredoc eingebetteten PHP-Dateien
+# in tests/_extracted/*.php — damit php -l sie lintet.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+INSTALL_SH="${SCRIPT_DIR}/../install.sh"
+OUT_DIR="${SCRIPT_DIR}/_extracted"
+
+[[ -f "$INSTALL_SH" ]] || { echo "install.sh nicht gefunden: $INSTALL_SH" >&2; exit 1; }
+
+mkdir -p "$OUT_DIR"
+rm -f "${OUT_DIR}"/*.php
+
+awk -v out="$OUT_DIR" '
+ /cat > "\$\{INSTALL_DIR\}\/index.php" <<.EOPHP/ { name="index.php"; cap=1; next }
+ /cat > "\$\{INSTALL_DIR\}\/login.php" <<.EOPHP/ { name="login.php"; cap=1; next }
+ /cat > "\$\{INSTALL_DIR\}\/logout.php" <<.EOPHP/ { name="logout.php"; cap=1; next }
+ /cat > "\$\{INSTALL_DIR\}\/lib\/auth.php" <<.EOPHP/{ name="auth.php"; cap=1; next }
+ /cat > "\$\{INSTALL_DIR\}\/api.php" <<.EOPHP/ { name="api.php"; cap=1; next }
+ /^EOPHP$/ { cap=0; name=""; next }
+ cap && name != "" { print > (out "/" name) }
+' "$INSTALL_SH"
+
+extracted=$(find "$OUT_DIR" -maxdepth 1 -name '*.php' -type f | wc -l)
+if [[ "$extracted" -lt 5 ]]; then
+ echo "Erwartete 5 extrahierte PHP-Dateien, fand $extracted" >&2
+ ls -la "$OUT_DIR" >&2
+ exit 1
+fi
+
+echo "Extrahiert nach $OUT_DIR:"
+ls -la "$OUT_DIR"
diff --git a/tests/install.bats b/tests/install.bats
new file mode 100644
index 0000000..609b1c3
--- /dev/null
+++ b/tests/install.bats
@@ -0,0 +1,134 @@
+#!/usr/bin/env bats
+# Bats-Unit-Tests für die Validierungsfunktionen aus install.sh.
+# install.sh wird gesourct — main() läuft nur, wenn die Datei direkt aufgerufen
+# wird (BASH_SOURCE-Guard am Ende von install.sh).
+
+setup() {
+ # shellcheck source=/dev/null
+ source "${BATS_TEST_DIRNAME}/../install.sh"
+}
+
+# ---- is_valid_domain --------------------------------------------------------
+
+@test "is_valid_domain: akzeptiert Standard-Domain" {
+ is_valid_domain "example.com"
+}
+
+@test "is_valid_domain: akzeptiert Subdomain" {
+ is_valid_domain "admin.example.com"
+}
+
+@test "is_valid_domain: akzeptiert mehrstufige Subdomain" {
+ is_valid_domain "a.b.c.example.org"
+}
+
+@test "is_valid_domain: akzeptiert Bindestriche" {
+ is_valid_domain "my-app.example.io"
+}
+
+@test "is_valid_domain: akzeptiert Wildcard" {
+ is_valid_domain "*.example.com"
+}
+
+@test "is_valid_domain: lehnt leeren String ab" {
+ run is_valid_domain ""
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_domain: lehnt fehlende TLD ab" {
+ run is_valid_domain "noTLD"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_domain: lehnt führenden Bindestrich ab" {
+ run is_valid_domain "-bad.com"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_domain: lehnt nachgestellten Bindestrich ab" {
+ run is_valid_domain "bad-.com"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_domain: lehnt zu kurze TLD ab" {
+ run is_valid_domain "foo.c"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_domain: lehnt Großbuchstaben ab" {
+ run is_valid_domain "Example.com"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_domain: lehnt Whitespace ab" {
+ run is_valid_domain "exa mple.com"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_domain: lehnt Caddyfile-Injection-Versuch ab" {
+ run is_valid_domain "evil.com { admin off }"
+ [ "$status" -ne 0 ]
+}
+
+# ---- is_valid_email ---------------------------------------------------------
+
+@test "is_valid_email: akzeptiert Standardformat" {
+ is_valid_email "user@example.com"
+}
+
+@test "is_valid_email: akzeptiert Plus-Tag" {
+ is_valid_email "user+tag@example.com"
+}
+
+@test "is_valid_email: akzeptiert Dot in Local-Part" {
+ is_valid_email "first.last@example.co.uk"
+}
+
+@test "is_valid_email: lehnt leeren String ab" {
+ run is_valid_email ""
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_email: lehnt fehlendes @ ab" {
+ run is_valid_email "noatsign.com"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_email: lehnt leeren Local-Part ab" {
+ run is_valid_email "@example.com"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_email: lehnt fehlende Domain ab" {
+ run is_valid_email "user@"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_email: lehnt fehlende TLD ab" {
+ run is_valid_email "user@example"
+ [ "$status" -ne 0 ]
+}
+
+# ---- Konstanten / Pfad-Setup ------------------------------------------------
+
+@test "Pfade: alle wichtigen Konstanten gesetzt" {
+ [ -n "$INSTALL_DIR" ]
+ [ -n "$ADMIN_CONFIG_DIR" ]
+ [ -n "$ADMIN_AUTH_FILE" ]
+ [ -n "$STAGE_FILE" ]
+ [ -n "$RELOAD_HELPER" ]
+ [ -n "$SUDOERS_FILE" ]
+}
+
+@test "Pfade: STAGE_FILE liegt unter STAGE_DIR" {
+ [[ "$STAGE_FILE" == "$STAGE_DIR"/* ]]
+}
+
+@test "Pfade: ADMIN_AUTH_FILE liegt außerhalb INSTALL_DIR (DocumentRoot)" {
+ [[ "$ADMIN_AUTH_FILE" != "$INSTALL_DIR"/* ]]
+}
+
+@test "Defaults: ENABLE_FIREWALL true, ENABLE_SSL false" {
+ [ "$ENABLE_FIREWALL" = "true" ]
+ [ "$ENABLE_SSL" = "false" ]
+}
diff --git a/tests/sample-caddyfile b/tests/sample-caddyfile
new file mode 100644
index 0000000..d0d5895
--- /dev/null
+++ b/tests/sample-caddyfile
@@ -0,0 +1,56 @@
+# Repräsentative Caddyfile-Form, die install.sh zur Laufzeit generiert.
+# Wird im CI-Job 'caddyfile-validation' per `caddy validate` geprüft.
+# IPs aus RFC 5737 (Documentation), Domains aus RFC 2606 (.example).
+
+{
+ admin localhost:2019
+ email admin@example.com
+}
+
+# Admin-Panel-Site (HTTPS-Variante mit Auto-HTTPS)
+admin.example.com {
+ root * /var/www/caddy-admin
+ php_fastcgi unix//run/php/php-fpm.sock
+ file_server
+ @denyPrivate path /lib/* /domains.json /.*
+ respond @denyPrivate 403
+}
+
+# Reverse-Proxy mit Let's Encrypt (Auto-HTTPS via globaler email-Direktive)
+app.example.com {
+ reverse_proxy http://192.0.2.10:8080 {
+ header_up Host {host}
+ header_up X-Real-IP {remote_host}
+ header_up X-Forwarded-For {remote_host}
+ header_up X-Forwarded-Proto {scheme}
+ }
+ log {
+ output file /var/log/caddy/app.example.com.log
+ }
+}
+
+# Reverse-Proxy zu HTTPS-Backend
+internal.example.org {
+ reverse_proxy https://192.0.2.20:8443 {
+ header_up Host {host}
+ header_up X-Real-IP {remote_host}
+ header_up X-Forwarded-For {remote_host}
+ header_up X-Forwarded-Proto {scheme}
+ }
+ log {
+ output file /var/log/caddy/internal.example.org.log
+ }
+}
+
+# Wildcard-Domain
+*.tenant.example.com {
+ reverse_proxy http://192.0.2.30:80 {
+ header_up Host {host}
+ header_up X-Real-IP {remote_host}
+ header_up X-Forwarded-For {remote_host}
+ header_up X-Forwarded-Proto {scheme}
+ }
+ log {
+ output file /var/log/caddy/_.tenant.example.com.log
+ }
+}
diff --git a/tests/test-validators.php b/tests/test-validators.php
new file mode 100644
index 0000000..8d1020b
--- /dev/null
+++ b/tests/test-validators.php
@@ -0,0 +1,146 @@
+ api.php.
+ *
+ * SYNCHRONISATIONSHINWEIS:
+ * Die Funktionen unten sind eine 1:1-Kopie der Validatoren aus install.sh
+ * (Heredoc-Block für api.php). Wird install.sh angepasst, müssen die Funktionen
+ * hier mitgepflegt werden. CI prüft Bash + PHP getrennt; Drift wird entweder
+ * im php-lint-Job oder als Test-Failure hier sichtbar.
+ */
+
+declare(strict_types=1);
+
+// ---- Funktionen unter Test (Spiegel von install.sh / api.php) --------------
+
+function validate_domain(string $d): string {
+ $d = strtolower(trim($d));
+ if ($d === 'localhost') return $d;
+ if (!preg_match('/^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $d)) {
+ throw new RuntimeException('Ungültige Domain');
+ }
+ return $d;
+}
+
+function validate_ip(string $ip): string {
+ if (!filter_var($ip, FILTER_VALIDATE_IP)) {
+ throw new RuntimeException('Ungültige IP-Adresse');
+ }
+ return $ip;
+}
+
+function validate_port($p): int {
+ $p = (int)$p;
+ if ($p < 1 || $p > 65535) throw new RuntimeException('Ungültiger Port');
+ return $p;
+}
+
+function validate_protocol(string $p): string {
+ if (!in_array($p, ['http', 'https'], true)) throw new RuntimeException('Ungültiges Protokoll');
+ return $p;
+}
+
+function safe_log_name(string $domain): string {
+ return preg_replace('/[^a-z0-9._-]/', '_', strtolower($domain));
+}
+
+// ---- Mini-Test-Framework ---------------------------------------------------
+
+$pass = 0;
+$fail = 0;
+$failures = [];
+
+function ok(bool $cond, string $msg): void {
+ global $pass, $fail, $failures;
+ if ($cond) { $pass++; echo " \033[32m✓\033[0m $msg\n"; }
+ else { $fail++; $failures[] = $msg; echo " \033[31m✗\033[0m $msg\n"; }
+}
+
+function eq($expected, $actual, string $msg): void {
+ ok($expected === $actual, "$msg (erwartet=" . var_export($expected, true) . ", erhalten=" . var_export($actual, true) . ')');
+}
+
+function throws(callable $fn, string $msg): void {
+ try { $fn(); ok(false, "$msg (keine Exception)"); }
+ catch (RuntimeException $e) { ok(true, $msg); }
+ catch (Throwable $t) { ok(false, "$msg (falscher Exception-Typ: " . get_class($t) . ')'); }
+}
+
+// ---- Tests: validate_domain ------------------------------------------------
+
+echo "validate_domain:\n";
+eq('example.com', validate_domain('example.com'), 'plain domain');
+eq('example.com', validate_domain('EXAMPLE.COM'), 'normalisiert auf lowercase');
+eq('admin.example.com', validate_domain(' admin.example.com '), 'trimmt Whitespace');
+eq('*.example.com', validate_domain('*.example.com'), 'akzeptiert Wildcard');
+eq('localhost', validate_domain('localhost'), 'erlaubt localhost als Sonderfall');
+
+throws(fn() => validate_domain(''), 'leerer String wird abgelehnt');
+throws(fn() => validate_domain('noTLD'), 'fehlende TLD wird abgelehnt');
+throws(fn() => validate_domain('-bad.com'), 'führender Bindestrich abgelehnt');
+throws(fn() => validate_domain('bad-.com'), 'nachgestellter Bindestrich abgelehnt');
+throws(fn() => validate_domain('foo.c'), 'zu kurze TLD abgelehnt');
+throws(fn() => validate_domain('foo bar.com'), 'Whitespace abgelehnt');
+throws(fn() => validate_domain('evil.com { admin off }'), 'Caddyfile-Injection abgelehnt');
+throws(fn() => validate_domain("foo.com\nrm -rf"), 'Newline-Injection abgelehnt');
+
+// ---- Tests: validate_ip ----------------------------------------------------
+
+echo "\nvalidate_ip:\n";
+eq('192.168.1.1', validate_ip('192.168.1.1'), 'IPv4 akzeptiert');
+eq('10.0.0.1', validate_ip('10.0.0.1'), 'private IPv4 akzeptiert');
+eq('::1', validate_ip('::1'), 'IPv6 loopback akzeptiert');
+eq('2001:db8::1', validate_ip('2001:db8::1'), 'IPv6 akzeptiert');
+
+throws(fn() => validate_ip(''), 'leere IP abgelehnt');
+throws(fn() => validate_ip('999.999.999.999'), 'IP-Bereich abgelehnt');
+throws(fn() => validate_ip('not.an.ip'), 'String abgelehnt');
+throws(fn() => validate_ip('192.168.1'), 'unvollständige IP abgelehnt');
+throws(fn() => validate_ip('192.168.1.1; rm -rf /'), 'Shell-Injection abgelehnt');
+
+// ---- Tests: validate_port --------------------------------------------------
+
+echo "\nvalidate_port:\n";
+eq(80, validate_port(80), 'Port 80');
+eq(443, validate_port('443'), 'Port als String');
+eq(1, validate_port(1), 'Port 1 (untere Grenze)');
+eq(65535, validate_port(65535), 'Port 65535 (obere Grenze)');
+
+throws(fn() => validate_port(0), 'Port 0 abgelehnt');
+throws(fn() => validate_port(-1), 'negativer Port abgelehnt');
+throws(fn() => validate_port(65536), 'Port > 65535 abgelehnt');
+throws(fn() => validate_port(99999), 'Port viel zu groß abgelehnt');
+
+// ---- Tests: validate_protocol ----------------------------------------------
+
+echo "\nvalidate_protocol:\n";
+eq('http', validate_protocol('http'), 'http akzeptiert');
+eq('https', validate_protocol('https'), 'https akzeptiert');
+
+throws(fn() => validate_protocol(''), 'leeres Protokoll abgelehnt');
+throws(fn() => validate_protocol('HTTP'), 'Großschreibung abgelehnt');
+throws(fn() => validate_protocol('ftp'), 'ftp abgelehnt');
+throws(fn() => validate_protocol('file'), 'file abgelehnt');
+throws(fn() => validate_protocol('javascript'), 'javascript abgelehnt');
+
+// ---- Tests: safe_log_name --------------------------------------------------
+
+echo "\nsafe_log_name:\n";
+eq('example.com', safe_log_name('example.com'), 'normale Domain bleibt');
+eq('_.example.com', safe_log_name('*.example.com'), 'Wildcard durch _ ersetzt');
+eq('a-b.example.io', safe_log_name('a-b.example.io'), 'Bindestrich erlaubt');
+eq('foo___com', safe_log_name('foo /\\com'), 'Path-Traversal-Versuch entschärft');
+eq('foo_.._bar', safe_log_name('foo/../bar'), '../-Versuch nicht durchgelassen');
+eq('foo.com', safe_log_name('FOO.COM'), 'normalisiert auf lowercase');
+
+// ---- Ergebnis --------------------------------------------------------------
+
+echo "\n" . str_repeat('=', 60) . "\n";
+echo "Ergebnis: \033[32m{$pass} bestanden\033[0m";
+if ($fail > 0) {
+ echo ", \033[31m{$fail} fehlgeschlagen\033[0m\n\nFehler:\n";
+ foreach ($failures as $f) echo " - $f\n";
+ exit(1);
+}
+echo ", 0 fehlgeschlagen\n";
+exit(0);