Opt-In Software Blog

Software, dvelopment, and practical insights

ProxyMapService: Chrome-Like TLS Fingerprinting

Read in: English | Русский

When ProxyMapService decrypts HTTPS traffic, it works as a man-in-the-middle: the TLS connection from the browser ends at the proxy, and the proxy opens its own TLS connection to the target server. The certificate the proxy presents is generated on the fly from its own root CA — but even with a valid certificate chain, there is one thing the server sees that cannot be disguised that easily: the TLS handshake itself.

A TLS ClientHello is very characteristic of the software that sent it. The .NET SslStream offers its own set of cipher suites and extensions, in its own order, with no GREASE. To anyone who looks — anti-bot systems, CDNs, CAPTCHA services — such a connection does not look like a browser. Fingerprint formats such as JA3 and JA4 compress the ClientHello into a short hash that identifies the client software, and this hash travels with every TLS connection the proxy opens.

What changed

A new version of ProxyMapService adds the UseBoringSsl option to SslClientOptions. When it is enabled, the upstream TLS handshake is performed not by SslStream, but by BoringSslStream — a TLS stream backed by BoringSSL, the TLS library used by Chromium.

BoringSslStream replicates Google Chrome’s ClientHello byte-for-byte: the same cipher suites, the same extensions in the same order, GREASE values, post-quantum key groups, ALPN and ALPS. From the server’s point of view, the connection looks like it was made by a real Chrome browser.

The difference is easy to see in the JA4 fingerprint of the ClientHello (both values are produced by the integration test bundled with ProxyMapService, with an HTTP/1.1 client behind the proxy):

Client JA4 fingerprint
.NET SslStream t13d311100_e8f1e7e78f70_f6eeaf393d2e
BoringSslStream (Chrome) t13d1517h2_8daaf6152771_cb7bf5808d99

The first part of the fingerprint decodes as: TCP, TLS 1.3, SNI present, then the number of cipher suites, the number of extensions and the ALPN protocol. The .NET handshake offers 31 ciphers and 11 extensions and does not look like any browser. The BoringSslStream handshake offers 15 ciphers and 17 extensions — and the cipher-suite hash 8daaf6152771 is exactly the hash Google Chrome itself produces.

How to enable it

Two conditions must hold:

  • SSL decryption is enabled (DecryptSSL), so the proxy opens its own TLS connections upstream — how to set that up is covered in the post about SSL/TLS Traffic Decryption.
  • UseBoringSsl is enabled in SslClientOptions:
"SslClientOptions": {
    "EnabledSslProtocols": "Tls12,Tls13",
    "CheckCertificateRevocation": true,
    "ApplicationProtocols": "http/1.1,h2",
    "UseBoringSsl": true
}

Restart the service for the change to take effect. The option affects only the outgoing (upstream) connections of decrypted traffic; it does not change how the proxy accepts client connections.

What stays the same

  • ALPN. The negotiated application protocol still follows SslClientOptions.ApplicationProtocols, so HTTP/2 to the server keeps working exactly as described in the post about HTTP/1.1 → HTTP/2 Translation.
  • Certificate validation. CheckCertificateRevocation, the TrustedCAs list and the IgnoreCertificateErrors mapping option remain fully effective with BoringSslStream.
  • No extra dependencies. The native BoringSSL binary ships inside the NuGet package for Windows x64, Linux x64 and macOS ARM64; nothing needs to be installed separately.

One setting intentionally stops playing a role: EnabledSslProtocols does not apply to BoringSslStream, because the TLS versions and cipher suites are chosen the same way Chrome chooses them — that is the whole point.

Quieter logging by default

In the same version, the Event Log became quieter. Many Information-level messages that used to be written for every connection are now disabled by default, so the log contains mostly what matters: requests, responses, warnings and errors. The verbose messages did not go anywhere — they are enabled in the DetailedLogging section:

"DetailedLogging": {
    "LogStep": false,
    "Http2Logging": false,
    "LogConnections": false,
    "LogTlsHandshake": false,
    "LogProtocolSwitch": false,
    "LogResponseSource": false
}
Setting Messages it enables
LogConnections Connection lifecycle: client connected, connected to server, disconnected. Warnings (connection failed, etc.) are always logged.
LogTlsHandshake Successful TLS handshakes. Failed handshakes are warnings and are always logged.
LogProtocolSwitch Protocol negotiation: h2 detection, HTTP/1.1 downgrade for host override and cache, HTTP/1.1 → HTTP/2 translation.
LogResponseSource Where a response came from: the server, the cache, or a file served by the proxy.
Http2Logging HTTP/2 diagnostics: aborted streams (RST_STREAM) and connection shutdown (GOAWAY) with error codes.
LogStep Session processing steps: one line per handler step in the state machine.

When you are diagnosing a connection problem, LogConnections and LogTlsHandshake are usually the first two to turn on; LogStep gives the full picture of how a session moved through the handlers.

Verifying the fingerprint

The repository includes an integration test that captures the TLS ClientHello the proxy sends upstream and computes its JA4 fingerprint. Start the test proxy instance (start_proxymapservice_test.sh / .ps1) and run:

dotnet test ProxyMapService.Tests --filter "FullyQualifiedName~Ja4Fingerprint" -l "console;verbosity=detailed"

The test prints the JA4 fingerprint together with the full list of offered cipher suites and extensions, and asserts that with UseBoringSsl: true the cipher-suite hash matches Chrome’s 8daaf6152771 — and that with the option disabled it does not.

ProxyMapService: HTTP/2 logging

Read in: English | Русский

ProxyMapService now logs HTTP/2 traffic the same way it logs HTTP/1.1: headers, bodies and the completion of every request show up in the same logs and the monitor, with nothing extra to configure.

What changed

Until now, only HTTP/1.1 traffic was visible in the log and the monitor. If both the client and the server spoke HTTP/2, the tunnel simply forwarded the bytes and nothing was left in the log. Traffic still flows through unchanged, but the proxy now reads a copy of it and hands it to the same loggers.

Scenario Before Now
HTTP/1.1 client, HTTP/1.1 server logged logged
HTTP/1.1 client, HTTP/2 server (translation) logged logged
HTTP/2 client, HTTP/2 server not visible logged
Files from RootDir over HTTP/2 the client received garbage served correctly

HTTP Traffic tab: HTTP/2 to HTTP/2 requests with the Transaction Inspector open

The HTTP Traffic tab: client and server both speak HTTP/2, and the response is inspected in the Transaction Inspector.

One more fix applies to every protocol: the “request completed” event is now delivered for each request, not once per session. Previously, when several requests shared one connection (keep-alive, and HTTP/2 even more so), the monitor could receive the completion only for the last one.

What goes into the log

For every HTTP/2 request the proxy passes the same things to the loggers as for HTTP/1.1:

  • request and response headers;
  • request and response bodies, if a body logger is enabled (without one, bodies are not accumulated);
  • request completion, separately for each request, even when dozens run in parallel on one connection;
  • URL matches for subscriptions (NotifyIfRequestUrlMatches).

To keep the log readable, HTTP/2 headers are shown in the familiar form: the request as GET /path HTTP/2, the response as HTTP/2 200 OK. The server name comes from :authority and is shown as host, and multiple cookie headers are merged into one.

The monitor shows which protocol the client speaks (the Client Proto column) and which one the server speaks (Target Proto). With translation it is HTTP/1.1 → HTTP/2; with transparent h2 it is HTTP/2 → HTTP/2:

The same monitor: an HTTP/1.1 client, some servers on HTTP/2 and some on HTTP/1.1

At Debug level, one summary line is written per request:

[h2 stream 5] GET /api/items -> 200, request 0 B, response 4821 B, 37 ms

Also at Debug level you can see individual requests being aborted (RST_STREAM) and the connection being closed (GOAWAY) with its error code.

How to enable it

There is no separate setting: observation switches on by itself when a session runs on HTTP/2 on both sides. Three conditions must hold:

  1. TLS decryption is enabled (DecryptSSL). Without it the proxy sees only encrypted bytes and there is nothing to parse.
  2. SslClientOptions.ApplicationProtocols (what the proxy offers the server) includes h2.
  3. SslServerOptions.ApplicationProtocols (what the proxy allows the client) includes h2 too.

If the client supports h2 and the server picks h2, the session runs as transparent HTTP/2 and the proxy starts observing it. How protocols are negotiated for every combination of settings is covered in the post about HTTP/1.1 → HTTP/2 translation, which has a table of scenarios.

Recommended starting point: http/1.1,h2 in both SslClientOptions and SslServerOptions. Browsers will use h2, clients such as curl will stay on HTTP/1.1, and logging works in both cases.

For FileRequestHandler (when the proxy itself serves files from RootDir) it is also enough to put h2 in SslServerOptions.ApplicationProtocols: the browser negotiates HTTP/2, and the files are served over it.

Cache and Host override

Observation changes nothing in the traffic, but the cache and Host override need to intervene: serve a ready-made response or rewrite the address in the request. For now only HTTP/1.1 can do that, so for such sessions the proxy offers the client HTTP/1.1 by default, even if it supports h2. Towards the server the proxy speaks h2 if translation is enabled, and HTTP/1.1 if not. Both downgrades are on by default, and each can be turned off with its own setting in the HTTP section:

  • DisableDowngradeToHttp1ForOverwrittenHost — Host override. By default (false) the client is downgraded to HTTP/1.1 when an override is set for the host, and the override works. With true the client stays on h2, and Host override does not take effect in such a session.
  • DisableDowngradeToHttp1ForCache — the cache. By default (false) the downgrade is on, and the cache works even for browser h2 traffic. With true the client keeps using h2, and the cache does not take effect in such sessions.
{
  "HTTP": {
    "DisableDowngradeToHttp1ForCache": false,
    "DisableDowngradeToHttp1ForOverwrittenHost": false
  }
}

When a downgrade happens, the log records that the client was offered HTTP/1.1. If the client does not support HTTP/1.1, or the ALPN settings do not allow it, the proxy logs a warning that the downgrade is impossible.

If something goes wrong

Observation cannot break your traffic: the bytes are always sent to the recipient first, and the analysis happens after them. If the proxy cannot parse something unusual, only the log suffers, not the session. Two warnings may appear in the log:

Message What it means
[h2] traffic analysis stopped for … Parsing of one direction (requests or responses) stopped until the end of the session. Traffic keeps flowing, but that direction is no longer logged. Usually this is a malformed stream from the client or the server.
cannot build an HTTP header for logging A header for the log could not be built for a single request (for example, an invalid status). That request is not recorded; the others work as usual.

If the first message keeps appearing for the same host, look at the reason in the log message: it points to a specific protocol violation and helps find the problematic server.

Limitations

  • Observation only watches: the cache and Host override do not work in transparent h2→h2 (see the downgrade to HTTP/1.1 above).
  • Bodies are accumulated in memory in full until the request ends, so large uploads with a body logger enabled take as much memory as the body itself.
  • Individual service frames (PING, WINDOW_UPDATE, SETTINGS) are not logged: you see only requests and responses, plus RST_STREAM and GOAWAY at Debug level.
  • After a parsing failure in one direction, the rest of the session is not logged in that direction.
  • At most 2048 concurrent requests are tracked per connection; this limit protects memory from a peer that opens streams endlessly.

How to Use a Local Ollama Model in VS Code Insiders

Read in: English | Русский

Step 1. Install Ollama and Download a Model

Install Ollama and download the Qwen3.5 model. For example:

ollama pull qwen3.5:latest

Check that the model is available:

ollama list

Ollama provides an OpenAI-compatible API that can be used to connect a model to VS Code.

Step 2. Check API Accessibility

By default, Ollama exposes its API on port 11434.

If VS Code runs directly on Windows, the API endpoint is:

http://localhost:11434/v1/chat/completions

If you use VS Code inside a Dev Container, keep in mind that localhost inside the container refers to the container itself, not the Windows host.

In this case, use:

http://host.docker.internal:11434/v1/chat/completions

You can check whether Ollama is accessible from the Dev Container terminal by running:

curl http://host.docker.internal:11434/v1/models

The response should contain a list of models available in Ollama.

Step 3. Add the Model to VS Code Insiders

Open the configuration file:

%APPDATA%\Code - Insiders\User\chatLanguageModels.json

Add the following configuration:

[
  {
    "name": "Ollama",
    "vendor": "customendpoint",
    "apiType": "chat-completions",
    "models": [
      {
        "id": "qwen3.5:latest",
        "name": "Qwen 3.5",
        "url": "http://host.docker.internal:11434/v1/chat/completions",
        "toolCalling": true,
        "maxInputTokens": 28672,
        "maxOutputTokens": 4096
      }
    ]
  }
]

This example uses host.docker.internal because VS Code is working with a project inside a Dev Container. If VS Code and Ollama both run directly on Windows, replace the URL with http://localhost:11434/v1/chat/completions.

The maxInputTokens and maxOutputTokens settings define the token limits used by VS Code when working with the model. Adjust them according to the model’s context window and the available memory.

Note the toolCalling setting. It allows VS Code to use the tool-calling capabilities supported by the model, for example when working with files.

Step 4. Set the API Key

After saving the configuration, open the Command Palette by pressing Ctrl+Shift+P and run:

Chat: Manage Language Models

Find the Qwen 3.5 model you added and select Update API Key.

Enter any value, for example:

ollama

A local Ollama server normally does not require an API key for authentication. However, VS Code expects one for this configuration, so you can use an arbitrary value.

You don’t need to add the apiKey property to the JSON manually. VS Code will generate the required entry when you configure the key. It will look something like this:

"apiKey": "${input:chat.lm.secret.ollama}"

Step 5. Select the Model and Test It

Open Chat in VS Code Insiders and select Qwen 3.5.

Start by asking the model to explain some code:

Explain what this PHP function does.

Then test file creation:

Create hello.php with a PHP Hello World example.

If everything is configured correctly and VS Code provides the necessary tools, the model should initiate file creation through a tool call instead of simply printing JSON describing the operation in the chat.

Keep in mind that tool-calling support depends on the model. For example, in my tests, qwen3.5:latest returned structured tool_calls through Ollama’s OpenAI-compatible API, whereas qwen2.5-coder:7b returned the tool-call description as ordinary text in a similar test.

Step 6. Check GPU Usage

If you run Ollama on a computer with limited video memory, the model size and context window can significantly affect performance.

To check how Ollama has distributed the model between the CPU and GPU, run:

ollama ps

The PROCESSOR column shows the distribution of processing between the CPU and GPU.

Building PHP 8.2.35 for CentOS 7 in Docker

Read in: English | Русский

CentOS 7 reached end of life in June 2024, but some existing servers still need to run it. In this article, I describe how I built PHP 8.2.35 for CentOS 7.9 x86_64, compiled several PECL extensions, and prepared the files for deployment to an Apache server.

The idea is to build PHP in a Docker container based on CentOS 7-compatible tooling, then transfer the resulting files to the target server. This avoids having to install all the build dependencies directly on the production machine.

Why I Needed PHP 8.2.35

On October 21, 2026, the PHP project released PHP 8.2.35 to address two critical remote code execution vulnerabilities: CVE-2026-109618 and CVE-2026-109619. Both affected PHP 8.2 releases earlier than 8.2.35 and could allow an unauthenticated attacker to execute code on a vulnerable server.

The vulnerabilities involved memory handling inside PHP itself. One concerned memory corruption during out-of-memory handling; the other involved stale internal state referencing memory that had already been freed. The PHP security team identified Apache with mod_php as a particularly high-risk configuration because a malicious request could trigger an exploit before application code ran.

Sources: PHP 8.2.35 release announcement and PHP security advisory.

My server was running an older PHP 8.2 release with Apache’s mod_php. Since the standard PHP packages available on the legacy CentOS 7 system did not provide the required patched version, I decided to build PHP 8.2.35 from source in Docker and deploy it separately under /opt/php-8.2.35.

This approach allowed me to keep the existing application stack while installing a PHP version containing the security fixes.

CentOS 7 itself is no longer supported, so this should be considered a compatibility measure for a legacy system, not a long-term replacement for upgrading the operating system.

1. Prepare the Docker build environment

I used the manylinux2014_x86_64 image as the starting point and configured CentOS 7.9 archive repositories inside it. The complete Dockerfile is available in this GitHub Gist.

On Windows, create a working directory and save the Dockerfile there:

mkdir C:\php-build
cd C:\php-build

Build the image for the x86_64 architecture:

docker build --platform linux/amd64 -t php82-centos7-builder .

Start a container:

docker run -it --name php82-builder php82-centos7-builder

The Dockerfile configures the CentOS 7.9 and EPEL 7 archive repositories, installs the development dependencies, and makes GCC from Developer Toolset 10 available. It also builds re2c 1.0.3 from source.

Inside the container, the build environment can be checked with:

cat /etc/centos-release
gcc --version
rpm -q httpd httpd-devel
apxs -q LIBEXECDIR
apxs -q INCLUDEDIR

The Apache development package is important because PHP is built with apxs to produce the Apache module.

2. Download and configure PHP

Download the PHP 8.2.35 source archive from the official PHP downloads.

Extract it and enter the source directory. The commands below assume that the source is in /usr/local/src/php-8.2.35.

cd /usr/local/src

curl -fL --retry 3 \
  https://www.php.net/distributions/php-8.2.35.tar.gz \
  -o php-8.2.35.tar.gz

tar -xzf php-8.2.35.tar.gz
cd php-8.2.35

The build uses the prefix /opt/php-8.2.35 and enables the extensions needed by the application:

cd /usr/local/src/php-8.2.35

PKG_CONFIG_PATH=/usr/lib64/pkgconfig \
SQLITE_CFLAGS="-I/usr/include" \
SQLITE_LIBS="-L/usr/lib64 -lsqlite3" \
./configure \
  --prefix=/opt/php-8.2.35 \
  --with-apxs2=/usr/bin/apxs \
  --with-config-file-path=/opt/php-8.2.35/etc \
  --with-config-file-scan-dir=/opt/php-8.2.35/etc/conf.d \
  --enable-bcmath \
  --enable-calendar \
  --enable-exif \
  --enable-ftp \
  --enable-mbstring \
  --enable-pcntl \
  --enable-posix \
  --enable-shmop \
  --enable-sockets \
  --enable-soap \
  --enable-sysvmsg \
  --enable-sysvsem \
  --enable-sysvshm \
  --enable-opcache \
  --enable-gd \
  --with-bz2 \
  --with-curl \
  --with-gmp \
  --with-openssl \
  --with-readline \
  --with-zlib \
  --with-mysqli=mysqlnd \
  --with-pdo-mysql=mysqlnd \
  --with-pdo-sqlite \
  --with-sqlite3 \
  --with-jpeg \
  --with-freetype \
  --with-snmp \
  --with-xsl \
  --with-gettext \
  --with-sodium

On this legacy system, I also applied a compatibility patch to ext/openssl/xp_ssl.c before compiling:

sed -i '/#include "php_openssl.h"/a #if OPENSSL_VERSION_NUMBER < 0x10100000L\n#define ASN1_STRING_get0_data ASN1_STRING_data\n#endif' ext/openssl/xp_ssl.c

Compile and install PHP:

make -j2
make install

I did not enable ZTS. The resulting build is intended for Apache with mpm_prefork and the PHP Apache module (mod_php).

3. Build the PECL extensions

I also needed the ds, mongodb, and xmlrpc extensions. They are built separately using the phpize and php-config tools from the new PHP installation.

DS

cd /usr/local/src
curl -fLO https://pecl.php.net/get/ds-1.8.0.tgz
tar -xzf ds-1.8.0.tgz
cd ds-1.8.0

/opt/php-8.2.35/bin/phpize
./configure --with-php-config=/opt/php-8.2.35/bin/php-config
make -j2
make install

MongoDB

cd /usr/local/src
curl -fL https://pecl.php.net/get/mongodb -o mongodb.tgz
tar -xzf mongodb.tgz
cd mongodb-*

/opt/php-8.2.35/bin/phpize
./configure \
  --with-php-config=/opt/php-8.2.35/bin/php-config \
  --enable-mongodb
make -j2
make install

XML-RPC

cd /usr/local/src
curl -fL https://pecl.php.net/get/xmlrpc -o xmlrpc.tgz
tar -xzf xmlrpc.tgz
cd /usr/local/src/xmlrpc-*

/opt/php-8.2.35/bin/phpize
./configure --with-php-config=/opt/php-8.2.35/bin/php-config
make -j2
make install

The MongoDB and XML-RPC commands download the current PECL release rather than a pinned version. For a repeatable build, choose and record the exact extension versions you need.

4. Configure PHP extensions

Create the configuration directory, copy the production php.ini template, and enable the extensions:

mkdir -p /opt/php-8.2.35/etc/conf.d

cp /usr/local/src/php-8.2.35/php.ini-production \
   /opt/php-8.2.35/etc/php.ini

cat > /opt/php-8.2.35/etc/conf.d/20-extensions.ini <<'EOF'
extension=ds.so
extension=mongodb.so
extension=xmlrpc.so
zend_extension=opcache.so
EOF

Check the configuration and loaded modules:

/opt/php-8.2.35/bin/php --ini
/opt/php-8.2.35/bin/php -v
/opt/php-8.2.35/bin/php -m

The extension configuration directory is defined by --with-config-file-scan-dir. The compiled shared extensions are installed in PHP’s extension directory, which can be checked with:

/opt/php-8.2.35/bin/php-config --extension-dir

5. Create the deployment bundle

Installing PHP in the build container does not automatically create a directory called php-8.2.35-bundle. We need to create the directory and copy the files into it explicitly.

In this container, the relevant paths are:

  • PHP installation: /opt/php-8.2.35
  • Apache module: /usr/lib64/httpd/modules/libphp.so
  • Apache module directory reported by apxs: /usr/lib64/httpd/modules

Create the bundle structure:

mkdir -p /tmp/php-8.2.35-bundle/opt
mkdir -p /tmp/php-8.2.35-bundle/usr/lib64/httpd/modules
mkdir -p /tmp/php-8.2.35-bundle/etc/httpd/conf.d

Copy the PHP installation and Apache module:

cp -a /opt/php-8.2.35 \
  /tmp/php-8.2.35-bundle/opt/

cp -a /usr/lib64/httpd/modules/libphp.so \
  /tmp/php-8.2.35-bundle/usr/lib64/httpd/modules/

Create an Apache configuration file for the PHP handler:

cat > /tmp/php-8.2.35-bundle/etc/httpd/conf.d/php.conf <<'EOF'
<IfModule php_module>
    <FilesMatch \.php$>
        SetHandler application/x-httpd-php
    </FilesMatch>
</IfModule>
EOF

This file configures the handler; it does not load the module by itself. The target server must also have a LoadModule directive pointing to the bundled module. If the server has a separate module configuration such as /etc/httpd/conf.modules.d/20-php.conf, update that configuration as part of deployment. Do not load the old and new PHP modules simultaneously.

The bundle now has this structure:

php-8.2.35-bundle/
├── opt/
│   └── php-8.2.35/
├── usr/
│   └── lib64/
│       └── httpd/
│           └── modules/
│               └── libphp.so
└── etc/
    └── httpd/
        └── conf.d/
            └── php.conf

Create the archive from /tmp, explicitly selecting the bundle directory:

tar -czf /tmp/php-8.2.35-centos7-x86_64.tar.gz \
  -C /tmp php-8.2.35-bundle

Verify that the archive exists and contains the expected files:

ls -lh /tmp/php-8.2.35-centos7-x86_64.tar.gz

tar -tzf /tmp/php-8.2.35-centos7-x86_64.tar.gz | head -40

I obtained an archive of approximately 80 MB.

6. Check the build before deployment

Run PHP and inspect the Apache module’s shared-library dependencies:

/opt/php-8.2.35/bin/php -v
/opt/php-8.2.35/bin/php -m

ldd /usr/lib64/httpd/modules/libphp.so | grep 'not found'

If the last command produces no output, ldd did not find missing dependencies in the build container. This does not guarantee that all dependencies are present on the target server, so check them there as well.

7. Copy the archive out of Docker

Open a PowerShell terminal on the Windows host. Replace php82-builder with the actual container name or ID if you used a different one.

docker cp php82-builder:/tmp/php-8.2.35-centos7-x86_64.tar.gz `
  "$env:USERPROFILE\Downloads\php-8.2.35-centos7-x86_64.tar.gz"

The archive can now be transferred to the CentOS server using your usual file-transfer method.

8. Deploy on the CentOS server

On the target server, unpack the archive into a temporary directory first:

mkdir -p /tmp/php-deploy
tar -xzf /path/to/php-8.2.35-centos7-x86_64.tar.gz \
  -C /tmp/php-deploy

Copy the files to their intended locations:

cp -a /tmp/php-deploy/php-8.2.35-bundle/opt/php-8.2.35 \
  /opt/

cp -a /tmp/php-deploy/php-8.2.35-bundle/usr/lib64/httpd/modules/libphp.so \
  /usr/lib64/httpd/modules/

cp -a /tmp/php-deploy/php-8.2.35-bundle/etc/httpd/conf.d/php.conf \
  /etc/httpd/conf.d/

Make sure Apache loads the new module. For example, /etc/httpd/conf.modules.d/20-php.conf should contain:

LoadModule php_module /usr/lib64/httpd/modules/libphp.so

Keep the existing application-specific Apache settings, such as DirectoryIndex, .phar handling, and session settings, if your configuration uses them. Back up the current Apache configuration before making changes.

Check that Apache uses mpm_prefork and that no other PHP module is loaded:

httpd -M | grep -E 'mpm_|php'

Check PHP’s dynamic-library dependencies on the target server:

ldd /usr/lib64/httpd/modules/libphp.so | grep 'not found'

Resolve any missing dependencies before restarting Apache.

9. SELinux contexts

When PHP is installed under /opt, SELinux may need persistent file-context rules for the PHP installation, configuration files, and Apache module.

On the target server, the rules used for this setup are:

semanage fcontext -a -t usr_t '/opt/php-8\.2\.35(/.*)?'
semanage fcontext -a -t httpd_config_t '/opt/php-8\.2\.35/etc(/.*)?'
semanage fcontext -a -t httpd_modules_t '/usr/lib64/httpd/modules/libphp\.so'

restorecon -Rv /opt/php-8.2.35
restorecon -v /usr/lib64/httpd/modules/libphp.so

If a rule already exists, inspect it before changing it rather than blindly adding a duplicate. Verify the resulting labels with ls -Z and matchpathcon -V.

10. Validate Apache and PHP

Before restarting Apache, test the configuration:

httpd -t

If the configuration is valid, restart Apache and inspect its status:

systemctl restart httpd
systemctl status httpd

Check the PHP CLI:

/opt/php-8.2.35/bin/php -v
/opt/php-8.2.35/bin/php -m

Finally, verify PHP through the web server using a temporary test script. Confirm that the reported version is 8.2.35, the SAPI is apache2handler, and the loaded configuration file is /opt/php-8.2.35/etc/php.ini. Remove the test script after verification.

Keep the previous PHP packages and configuration until the new build has been verified in production. If anything goes wrong, restore the saved Apache configuration and return to the previous module.

Conclusion

Building PHP in a separate CentOS 7-compatible container makes it possible to keep the compiler and development packages off the production server. The key is to package the installed PHP tree, the Apache module, and the handler configuration explicitly—and to verify the dependencies and SELinux labels on the target system.

CentOS 7 is no longer supported, so this approach should be treated as a way to maintain an existing system while planning an upgrade to a supported operating system.

ProxyMapService: HTTP/1.1 → HTTP/2 Translation

Read in: English | Русский

ProxyMapService supports HTTP/1.1 → HTTP/2 translation. This is particularly useful when a client supports only HTTP/1.1, but the upstream server requires HTTP/2. The client continues to speak HTTP/1.1, while the proxy communicates with the server over h2 and converts responses back to HTTP/1.1.

Why it’s useful

Many modern servers (gRPC gateways, Kestrel configured for h2-only, and CDNs) accept or prefer HTTP/2. At the same time, client applications, scripts, and older libraries often support only HTTP/1.1. The proxy bridges this gap: the client needs no changes, while the server gets to use a modern protocol. All traffic still passes through the proxy and is logged, including h2 frames on the server side.

Configuration

HTTP/1.1 → HTTP/2 translation is enabled by default in ProxyMapService.

The EnableTranslateH1ToH2 flag can be set at three levels: globally, for a listening port, or for an individual host rule. You can explicitly set it to false if you need to disable translation.

For example, to disable translation globally:

{
  "HTTP": {
    "EnableTranslateH1ToH2": false
  }
}

You can also configure the flag for a specific listening port or host rule when you need more fine-grained control.

Translation works only for HTTPS connections with decryption (MITM) enabled: the proxy needs to inspect requests in order to repackage them into frames. This flag has no effect on transparent tunnels or regular HTTP.

Important: Enabling translation does not guarantee that HTTP/2 will be used. The upstream server must select h2 during the TLS handshake, so h2 must be included in SslClientOptions.ApplicationProtocols (see below).

How it works and limitations

Each client request is parsed as HTTP/1.1. The proxy then sends HEADERS and DATA frames to the server and converts the h2 response back into a status line, headers, and a body. The conversion rules are:

  • Hop-by-hop headers (Connection, Keep-Alive, Transfer-Encoding, Upgrade, Host) are removed; Host becomes the :authority pseudo-header.
  • The method, scheme, and path are mapped to :method, :scheme, and :path.
  • If the response has no Content-Length, the client receives it with Transfer-Encoding: chunked; trailers are forwarded.
  • Invalid headers (CR/LF/NUL, conflicting Content-Length values, or ambiguous Transfer-Encoding) are rejected to protect against request smuggling.

Limitations:

  • Only h1 → h2 translation is supported; the reverse direction, h2 → h1, is not.
  • Only one h2 stream is active at a time; client pipelined requests are processed sequentially.
  • CONNECT and WebSocket upgrades are not translated and receive a 501 Not Implemented response.
  • Server push is disabled (ENABLE_PUSH=0).
  • If the server sends RST_STREAM, the client receives a 502 response.

The role of ApplicationProtocols

The proxy performs two TLS handshakes, each with its own ALPN list. First, three terms:

  • C — SslClientOptions.ApplicationProtocols: what the proxy offers to the upstream server.
  • S — SslServerOptions.ApplicationProtocols: what the proxy allows the client to use.
  • K — the ALPN list in the client’s own ClientHello: the protocols the client actually supports.

The handshakes happen in reverse order: the proxy negotiates with the upstream server first and then responds to the client. More precisely, it peeks at the ClientHello in advance (SocketFlags.Peek) and uses that information to decide what to offer the server.

If a list is empty (in K or S), only http/1.1 is assumed. An empty ALPN list means the client does not negotiate a protocol, so HTTP/1.1 is used over TLS.

The decision is made in three steps (T means translation is enabled):

  1. Determine client capabilities: h1 = K ∩ S contains http/1.1, h2 = K ∩ S contains h2.
  2. Decide what to offer upstream: offer h2 if it is present in C and (the client supports h2, or T is enabled and the client supports h1); offer http/1.1 if it is present in C and the client supports h1.
  3. After the upstream responds: if it selects h2 and the client supports h2, use transparent HTTP/2; if it selects h2 and the client supports only h1 while T is enabled, translate h1 → h2; if it selects http/1.1 and the client supports h1, use regular HTTP/1.1. Any other combination results in a negotiation error.

The proxy records the outcome and offers the client exactly one protocol — the one that will be used for the session.

Scenarios

The following scenarios illustrate how ALPN negotiation works with translation enabled or disabled. Since translation is enabled by default, the Disabled cases apply when EnableTranslateH1ToH2 is explicitly set to false.

A browser typically offers both http/1.1 and h2, unless configured otherwise. curl and similar clients often offer only http/1.1.

C (SslClientOptions) S (SslServerOptions) K (client) T Result
http/1.1 http/1.1 Any client supporting h1 Either HTTP/1.1 on both sides
http/1.1,h2 http/1.1,h2 http/1.1,h2 Either Uses the protocol selected by the server: transparent HTTP/2 if h2 is selected; otherwise HTTP/1.1
h2 http/1.1 http/1.1 Enabled h1 → h2 translation
h2 http/1.1 http/1.1 Disabled Negotiation error
h2 http/1.1,h2 http/1.1,h2 Either Transparent HTTP/2; translation is not needed
h2 http/1.1,h2 http/1.1 (curl) Enabled h1 → h2 translation
h2 http/1.1,h2 http/1.1 (curl) Disabled Negotiation error
http/1.1,h2 http/1.1 http/1.1,h2 Enabled If upstream selects h2, translate; if it selects http/1.1, use regular HTTP/1.1
http/1.1,h2 http/1.1 http/1.1,h2 Disabled HTTP/1.1 on both sides (h2 is not offered upstream)
http/1.1 h2 http/1.1,h2 Either Error: the client supports only h2, but h2 is not offered upstream (there is no h2 → h1 translation)

The last row is a common trap: http/1.1 in C and a strict h2 in S are incompatible under any settings, because translation is supported in only one direction.

Diagnostics

When translation succeeds, the log contains an Info-level entry:

HTTP/1.1 -> HTTP/2 translation: client 127.0.0.1:39461 speaks HTTP/1.1, upstream localhost:5112 negotiated h2

A good starting configuration is SslClientOptions.ApplicationProtocols = "http/1.1,h2" and SslServerOptions.ApplicationProtocols = "http/1.1". HTTP/1.1 → HTTP/2 translation is enabled by default, so no additional setting is required.

With this configuration, the proxy can negotiate HTTP/2 with the upstream server while keeping the client on HTTP/1.1.

Search