Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
FROM nginx:1.27.0-alpine
FROM nginx:1.30.6-alpine AS builder

# Build the independent TLSFP/SYN module against unmodified nginx sources.
ARG CONNFP_REVISION=7d83cab08d1521df8a453c8b4fd0df9700a0f652
RUN apk add --no-cache build-base linux-headers openssl-dev pcre2-dev zlib-dev curl
WORKDIR /build
RUN curl -fsSL "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" -o nginx.tar.gz \
&& echo '3cf46f5ed7504d460125866b7bea99fb357f31d887e2e63b35503310e9b8b6c8 nginx.tar.gz' | sha256sum -c - \
&& curl -fsSL "https://codeload.github.com/nuclearcat/nginx-connfp/tar.gz/${CONNFP_REVISION}" -o connfp.tar.gz \
&& echo '1c5caf70e63f0fb3fefb4eb344b02efd192091ca80da8c7549bdfa8dbb7df774 connfp.tar.gz' | sha256sum -c - \
&& tar -xzf nginx.tar.gz \
&& mkdir connfp \
&& tar -xzf connfp.tar.gz -C connfp --strip-components=1
RUN cd "nginx-${NGINX_VERSION}" \
&& configure_args="$(nginx -V 2>&1 | sed -n 's/^configure arguments: //p')" \
&& eval "./configure ${configure_args} --add-module=/build/connfp" \
&& make -j4 \
&& cp objs/nginx /build/nginx

FROM nginx:1.30.6-alpine

# The stock debug binary lacks connfp variables. Keep the official entrypoint,
# envsubst support, log links and ABI-compatible stock dynamic modules.
RUN rm -f /usr/sbin/nginx-debug
COPY --from=builder /build/nginx /usr/sbin/nginx
COPY --from=builder /build/connfp/LICENSE /build/connfp/NOTICE /usr/share/licenses/nginx-connfp/

VOLUME ["/data/static"]

Expand Down
67 changes: 67 additions & 0 deletions proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Proxy fingerprints

The image builds nginx 1.30.6 with the pinned
[nginx-connfp module](https://github.com/nuclearcat/nginx-connfp) for TLSFP and
Linux TCP SYN capture. No nginx core patches are needed. Source archives are checked
against SHA-256 hashes. Keep both image versions, source hashes and the module
revision in `Dockerfile` in sync when updating them.

The official image's entrypoint, template substitution and stdout/stderr log
links remain in use. Stock dynamic modules remain ABI-compatible. The stock
debug binary is removed because it does not include the connfp variables.

## Configuration and logs

The default template serves HTTP on port 80 and HTTPS on port 443. Mount the
certificate chain at `/etc/nginx/ssl/fullchain.pem` and its key at
`/etc/nginx/ssl/privkey.pem`, as the deployment Compose files already do. Publish
port 443 when terminating public TLS here. The development template replaces
this server with an HTTP-only proxy and does not require certificates.

Public access logs are JSON on stdout. `tlsfp` describes the TLS ClientHello
using [ntop TLSFP draft 0.1](https://github.com/ntop/nDPI/blob/a01ea9463125b24dcf7b37d71274291dc4a95753/fingerprint/nDPI_TLS_Fingerprint.md).
`tlsfp_registry` records ephemeral extension registry version `1`. `tcp_syn`
uses connfp's `syn1:ip_version:ttl_or_hop_limit:df:window:mss:window_scale:option_kinds`
format. For example, `syn1:4:64:1:64240:1460:7:2-4-8-1-3`. Missing MSS, window
scale or options use `-`; IPv6 has no DF bit and uses `-` there.
The format also records the request, status, bytes, timing, user agent, peer
address/port, request ID, connection ID, negotiated TLS version, cipher and ALPN.
Client-supplied fingerprint headers are ignored. The internal port 8080 keeps
the original access-log format and does not collect SYNs, so its Anubis
connections are not mistaken for public-client fingerprints. Log consumers
must handle the public JSON records alongside these internal combined records.

Capture must happen where the original connection arrives. If another proxy
terminates TCP or TLS first, these fields describe that proxy or are unavailable;
`X-Forwarded-For` cannot restore the original handshakes. Plain HTTP has no TLS
fingerprint. SYN fingerprints can be unavailable with SYN cookies or unsupported
kernels, and HTTP/3 has no TCP SYN. Empty strings or `-` mean unknown, not trusted.
The current listeners do not enable HTTP/3.

This adds logging only; Anubis policy is unchanged. Fingerprints are shared by
many clients and can be imitated. They are not unique identities.

The module is independently implemented under BSD-2-Clause. Its license and
ntop specification attribution are included at `/usr/share/licenses/nginx-connfp/`.
It contains no FoxIO module source or patches. `tcp_save_syn` and the two legacy
nginx variables `$http_ssl_ja4` and `$http_ssl_ja4t` remain accepted as aliases,
but values use TLSFP and `syn1:`. Log consumers must migrate the old `tls_ja4`
and `tcp_ja4t` keys and their expected formats to `tlsfp` and `tcp_syn`.

## Local verification

On a Linux Docker host with Python 3, OpenSSL and curl with HTTP/2 support installed,
run from the repository root:

```sh
docker build -t dashboard-proxy:fingerprints ./proxy
python3 proxy/test-fingerprints.py dashboard-proxy:fingerprints
```

The script uses ephemeral certificates, loopback-only randomly allocated ports,
an isolated Docker network, the repository's Anubis policy and a small backend
stub. It checks actual access-log values for HTTP, TLS 1.2 and TLS 1.3, connection
reuse, forged fingerprint headers, JSON escaping, static/API routing through
Anubis and the certificate-free development override. It prints the TLS/SYN
records, checks HTTP/2 negotiation, and removes its containers, volumes and network
when finished.
3 changes: 3 additions & 0 deletions proxy/dev/dev.conf.template
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
server {
tcp_save_syn on;
access_log /var/log/nginx/access.log fingerprints;

location /api {
proxy_pass ${PROXY_TARGET};
proxy_connect_timeout 240s;
Expand Down
13 changes: 13 additions & 0 deletions proxy/etc/nginx/conf.d/00-fingerprint-log.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# JSON escaping applies to every string, including client-controlled headers.
# Fingerprints come from the connection, never from incoming HTTP headers.
log_format fingerprints escape=json
'{"time":"$time_iso8601","remote_addr":"$remote_addr",'
'"remote_port":"$remote_port","connection":"$connection",'
'"connection_requests":"$connection_requests","request_id":"$request_id",'
'"request":"$request","status":$status,"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,"referer":"$http_referer",'
'"user_agent":"$http_user_agent","forwarded_for":"$http_x_forwarded_for",'
'"tls_protocol":"$ssl_protocol","tls_cipher":"$ssl_cipher",'
'"tls_alpn":"$ssl_alpn_protocol",'
'"tlsfp":"$connfp_tls","tcp_syn":"$connfp_syn",'
'"tlsfp_registry":"$connfp_registry"}';
8 changes: 8 additions & 0 deletions proxy/etc/nginx/templates/default.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ upstream anubis {

server {
listen 80 default_server;
listen 443 ssl;
http2 on;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;

tcp_save_syn on;
access_log /var/log/nginx/access.log fingerprints;

# NGINX remains the public entry point and TLS terminator. Anubis filters
# requests before returning accepted traffic to the internal server below.
Expand Down
188 changes: 188 additions & 0 deletions proxy/test-fingerprints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Exercise the built proxy through Anubis, without starting the dashboard stack."""

import http.client
import json
from pathlib import Path
import re
import ssl
import subprocess
import sys
import tempfile
import time
import uuid


IMAGE = sys.argv[1] if len(sys.argv) > 1 else "dashboard-proxy:fingerprints"
ROOT = Path(__file__).resolve().parents[1]
NETWORK = f"dashboard-fingerprint-test-{uuid.uuid4().hex[:8]}"
CONTAINERS = []
USER_AGENT = 'curl/fingerprint-test "quoted" \\ escaped'


def docker(*args):
return subprocess.check_output(["docker", *args], text=True).strip()


def start(role, *args):
name = f"{NETWORK}-{role}"
docker("run", "-d", "--name", name, "--network", NETWORK, *args)
CONTAINERS.append(name)
return name


def port(name, container_port):
return int(docker("port", name, str(container_port)).rsplit(":", 1)[1])


def wait_ready(name, container_port):
for _ in range(100):
try:
connection = http.client.HTTPConnection("localhost", port(name, container_port), timeout=1)
connection.request("GET", "/", headers={"User-Agent": "curl/readiness"})
response = connection.getresponse()
body = response.read()
connection.close()
if response.status == 200 and body == b"fingerprint-test\n":
return
except (OSError, http.client.HTTPException):
pass
time.sleep(0.1)
raise AssertionError(f"Proxy did not become ready: {docker('logs', name)}")


def request(connection, path):
connection.request("GET", path, headers={
"User-Agent": USER_AGENT,
# These names overlap the module's variables. They must never supply
# fingerprints, even on plain HTTP where TLSFP is unavailable.
"SSL-JA4": "forged-tls",
"SSL-JA4T": "forged-tcp",
"Connfp-Tls": "forged-tls",
"Connfp-Syn": "forged-tcp",
})
response = connection.getresponse()
body = response.read()
assert response.status == 200, (path, response.status, body)
assert body == b"fingerprint-test\n", (path, body)


def records(name, expected_paths):
for _ in range(50):
rows = [json.loads(line) for line in docker("logs", name).splitlines() if line.startswith("{")]
selected = {row["request"].split()[1]: row for row in rows
if row["request"].split()[1] in expected_paths}
if selected.keys() == expected_paths:
return selected
time.sleep(0.1)
raise AssertionError(f"Missing access logs: {expected_paths - selected.keys()}")


def check_common(row):
assert row["status"] == 200, row
assert row["user_agent"] == USER_AGENT, row
assert re.fullmatch(r"syn1:[46]:\d+:[01-]:\d+:(?:\d+|-):(?:\d+|-):(?:\d+(?:-\d+)*|-)",
row["tcp_syn"]), row
assert row["tlsfp_registry"] == "1", row
assert re.fullmatch(r"[0-9a-f]{32}", row["request_id"]), row


def main():
with tempfile.TemporaryDirectory(prefix="dashboard-fingerprints-") as temporary:
directory = Path(temporary)
# nginx workers need to traverse the bind-mounted static directory.
directory.chmod(0o755)
(directory / "index.html").write_text("fingerprint-test\n")
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(directory / "privkey.pem"), "-out", str(directory / "fullchain.pem"),
"-days", "1", "-subj", "/CN=localhost",
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
(directory / "backend.conf").write_text(
"events {} http { server { listen 8000; listen 5173; "
"location / { return 200 'fingerprint-test\\n'; } } }\n"
)
docker("network", "create", NETWORK)
try:
start("backend", "--network-alias", "backend", "--network-alias", "dashboard_dev",
"-v", f"{directory}/backend.conf:/tmp/backend.conf:ro", "--entrypoint", "nginx",
IMAGE, "-c", "/tmp/backend.conf", "-g", "daemon off;")
start("anubis", "--network-alias", "anubis",
"-e", "BIND=:8923", "-e", "TARGET=http://proxy:8080",
"-e", "POLICY_FNAME=/data/cfg/botPolicy.yaml",
"-v", f"{ROOT}/anubis/botPolicy.yaml:/data/cfg/botPolicy.yaml:ro",
"ghcr.io/techarohq/anubis:v1.25.0")
proxy = start("proxy", "--network-alias", "proxy",
"-p", "127.0.0.1::80", "-p", "127.0.0.1::443",
"-e", "PROXY_TARGET=http://backend:8000",
"-v", f"{directory}:/etc/nginx/ssl:ro",
"-v", f"{directory}:/data/static:ro", IMAGE)
wait_ready(proxy, 80)
docker("exec", proxy, "nginx", "-t")

plain = http.client.HTTPConnection("localhost", port(proxy, 80), timeout=5)
request(plain, "/plain")
plain.close()
for version, label in [(ssl.TLSVersion.TLSv1_2, "12"), (ssl.TLSVersion.TLSv1_3, "13")]:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE # Ephemeral local test certificate.
context.minimum_version = context.maximum_version = version
context.set_alpn_protocols(["http/1.1"])
connection = http.client.HTTPSConnection("localhost", port(proxy, 443),
context=context, timeout=5)
request(connection, f"/tls{label}")
request(connection, f"/api/reuse{label}")
connection.close()

http2 = subprocess.check_output([
"curl", "--silent", "--show-error", "--fail", "--insecure", "--http2",
"--noproxy", "*", "--max-time", "10", "--user-agent", USER_AGENT,
f"https://localhost:{port(proxy, 443)}/http2",
], text=True)
assert http2 == "fingerprint-test\n", http2

paths = {"/plain", "/tls12", "/api/reuse12", "/tls13", "/api/reuse13", "/http2"}
rows = records(proxy, paths)
for row in rows.values():
check_common(row)
assert rows["/plain"]["tlsfp"] in ("", "-"), rows["/plain"]
assert rows["/http2"]["request"] == "GET /http2 HTTP/2.0", rows["/http2"]
assert rows["/http2"]["tls_alpn"] == "h2", rows["/http2"]
assert re.fullmatch(r"t13d\d{4}h2_[0-9a-f]{12}_[0-9a-f]{12}_[0-9a-f]{12}",
rows["/http2"]["tlsfp"]), rows["/http2"]
for label in ("12", "13"):
first, second = rows[f"/tls{label}"], rows[f"/api/reuse{label}"]
assert re.fullmatch(rf"t{label}d\d{{4}}h1_[0-9a-f]{{12}}_[0-9a-f]{{12}}_[0-9a-f]{{12}}",
first["tlsfp"]), first
assert first["tls_protocol"] == f"TLSv1.{label[-1]}", first
assert first["tls_alpn"] == "http/1.1", first
for field in ("connection", "tlsfp", "tcp_syn"):
assert first[field] == second[field], (field, first, second)
assert (first["connection_requests"], second["connection_requests"]) == ("1", "2")
assert first["request_id"] != second["request_id"]
print(json.dumps(rows, indent=2))

# The development override must still start without certificates.
dev = start("dev", "-p", "127.0.0.1::80", "-e", "PROXY_TARGET=http://backend:8000",
"-v", f"{ROOT}/proxy/dev/dev.conf.template:/etc/nginx/templates/default.conf.template:ro",
IMAGE)
wait_ready(dev, 80)
docker("exec", dev, "nginx", "-t")
connection = http.client.HTTPConnection("localhost", port(dev, 80), timeout=5)
request(connection, "/dev")
request(connection, "/api/dev")
connection.close()
for row in records(dev, {"/dev", "/api/dev"}).values():
check_common(row)
assert row["tlsfp"] in ("", "-"), row
print("PASS: HTTP/1.1 and HTTP/2, TLS 1.2/1.3, keepalive, Anubis, static/API routing, "
"header spoofing, JSON escaping and certificate-free development config")
finally:
for name in reversed(CONTAINERS):
subprocess.run(["docker", "rm", "-f", "-v", name], check=False, stdout=subprocess.DEVNULL)
subprocess.run(["docker", "network", "rm", NETWORK], check=False, stdout=subprocess.DEVNULL)


if __name__ == "__main__":
main()
Loading