Opt-In Software Blog

Software, dvelopment, and practical insights

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 now supports HTTP/1.1 → HTTP/2 translation: the client continues to speak HTTP/1.1, while the proxy communicates with the upstream 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.

How to enable it

The EnableTranslateH1ToH2 flag can be set at three levels: globally, for a listening port, or for an individual host rule:

{
  "HTTP": {
    "EnableTranslateH1ToH2": true
  },
  "ProxyMappings": [
    {
      "Listen": {
        "Port": 8080,
        "Action": "Bypass",
        "DecryptSSL": true,
        "EnableTranslateH1ToH2": true
      }
    }
  ],
  "HostRules": {
    "Items": [
      {
        "HostName": "api.example.com",
        "DecryptSSL": true,
        "EnableTranslateH1ToH2": true
      }
    ]
  }
}

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: the flag alone does not do anything — the upstream server must select h2 during the TLS handshake. Therefore, 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

A browser sends K = http/1.1, h2 unless configured otherwise. curl and similar clients 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", SslServerOptions.ApplicationProtocols = "http/1.1", and EnableTranslateH1ToH2 = true. The proxy will offer h2 to the server while keeping the client on HTTP/1.1.

Using OpenRouter in the VS Code Agent without GitHub Copilot

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

Tested on VS Code 1.140 (Stable) and VS Code Insiders 1.141, October 2026.

I wanted to use OpenRouter models in the VS Code Agent without signing in to GitHub Copilot. VS Code can do this with custom language model providers, but on my machine it only worked in VS Code Insiders. This post shows the setup, how to tell whether you need Insiders, and an nginx reverse proxy for networks where OpenRouter is not reachable.

TL;DR

  1. Try the regular VS Code first. If you see the symptoms described below, switch to VS Code Insiders.
  2. Create chatLanguageModels.json with an OpenRouter provider (vendor: "customendpoint") and your models. Use the exact model ID from openrouter.ai.
  3. Set the API key with Manage Models → Update API Key. Don’t leave the raw key in the JSON: it didn’t work for me.
  4. Select the model in Models and switch to Agent.
  5. If OpenRouter is blocked in your country, see the proxy section below.

Prerequisites

  • An OpenRouter account with an API key and some credit. Free models come and go (see Troubleshooting).
  • A model that supports tool calling. Agent mode needs it.
  • VS Code Insiders, if the regular VS Code doesn’t work for you (see the next section).

Do you need VS Code Insiders?

Maybe not, so try the regular VS Code first. On my machine (Stable 1.140), it didn’t work:

  • With an empty chatLanguageModels.json there was no Manage Models button and no Chat: Manage Language Models command.
  • After I added a provider to the file by hand and restarted, the button and the command appeared, but the Language Models dialog showed only Copilot’s own models. My OpenRouter models were nowhere to be found, and I couldn’t select them in the chat.
  • Add Models → OpenRouter accepted an API key and wrote a group to chatLanguageModels.json, but the group never showed up in the dialog or in the model picker, even after a restart.

I didn’t dig into why. The official docs currently list the Custom endpoint provider as an Insiders feature, and in Insiders everything below worked. So if you see the same symptoms, install VS Code Insiders. It installs side by side with the regular VS Code and keeps its own settings.

Where is chatLanguageModels.json

The file lives in the user data folder of VS Code Insiders, which is separate from the regular VS Code (Code - Insiders, not Code):

  • Windows: press Win + R, enter %APPDATA%\Code - Insiders\User and press Enter.
  • macOS: in Finder press Cmd + Shift + G and go to ~/Library/Application Support/Code - Insiders/User.
  • Linux: go to ~/.config/Code - Insiders/User.

Note: the regular VS Code uses Code instead of Code - Insiders in these paths. You can edit the file there, but if the regular VS Code doesn’t show your models, editing it won’t help. Edit the Insiders copy.

Create the provider in chatLanguageModels.json

I didn’t add the provider through the Manage Models dialog. I wrote it in chatLanguageModels.json by hand and VS Code picked it up. This is the file I use:

[
	{
		"name": "OpenRouter",
		"vendor": "customendpoint",
		"apiKey": "${input:chat.lm.secret.-32b9a01a}",
		"apiType": "chat-completions",
		"models": [
			{
				"id": "qwen/qwen3.8-27b",
				"name": "Qwen3.8 27B",
				"url": "https://openrouter.ai/api/",
				"toolCalling": true,
				"vision": true,
				"maxInputTokens": 262000,
				"maxOutputTokens": 16000
			},
			{
				"id": "deepseek/deepseek-r1",
				"name": "DeepSeek R1",
				"url": "https://openrouter.ai/api/",
				"toolCalling": true,
				"vision": false,
				"maxInputTokens": 64000,
				"maxOutputTokens": 16000
			}
		]
	}
]

The apiKey value is what VS Code writes after the next step. Until then, don’t worry about it.

What the fields mean:

  • vendor: "customendpoint" marks this as a custom model provider.
  • apiType: "chat-completions" makes VS Code use the Chat Completions API.
  • id is the model ID on OpenRouter. Copy it from the model’s page.
  • url is the base URL. VS Code builds the Chat Completions request from it (the request ends up at /v1/chat/completions), which is why it is .../api/ and not the full endpoint.
  • toolCalling enables tool calling. Without it the Agent can’t work.
  • vision says whether the model accepts images.
  • maxInputTokens and maxOutputTokens are the limits VS Code uses.

Choosing values for a model

Take toolCalling and vision from the model’s page on OpenRouter. For example, Qwen3.8 27B is a vision-language model that accepts tools, while DeepSeek R1 is text-only, so vision is false there.

Limits are less clear-cut. The context size can differ between providers and sources: for R1 I’ve seen 64K on the model page and 131K-164K elsewhere, and for Qwen3.8 27B 262K and 1M. I use the smaller figure, which is safe with any provider. Don’t set maxOutputTokens too low for reasoning models, because reasoning tokens count as output. 4096 is too small, since answers get cut off.

If you want a Claude model, the same block works with the slug from its OpenRouter page (for Sonnet 5 it is anthropic/claude-sonnet-5, with vision: true and toolCalling: true).

Set the API key (this step is not optional)

It might seem natural to write your OpenRouter key directly into apiKey:

"apiKey": "sk-or-v1-..."

That is what I did first, and it didn’t work. OpenRouter rejected every request with a 403. I checked the nginx log and found that the Authorization header contained just Bearer, with no token after it.

What fixed it: in Manage Models I selected the OpenRouter provider and chose Update API Key, then entered the key there. VS Code replaced the value in the file with a reference to a secret it stores itself:

"apiKey": "${input:chat.lm.secret.-32b9a01a}"

After that the header was passed correctly and requests went through. As a bonus, the key no longer sits in a file. The reference only works on the machine where you ran Update API Key, so don’t copy it to another machine. Run Update API Key there too.

VS Code replaced the raw key in the file with that reference on its own, so nothing else needs cleaning up.

Using the model

Go back to the Chat view, click Models, pick the OpenRouter model and switch to Agent. VS Code stays the interface and the agent, while OpenRouter provides the model, so you can swap models by editing one entry in the JSON.

If OpenRouter is not reachable from your country

If OpenRouter works for you, skip this section. Otherwise, you can route requests through an nginx reverse proxy on a VPS that can reach it:

VS Code → https://example.com/openrouter/api/ → nginx → https://openrouter.ai/api/v1/chat/completions

In the model config, change the URL:

"url": "https://example.com/openrouter/api/"

nginx configuration

This is the configuration that works for me (TLS for example.com is assumed to be configured already):

location /openrouter/api/v1/chat/completions {
    proxy_pass https://openrouter.ai/api/v1/chat/completions;
    proxy_ssl_server_name on;
    proxy_ssl_name openrouter.ai;
    proxy_ssl_protocols TLSv1.2 TLSv1.3;
    proxy_ssl_session_reuse on;
    proxy_set_header Host openrouter.ai;
    proxy_set_header Authorization $http_authorization;
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
    proxy_read_timeout 300s;
    proxy_connect_timeout 75s;
    proxy_send_timeout 300s;
    proxy_buffer_size 32k;
    proxy_buffers 8 32k;
    proxy_busy_buffers_size 64k;
}

Your OpenRouter key is passed through from the Authorization header sent by VS Code, so it is never stored on the server. The location covers only the Chat Completions path, so other endpoints, such as /v1/models, are not proxied. That’s fine here because the models are listed manually in the JSON.

Security notes

  • The proxy sees your API key and every prompt, including your project’s code. Only run it on a server you control.
  • A public proxy can be used by anyone who finds it. Restrict access, for example with allow/deny for your IP addresses and limit_req for rate limiting. Basic auth won’t work here, because it uses the same Authorization header that carries your OpenRouter key.
  • Check OpenRouter’s terms of service and any regional restrictions that apply to you before routing around network blocks.

Troubleshooting

The model doesn’t appear in the picker. First check that you are in VS Code Insiders and not in the regular VS Code, and that you edited the file in the Code - Insiders folder. Then check that the JSON is valid and that the provider has an API key set.

403, and the Authorization header contains only Bearer. I got this when I wrote the raw key into apiKey. Run Update API Key (see above) so that VS Code stores the key as a secret. If you use the nginx proxy, you can see what actually arrives by temporarily logging $http_authorization in your log format. Remove it afterwards, because it puts your key into the log.

404 — This model is unavailable for free. This is what I got when I tried a :free model:

{"message":"This model is unavailable for free. The paid version is available now - use this slug instead: deepseek/deepseek-r1","code":404}

Free variants of models get removed. The message contains the paid slug, so put it into id. The error also tells you that the request reached OpenRouter and the key was accepted, so the config and the proxy are fine.

401. The API key is missing or wrong. Run Update API Key again.

The Agent ignores tools or fails. The model, or the specific provider OpenRouter routes you to, may not support tool calling. Check the model’s page and try another model.

Responses hang or arrive all at once (when using the proxy). This is usually buffering. Make sure proxy_buffering off; is set.

A note on privacy

In Agent mode, parts of your project are sent to the model provider as prompts. OpenRouter forwards them to the provider that serves the model, so check the data policy of the models you use, especially the free ones.

Integrating Developer PowerShell into Visual Studio Code

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

To build projects using the MSVC compiler (cl.exe), CMake, and Ninja directly from the Visual Studio Code interface, you need to add the Visual Studio environment profiles to the built-in terminal.

Step-by-Step Instructions

  1. Open the settings.json configuration file:
    • Press Ctrl + Shift + P.
    • Type Preferences: Open User Settings (JSON) into the search bar and press Enter.
  2. Add the profile configuration:
    Insert the following block of settings inside the main curly braces { … } of your settings.json file:
"terminal.integrated.profiles.windows": {
    "Developer PowerShell (x64)": {
        "source": "PowerShell",
        "overrideName": true,
        "args": [
            "-NoExit",
            "-Command",
            "& { Import-Module 'C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\Common7\\Tools\\Microsoft.VisualStudio.DevShell.dll'; Enter-VsDevShell 43240475 -SkipAutomaticLocation -DevCmdArguments '-arch=x64' }"
        ]
    },
    "Developer PowerShell (x86)": {
        "path": "C:\\Windows\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe",
        "overrideName": true,
        "args": [
            "-NoExit",
            "-Command",
            "& { Import-Module 'C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\Common7\\Tools\\Microsoft.VisualStudio.DevShell.dll'; Enter-VsDevShell 43240475 -SkipAutomaticLocation }"
        ]
    }
}

(Note: The paths are specified for a standard Visual Studio 2022 Community installation. If you are using Professional or Enterprise editions, replace Community in the paths with the corresponding name).

  1. Apply the settings:
    • Save the settings.json file.
    • Close active terminal sessions in VS Code using the trash can icon (Kill Terminal) in the terminal panel to refresh the profile list.
  2. Launch the terminals:
    • Click the arrow next to the “plus” sign in the upper-right corner of the terminal panel.
    • Select the required architecture from the dropdown list: Developer PowerShell (x64) or Developer PowerShell (x86).

BoringSSL on Top of Any Stream in .NET

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

The standard SslStream in .NET is good in every way except one: it doesn’t let you control what your ClientHello looks like. The set of ciphers, the order of extensions, GREASE, ALPS, post-quantum groups are determined by the platform (SChannel, OpenSSL) and are almost non-configurable. Usually this isn’t a problem. But if a client needs to look like Chrome in TLS fingerprints (JA3/JA4), SslStream won’t do.

Chrome has its own TLS stack, BoringSSL, Google’s fork of OpenSSL. I built a proof-of-concept BoringSslConsole: a .NET 8 console application and a BoringSslStream wrapper that exposes BoringSSL as an ordinary Stream.

Sources: https://github.com/optinsoft/BoringSslConsole

The main architectural requirement for the wrapper is this: BoringSSL must not perform any network operations. All I/O goes through the managed innerStream. Below I’ll explain why this is needed and how it’s structured.

Why a “networkless” BoringSSL

BoringSSL can work with the network itself: it has socket and file BIOs (BIO_new_socket, BIO_new_fd). If I had used them, the library would drive data through the socket itself, and there would be no way to implement asynchrony or substitute my own transport.

I needed something different: for the TLS layer to work on top of any Stream. This could be a NetworkStream, a tunnel through a proxy (CONNECT, SOCKS), a MemoryStream in tests, or another layer on top of TLS. Therefore the native part is responsible only for TLS state: handshake, encryption, and decryption of records. And C# moves the bytes to and from the network.

Architecture

TcpClient
   |
NetworkStream  (innerStream)
   |
BoringSslStream (C#): pumps bytes between the stream and the buffers
   |
BoringSSL, memory BIO (pure TLS state, no network)
   |
HTTP/1.1 or HTTP/2

Between C# and BoringSSL there are two in-memory buffers (BIO_s_mem):

  • input: C# puts bytes read from innerStream here (pms_ssl_feed_read);
  • output: C# takes bytes from here that need to be sent to innerStream (pms_ssl_take_write, the size can be queried via pms_ssl_pending_write).

The native DLL (proxymap_boringssl.dll) exports a small flat C API: pms_ssl_create, pms_ssl_connect, pms_ssl_read, pms_ssl_write, plus buffer-pumping functions and a few getters (protocol version, cipher, selected ALPN, server DER certificate, last error). All ClientHello parameters are passed from C# as strings and numbers.

BoringSslStream inherits from Stream, so it can be substituted anywhere an ordinary stream is expected. Everything is fully asynchronous, with CancellationToken.

The main loop: pumping bytes

The whole mechanism fits into a single pattern. We call a BoringSSL operation, flush the accumulated output to the network, and if the library asks for data, we read it from the network and feed it back. Here is the handshake:

while (true)
{
    int result = Native.pms_ssl_connect(_connection);

    await FlushWriteBioAsync(cancellationToken);

    switch (result)
    {
        case Native.PMS_SSL_OK:
            ValidateServerCertificate(_hostname);
            _authenticated = true;
            return;

        case Native.PMS_SSL_WANT_READ:
            await ReadFromNetworkAsync(cancellationToken);
            break;

        case Native.PMS_SSL_WANT_WRITE:
            break; // output has already been flushed to the network
    }
}

FlushWriteBioAsync takes everything accumulated in the output buffer and writes it to innerStream. ReadFromNetworkAsync reads from innerStream and feeds the bytes to the input buffer. The same loop is repeated in ReadAsync and WriteAsync.

The output must be flushed after every BoringSSL call, not just during the handshake: even SSL_read can itself generate TLS records (for example, KeyUpdate or alerts).

Buffers are taken from ArrayPool<byte>, and pointers are passed to native code via Memory<T>.Pin(), without unnecessary copies.

Synchronization

The class has four SemaphoreSlims: _readLock and _writeLock serialize reads and writes respectively, while _networkReadLock and _networkWriteLock protect the exchange with innerStream. Thanks to this, concurrent ReadAsync and WriteAsync calls don’t break each other’s work with the stream and buffers. The handshake takes both outer locks at once.

A caveat: this is sufficient for a PoC, but a full-fledged full-duplex over a single SSL* requires care on the native library side as well, not just in C#. I’ll mention this again in the limitations.

The “like Chrome” preset

The BoringSslStream constructor accepts a set of parameters, filled by default with Chrome’s values:

  • cipher list: ECDHE variants of AES-GCM and ChaCha20-Poly1305, plus a legacy tail;
  • GREASE and ECH GREASE;
  • ALPN h2:http/1.1 and ALPS h2;
  • signature algorithms, including post-quantum ML-DSA-44/65/87;
  • SCT, status_request, Brotli certificate compression;
  • trust_anchors as a hex string.

The fine-grained work with extensions (reordering, dynamic formation of ALPN/ALPS, padding of status_request) is done on the C++ side. Brotli is linked statically so that everything builds into a single self-contained DLL and there’s no DllNotFoundException at runtime.

On tls.peet.ws the JA4 fingerprint matched Chrome. But JA3 does not match, and that’s how it should be. Starting with Chrome 110, the browser randomly shuffles the order of TLS extensions in ClientHello on every connection. JA3 is built from the lists of ciphers, extensions, groups, and point formats in the order they arrived, so a real Chrome’s JA3 hash is different every time, and comparing it to some “reference” is meaningless. Our client does the same: the native layer shuffles extensions, and JA3 changes from connection to connection, just like in the browser.

JA4 is structured differently: before hashing, it sorts ciphers and extensions (and discards GREASE), so shuffling doesn’t affect it. That’s exactly why JA4 is stable and suitable for verification: if it matches, then the set of ciphers, extensions, signature algorithms, and ALPN is the same as Chrome’s.

Certificate verification

Here BoringSSL only negotiates the channel, while the trust decision is made by .NET. Immediately after a successful handshake, the native layer returns the server’s DER certificate, and then:

using var certificate = new X509Certificate2(memory.Span);
using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
chain.ChainPolicy.ApplicationPolicy.Add(new Oid("1.3.6.1.5.5.7.3.1")); // Server Auth

if (!chain.Build(certificate))
    throw new AuthenticationException(...);

if (!certificate.MatchesHostname(hostname))
    throw new AuthenticationException(...);

The system root certificate store is used, online revocation checking is enabled, and the hostname is verified via MatchesHostname (available in .NET 8, takes SAN and wildcard rules into account).

It’s easy to test on badssl.com: expired.badssl.com fails on chain building, untrusted-root.badssl.com on an unknown root, revoked.badssl.com on revocation checking. The relevant hosts are commented out in Program.cs; just change one constant.

HTTP/2 by hand

Since ALPN negotiates h2, I had to write a minimal HTTP/2 client right in Program.cs, without third-party libraries.

Sending. First a 24-byte connection preface, then an empty SETTINGS. Then the headers: a tiny HpackEncoder writes indexed fields from the static table (:method: GET is index 2, :scheme: https is 7) and literals without indexing for :authority, :path, and the rest. The block is wrapped in a 9-byte HEADERS frame header with flags END_STREAM | END_HEADERS on stream 1.

Receiving. We read strictly 9 bytes of frame header, parse the length (24 bits), type, flags, and stream id, then read exactly that many bytes of payload. Then we parse by type:

  • DATA is written to a file and to the console, and on END_STREAM we exit;
  • HEADERS prints the status (see below) and also checks for END_STREAM;
  • SETTINGS without the ACK flag is acknowledged with SETTINGS ACK, as RFC requires;
  • RST_STREAM and GOAWAY are printed with a decoded error code;
  • WINDOW_UPDATE is ignored.

There’s no full HPACK decoder, so the response status is determined by a heuristic: we look in the block for bytes 0x88–0x8E, corresponding to indexed statuses from the static table (0x88 is 200, 0x8D is 404). For a demo this is enough, but it’s precisely a hack.

If ALPN returns http/1.1, everything is simpler: an ordinary text request with Connection: close goes out, and the response is read until the stream closes.

Running

git clone --recursive https://github.com/optinsoft/BoringSslConsole.git

cd Native
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build

cd ..
dotnet run --project BoringSslConsole

To build on Windows you need the .NET 8 SDK, Visual Studio 2022 with C++ tools, CMake 3.22+, Ninja, Git, NASM, and Go (the last two are required by BoringSSL itself). BoringSSL and Brotli are included as git submodules; after building, the DLL is copied to the .NET output directory. The response is printed to the console and saved to ./output/<host>.txt.

Results and limitations

The result is a compact asynchronous Stream with a Chrome-like TLS stack inside, with real certificate verification, while the network is entirely in .NET’s hands. On top of it you can build proxies, an HTTP client, or testing tools, and the transport can be swapped without touching the TLS layer.

An honest list of what’s missing here:

  • HTTP/2 is minimal: no HPACK decoder, no CONTINUATION, no flow control, no multiplexing.
  • The TLS fingerprint matches, not the whole client. HTTP/2 has its own fingerprint (SETTINGS values, WINDOW_UPDATE, pseudo-header order), and here it differs from Chrome’s.
  • Thread safety needs separate verification. The locks in C# separate reads, writes, and network operations, but the safety of concurrent SSL_read and SSL_write on a single SSL* also depends on the native layer. Freeing the SSL* in Dispose should also be synchronized with in-flight operations.
  • Chain validation: only the leaf certificate is taken from the native layer, so chain building may depend on AIA fetching of intermediates, and online revocation checking blocks the thread inside async code.
  • Platform: building requires a heavy toolchain, and the native part is currently Windows x64 only.

This is a PoC, but the architectural idea — “BoringSSL as a pure TLS machine, with I/O outside” — turned out well, in my opinion, and easily transfers to other transports.

The project on GitHub: optinsoft/BoringSslConsole

Search