one-click deploy proxy for mTLS-protected HTTP requests with GOST certificates
631
A transparent proxy for an API that accepts only GOST TLS with a client certificate.
Some APIs accept only a TLS connection with the Russian GOST algorithms and a client certificate. Standard HTTP clients cannot make a connection of that type.
gost-mtls-proxy is a container image with a complete GOST TLS stack.
Your client sends a plain HTTP request to the container.
The container sends the same request to the target API over GOST TLS 1.2 with your client certificate.
Then the container sends the response of the target API back to your client.
The proxy keeps the method, the path, the query, the headers and the body. The query string reaches the target API with no change. A parameter can appear more than one time in the query string. The proxy makes only these five changes to a request:
Host header with the host of the target API.X-Proxy-Token header.Connection, Keep-Alive, Proxy-Authenticate,
Proxy-Authorization, TE, Trailer, Transfer-Encoding and Upgrade.User-Agent header with its own value, GostMtlsProxy/v<version>. Set
USER_AGENT_OVERRIDE to false to send the User-Agent header of your client without a change.X-Request-Id header to the request's own id: your value when you send a non-blank one,
otherwise a new UUID4. Exactly one X-Request-Id header reaches the target API, whatever you sent.A request to http://localhost:8080/api/v2/company becomes a request to
https://gost-openapi.tbank.ru/api/v2/company.
gost-mtls-proxy container
+--------------------------------------------+
plain HTTP | http4s app stunnel client | GOST TLS 1.2
request | | client certificate
client --------->| 0.0.0.0:8080 -----> 127.0.0.1:8443 |-------> target API
<---------| (PORT) (UPSTREAM_PORT) |<------- (TARGET_URL)
response +--------------------------------------------+
The container holds two processes:
PORT. The default port is 8080.127.0.0.1 at UPSTREAM_PORT. The default port is 8443.The application sends each request to stunnel over the loopback interface. stunnel makes the GOST TLS 1.2 connection to the target API. stunnel also sends the client certificate. The JVM does no TLS work at all.
The standard OpenSSL build has no GOST algorithms. The image adds them with two separate builds of gost-engine:
3dd0f0e4299489a537398cfa4d9daad260ac87a8, in provider mode.
Only this build reads a CryptoPro pfx file.The entrypoint script does these steps at each start of the container:
TARGET_URL.UPSTREAM_PORT accepts a connection. The limit is 5 seconds.CERT_PASSWORD and CERT_PASSWORD_FILE from the environment.The script writes the unencrypted private key to /run/gost-proxy/key.pem only.
The mode of that file is 600.
The owner is the non-root user gostproxy with uid 10001.
The final stage of the image starts from ubuntu:24.04. The build hardens that stage:
java.base, java.management and jdk.unsupported.gostproxy.NOTE: the requests in this document use T-Bank's GOST API as the example target API. See the API reference.
client.pfx.docker run --rm -p 8080:8080 \
-e TARGET_URL=https://gost-openapi.tbank.ru \
-v "$PWD/client.pfx:/certs/client.pfx:ro" \
docker.io/nyorf/gost-mtls-proxy:latest
The entrypoint script finds /certs/client.pfx without CERT_PATH.
CAUTION: USE ONLY ASCII CHARACTERS IN THE PFX PASSWORD. GOST-ENGINE CANNOT READ A PASSWORD WITH OTHER CHARACTERS. THE CONTAINER STOPS WITH AN ERROR.
docker run --rm -p 8080:8080 \
-e TARGET_URL=https://gost-openapi.tbank.ru \
-e CERT_PATH=/certs/client.pfx \
-e CERT_PASSWORD=your-pfx-password \
-v "$PWD/client.pfx:/certs/client.pfx:ro" \
docker.io/nyorf/gost-mtls-proxy:latest
A password on the command line is visible in the process list. A password file keeps the value out of the process list.
docker run --rm -p 8080:8080 \
-e TARGET_URL=https://gost-openapi.tbank.ru \
-e CERT_PASSWORD_FILE=/run/secrets/pfx-password \
-v "$PWD/client.pfx:/certs/client.pfx:ro" \
-v "$PWD/pfx-password.txt:/run/secrets/pfx-password:ro" \
docker.io/nyorf/gost-mtls-proxy:latest
docker run --rm -p 8080:8080 \
-e TARGET_URL=https://gost-openapi.tbank.ru \
-e CERT_PEM_PATH=/certs/client.pem \
-e KEY_PEM_PATH=/certs/client-key.pem \
-v "$PWD/certs:/certs:ro" \
docker.io/nyorf/gost-mtls-proxy:latest
Set PROXY_TOKEN to a long random value.
Every caller must then send that value in the X-Proxy-Token header.
docker run --rm -p 8080:8080 \
-e TARGET_URL=https://gost-openapi.tbank.ru \
-e PROXY_TOKEN=change-me-to-a-long-random-value \
-v "$PWD/client.pfx:/certs/client.pfx:ro" \
docker.io/nyorf/gost-mtls-proxy:latest
Send your API token in the Authorization header.
The proxy sends that header to the target API without a change.
curl -s http://localhost:8080/api/v2/company \
-H 'Authorization: Bearer t.XXXXXXXXXXXXXXXXXXXXXX'
With PROXY_TOKEN one more header is necessary:
curl -s http://localhost:8080/api/v2/company \
-H 'X-Proxy-Token: change-me-to-a-long-random-value' \
-H 'Authorization: Bearer t.XXXXXXXXXXXXXXXXXXXXXX'
| Variable | Necessary | Default | Description |
|---|---|---|---|
TARGET_URL | Yes | none | The address of the target API. The form is https://host[:port] with no path, no query and no fragment. |
CERT_PATH | No | /certs/client.pfx when that file is present | The path of the pfx file in the container. |
CERT_PASSWORD | No | none | The password of the pfx file. |
CERT_PASSWORD_FILE | No | none | The path of a file that holds the password of the pfx file. |
CERT_PEM_PATH | No | none | The path of the client certificate in PEM format. |
KEY_PEM_PATH | No | none | The path of the private key in PEM format. |
PORT | No | 8080 | The port for the plain HTTP requests of your client. |
UPSTREAM_PORT | No | 8443 | The loopback port of the local stunnel process. |
TLS_VERIFY | No | true | The value false stops the check of the certificate chain of the target API. TLS_VERIFY accepts the same values as LOG_BODIES and USER_AGENT_OVERRIDE. An invalid value stops the container at startup. |
CA_BUNDLE_PATH | No | the built-in CA bundle | The path of a CA bundle file in the container. This value replaces the built-in CA bundle. |
EXTRA_CA_PATH | No | none | The path of a file with extra CA certificates in the container. The proxy adds this file to the active CA bundle. |
PROXY_TOKEN | No | none | A shared secret. Each caller must send this value in the X-Proxy-Token header. An empty value stops the container at startup. |
USER_AGENT_OVERRIDE | No | true | The value false sends the User-Agent header of your client to the target API without a change. |
LOG_BODIES | No | true | The value false keeps the JSON bodies out of the log lines. |
LOG_BODY_MAX_BYTES | No | 10485760 | The threshold, in bytes, that decides whether the proxy buffers a body to log it. This value never limits the proxied request itself. See Logs for the details. |
LOG_LEVEL | No | INFO | DEBUG, INFO, WARNING or ERROR, case-insensitive. WARN is also accepted as an alias for WARNING. An invalid value stops the container at startup. See Logs for the levels and what DEBUG adds. |
SERVICE_NAME | No | gost-mtls-proxy | The value of the system field and the api field in each log line. |
ENV | No | dev | The value of the env field in each log line. The proxy makes this value lowercase. |
The Dockerfile also accepts two build arguments, CI_COMMIT and CI_REF.
The build stores both values in the image as environment variables.
Both values appear in the ci object of each log line.
PORT and UPSTREAM_PORT accept a number between 0 and 65535.
TLS_VERIFY, LOG_BODIES and USER_AGENT_OVERRIDE accept true, 1, yes, on, false, 0, no and off.
LOG_LEVEL accepts DEBUG, INFO, WARNING and ERROR, plus the alias WARN for WARNING.
LOG_BODY_MAX_BYTES accepts a non-negative number of bytes.
A bad value stops the container with a message on stderr.
NOTE: the entrypoint script always sets UPSTREAM_HOST to 127.0.0.1. A different value has no effect.
The proxy reads the client certificate in one of two modes.
The two modes are mutually exclusive.
A container with CERT_PATH and CERT_PEM_PATH together stops with an error.
The proxy reads a PKCS#12 file:
CERT_PATH to the path of the pfx file in the container./certs/client.pfx. The script then finds it without CERT_PATH.CERT_PASSWORD or CERT_PASSWORD_FILE.CERT_PASSWORD and CERT_PASSWORD_FILE are mutually exclusive. Both together stop the container.At each start the script converts the pfx file:
/run/gost-proxy/key.pem with mode 600./run/gost-proxy/cert.pem.The proxy reads two separate files:
CERT_PEM_PATH to the path of the client certificate.KEY_PEM_PATH to the path of the private key.The script writes a warning to the log when the key file is world-readable. The script gives both files to stunnel without a change.
Set CA_BUNDLE_PATH to the path of your own CA bundle file. This value replaces the built-in CA bundle.
Set EXTRA_CA_PATH to the path of a file with extra CA certificates. The proxy adds this file to the active CA bundle.
Both variables take effect at each start of the container. No rebuild of the image is necessary.
docker run --rm -p 8080:8080 \
-e TARGET_URL=https://gost-openapi.tbank.ru \
-e CA_BUNDLE_PATH=/etc/gost-proxy/custom-ca-bundle.pem \
-v "$PWD/client.pfx:/certs/client.pfx:ro" \
-v "$PWD/custom-ca-bundle.pem:/etc/gost-proxy/custom-ca-bundle.pem:ro" \
docker.io/nyorf/gost-mtls-proxy:latest
The proxy answers GET /healthz with JSON:
{"status":"ok"}.{"status":"upstream unreachable"}./healthz writes no log lines.PROXY_TOKEN does not apply to /healthz.The image also holds a HEALTHCHECK instruction for this path.
The interval is 30 seconds, the timeout is 5 seconds and the start period is 10 seconds.
The retry count is 3.
Every line of the proxy is single-line JSON on stdout. stunnel writes its own diagnostics to stderr.
Each line holds these fields:
| Field | Description |
|---|---|
message | The text of the event. |
level | DEBUG, INFO, WARNING or ERROR. A line below the configured LOG_LEVEL never reaches stdout. |
@timestamp | The time with microseconds and the UTC offset. |
logger | app.http, app.outbound or app.operations for the lines of the proxy. The entrypoint script uses entrypoint. The slf4j bridge passes an http4s or cats-effect record through with its own class name. One example is org.http4s.ember.server.EmberServerBuilder. |
system | The value of SERVICE_NAME. |
env | The value of ENV, in lowercase. |
inst | The hostname of the container. |
ci | An object with deployed_at. The fields commit and ref are present only when the build sets CI_COMMIT and CI_REF. |
request_id | The caller's X-Request-Id header, or a new UUID4 when the caller sent none or a blank one. The same value is forwarded to the target API and echoed on the response. |
trace-id | The value of x-b3-traceid, or the trace part of traceparent, or a new value. |
span-id | A new 16-character value for each request. |
method | The HTTP method of the request. |
route | The path of the request. |
One request gives six lines in this order:
Received http requestMethod proxy was called for <path> with body '<type>'Sending http requestGot http response with code=<code>Method proxy returned responseapi operation executedThe last line carries the fields api, path, uri, ip, start_time, duration_ms, result and details.
The details object always holds http_code.
It also holds username, procedure_run_id, client_id and user_agent from the request headers
X-Username, X-Procedure-Run-Id, X-Client-Id and User-Agent.
The user_agent field always holds the User-Agent header of the caller. USER_AGENT_OVERRIDE has no effect on this field.
LOG_LEVEL sets the lowest level that reaches stdout. The order from lowest to highest is DEBUG, INFO,
WARNING and ERROR.
LOG_LEVEL=DEBUG suppresses nothing.LOG_LEVEL=INFO suppresses DEBUG lines.LOG_LEVEL=WARNING suppresses DEBUG and INFO lines.LOG_LEVEL=ERROR suppresses DEBUG, INFO and WARNING lines. Only ERROR lines reach stdout.The level field of an emitted line holds DEBUG, INFO, WARNING or ERROR.
A library record from the slf4j bridge obeys the same LOG_LEVEL threshold as the lines of the proxy itself.
WARNING: LOG_LEVEL=DEBUG WRITES THE FULL BODY OF EVERY REQUEST AND EVERY RESPONSE INTO THE LOG.
DO NOT USE LOG_LEVEL=DEBUG IN PRODUCTION.
LOG_BODY_MAX_BYTES still bounds this behavior. A lower value limits how much body content one log
line can hold at LOG_LEVEL=DEBUG.
LOG_LEVEL=DEBUG makes three differences, on top of every line at every lower level:
Received http request) and line 5 (Method proxy returned response) each gain a debug object.
Its fields are http_version, query_params, outbound_uri (the full target URL, query string included),
remote_addr, request_content_type, request_content_length, response_content_type and
response_content_length. A field is absent, not null, when the proxy has nothing to report for it — for
example response_content_* on line 1, before the target API has answered.text or a multipart body also renders as text, instead of the empty string it gets at every other level.
The headers and the JSON keys below still become ***. A body above LOG_BODY_MAX_BYTES is still unread
and is never buffered, at every level.GET /healthz, silent at every other level, emits Received http request and api operation executed at DEBUG.LOG_BODIES=false still wins over LOG_LEVEL=DEBUG: an explicit body opt-out is respected at every level.
Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-Api-Key and
X-Proxy-Token become ***, at every LOG_LEVEL.token, raw_token, rawtoken, password, secret, apikey and api_key become ***,
at every LOG_LEVEL.text and multipart bodies. A body of another type never reaches the log.
text and multipart bodies render only at LOG_LEVEL=DEBUG; below that they are the empty string.identity becomes binary. The proxy never reads its content as JSON.LOG_BODY_MAX_BYTES sets the threshold, in bytes, that decides whether the proxy buffers a body to log it.
The proxy buffers a body within the threshold. The proxy also detects its type from the buffered bytes.LOG_LEVEL=DEBUG, a JSON body above 256 KiB becomes a short summary, and a longer text gets a clip after
10000 characters. At LOG_LEVEL=DEBUG a buffered JSON body always renders in full.LOG_BODY_MAX_BYTES threshold streams through unbuffered. A body sent with
Transfer-Encoding: chunked and no Content-Length also streams through unbuffered. The log shows
unread for both, at every LOG_LEVEL.LOG_BODY_MAX_BYTES only decides what the proxy buffers to build a log line. It never caps, truncates
or rejects the proxied request. The proxy forwards the full request body to the target API, whatever
the threshold. The proxy also returns the full response body to your client, whatever the threshold.0 buffers no body at all. Every body then streams through unbuffered, with zero-copy passthrough.
Its type in the log is unread.LOG_BODIES=false replaces every body text with logging is disabled by flag, at every LOG_LEVEL."proxy_token":true only. The value of PROXY_TOKEN never reaches the log.WARNING: DO NOT MAKE THE PROXY PORT AVAILABLE ON AN UNTRUSTED NETWORK. THE CONTAINER HOLDS A CLIENT CERTIFICATE THAT IDENTIFIES YOU. EVERY CALLER THAT REACHES THE PORT CAN USE THAT IDENTITY.
WARNING: SET TLS_VERIFY=false ONLY FOR A DEBUG SESSION. THE PROXY THEN ACCEPTS ANY CERTIFICATE FROM
THE TARGET API. AN ATTACKER CAN THEN READ AND CHANGE YOUR TRAFFIC.
The container writes a loud WARNING line when TLS_VERIFY is false.
PROXY_TOKEN gives a second layer of protection.
PROXY_TOKEN is not a replacement for network control.
A caller without the correct X-Proxy-Token header gets status 401 and the body {"error":"unauthorized"}.
The proxy compares the two values with a constant-time function.
These properties of the image lower the risk:
/run/gost-proxy, with mode 600 and the owner gostproxy.CERT_PASSWORD and CERT_PASSWORD_FILE before the start of the application.gostproxy with uid 10001.X-Proxy-Token header from every request to the target API.| Symptom | Cause | Action |
|---|---|---|
The target API answers 400 with No required SSL certificate was sent. | The target API did not get the client certificate, or did not accept it. | Check CERT_PATH. Make sure that the pfx file holds the leaf certificate and the private key. |
| The target API answers 401 with a JSON body. | The mutual TLS connection is correct. The Authorization token is absent or wrong. | Send a valid token in the Authorization header. |
| The target API answers 404 on every path. | The Host header did not reach the target API. | Send your requests through this proxy. The proxy always sets the Host header. |
| The target API answers 200. | The request is correct and complete. | No action is necessary. |
The container stops with could not read ...: wrong CERT_PASSWORD, or unsupported pkcs12. | The password is wrong, or the password holds non-ASCII characters. | Set the correct password. Use only ASCII characters. |
The container stops with it needs a password (CERT_PASSWORD). | The pfx file has a password. The configuration has none. | Set CERT_PASSWORD or CERT_PASSWORD_FILE. |
The container stops with TARGET_URL must be https://host[:port] with no path. | TARGET_URL holds a path, a query, a fragment or the wrong scheme. | Set TARGET_URL to https://host or https://host:port. |
The container stops with no client certificate. | There is no CERT_PATH, no PEM pair and no file at /certs/client.pfx. | Mount your client certificate. Set the correct variables. |
The container stops with stunnel exited during startup. | stunnel cannot read the certificate, the key or the CA bundle. | Read the stunnel diagnostics on stderr. |
The container stops with stunnel is not listening on 127.0.0.1:8443 after 5s. | Another process in the container holds that port. | Set UPSTREAM_PORT to a free port. |
The proxy answers 502 with {"error":"upstream unreachable"}. | The proxy cannot connect to the local stunnel port. | Read the stunnel diagnostics on stderr. |
The proxy answers 504 with {"error":"upstream timeout"}. | The target API did not answer in time. | Send the request again. Check the network route to the target API. |
The proxy answers 401 with {"error":"unauthorized"}. | PROXY_TOKEN has a value. The request has no X-Proxy-Token header, or a wrong one. | Send the correct value in the X-Proxy-Token header. |
/healthz answers 503 with {"status":"upstream unreachable"}. | stunnel does not accept connections on the loopback port. | Read the stunnel diagnostics on stderr. |
The 400, the 401 and the 404 answers separate three different faults.
A 400 answer means a fault in the client certificate.
A 401 answer means a correct mutual TLS connection and a fault in the API token.
A 404 answer on every path means a lost Host header.
NOTE: a 404 answer on every path is not possible through this proxy. The proxy always sets the Host
header of the target API.
A pfx password with non-ASCII characters does not work.
This is a limit of gost-engine, not of this image.
The error text is could not read ...: wrong CERT_PASSWORD, or unsupported pkcs12.
This project is licensed under Apache-2.0. See the LICENSE file for the full text.
The container image bundles third-party software. See THIRD-PARTY-NOTICES.md for the full list.
stunnel is licensed under GPL-2.0-or-later. Its license text ships inside the image under /usr/share/doc.
The CA certificates under certs/ are public certificates.
Content type
Image
Digest
sha256:8e427fbf5…
Size
106 MB
Last updated
about 1 month ago
docker pull nyorf/gost-mtls-proxy