Nginx reverse proxy to Apache (bound to 127.0.0.1) + ISPConfig panel routing

Discussion in 'Plugins/Modules/Addons' started by Alex Mamatuik, Feb 5, 2026 at 11:54 PM.

  1. Alex Mamatuik

    Alex Mamatuik Member

    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 -'include "/usr/local/ispconfig/interface/lib/config.inc.php"; echo ($conf["app_version"] ?? "not found").PHP_EOL;

    ISPConfig version: 3.3.1
    Target 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">
    nginx reverse proxy.png

    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,+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);

     
  2. Alex Mamatuik

    Alex Mamatuik Member

    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,+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,+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,+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_fileLOGLEVEL_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), 0160)
    +    . 
    "\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), 0160)
    +    . 
    "\n",
    +    
    FILE_APPEND
    +);
    +
    +
    $app->system->file_put_contents($vhost_file$merged);
    +
    $app->log('Writing the vhost file: '.$vhost_fileLOGLEVEL_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,+3018,@@
             
    $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');
     
  3. Alex Mamatuik

    Alex Mamatuik Member

    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
     

    Attached Files:

  4. Alex Mamatuik

    Alex Mamatuik Member

    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
    
    
     
  5. Alex Mamatuik

    Alex Mamatuik Member

    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.
     

Share This Page