<?php
/*
ISPConfig 3 - Custom plugin:
reverse_proxy_mode_plugin
Server-level only:
- nginx_apache:
* Enforce Apache loopback listens for backend/apps/ispconfig
* Enable Apache RemoteIP (trust 127.0.0.1)
* Write ONE nginx core reverse-proxy file: /etc/nginx/conf.d/reverse-proxy.conf
* Reload services safely
* Regenerate ISPConfig configs now
- none:
* Restore Apache ports.conf from ports.conf.orig if available, else remove managed block
* Disable RemoteIP if enabled by us
* Remove /etc/nginx/conf.d/reverse-proxy.conf
* Reload services safely
*/
class reverse_proxy_mode_plugin {
var $plugin_name = 'reverse_proxy_mode_plugin';
var $class_name = 'reverse_proxy_mode_plugin';
public function onLoad() {
global $app;
$app->plugins->registerEvent('server_update', $this->plugin_name, 'server_update');
}
// ---------- config parser (server.config text key=value) ----------
private function getCfgMap($server_id) {
global $app;
$server_id = intval($server_id);
$rec = $app->db->queryOneRecord("SELECT config FROM server WHERE server_id = ?", $server_id);
$cfg_text = isset($rec['config']) ? (string)$rec['config'] : '';
$m = array();
foreach(preg_split("/\r\n|\n|\r/", $cfg_text) as $line) {
$line = trim($line);
if($line === '' || $line[0] === '#' || $line[0] === ';') continue;
if(substr($line, 0, 2) === '-[' && substr($line, -3) === ']-/') continue;
$pos = strpos($line, '=');
if($pos === false) continue;
$k = trim(substr($line, 0, $pos));
$v = trim(substr($line, $pos + 1));
if($k !== '') $m[$k] = $v;
}
return $m;
}
private function getInt($m, $key, $default) {
$v = isset($m[$key]) ? intval($m[$key]) : intval($default);
if($v < 1) $v = intval($default);
return $v;
}
private function sanitizePort($p, $fallback) {
$p = intval($p);
if($p < 1024 || $p > 65535) return intval($fallback);
return $p;
}
// config parser (server.config text key=value) ----------
// ---------- In 'nginx_apache' all website vhosts should bind to loopback backend port: 127.0.0.1:8080
//------------In 'none' *:80 & *:443
private function apacheRewriteVhostBind($mode, $backend_port) {
@file_put_contents('/tmp/rp_rewrite.log', date('c')." mode=$mode port=$backend_port\n", FILE_APPEND);
$dir = '/etc/apache2/sites-available';
if(!is_dir($dir)) return;
// Choose which files to rewrite.
// ISPConfig website vhosts are commonly like: domain.vhost or 100-domain.vhost etc.
// Adjust pattern if needed.
$files = glob($dir . '/*.vhost');
if(!is_array($files)) $files = [];
foreach($files as $file) {
if(!is_file($file)) continue;
// Skip ISPConfig panel vhost and acme vhost (you already manage those separately)
$base = basename($file);
if(substr($base, -11) === '.rpnone.orig') continue;
if(stripos($base, 'ispconfig') !== false) continue;
if(stripos($base, 'acme') !== false) continue;
$txt = @file_get_contents($file);
if($txt === false) continue;
$new = $txt;
// This makes rollback deterministic and avoids regex guessing.
if($mode !== 'nginx_apache') {
if(is_file($file . '.rporig')) {
@copy($file . '.rporig', $file);
continue;
}
}
// Create a stable rollback backup ONLY when entering nginx_apache,
// and only if it looks like a public vhost (contains *:80 or *:443).
/*
1) Backup step (ONLY when switching into nginx_apache).
Create a stable "public binds" backup once, only if file is still public.
*/
if($mode === 'nginx_apache') {
if(!is_file($file . '.rporig')) {
if(
stripos($txt, '<VirtualHost *:80>') !== false ||
stripos($txt, '<VirtualHost *:443>') !== false
) {
@copy($file, $file . '.rporig');
}
}
}
/*
2) Rewrite/restore step
*/
if($mode === 'nginx_apache') {
$port = intval($backend_port);
// Convert public vhosts to loopback bind
$new = preg_replace('/<VirtualHost\s+\*\s*:\s*80\s*>/i', '<VirtualHost 127.0.0.1:' . $port . '>', $new);
$new = preg_replace('/<VirtualHost\s+\*\s*:\s*443\s*>/i', '<VirtualHost 127.0.0.1:' . $port . '>', $new);
} else { // mode === none
// Best rollback: restore from stable backup if it exists
if(is_file($file . '.rporig')) {
@copy($file . '.rporig', $file);
continue;
}
// Fallback rollback: rewrite loopback blocks back to *:80 / *:443
$port = intval($backend_port);
$new = preg_replace_callback(
'/<VirtualHost\s+127\.0\.0\.1\s*:\s*' . $port . '\s*>(.*?)<\/VirtualHost>/is',
function($m) use ($port) {
$block = $m[0];
$inner = $m[1];
$is_ssl = (
stripos($inner, 'SSLEngine on') !== false ||
stripos($inner, 'SSLCertificateFile') !== false ||
stripos($inner, 'SSLCertificateKeyFile') !== false
);
$target = $is_ssl ? '*:443' : '*:80';
// Replace ONLY the opening tag of this block
$block = preg_replace(
'/^<VirtualHost\s+127\.0\.0\.1\s*:\s*' . $port . '\s*>/i',
'<VirtualHost ' . $target . '>',
$block,
1
);
return $block;
},
$new
);
}
// Write back only if changed
if($new !== $txt) {
@file_put_contents($file, $new);
}
} // foreach
}
private function detectIspconfigPort() {
$candidates = array(
'/etc/apache2/sites-available/ispconfig.vhost',
'/etc/apache2/sites-available/000-ispconfig.conf',
'/etc/apache2/sites-available/ispconfig.conf',
'/etc/apache2/sites-enabled/ispconfig.vhost',
'/etc/apache2/sites-enabled/000-ispconfig.conf',
'/etc/apache2/sites-enabled/000-ispconfig.vhost',
);
foreach($candidates as $f) {
if(!is_file($f)) continue;
$txt = @file_get_contents($f);
if($txt === false) continue;
if(preg_match('/<VirtualHost\s+[^>]*:(\d+)\s*>/i', $txt, $m)) {
$p = intval($m[1]);
if($p > 0) return $p;
}
if(preg_match('/^\s*Listen\s+(\d+)\s*$/mi', $txt, $m)) {
$p = intval($m[1]);
if($p > 0) return $p;
}
}
return 8080;
}
private function getIspconfigVhostFile() {
$candidates = array(
'/etc/apache2/sites-available/ispconfig.vhost',
'/etc/apache2/sites-available/000-ispconfig.vhost',
'/etc/apache2/sites-available/000-ispconfig.conf',
'/etc/apache2/sites-enabled/ispconfig.vhost',
'/etc/apache2/sites-enabled/000-ispconfig.vhost',
'/etc/apache2/sites-enabled/000-ispconfig.conf',
);
foreach($candidates as $f) if(is_file($f)) return $f;
return '';
}
private function toggleListenInIspconfigVhost($comment_listen) {
// $file = '/etc/apache2/sites-available/ispconfig.vhost';
// if(!is_file($file)) return;
$file = $this->getIspconfigVhostFile();
if($file === '') return;
// Backup once
if(!is_file($file . '.orig')) {
@copy($file, $file . '.orig');
}
$txt = @file_get_contents($file);
if($txt === false) return;
$lines = explode("\n", $txt);
$changed = false;
foreach($lines as &$line) {
// Match: optional indentation, optional '#', then 'Listen', then rest of line
// We only toggle leading comment marker for Listen directives.
if(preg_match('/^(\s*)(#\s*)?Listen(\s+.*)$/', $line, $m)) {
$indent = $m[1];
$has_hash = isset($m[2]) && $m[2] !== '';
$rest = $m[3];
if($comment_listen) {
if(!$has_hash) {
$line = $indent . '#Listen' . $rest;
$changed = true;
}
} else {
if($has_hash) {
// remove only the first "#" before Listen
$line = $indent . 'Listen' . $rest;
$changed = true;
}
}
}
}
unset($line);
if($changed) {
@file_put_contents($file, implode("\n", $lines) . "\n");
}
}
private function toggleApachePublicPortsInPortsConf($comment_public_ports) {
$file = '/etc/apache2/ports.conf';
$begin = "# ISPConfig Reverse Proxy Mode BEGIN";
$end = "# ISPConfig Reverse Proxy Mode END";
if(!is_file($file)) return;
$txt = @file_get_contents($file);
if($txt === false) return;
$lines = explode("\n", $txt);
$changed = false;
$in_managed = false;
foreach($lines as &$line) {
if(strpos($line, $begin) !== false) { $in_managed = true; continue; }
if(strpos($line, $end) !== false) { $in_managed = false; continue; }
// Never touch our managed block
if($in_managed) continue;
// Match Listen 80 / Listen 443 with optional leading "#"
if(preg_match('/^(\s*)(#\s*)?Listen(\s+)(80|443)\s*$/', $line, $m)) {
$indent = $m[1];
$has_hash = isset($m[2]) && $m[2] !== '';
$spaces = $m[3];
$port = $m[4];
if($comment_public_ports) {
if(!$has_hash) {
$line = $indent . '#Listen' . $spaces . $port;
$changed = true;
}
} else {
if($has_hash) {
$line = $indent . 'Listen' . $spaces . $port;
$changed = true;
}
}
}
}
unset($line);
if($changed) {
@file_put_contents($file, implode("\n", $lines) . "\n");
}
}
// Replace or append a managed block in a file
private function writeManagedBlock($file, $begin, $end, $content) {
$old = is_file($file) ? file_get_contents($file) : '';
if($old === false) $old = '';
$block = $begin . "\n" . $content . "\n" . $end;
if(strpos($old, $begin) !== false && strpos($old, $end) !== false) {
$pattern = '/' . preg_quote($begin, '/') . '.*?' . preg_quote($end, '/') . '/s';
$new = preg_replace($pattern, $block, $old, 1);
} else {
$new = rtrim($old) . "\n\n" . $block . "\n";
}
if($new !== $old) {
file_put_contents($file, $new);
}
}
private function removeManagedBlock($file, $begin, $end) {
if(!is_file($file)) return;
$old = file_get_contents($file);
if($old === false) return;
if(strpos($old, $begin) === false || strpos($old, $end) === false) return;
$pattern = '/' . preg_quote($begin, '/') . '.*?' . preg_quote($end, '/') . "\n?/s";
$new = preg_replace($pattern, '', $old, 1);
if($new !== $old) file_put_contents($file, trim($new) . "\n");
}
// Disable enabled symlinks Nginx
private function nginxDisableIspconfigVhostsEnabled() {
$enabled_dir = '/etc/nginx/sites-enabled';
if(!is_dir($enabled_dir)) return;
foreach(glob($enabled_dir . '/*-*.vhost') as $lnk) {
if(is_link($lnk)) {
@unlink($lnk);
}
}
}
//Enable symlinks again
private function nginxEnsureIspconfigVhostsEnabled($app) {
$avail_dir = '/etc/nginx/sites-available';
$enabled_dir = '/etc/nginx/sites-enabled';
if(!is_dir($avail_dir) || !is_dir($enabled_dir)) return;
// We only enable ISPConfig vhost files: *.vhost
$files = glob($avail_dir . '/*.vhost');
if(!is_array($files)) return;
foreach($files as $src) {
if(!is_file($src)) continue;
$base = basename($src);
// Create matching enabled link:
// If your system uses 100-<domain>.vhost naming, preserve that.
// Example: sites-available/micino.site.vhost -> sites-enabled/100-micino.site.vhost
// If it's already prefixed (100- / 900-), don't double-prefix.
if(preg_match('/^\d+-/', $base)) {
$dst = $enabled_dir . '/' . $base;
} else {
$dst = $enabled_dir . '/100-' . $base;
}
if(is_link($dst) || is_file($dst)) continue; // already enabled
// Ensure link target is absolute
$ok = @symlink($src, $dst);
if(!$ok) {
$app->log("WARN: failed to symlink nginx vhost: $dst -> $src", LOGLEVEL_WARN);
}
}
}
private function safeReloadServices($app) {
$app->system->exec_safe("apache2ctl configtest >/dev/null 2>&1 && systemctl reload apache2 || systemctl restart apache2");
$app->system->exec_safe("nginx -t >/dev/null 2>&1 && systemctl reload nginx || systemctl restart nginx");
}
// ---------- main handler ----------
public function server_update($event_name, $data) {
global $app;
$server_id = intval($data['new']['server_id']);
if($server_id <= 0) return;
$cfg = $this->getCfgMap($server_id);
$mode = isset($cfg['reverse_proxy_mode']) ? $cfg['reverse_proxy_mode'] : 'none';
//Now $backend_port exists in BOTH branches.
$backend_port = $this->sanitizePort($this->getInt($cfg, 'apache_backend_port', 8080), 8080);
// ----------------------------
// ROLLBACK branch: mode == none
// ----------------------------
if($mode === 'none') {
// 1) Restore Apache ports.conf if we saved an original
if(is_file('/etc/apache2/ports.conf.orig')) {
@copy('/etc/apache2/ports.conf.orig', '/etc/apache2/ports.conf');
} else {
// If no orig, remove only our managed block
$this->removeManagedBlock(
'/etc/apache2/ports.conf',
"# ISPConfig Reverse Proxy Mode BEGIN",
"# ISPConfig Reverse Proxy Mode END"
);
}
// Restore public ports Listen directives (if not restoring full ports.conf.orig)
$this->toggleApachePublicPortsInPortsConf(false);
// 2) Disable RemoteIP (only what we enabled)
// We leave the file in place; disabling conf+module is enough.
$app->system->exec_safe("a2disconf remoteip >/dev/null 2>&1 || true");
$app->system->exec_safe("a2dismod remoteip >/dev/null 2>&1 || true");
@unlink('/etc/apache2/conf-available/remoteip.conf');
// 3) Remove nginx core reverse proxy file
$ng_file = '/etc/nginx/conf.d/reverse-proxy.conf';
if(is_file($ng_file)) @unlink($ng_file);
// 3+) Ensure ISPConfig panel vhost Listen directives are enabled in "none" mode
$this->toggleListenInIspconfigVhost(false);
//3++) Disable ISPConfig nginx site vhosts (so nginx stops binding 80/443
$this->nginxDisableIspconfigVhostsEnabled();
// 4) Regenerate ISPConfig configs (optional but helpful)
$app->system->exec_safe("/usr/local/ispconfig/server/server.sh --force >/dev/null 2>&1 || true");
// 5) rewrite apache website vhosts back to public binds
$this->apacheRewriteVhostBind('none', $backend_port);
// 6) Reload services
$this->safeReloadServices($app);
return;
}
// ----------------------------
// APPLY branch: mode == nginx_apache
// ----------------------------
if($mode !== 'nginx_apache') {
// haproxy_apache not implemented here
return;
}
// Ports (from server.config)
$backend_port = $this->sanitizePort($this->getInt($cfg, 'apache_backend_port', 8080), 8080);
$apps_enabled = (isset($cfg['apps_vhost_enabled']) && $cfg['apps_vhost_enabled'] === 'y');
$apps_port = $this->sanitizePort($this->getInt($cfg, 'apps_vhost_port', 8081), 8081);
$ispconfig_port = 0;
if(isset($cfg['apache_ispconfig_port'])) $ispconfig_port = intval($cfg['apache_ispconfig_port']);
if($ispconfig_port <= 0) $ispconfig_port = $this->detectIspconfigPort();
$ispconfig_port = $this->sanitizePort($ispconfig_port, 8086);
// Disable Listen directives in ISPConfig panel vhost when nginx is in front
$this->toggleListenInIspconfigVhost(true);
// ---------- 1) Apache loopback listens ----------
if(is_file('/etc/apache2/ports.conf') && !is_file('/etc/apache2/ports.conf.orig')) {
@copy('/etc/apache2/ports.conf', '/etc/apache2/ports.conf.orig');
}
// In reverse proxy mode, Apache must NOT bind public 80/443
$this->toggleApachePublicPortsInPortsConf(true);
$listens = array();
$listens[] = "Listen 127.0.0.1:" . $backend_port;
if($apps_enabled) $listens[] = "Listen 127.0.0.1:" . $apps_port;
$listens[] = "Listen 127.0.0.1:" . $ispconfig_port;
$listens = array_values(array_unique($listens));
$this->writeManagedBlock(
'/etc/apache2/ports.conf',
"# ISPConfig Reverse Proxy Mode BEGIN",
"# ISPConfig Reverse Proxy Mode END",
implode("\n", $listens)
);
// ---------- 2) Apache RemoteIP ----------
$remoteip_conf = "RemoteIPHeader X-Forwarded-For\nRemoteIPTrustedProxy 127.0.0.1\n";
@file_put_contents('/etc/apache2/conf-available/remoteip.conf', $remoteip_conf);
$app->system->exec_safe("a2enmod remoteip >/dev/null 2>&1 || true");
$app->system->exec_safe("a2enconf remoteip >/dev/null 2>&1 || true");
// ---------- 3) Nginx core reverse proxy config (exactly as requested) ----------
$ng_file = '/etc/nginx/conf.d/reverse-proxy.conf';
$ssl_crt = '/etc/ssl/certs/nginx-selfsigned.crt';
$ssl_key = '/etc/ssl/private/nginx-selfsigned.key';
$ng = "";
$ng .= "server {\n";
$ng .= " listen 80 default_server;\n";
$ng .= " listen [::]:80 default_server;\n";
$ng .= " server_name _;\n\n";
$ng .= " include /etc/nginx/snippets/ispconfig-acme.conf;\n\n";
$ng .= " location / {\n";
$ng .= " proxy_pass http://127.0.0.1:$backend_port;\n";
$ng .= " proxy_set_header Host \$host;\n";
$ng .= " proxy_set_header X-Real-IP \$remote_addr;\n";
$ng .= " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n";
$ng .= " proxy_set_header X-Forwarded-Proto \$scheme;\n";
$ng .= " proxy_hide_header Upgrade;\n";
$ng .= " }\n";
$ng .= "}\n\n";
$ng .= "server {\n";
$ng .= " listen 443 ssl http2 default_server;\n";
$ng .= " listen [::]:443 ssl http2 default_server;\n";
$ng .= " server_name _;\n\n";
$ng .= " ssl_certificate $ssl_crt;\n";
$ng .= " ssl_certificate_key $ssl_key;\n\n";
$ng .= " ssl_protocols TLSv1.2 TLSv1.3;\n\n";
$ng .= " # optional: make /ispconfig redirect to /ispconfig/\n";
$ng .= " location = /ispconfig {\n";
$ng .= " return 301 /ispconfig/;\n";
$ng .= " }\n\n";
$ng .= " location = /ispconfig/index.php {\n";
$ng .= " return 301 /ispconfig/;\n";
$ng .= " }\n\n";
$ng .= " # ISPConfig assets requested without /ispconfig prefix (fixes favicon/theme assets)\n";
$ng .= " location ^~ /themes/ {\n";
$ng .= " proxy_pass https://127.0.0.1:$ispconfig_port/themes/;\n";
$ng .= " proxy_set_header Host \$host;\n";
$ng .= " proxy_ssl_verify off;\n";
$ng .= " }\n\n";
$ng .= " location ^~ /ispconfig/ {\n";
$ng .= " proxy_pass https://127.0.0.1:$ispconfig_port/;\n";
$ng .= " proxy_set_header Host \$host;\n";
$ng .= " proxy_set_header X-Real-IP \$remote_addr;\n";
$ng .= " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n";
$ng .= " proxy_set_header X-Forwarded-Proto \$scheme;\n";
$ng .= " proxy_ssl_verify off;\n\n";
$ng .= " # fix backend redirects like Location: /login/\n";
$ng .= " proxy_redirect ~^(/.*)\$ /ispconfig\$1;\n";
$ng .= " }\n\n";
$ng .= " location / {\n";
$ng .= " proxy_pass http://127.0.0.1:$backend_port;\n";
$ng .= " proxy_set_header Host \$host;\n";
$ng .= " proxy_set_header X-Real-IP \$remote_addr;\n";
$ng .= " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n";
$ng .= " proxy_set_header X-Forwarded-Proto \$scheme;\n";
$ng .= " proxy_hide_header Upgrade;\n";
$ng .= " }\n";
$ng .= "}\n";
@file_put_contents($ng_file, $ng);
// ---------- 3++) Ensure ISPConfig nginx vhost links are enabled ----------
$this->nginxEnsureIspconfigVhostsEnabled($app);
// ---------- 4) Regenerate ISPConfig configs now ----------
$app->system->exec_safe("/usr/local/ispconfig/server/server.sh --force >/dev/null 2>&1 || true");
// ---------- 5) rewrite apache website vhosts to loopback
$this->apacheRewriteVhostBind('nginx_apache', $backend_port);
// ---------- 6) Reload services safely ----------
$this->safeReloadServices($app);
}
}