Name: Debian GNU/Linux ID: debian Version: 11 Codename: bullseye Code: source /etc/os-release echo "Name: $NAME" echo "ID: $ID" echo "Version: $VERSION_ID" echo "Codename: $VERSION_CODENAME" PHP: php -r 'include "/usr/local/ispconfig/interface/lib/config.inc.php"; echo ($conf["app_version"] ?? "not found").PHP_EOL; ISPConfig version: 3.3.1Target architecture · Nginx listens publicly: o 0.0.0.0:80, [::]:80 o 0.0.0.0:443, [::]:443 · Apache listens only locally: o 127.0.0.1:8080 = websites o 127.0.0.1:8081 = apps vhost o 127.0.0.1:8086 = ISPConfig panel So public traffic always enters via nginx, never Apache. Part A — Make Apache “backend only” (no :80/:443) 1) Apache ports Edit: cp /etc/apache2/ports.conf /etc/apache2/ports.conf.orig nano /etc/apache2/ports.conf Make it only: Code: Listen 127.0.0.1:8080 Listen 127.0.0.1:8081 Listen 127.0.0.1:8086 2) Disable server-level Apache directive Listen /etc/apache2/sites-enabled/000-apps.vhost: Code: # Listen 8081 # NameVirtualHost *:8081 /etc/apache2/sites-enabled/000-ispconfig.vhost Code: # Listen 8086 #NameVirtualHost *:8086 Part B — Install Nginx Code: apt update apt install -y nginx Enable & start: Code: systemctl enable --now nginx 1) Disable default site rm -f /etc/nginx/sites-enabled/default 2) ACME challenge snippet Challenge directory/ Nginx: Code: cat >/etc/nginx/snippets/ispconfig-acme.conf <<'EOF' location ^~ /.well-known/acme-challenge/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } EOF 3) Generate temporary self-signed certificate This is the easiest way to check the config and make sure nginx is working. Code: sudo mkdir -p /etc/ssl/certs /etc/ssl/private SERVER_IP=$(hostname -I | awk '{print $1}') BASE_DOMAIN=$(hostname -f | awk -F. '{print $(NF-1)"."$NF}') sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/private/nginx-selfsigned.key -out /etc/ssl/certs/nginx-selfsigned.crt -subj "/C=DE/ST=Nordrhein-Westfalen/L=Köln/O=ISPConfig GmbH/OU=IT/CN=${SERVER_IP}/emailAddress=server@${BASE_DOMAIN}" /etc/ssl/certs/nginx-selfsigned.crt — public certificate; /etc/ssl/private/nginx-selfsigned.key — private key. 4) Create main reverse proxy config nano /etc/nginx/conf.d/reverse-proxy.conf HTML: server { listen 80 default_server; listen [::]:80 default_server; server_name _; include /etc/nginx/snippets/ispconfig-acme.conf; location / { proxy_pass http://127.0.0.1:8080; 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 $scheme; proxy_hide_header Upgrade; } } server { listen 443 ssl http2 default_server; listen [::]:443 ssl http2 default_server; server_name _; # Use ISPConfig/Apache existing LE cert for your.server.name or just self-signed ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt; ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key; ssl_protocols TLSv1.2 TLSv1.3; # optional: make /ispconfig redirect to /ispconfig/ location = /ispconfig { return 301 /ispconfig/; } location = /ispconfig/index.php { return 301 /ispconfig/; } # ISPConfig assets requested without /ispconfig prefix (fixes favicon/theme assets) location ^~ /themes/ { proxy_pass https://127.0.0.1:8086/themes/; proxy_set_header Host $host; proxy_ssl_verify off; } location ^~ /ispconfig/ { # strip the prefix so backend sees / # rewrite ^/ispconfig(/.*)$ $1 break; proxy_pass https://127.0.0.1:8086/; 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 $scheme; proxy_ssl_verify off; # fix backend redirects like Location: /login/ proxy_redirect ~^(/.*)$ /ispconfig$1; } # safety net: if something still redirects to /login/, catch it #location ^~ /login/ { return 301 /ispconfig/login/; } #location ^~ /logout/ { return 301 /ispconfig/logout/; } location / { proxy_pass http://127.0.0.1:8080; 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 $scheme; #curl: HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1) proxy_hide_header Upgrade; } } nginx -t && systemctl reload nginx 5) Enable proxy headers in Apache (CRITICAL) Apache must trust Nginx. When Apache sits behind NGINX, Apache no longer sees the real client IP — it only sees NGINX’s IP unless you explicitly tell Apache to trust proxy headers. What Apache sees without this config Request flow: Client (1.2.3.4) ↓ NGINX (reverse proxy) ↓ Apache Apache’s view: REMOTE_ADDR = 127.0.0.1 (or NGINX IP) Edit: nano /etc/apache2/conf-available/remoteip.conf Code: RemoteIPHeader X-Forwarded-For RemoteIPTrustedProxy 127.0.0.1 RemoteIPTrustedProxy ::1 Enable: a2enconf remoteip systemctl reload apache2 Apache is failing because the directive RemoteIPHeader is unknown until the remoteip module is loaded. a2enmod remoteip systemctl restart apache2 RemoteIPHeader X-Forwarded-For ➡️ Tells Apache: “The real client IP is in the X-Forwarded-For header.” NGINX sets this automatically when proxying. RemoteIPTrustedProxy 127.0.0.1 ➡️ Tells Apache: “I trust this proxy to tell the truth about client IPs.” CRITICAL point: Apache will ignore X-Forwarded-For unless the request comes from a trusted proxy. This prevents spoofing: A client cannot just send X-Forwarded-For: 8.8.8.8 Apache will only accept it if the sender is trusted Security feature, not a bug. Why this is essential with NGINX reverse proxy ✅ Essential if you want: Correct Apache access logs Real IPs in PHP ($_SERVER['REMOTE_ADDR']) Working fail2ban Correct mod_security behavior Rate limiting / brute-force protection Accurate GeoIP Anything security-related Part C — Stop ISPConfig from putting Apache back on 80/443 The key is to make your port changes survive an ISPConfig “resync” (it regenerates vhosts on updates / “Apply changes” / cron). I. Tell ISPConfig that Apache is behind a reverse proxy 1. UI: add fields to Server Config (reverse-proxy toggle + backend port) 1.1 Add fields in: /usr/local/ispconfig/interface/web/admin/form/server_config.tform.php /usr/local/ispconfig/interface/web/admin/templates/server_config_web_edit.htm /usr/local/ispconfig/interface/web/admin/lib/lang/en_server_config.lng /usr/local/ispconfig/interface/web/admin/form/server_config.tform.php: Add these under the web tab (exact placement doesn’t matter, but keep it near nginx/apache settings): Code: 'reverse_proxy_mode' => array( 'datatype' => 'VARCHAR', 'formtype' => 'SELECT', 'default' => 'none', 'value' => array( 'none' => 'None', 'nginx_apache' => 'NGINX over Apache', 'haproxy_apache' => 'HAProxy over Apache' ) ), 'apache_backend_port' => array( 'datatype' => 'VARCHAR', 'formtype' => 'TEXT', 'default' => '8080', 'value' => '', 'width' => '40', 'maxlength' => '255' ), 1.2 Show fields in template: /usr/local/ispconfig/interface/web/admin/templates/server_config_web_edit.htm: Add: HTML: {tmpl_var name='vhost_proxy_protocol_enabled'} </select> </div> + <div class="alert alert-info"> + <div class="form-group"> + <label for="reverse_proxy_mode" class="col-sm-3 control-label">{tmpl_var name='reverse_proxy_mode_txt'}</label> + <div class="col-sm-9"> + <select name="reverse_proxy_mode" id="reverse_proxy_mode" class="form-control"> + {tmpl_var name='reverse_proxy_mode'} + </select> + </div> + </div> + <div class="form-group"> + <label for="apache_backend_port" class="col-sm-3 control-label">{tmpl_var name='apache_backend_port_txt'}</label> + <div class="col-sm-9"><input type="text" name="apache_backend_port" id="apache_backend_port" value="{tmpl_var name='apache_backend_port'}" class="form-control"/></div> + </div> + </div> <div class="form-group"> <label for="vhost_proxy_protocol_protocols" class="col-sm-3 control-label">{tmpl_var name='vhost_proxy_protocol_protocols_txt'}</label> <div class="col-sm-9"> 1.3 Add language strings: Add to your language file: /usr/local/ispconfig/interface/web/admin/lib/lang/en_server_config.lng Code: $wb['reverse_proxy_mode_txt'] = 'NGINX Reverse Proxy'; $wb['apache_backend_port_txt'] = 'Apache backend port (127.0.0.1)'; 2. Change the templates ISPConfig uses to generate vhosts ISPConfig regenerates vhosts from templates. On Debian, templates live under this: /usr/local/ispconfig/server/conf/ apache_apps.vhost.master / apps vhost.conf.master / websites apache_ispconfig.conf.master / panel vhost templates Best practice: copy master templates to “custom” templates so updates don’t overwrite them. Create conf-custom (safe overrides) mkdir -p /usr/local/ispconfig/server/conf-custom 2.1- REMOVE the Listen ... line from apache_apps.vhost.master ISPConfig generates: /etc/apache2/sites-available/apps.vhost from template: /usr/local/ispconfig/server/conf/apache_apps.vhost.master cp -a /usr/local/ispconfig/server/conf/apache_apps.vhost.master \ /usr/local/ispconfig/server/conf-custom/apache_apps.vhost.master Comment out lines: nano /usr/local/ispconfig/server/conf-custom/apache_apps.vhost.master Code: #{tmpl_var name='vhost_port_listen'} Listen {tmpl_var name='apps_vhost_port'} # NameVirtualHost *:{tmpl_var name='apps_vhost_port'} or Code: FILE="/usr/local/ispconfig/server/conf-custom/apache_apps.vhost.master" sed -i \ "/^[[:space:]]*{tmpl_var name='vhost_port_listen'} Listen {tmpl_var name='apps_vhost_port'}/{ /^[[:space:]]*#/! s/^/#/ }" \ "$FILE" grep -n "vhost_port_listen.*apps_vhost_port" \ /usr/local/ispconfig/server/conf-custom/apache_apps.vhost.master 2.2 (Apache) Edit website vhost template to use 8080 (not 80/443) - vhost.conf.master Code: cp -a /usr/local/ispconfig/server/conf/vhost.conf.master \ /usr/local/ispconfig/server/conf-custom/vhost.conf.master nano /usr/local/ispconfig/server/conf-custom/vhost.conf.master Here we do two replacements: A) Replace the HTTP vhost header | Replace SSL vhost header Change any of these forms: <VirtualHost *:80> <VirtualHost {IP}:80> <VirtualHost [IPV6]:80> to: <VirtualHost 127.0.0.1:8080> Change any of: <VirtualHost *:443> <VirtualHost {IP}:443> <VirtualHost [IPV6]:443> to: <VirtualHost 127.0.0.1:8080> In other words, Change <VirtualHost> from: <VirtualHost {tmpl_var name='ip_address'}:{tmpl_var name='port'}> to: <VirtualHost {tmpl_var name='apache_backend_ip'}:{tmpl_var name='apache_backend_port'}> Code: sed -Ei ' /^<VirtualHost[[:space:]]+/ { s/\{tmpl_var name='\''ip_address'\''\}/\{tmpl_var name='\''apache_backend_ip'\''\}/g s/\{tmpl_var name='\''port'\''\}/\{tmpl_var name='\''apache_backend_port'\''\}/g } ' /usr/local/ispconfig/server/conf-custom/vhost.conf.master B) Disable RemoteIPProxyProtocol comment out: Code: # <IfModule mod_remoteip.c> # RemoteIPProxyProtocol On # </IfModule> because it breaks client IP detection when NGINX is the reverse proxy. This tells Apache: “Expect every incoming TCP connection to start with a PROXY protocol header.” 2.3 (Nginx) Edit vhost templates 1) Neutralize the templates that cause “unwanted things” List nginx templates ISPConfig might use: Code: ls -1 /usr/local/ispconfig/server/conf | grep -i nginx | sort nginx_apps.vhost.master nginx_vhost.conf.master nginx_reverse_proxy_plugin.vhost.conf.master Create “empty” overrides in conf-custom/ so ISPConfig outputs nothing from them: Code: for f in nginx_vhost.conf.master nginx_apps.vhost.master; do if [ -f "/usr/local/ispconfig/server/conf/$f" ]; then printf "# disabled by reverse proxy mode\n" > "/usr/local/ispconfig/server/conf-custom/$f" fi done 2) Nginx Reverse Proxy Vhost (nginx_reverse_proxy_plugin.vhost.conf.master) Code: cp -a /usr/local/ispconfig/server/conf/nginx_reverse_proxy_plugin.vhost.conf.master \ /usr/local/ispconfig/server/conf-custom/nginx_reverse_proxy_plugin.vhost.conf.master nano /usr/local/ispconfig/server/conf-custom/nginx_reverse_proxy_plugin.vhost.conf.master Code: # ------------------------- # HTTP vhost (port 80) # ------------------------- server { listen 80; listen [::]:80; server_name <tmpl_var name='domain'><tmpl_if name='alias'> <tmpl_var name='alias'></tmpl_if>; include /etc/nginx/snippets/ispconfig-acme.conf; location / { proxy_pass http://127.0.0.1:<tmpl_var name='apache_backend_port'>; 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 http; proxy_hide_header Upgrade; } } <tmpl_if name='ssl_enabled'> # ------------------------- # HTTPS vhost (port 443) # ------------------------- server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name <tmpl_var name='domain'><tmpl_if name='alias'> <tmpl_var name='alias'></tmpl_if>; ssl_certificate <tmpl_var name='web_document_root_ssl'>/<tmpl_var name='ssl_domain'>-le.crt; ssl_certificate_key <tmpl_var name='web_document_root_ssl'>/<tmpl_var name='ssl_domain'>-le.key; location / { proxy_pass http://127.0.0.1:<tmpl_var name='apache_backend_port'>; 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; proxy_hide_header Upgrade; } } </tmpl_if> Enable nginx_plugin (needed for template rendering): Code: ln -s /usr/local/ispconfig/server/plugins-available/nginx_plugin.inc.php \ /usr/local/ispconfig/server/plugins-enabled/nginx_plugin.inc.php II. Apache (/usr/local/ispconfig/server/plugins-available/apache2_plugin.inc.php) PHP: --- apache2_plugin.inc.php.orig 2026-02-05 22:03:55.568570059 +0000+++ apache2_plugin.inc.php 2026-02-05 22:03:55.572570134 +0000@@ -1899,6 +1899,49 @@ } }++// pass backend bind vars into the vhosts loop for the template+$web_config = $app->getconf->get_server_config($data['new']['server_id'], 'web');++// --- reverse proxy: define template vars apache_backend_ip/apache_backend_port ---+$reverse_proxy_mode = (isset($web_config['reverse_proxy_mode']) ? $web_config['reverse_proxy_mode'] : 'none');++// take backend port from server config (default 8080)+$backend_port = (isset($web_config['apache_backend_port']) && intval($web_config['apache_backend_port']) > 0)+ ? intval($web_config['apache_backend_port'])+ : 8080;++// backend ip: if you don't store it yet, keep it fixed+$backend_ip = (isset($web_config['apache_backend_ip']) && $web_config['apache_backend_ip'] != '')+ ? $web_config['apache_backend_ip']+ : '127.0.0.1';++if (is_array($vhosts)) {+ foreach ($vhosts as $k => $vh) {++ if ($reverse_proxy_mode === 'nginx_apache') {++ // Always bind Apache to backend (nginx/haproxy proxies here)+ $vhosts[$k]['apache_backend_ip'] = $backend_ip;+ $vhosts[$k]['apache_backend_port'] = $backend_port;++ } else {++ // "none": bind exactly like normal ISPConfig vhost would+ $vhosts[$k]['apache_backend_ip'] = isset($vh['ip_address']) ? $vh['ip_address'] : '*';++ // pick the correct port for this vhost entry+ if (!empty($vh['ssl_enabled']) && $vh['ssl_enabled'] == 1) {+ $vhosts[$k]['apache_backend_port'] = isset($vh['ssl_port']) ? $vh['ssl_port'] : 443;+ } else {+ $vhosts[$k]['apache_backend_port'] = isset($vh['port']) ? $vh['port'] : 80;+ }+ }+ }+}+++ //* Set the vhost loop $tpl->setLoop('vhosts', $vhosts);
III. Nginx (/usr/local/ispconfig/server/plugins-available/nginx_plugin.inc.php) PHP: --- nginx_plugin.inc.php.orig 2026-02-05 22:06:04.274957908 +0000+++ nginx_plugin.inc.php 2026-02-05 22:06:04.274957908 +0000@@ -323,12 +323,31 @@ function update($event_name, $data) { global $app, $conf;+ + $app->uses('getconf');+ $web_config = $app->getconf->get_server_config($conf['server_id'], 'web');+ $rp_mode = isset($web_config['reverse_proxy_mode']) ? (string)$web_config['reverse_proxy_mode'] : 'none';+ //* Check if the apache plugin is enabled- if(@is_link('/usr/local/ispconfig/server/plugins-enabled/apache2_plugin.inc.php')) {- $app->log('The nginx plugin cannot be used together with the apache2 plugin.', LOGLEVEL_WARN);- return 0;- }+ $apache_plugin_enabled = @is_link('/usr/local/ispconfig/server/plugins-enabled/apache2_plugin.inc.php');+if($apache_plugin_enabled && $rp_mode !== 'nginx_apache') {+ $app->log('The nginx plugin cannot be used together with the apache2 plugin.', LOGLEVEL_WARN);+ return 0;+}+if($apache_plugin_enabled && $rp_mode === 'nginx_apache') {+ $app->log('DEBUG: allowing nginx_plugin with apache2_plugin because rp_mode=nginx_apache', LOGLEVEL_DEBUG);+}+ if($this->action != 'insert') $this->action = 'update';@@ -1038,7 +1057,20 @@ $app->load('tpl'); $tpl = new tpl();- $tpl->newTemplate('nginx_vhost.conf.master');++$tpl_file = ($rp_mode === 'nginx_apache')+ ? 'nginx_reverse_proxy_plugin.vhost.conf.master'+ : 'nginx_vhost.conf.master';++$tpl_path = SCRIPT_PATH . '/conf-custom/' . $tpl_file;+if(!is_file($tpl_path)) $tpl_path = SCRIPT_PATH . '/conf/' . $tpl_file;+++$tpl->newTemplate($tpl_path);+ // IPv4 if($data['new']['ip_address'] == '') $data['new']['ip_address'] = '*';@@ -1052,6 +1084,16 @@ } $vhost_data = $data['new'];+ + $vhost_data['reverse_proxy_mode'] = $rp_mode;+$vhost_data['apache_backend_port'] = isset($web_config['apache_backend_port']) ? (int)$web_config['apache_backend_port'] : 8080;+// Ensure SSL dir is available for template+// ISPConfig stores LE certs per-site in: /var/www/clients/clientX/webY/ssl/+if(!empty($vhost_data['document_root'])) {+ $vhost_data['web_document_root_ssl'] = rtrim($vhost_data['document_root'], '/') . '/ssl';+}+ //unset($vhost_data['ip_address']); $vhost_data['web_document_root'] = $data['new']['document_root'].'/' . $web_folder;@@ -1930,9 +1972,44 @@ if(file_exists($vhost_file)) copy($vhost_file, $vhost_file.'~'); //* Write vhost file- $app->system->file_put_contents($vhost_file, $this->nginx_merge_locations($tpl->grab()));- $app->log('Writing the vhost file: '.$vhost_file, LOGLEVEL_DEBUG);- unset($tpl);++//* Write vhost file+$tpl_out = $tpl->grab();++// GRAB PROBE (file-based, cannot be swallowed by ispconfig logger)+file_put_contents(+ '/tmp/nginx_plugin.grab',+ date('c')+ . " domain=" . $data['new']['domain']+ . " vhost_file=" . $vhost_file+ . " grab_len=" . strlen($tpl_out)+ . " head=" . substr(str_replace(array("\r","\n","\t"), array('\\r','\\n','\\t'), $tpl_out), 0, 160)+ . "\n",+ FILE_APPEND+);++$merged = $this->nginx_merge_locations($tpl_out);++// Optional: probe merged length too+file_put_contents(+ '/tmp/nginx_plugin.grab',+ date('c')+ . " domain=" . $data['new']['domain']+ . " merged_len=" . strlen($merged)+ . " merged_head=" . substr(str_replace(array("\r","\n","\t"), array('\\r','\\n','\\t'), $merged), 0, 160)+ . "\n",+ FILE_APPEND+);++$app->system->file_put_contents($vhost_file, $merged);+$app->log('Writing the vhost file: '.$vhost_file, LOGLEVEL_DEBUG);+unset($tpl_out, $merged);+unset($tpl);+ //* Set the symlink to enable the vhost //* First we check if there is a old type of symlink and remove it@@ -2941,6 +3018,7 @@ $tpl->setVar('use_tcp', $use_tcp); $tpl->setVar('use_socket', $use_socket);+ $fpm_socket = $socket_dir.$pool_name.'.sock'; $tpl->setVar('fpm_socket', $fpm_socket); $tpl->setVar('fpm_listen_mode', '0660');
IV. Nginx Reverse Proxy Mode PLUGIN nano /usr/local/ispconfig/server/plugins-available/reverse_proxy_mode_plugin.inc.php PHP: <?php/* ISPConfig server plugin: Reverse Proxy Mode Plugin - Fires on: server_update only - reverse_proxy_mode = nginx_apache: * Generate nginx vhosts in /etc/nginx/sites-available + enable them * 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 - reverse_proxy_mode = none: * Only disable nginx vhosts (remove symlinks from sites-enabled) * Restore Apache ports.conf from ports.conf.orig if available, else remove managed block * Return listen' values for apps/ispconfig * Disable RemoteIP if enabled by us * Remove /etc/nginx/conf.d/reverse-proxy.conf * Reload services safely*/......................................................................... due to the limit "... 20000 characters", the plugin is attached as a file Enable plugin: ln -s /usr/local/ispconfig/server/plugins-available/reverse_proxy_mode_plugin.inc.php \ /usr/local/ispconfig/server/plugins-enabled/reverse_proxy_mode_plugin.inc.php
V. After the update "ispconfig_update.sh --force", run the idempotent “ispconfig-inject-reverse-proxy-ui” script (needs testing) 1) en_server_config.lng · verify each line exists · if missing → append to bottom 2) server_config.tform.php · find the exact vhost_proxy_protocol_protocols block · inject reverse_proxy_mode block immediately after it · do nothing if already present 3) server_config_web_edit.htm · find the exact vhost_rewrite_v6 form-group block · insert 2 blocks right after it · do nothing if already present Create Python-based injector: /usr/local/sbin/ispconfig-inject-reverse-proxy-ui Code: #!/bin/bash set -euo pipefail python3 - <<'PY' from pathlib import Path import re import sys def read(p: Path) -> str: return p.read_text(encoding="utf-8", errors="replace") def write(p: Path, s: str) -> None: p.write_text(s, encoding="utf-8") def inject_before_captured_anchor_if_missing(p: Path, signature: str, anchor_regex: str, insert_text: str) -> tuple[bool, str]: txt = read(p) if signature in txt: return (False, "already") m = re.search(anchor_regex, txt, flags=re.DOTALL) if not m: return (False, "anchor_not_found") insert_pos = m.start(1) new_txt = txt[:insert_pos] + insert_text + txt[insert_pos:] write(p, new_txt) return (True, "inserted") def inject_after_captured_anchor_if_missing(p: Path, signature: str, anchor_regex: str, insert_text: str) -> tuple[bool, str]: """ Anchor regex MUST contain a capturing group ( ... ). Insert happens immediately after group(1). """ txt = read(p) if signature in txt: return (False, "already") m = re.search(anchor_regex, txt, flags=re.DOTALL | re.MULTILINE) if not m: return (False, "anchor_not_found") insert_pos = m.end(1) new_txt = txt[:insert_pos] + insert_text + txt[insert_pos:] write(p, new_txt) return (True, "inserted") def ensure_line_at_bottom(p: Path, line: str) -> bool: txt = read(p) lines = txt.splitlines() if line in lines: return False if not txt.endswith("\n"): txt += "\n" txt += line + "\n" write(p, txt) return True def ensure_insert_after_anchor(p: Path, signature: str, anchor_regex: str, insert_text: str) -> tuple[bool, str]: """ Insert insert_text immediately after anchor_regex match, but only if signature not present. Returns (changed, status): status in {"already","inserted","anchor_not_found"}. """ txt = read(p) if signature in txt: return (False, "already") m = re.search(anchor_regex, txt, flags=re.DOTALL) if not m: return (False, "anchor_not_found") pos = m.end() new_txt = txt[:pos] + insert_text + txt[pos:] write(p, new_txt) return (True, "inserted") def ensure_insert_before_anchor(p: Path, signature: str, anchor_regex: str, insert_text: str) -> tuple[bool, str]: """ Insert insert_text immediately before anchor_regex match, but only if signature not present. """ txt = read(p) if signature in txt: return (False, "already") m = re.search(anchor_regex, txt, flags=re.DOTALL) if not m: return (False, "anchor_not_found") pos = m.start() new_txt = txt[:pos] + insert_text + txt[pos:] write(p, new_txt) return (True, "inserted") def replace_regex(p: Path, pattern: str, repl: str, flags=re.DOTALL | re.MULTILINE) -> int: txt = read(p) new_txt, n = re.subn(pattern, repl, txt, flags=flags) if n: write(p, new_txt) return n def ensure_block_after_line_once(p: Path, line_regex: str, block_lines: list[str], signature: str) -> tuple[bool, str]: """ 1) Remove all occurrences of signature/block lines anywhere. 2) Insert block immediately after the first line that matches line_regex. """ txt = read(p) # Remove any existing occurrences of the exact signature line (and the companion line if present). # We'll remove both target lines anywhere to avoid dupes from manual edits. for ln in block_lines: ln_re = re.escape(ln).replace(r"\ ", r"[ \t]*") txt = re.sub(rf"^[ \t]*{ln_re}[ \t]*\r?$", "", txt, flags=re.MULTILINE) # Clean up excessive blank lines created by deletions txt = re.sub(r"\n{3,}", "\n\n", txt) if signature in txt: # signature string exists somewhere else (unlikely after deletions) -> still handle by reinserting once pass m = re.search(line_regex, txt, flags=re.MULTILINE) if not m: write(p, txt) return (False, "anchor_not_found") insert_pos = m.end() # Detect indentation from anchor line anchor_line = m.group(0) indent = re.match(r"^([ \t]*)", anchor_line).group(1) block = "\n" + "".join([indent + ln + "\n" for ln in block_lines]) new_txt = txt[:insert_pos] + block + txt[insert_pos:] write(p, new_txt) return (True, "normalized_and_inserted") # Paths LANG_FILE = Path("/usr/local/ispconfig/interface/web/admin/lib/lang/en_server_config.lng") TFORM_FILE = Path("/usr/local/ispconfig/interface/web/admin/form/server_config.tform.php") TPL_FILE = Path("/usr/local/ispconfig/interface/web/admin/templates/server_config_web_edit.htm") CACHE_DIR = Path("/usr/local/ispconfig/interface/temp") # --- 1) lang lines --- print("== Lang injection ==") for p in (LANG_FILE,): if not p.exists(): sys.exit(f"ERROR: missing {p}") lang_lines = [ "$wb['reverse_proxy_mode_txt'] = 'NGINX Reverse Proxy';", "$wb['apache_backend_port_txt'] = 'Apache backend port (127.0.0.1)';", ] for ln in lang_lines: changed = ensure_line_at_bottom(LANG_FILE, ln) print(("FIXED:" if changed else "OK:"), ln) # --- 2) tform injection (add BOTH fields) --- print("\n== TFORM injection ==") if not TFORM_FILE.exists(): raise SystemExit(f"ERROR: missing {TFORM_FILE}") # 1) Remove any existing blocks of reverse_proxy_mode or apache_backend_port fields (clean slate) removed1 = replace_regex( TFORM_FILE, r"\n[ \t]*'reverse_proxy_mode'\s*=>\s*array\(\s*.*?\)\s*,\s*", "\n" ) removed2 = replace_regex( TFORM_FILE, r"\n[ \t]*'apache_backend_port'\s*=>\s*array\(\s*.*?\)\s*,\s*", "\n" ) if removed1 or removed2: print("FIXED: removed existing reverse proxy fields (to reinsert cleanly)") tform_insert = ( "\n" " 'reverse_proxy_mode' => array(\n" " 'datatype' => 'VARCHAR',\n" " 'formtype' => 'SELECT',\n" " 'default' => 'none',\n" " 'value' => array(\n" " 'none' => 'None',\n" " 'nginx_apache' => 'NGINX over Apache',\n" " 'haproxy_apache' => 'HAProxy over Apache'\n" " )\n" " ),\n" " 'apache_backend_port' => array(\n" " 'datatype' => 'VARCHAR',\n" " 'formtype' => 'TEXT',\n" " 'default' => '8080',\n" " 'value' => '',\n" " 'width' => '40',\n" " 'maxlength'=> '255'\n" " ),\n" ) # 2) Insert right after the web tab fields array begins: # Find: $form["tabs"]['web'] ... 'fields' => array( web_fields_anchor = r"(\$form\[\s*\"tabs\"\s*\]\s*\[\s*'web'\s*\]\s*=\s*array\(.*?'fields'\s*=>\s*array\()\s*" changed, status = inject_after_captured_anchor_if_missing( TFORM_FILE, signature="'reverse_proxy_mode' => array(", anchor_regex=web_fields_anchor, insert_text=tform_insert ) print("OK: already present" if status == "already" else "FIXED: inserted reverse proxy fields at top of web fields" if status == "inserted" else "ERROR: anchor not found (tform)") # --- 3) template injection --- print("\n== Template injection ==") if not TPL_FILE.exists(): raise SystemExit(f"ERROR: missing {TPL_FILE}") # 3-1) Remove any previously injected reverse proxy block (to avoid nesting damage) # This removes the reverse_proxy_mode + apache_backend_port form-groups if present anywhere. removed = replace_regex( TPL_FILE, r"\n?[ \t]*<div class=\"form-group\">\s*" r"<label for=\"reverse_proxy_mode\".*?</div>\s*</div>\s*" r"<div class=\"form-group\">\s*" r"<label for=\"apache_backend_port\".*?</div>\s*</div>\s*", "\n" ) if removed: print("FIXED: removed existing reverse proxy block (to reinsert cleanly)") tpl_insert = ( "\n" " <div class=\"form-group\">\n" " <label for=\"reverse_proxy_mode\" class=\"col-sm-3 control-label\">{tmpl_var name='reverse_proxy_mode_txt'}</label>\n" " <div class=\"col-sm-9\">\n" " <select name=\"reverse_proxy_mode\" id=\"reverse_proxy_mode\" class=\"form-control\">\n" " {tmpl_var name='reverse_proxy_mode'}\n" " </select>\n" " </div>\n" " </div>\n" " <div class=\"form-group\">\n" " <label for=\"apache_backend_port\" class=\"col-sm-3 control-label\">{tmpl_var name='apache_backend_port_txt'}</label>\n" " <div class=\"col-sm-9\"><input type=\"text\" name=\"apache_backend_port\" id=\"apache_backend_port\" value=\"{tmpl_var name='apache_backend_port'}\" class=\"form-control\"/></div>\n" " </div>\n" ) # 3-2) Capture the ENTIRE enabled block (including its closing </div></div>) # and insert immediately after it. tpl_anchor = ( r"(" r"[ \t]*<div class=\"form-group\">\s*" r"<label for=\"vhost_proxy_protocol_enabled\"[^>]*>\{tmpl_var name='vhost_proxy_protocol_enabled_txt'\}</label>\s*" r"<div class=\"col-sm-9\">\s*" r"<select name=\"vhost_proxy_protocol_enabled\" id=\"vhost_proxy_protocol_enabled\" class=\"form-control\">\s*" r"\{tmpl_var name='vhost_proxy_protocol_enabled'\}\s*" r"</select>\s*" r"</div>\s*" r"</div>\s*" r")" r"\s*" r"<div class=\"form-group\">\s*<label for=\"vhost_proxy_protocol_protocols\"" ) # Use your captured-anchor helper: changed, status = inject_after_captured_anchor_if_missing( TPL_FILE, signature="name='reverse_proxy_mode_txt'", anchor_regex=tpl_anchor, insert_text=tpl_insert ) print("OK: already present" if status == "already" else "FIXED: inserted reverse proxy block between proxy_protocol_enabled and proxy_protocol_protocols" if status == "inserted" else "ERROR: anchor not found (template)") # --- 4) Apache ISPConfig panel vhost: comment out Listen lines --- print("\n== Apache ispconfig.vhost Listen cleanup ==") ISP_VHOST = Path("/etc/apache2/sites-enabled/000-ispconfig.vhost") if ISP_VHOST.exists() and ISP_VHOST.is_file(): txt = read(ISP_VHOST) new_lines = [] changed = False for line in txt.splitlines(keepends=True): # Match lines that START with optional whitespace + Listen if re.match(r'^\s*Listen\s+', line) and not re.match(r'^\s*#\s*Listen\s+', line): new_lines.append(re.sub(r'^(\s*)Listen', r'\1# Listen', line)) changed = True else: new_lines.append(line) if changed: write(ISP_VHOST, "".join(new_lines)) print("FIXED: commented out Listen directives in 000-ispconfig.vhost") else: print("OK: no active Listen directives found in 000-ispconfig.vhost") else: print("OK: 000-ispconfig.vhost not present (nothing to do)") # --- clear interface cache --- if CACHE_DIR.exists() and CACHE_DIR.is_dir(): for f in CACHE_DIR.iterdir(): if f.is_file(): try: f.unlink() except Exception: pass print("\nOK: cleared ISPConfig interface cache") PY
ACME HTTP-01 needs to work on port 80 Code: BASE_DOMAIN=$(hostname -f | awk -F. '{print $(NF-1)"."$NF}') 1) Test nginx → Apache pass-through (critical step) This confirms nginx routes ACME correctly: Code: curl -i -H "Host: $BASE_DOMAIN" http://127.0.0.1/.well-known/acme-challenge/empty.dir Expected result: HTTP/1.1 200 OK Server: nginx/1.18.0 ... This empty directory is needed by ISPConfig. 2) Test Apache backend directly (bypassing nginx) This confirms Apache serves the challenge correctly: Code: curl -i -H "Host: $BASE_DOMAIN" \ > http://127.0.0.1:8080/.well-known/acme-challenge/empty.dir Expected result: HTTP/1.1 200 OK Date: Tue, 03 Feb 2026 21:20:32 GMT Server: Apache ... This empty directory is needed by ISPConfig. 3) Verify public access (real Let’s Encrypt view) From any external machine (or your local PC): Code: curl -i http://$BASE_DOMAIN/.well-known/acme-challenge/empty.dir Expected result: HTTP/1.1 200 OK Server: nginx/1.18.0 ... This empty directory is needed by ISPConfig.