<?php if (!defined('DASHBOARD_ROOT')) { http_response_code(403); exit; }
class GatewaySync {
    private static function hopsTo(string $target): array {
        // Load externalized topology if available, otherwise fallback
        $user = getenv('FLEET_USER') ?: 'admin';
        $shieldIp = getenv('FLEET_SHIELD_IP') ?: '10.69.69.1';
        $targetIp = getenv('FLEET_' . strtoupper($target) . '_IP');
        if (!$targetIp) {
            $targetIp = $target === 'filter' ? '10.69.69.4' : '10.69.69.3';
        }
        return [
            ['host' => $shieldIp, 'port' => 33022, 'user' => $user],
            ['host' => $targetIp, 'port' => 33022, 'user' => $user],
        ];
    } private static function getConfPath(string $key): string { return getenv('FLEET_CONF_' . strtoupper($key)) ?: ($key === 'proxy' ? '/etc/nginx/conf.d/gateway.conf' : '/etc/nginx/conf.d/ghost-protocol.conf'); } private static function getLogFile(): string { return getenv('FLEET_LOG_FILE') ?: '/var/log/gateway_sync.log'; } private const SETTINGS_KEY = 'fleet_hop_password'; private static function getHopPassword(): ?string { try { $row = Database::fetchOne( "SELECT setting_value FROM settings WHERE setting_key = :k", [':k' => self::SETTINGS_KEY] ); if (!$row) return null; $val = $row['setting_value']; if (class_exists('Encryption') && strlen($val) > 30) { return Encryption::decrypt($val); } return $val; } catch (\Throwable $e) { return null; } } public static function setHopPassword(string $password): bool { try { $enc = class_exists('Encryption') ? Encryption::encrypt($password, 'default') : $password; $existing = Database::fetchOne( "SELECT id FROM settings WHERE setting_key = :k", [':k' => self::SETTINGS_KEY] ); if ($existing) { Database::execute( "UPDATE settings SET setting_value = :v WHERE setting_key = :k", [':v' => $enc, ':k' => self::SETTINGS_KEY] ); } else { Database::execute( "INSERT INTO settings (setting_key, setting_value) VALUES (:k, :v)", [':k' => self::SETTINGS_KEY, ':v' => $enc] ); } return true; } catch (\Throwable $e) { return false; } } private static function buildHopChain(string $password, string $finalCommand, array $hops): string { $cmd = $finalCommand; for ($i = count($hops) - 1; $i >= 0; $i--) { $hop = $hops[$i]; $sshOpts = '-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o  -o LogLevel=ERROR'; $cmd = sprintf( 'sshpass -p %s ssh %s -p %d %s@%s %s', escapeshellarg($password), $sshOpts, $hop['port'], $hop['user'], $hop['host'], escapeshellarg($cmd) ); } return $cmd; } public static function syncDomains(): array { $log = []; $results = ['proxy' => false, 'filter' => false]; $password = self::getHopPassword(); if (!$password) { return ['ok' => false, 'message' => 'Fleet hop password not set. Go to Settings to configure.']; } try { $rows = Database::fetchAll( "SELECT domain FROM stores WHERE is_active = 1 AND domain IS NOT NULL AND domain != '' ORDER BY id ASC" ); } catch (\Throwable $e) { return ['ok' => false, 'message' => 'DB query failed: ' . $e->getMessage()]; } $domains = []; foreach (array_column($rows, 'domain') as $raw) { $dec = Encryption::decrypt($raw); if ($dec && $dec !== $raw) $domains[] = $dec; elseif (preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/i', $raw)) $domains[] = $raw; } if (!in_array('cleanearth.cc', $domains)) { array_unshift($domains, 'cleanearth.cc'); } foreach ($domains as $d) { if (!preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/i', $d)) { return ['ok' => false, 'message' => "Invalid domain: $d"]; } } $domainLine = implode(' ', $domains); $trustToken = bin2hex(random_bytes(8)); $log[] = "Domains: $domainLine"; $proxyConf = self::getConfPath('proxy'); $proxyCmd = implode(' && ', [ 'sudo chattr -i ' . $proxyConf . ' 2>/dev/null; true', "sudo sed -i '/^    listen 80;\$/{n;s/    server_name .*;/    server_name {$domainLine};/}' " . $proxyConf, "sudo sed -i '/^    listen 443 ssl http2;\$/{n;s/    server_name .*;/    server_name {$domainLine};/}' " . $proxyConf, 'sudo nginx -t 2>&1', 'sudo nginx -s reload', 'sudo chattr +i ' . $proxyConf . ' 2>/dev/null; true', "echo PROXY_OK_$trustToken" ]); $fullCmd = self::buildHopChain($password, $proxyCmd, self::hopsTo('proxy')) . ' 2>&1'; $out = []; $rc = 0; exec($fullCmd, $out, $rc); $outStr = implode("\n", $out); $results['proxy'] = strpos($outStr, "PROXY_OK_$trustToken") !== false; $log[] = 'Proxy: ' . ($results['proxy'] ? 'OK' : "FAIL($rc)"); $filterConf = self::getConfPath('filter'); $filterCmd = implode(' && ', [ "sudo sed -i 's/proxy_set_header Host [^;]*/proxy_set_header Host \$host/g' " . $filterConf, 'sudo nginx -t 2>&1', 'sudo nginx -s reload', "echo FILTER_OK_$trustToken" ]); $fullCmd = self::buildHopChain($password, $filterCmd, self::hopsTo('filter')) . ' 2>&1'; $out = []; $rc = 0; exec($fullCmd, $out, $rc); $outStr = implode("\n", $out); $results['filter'] = strpos($outStr, "FILTER_OK_$trustToken") !== false; $log[] = 'Filter: ' . ($results['filter'] ? 'OK' : "FAIL($rc)"); self::writeLog($log); $allOk = $results['proxy'] && $results['filter']; $failed = array_keys(array_filter($results, fn($v) => !$v)); return [ 'ok' => $allOk, 'message' => $allOk ? 'Both chain nodes synced' : 'Failed: ' . implode(', ', $failed), 'domains' => $domains, 'nodes' => $results, 'trust_token' => $trustToken ]; } public static function verify(): array { $password = self::getHopPassword(); if (!$password) return ['ok' => false, 'message' => 'No hop password']; try { $rows = Database::fetchAll( "SELECT domain FROM stores WHERE is_active = 1 AND domain IS NOT NULL AND domain != '' ORDER BY id ASC" ); } catch (\Throwable $e) { return ['ok' => false, 'message' => 'DB error']; } $domains = []; foreach (array_column($rows, 'domain') as $raw) { $dec = Encryption::decrypt($raw); if ($dec && $dec !== $raw) $domains[] = $dec; elseif (preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/i', $raw)) $domains[] = $raw; } $results = []; $proxyCmd = 'grep server_name ' . self::getConfPath('proxy') . ' 2>/dev/null'; $fullCmd = self::buildHopChain($password, $proxyCmd, self::hopsTo('proxy')) . ' 2>&1'; $out = []; exec($fullCmd, $out); $proxyOut = implode("\n", $out); $proxyOk = true; foreach ($domains as $d) { if (strpos($proxyOut, $d) === false) { $proxyOk = false; break; } } $results['proxy'] = ['ok' => $proxyOk, 'detail' => trim($proxyOut)]; $filterCmd = 'grep proxy_set_header.Host ' . self::getConfPath('filter') . ' 2>/dev/null'; $fullCmd = self::buildHopChain($password, $filterCmd, self::hopsTo('filter')) . ' 2>&1'; $out = []; exec($fullCmd, $out); $filterOut = implode("\n", $out); $filterOk = strpos($filterOut, '$host') !== false; $results['filter'] = ['ok' => $filterOk, 'detail' => trim($filterOut)]; $originCmd = 'sudo nginx -t 2>&1 && curl -sk http://127.0.0.1/ -o /dev/null -w "%{http_code}"'; $out = []; exec($originCmd, $out); $originOut = implode("\n", $out); $originOk = strpos($originOut, 'syntax is ok') !== false; $results['origin'] = ['ok' => $originOk, 'detail' => trim($originOut)]; $allOk = $results['proxy']['ok'] && $results['filter']['ok'] && $results['origin']['ok']; return ['ok' => $allOk, 'nodes' => $results]; } public static function ping(): array { $password = self::getHopPassword(); if (!$password) return ['ok' => false, 'reachable' => []]; $results = []; $cmd = self::buildHopChain($password, 'echo HOP_OK', [self::hopsTo('filter')[0]]) . ' 2>&1'; $out = []; exec($cmd, $out); $results['Shield'] = in_array('HOP_OK', $out); if (!$results['Shield']) { return ['ok' => false, 'reachable' => $results]; } $cmd = self::buildHopChain($password, 'echo HOP_OK', self::hopsTo('filter')) . ' 2>&1'; $out = []; exec($cmd, $out); $results['Filter'] = in_array('HOP_OK', $out); $cmd = self::buildHopChain($password, 'echo HOP_OK', self::hopsTo('proxy')) . ' 2>&1'; $out = []; exec($cmd, $out); $results['Proxy'] = in_array('HOP_OK', $out); $allOk = $results['Filter'] && $results['Proxy']; return ['ok' => $allOk, 'reachable' => $results]; } public static function deployToAll(string $domain, int $storeId): array { $domain = strtolower(trim($domain)); if (!preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/', $domain)) { return ['ok' => false, 'message' => "Invalid domain: $domain"]; } $results = []; $password = self::getHopPassword(); $key = '/home/admin/.ssh/fleet-origin-outbound'; $originConf = self::buildOriginConf($domain, $storeId); $confFile = '/etc/nginx/sites-enabled/' . $domain . '.conf'; $originCmd = implode(' && ', [ 'echo ' . escapeshellarg($originConf) . ' | sudo tee ' . escapeshellarg($confFile) . ' > /dev/null', 'sudo nginx -t 2>&1', 'sudo nginx -s reload', "echo ORIGIN_OK_{$storeId}", ]); $sshOrigin = sprintf('ssh -i %s -p 22 -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 admin@127.0.0.1 %s 2>&1', escapeshellarg($key), escapeshellarg($originCmd)); $out = []; exec($sshOrigin, $out, $rc); $results['origin'] = strpos(implode("\n", $out), "ORIGIN_OK_{$storeId}") !== false; if (!$password) { self::writeLog(["deployToAll($domain)", 'No hop password — Origin only']); return ['ok' => $results['origin'], 'nodes' => $results, 'message' => 'Origin only (no hop password)']; } $filterConf = self::buildFilterConf($domain); $filterFile = '/etc/nginx/conf.d/stores/' . $domain . '.conf'; $filterCmd = implode(' && ', [ 'sudo mkdir -p /etc/nginx/conf.d/stores', 'echo ' . escapeshellarg($filterConf) . ' | sudo tee ' . escapeshellarg($filterFile) . ' > /dev/null', 'sudo nginx -t 2>&1', 'sudo nginx -s reload', "echo FILTER_OK_{$storeId}", ]); $fullCmd = self::buildHopChain($password, $filterCmd, self::hopsTo('filter')) . ' 2>&1'; $out = []; exec($fullCmd, $out); $results['filter'] = strpos(implode("\n", $out), "FILTER_OK_{$storeId}") !== false; $proxyConf = self::buildProxyConf($domain); $proxyFile = '/etc/nginx/conf.d/stores/' . $domain . '.conf'; $proxyCmd = implode(' && ', [ 'sudo mkdir -p /etc/nginx/conf.d/stores', 'echo ' . escapeshellarg($proxyConf) . ' | sudo tee ' . escapeshellarg($proxyFile) . ' > /dev/null', 'sudo nginx -t 2>&1', 'sudo nginx -s reload', "echo PROXY_OK_{$storeId}", ]); $fullCmd = self::buildHopChain($password, $proxyCmd, self::hopsTo('proxy')) . ' 2>&1'; $out = []; exec($fullCmd, $out); $results['proxy'] = strpos(implode("\n", $out), "PROXY_OK_{$storeId}") !== false; self::syncDomains(); $allOk = !in_array(false, $results); $failed = array_keys(array_filter($results, fn($v) => !$v)); self::writeLog(["deployToAll($domain/$storeId)", $allOk ? 'ALL OK' : 'FAIL: ' . implode(',', $failed)]); return ['ok' => $allOk, 'nodes' => $results, 'message' => $allOk ? 'Deployed to all 3 nodes' : 'Failed: ' . implode(', ', $failed)]; } public static function removeFromAll(string $domain): array { $domain = strtolower(trim($domain)); $password = self::getHopPassword(); $results = []; $key = '/home/admin/.ssh/fleet-origin-outbound'; $rmFile = '/etc/nginx/conf.d/stores/' . $domain . '.conf'; $cmd = 'sudo rm -f ' . escapeshellarg('/etc/nginx/sites-enabled/' . $domain . '.conf') . ' && sudo nginx -s reload && echo ORIGIN_RM_OK'; $out = []; exec(sprintf('ssh -i %s -p 22 -o StrictHostKeyChecking=accept-new admin@127.0.0.1 %s 2>&1', escapeshellarg($key), escapeshellarg($cmd)), $out); $results['origin'] = in_array('ORIGIN_RM_OK', $out); if ($password) { $cmd = 'sudo rm -f ' . escapeshellarg($rmFile) . ' && sudo nginx -s reload && echo FILTER_RM_OK'; $fullCmd = self::buildHopChain($password, $cmd, self::hopsTo('filter')) . ' 2>&1'; $out = []; exec($fullCmd, $out); $results['filter'] = in_array('FILTER_RM_OK', $out); $cmd = 'sudo rm -f ' . escapeshellarg($rmFile) . ' && sudo nginx -s reload && echo PROXY_RM_OK'; $fullCmd = self::buildHopChain($password, $cmd, self::hopsTo('proxy')) . ' 2>&1'; $out = []; exec($fullCmd, $out); $results['proxy'] = in_array('PROXY_RM_OK', $out); self::syncDomains(); } self::writeLog(["removeFromAll($domain)", implode(' ', array_map(fn($k, $v) => "$k:" . ($v ? 'OK' : 'FAIL'), array_keys($results), $results))]); return ['ok' => !in_array(false, $results), 'nodes' => $results]; } private static function buildOriginConf(string $domain, int $storeId): string { return "# GatewaySync — Origin vhost — store:{$storeId} — {$domain} server {     listen 80; server_name {$domain} www.{$domain};     root /var/www/public; index index.php;     location / { try_files \$uri \$uri/ /index.php?\$query_string; }     location ~ \\.php\$ {         fastcgi_pass unix:/run/php-fpm/www.sock; fastcgi_index index.php;         fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name;         fastcgi_param X_STORE_ID {$storeId}; fastcgi_param HTTP_HOST \$host;         include fastcgi_params;     }     location ~* \\.(css|js|jpg|jpeg|png|gif|ico|svg|woff2?)\$ { expires 30d; access_log off; }     location ~ /\\. { deny all; } }"; } private static function buildFilterConf(string $domain): string { return "# GatewaySync — Filter vhost — {$domain} server {     listen 80; server_name {$domain} www.{$domain};     modsecurity on;     modsecurity_rules_file /etc/modsecurity/modsecurity.conf;     location / {         proxy_pass http://172.20.69.2; proxy_set_header Host \$host;         proxy_set_header X-Real-IP \$remote_addr;         proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;     } }"; } private static function buildProxyConf(string $domain): string { return "# GatewaySync — Proxy vhost — {$domain} server { listen 80; server_name {$domain} www.{$domain}; return 301 https://\$host\$request_uri; } server {     listen 443 ssl http2; server_name {$domain} www.{$domain};     ssl_certificate /etc/letsencrypt/live/{$domain}/fullchain.pem;     ssl_certificate_key /etc/letsencrypt/live/{$domain}/privkey.pem;     ssl_protocols TLSv1.2 TLSv1.3;     location / {         proxy_pass http://10.69.69.4; proxy_set_header Host \$host;         proxy_set_header X-Real-IP \$remote_addr;         proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;         proxy_set_header X-Forwarded-Proto https;     } }"; } public static function originDeploy(string $domain, int $storeId): array { return self::deployToAll($domain, $storeId); } public static function originRemove(string $domain): array { return self::removeFromAll($domain); } private static function writeLog(array $lines): void { $entry = '[' . date('Y-m-d H:i:s') . '] ' . implode(' | ', $lines) . "\n"; @file_put_contents(self::getLogFile(), $entry, FILE_APPEND | LOCK_EX); } }