Skip to content
Merged
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
41 changes: 41 additions & 0 deletions .github/workflows/blocking-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,46 @@ jobs:
- name: Install dependencies
run: uv sync --locked --all-extras --dev

- name: Checkout Danny-Dasilva/tlsfingerprint.com
uses: actions/checkout@v4
with:
repository: Danny-Dasilva/tlsfingerprint.com
ref: master
path: .tlsfingerprint-server

- name: Generate TLS certificates and config
working-directory: .tlsfingerprint-server
run: |
mkdir -p certs
openssl req -x509 -newkey rsa:4096 \
-keyout certs/key.pem \
-out certs/chain.pem \
-sha256 -days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=IP:127.0.0.1,DNS:localhost"
jq '.log_to_db = false | .mongo_url = "" | .device = ""' \
config.example.json > config.json
cat /etc/ssl/certs/ca-certificates.crt certs/chain.pem > /tmp/combined-test-cas.crt

- name: Build and start tlsfingerprint.com server
working-directory: .tlsfingerprint-server
run: |
docker compose up -d --build
echo "Waiting for tlsfingerprint.com server to become ready..."
if ! timeout 90 bash -c 'until curl -sk --max-time 3 https://localhost/api/clean -o /dev/null; do sleep 2; done'; then
echo "Server did not become ready in time. Container logs:"
docker compose logs
exit 1
fi
echo "tlsfingerprint.com server is ready."

- name: Run blocking tests
env:
TLSFP_URL: https://localhost
SSL_CERT_FILE: /tmp/combined-test-cas.crt
run: uv run pytest -v --color=yes -m "blocking" tests/

- name: Tear down tlsfingerprint.com server
if: always()
working-directory: .tlsfingerprint-server
run: docker compose down -v || true
52 changes: 50 additions & 2 deletions .github/workflows/live-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,72 @@

jobs:
live-tests:
name: Live Tests (Python 3.12)
name: Live Tests (Python ${{ matrix.python }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12', '3.13']

steps:
- uses: actions/checkout@v6

- name: Setup CycleTLS (Go build)
uses: ./.github/actions/setup-cycletls

- name: Setup uv
uses: astral-sh/setup-uv@v7
with:
python-version: '3.12'
python-version: ${{ matrix.python }}
enable-cache: true

- name: Install dependencies
run: uv sync --locked --all-extras --dev

- name: Checkout Danny-Dasilva/tlsfingerprint.com
uses: actions/checkout@v4
with:
repository: Danny-Dasilva/tlsfingerprint.com
ref: master
path: .tlsfingerprint-server

- name: Generate TLS certificates and config
working-directory: .tlsfingerprint-server
run: |
mkdir -p certs
openssl req -x509 -newkey rsa:4096 \
-keyout certs/key.pem \
-out certs/chain.pem \
-sha256 -days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=IP:127.0.0.1,DNS:localhost"
# Disable mongo logging and clear mongo_url so the server doesn't try to connect to a DB.
jq '.log_to_db = false | .mongo_url = "" | .device = ""' \
config.example.json > config.json
# Combine system CAs with the test CA so the Go transport (and any tooling)
# trusts our self-signed cert via SSL_CERT_FILE.
cat /etc/ssl/certs/ca-certificates.crt certs/chain.pem > /tmp/combined-test-cas.crt

- name: Build and start tlsfingerprint.com server
working-directory: .tlsfingerprint-server
run: |
docker compose up -d --build
echo "Waiting for tlsfingerprint.com server to become ready..."
if ! timeout 90 bash -c 'until curl -sk --max-time 3 https://localhost/api/clean -o /dev/null; do sleep 2; done'; then
echo "Server did not become ready in time. Container logs:"
docker compose logs
exit 1
fi
echo "tlsfingerprint.com server is ready."

- name: Run live tests
env:
TLSFP_URL: https://localhost
SSL_CERT_FILE: /tmp/combined-test-cas.crt
run: uv run pytest -v --color=yes --reruns=3 -m "live and not blocking" tests/

- name: Tear down tlsfingerprint.com server
if: always()
working-directory: .tlsfingerprint-server
run: docker compose down -v || true

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,6 @@ site/

# Continuous Claude cache (local only)
.claude/cache/

# Local checkout of Danny-Dasilva/tlsfingerprint.com used by live-tests workflow
.tlsfingerprint-server/
60 changes: 60 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# CycleTLS Python Tests

## Quick start

```bash
uv sync --all-extras --dev
uv run pytest -m "not live" tests/ # offline, no external deps
uv run pytest -m live tests/ # hits https://tls.peet.ws by default
```

## Live tests against a local tlsfingerprint.com Docker instance

Live tests target `https://tls.peet.ws` by default. To run them against a
local instance of [Danny-Dasilva/tlsfingerprint.com](https://github.com/Danny-Dasilva/tlsfingerprint.com)
(the open-source server behind `tls.peet.ws`), bring up a local container and
point the suite at it via the `TLSFP_URL` env var. CI does this automatically;
locally:

```bash
# 1. Clone the server into a sibling directory
git clone https://github.com/Danny-Dasilva/tlsfingerprint.com.git
cd tlsfingerprint.com

# 2. Generate self-signed certs
mkdir -p certs
openssl req -x509 -newkey rsa:4096 \
-keyout certs/key.pem -out certs/chain.pem \
-sha256 -days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=IP:127.0.0.1,DNS:localhost"

# 3. Create config.json with DB logging disabled
jq '.log_to_db = false | .mongo_url = "" | .device = ""' \
config.example.json > config.json

# 4. Boot it (binds 80/443; needs sudo on most distros)
docker compose up -d --build

# 5. Trust the cert and run the live tests against the local server
cd ../cycletls_python
cat /etc/ssl/certs/ca-certificates.crt \
../tlsfingerprint.com/certs/chain.pem > /tmp/combined-test-cas.crt
TLSFP_URL=https://localhost SSL_CERT_FILE=/tmp/combined-test-cas.crt \
uv run pytest -v -m live tests/
```

If `TLSFP_URL` is unset, the suite falls back to `https://tls.peet.ws`.

## Markers

- `live` — exercises a real fingerprint server (`tls.peet.ws` or local).
- `blocking` — CI-critical fingerprint validation; subset of `live`.

## Connection reuse note

`tls.peet.ws` and the local tlsfingerprint.com container both close the TLS
connection after each response. The CycleTLS Go transport caches connections
globally, so a closed connection can leak into the next test as
`use of closed network connection`. Most fixtures default
`enable_connection_reuse=False` to avoid this.
162 changes: 157 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,44 @@
pytest configuration and shared fixtures for CycleTLS tests.
"""

import pytest
import sys
import os
import re
import sys

import pytest

# tlsfingerprint.com base URL — override with TLSFP_URL env var to point at a local instance.
# Default is the production endpoint (https://tls.peet.ws); CI sets TLSFP_URL to a local Docker
# container running Danny-Dasilva/tlsfingerprint.com (the source of tls.peet.ws).
_TLSFP_URL = os.environ.get("TLSFP_URL", "https://tls.peet.ws")

# Add parent directory to path to import cycletls
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

from cycletls import CycleTLS, AsyncCycleTLS
from cycletls import AsyncCycleTLS, CycleTLS


@pytest.fixture(scope="session")
def cycletls_client():
"""
Session-scoped CycleTLS client fixture.
Creates a single client instance for all tests.

Connection reuse is disabled ONLY for requests against the local
tlsfingerprint.com server (which closes the TLS connection after every
response, leaving a stale cached connection in the global Go transport
pool for the next test). Requests against httpbin.org and other public
endpoints rely on HTTP/1.1 keep-alive working normally; force-disabling
reuse there causes "server closed idle connection" / EOF errors on
multi-request flows (e.g. cookie set+get, redirect chains).
"""
client = CycleTLS()
_orig = client.request
def _no_reuse_for_tlsfp(method, url, **kwargs):
if _TLSFP_URL in url:
kwargs.setdefault("enable_connection_reuse", False)
return _orig(method, url, **kwargs)
client.request = _no_reuse_for_tlsfp
yield client
client.close()

Expand All @@ -37,13 +58,19 @@ def cycletls_client_function():
@pytest.fixture
def test_url():
"""Base test URL for most tests."""
return "https://tls.peet.ws/api/clean"
return f"{_TLSFP_URL}/api/clean"


@pytest.fixture
def ja3_test_url():
"""TLS fingerprint test URL (replacement for defunct ja3er.com)."""
return "https://tls.peet.ws/api/clean"
return f"{_TLSFP_URL}/api/clean"


@pytest.fixture(scope="session")
def tlsfp_url():
"""tlsfingerprint.com base URL. Set TLSFP_URL env var to point at a local instance."""
return _TLSFP_URL


@pytest.fixture
Expand Down Expand Up @@ -92,3 +119,128 @@ async def async_cycletls_client_function():
client = AsyncCycleTLS()
yield client
await client.close()


# ==============================================================================
# JA4_r structural matchers (shared across test modules)
# ==============================================================================
#
# JA4_r header format: t<TLS_ver>d<cipher_count><ext_count><ALPN>
# Per the JA4 spec, cipher_count and ext_count are 2-digit zero-padded.
# Production tls.peet.ws emits an unpadded form (e.g. "t12d128h2" for 12+8),
# while local tlsfingerprint.com Docker emits the spec form ("t12d1208h2").
# Both are accepted: helpers validate STRUCTURE rather than exact prefixes.

_JA4R_HEADER_RE = re.compile(r"^t(?P<ver>\d{2})d(?P<counts>\d+)(?P<alpn>h2|h1|http)$")


def _decode_counts(counts: str, observed_cipher_count: int) -> tuple[int, int]:
"""
Decode the concatenated cipher_count + ext_count field from a JA4_r
header. Returns (cipher_count, ext_count).

Strategy: enumerate every (cc, ec) split where cc is a prefix of
`counts`, prefer the split where cc equals the observed cipher count
(this disambiguates unpadded production output). Otherwise fall back
to the spec form (2-digit padded each, length 4).
"""
candidates: list[tuple[int, int]] = []
for split in range(1, len(counts)):
try:
cc = int(counts[:split])
ec = int(counts[split:])
except ValueError:
continue
candidates.append((cc, ec))

# Prefer the candidate whose cipher count matches what we actually saw.
for cc, ec in candidates:
if cc == observed_cipher_count:
return cc, ec

# Fall back to the spec form (4-char zero-padded) if available.
if len(counts) == 4:
return int(counts[:2]), int(counts[2:])

# Last resort: assume single-digit cipher count.
if candidates:
return candidates[0]
raise AssertionError(f"Could not decode JA4_r counts field: {counts!r}")


def parse_ja4r(s: str) -> dict:
"""
Parse a JA4_r string into its structural components.

JA4_r format: t<ver>d<cipher_count><ext_count><ALPN>_<ciphers>_<extensions>_<sigalgs>

The cipher_count and ext_count fields in the header may be either:
- Unpadded (e.g. "128" -- 12 ciphers + 8 extensions, the format
currently produced by the production tls.peet.ws server)
- Zero-padded to 2 digits each (e.g. "1208" -- 12 + 08, per the JA4
spec, the format produced by the local tlsfingerprint.com Docker
server)

Note: the cipher_count and ext_count *header* fields refer to the
counts seen on the wire and may include SNI (0x0000) and ALPN (0x0010),
while the rendered extension list excludes those. So header counts will
NOT always equal `len(extensions)`. This helper returns the header
counts as ints (best-effort interpretation, preferring the spec
zero-padded form when ambiguous) and the observed list lengths
separately.

Returns a dict with keys:
tls_version, alpn, header_cipher_count, header_ext_count,
ciphers, extensions, sig_algs, header, raw.
"""
parts = s.split("_")
assert len(parts) == 4, f"JA4_r should have 4 underscore-separated parts, got {len(parts)}: {s}"

header, ciphers_s, exts_s, sigs_s = parts
m = _JA4R_HEADER_RE.match(header)
assert m, f"JA4_r header malformed: {header!r}"

ciphers = [c for c in ciphers_s.split(",") if c]
extensions = [e for e in exts_s.split(",") if e]
sig_algs = [a for a in sigs_s.split(",") if a]

counts = m.group("counts")
header_cc, header_ec = _decode_counts(counts, len(ciphers))

return {
"tls_version": m.group("ver"),
"alpn": m.group("alpn"),
"header_cipher_count": header_cc,
"header_ext_count": header_ec,
"ciphers": ciphers,
"extensions": extensions,
"sig_algs": sig_algs,
"header": header,
"raw": s,
}


def assert_ja4r_equivalent(actual: str, expected: str) -> None:
"""
Assert two JA4_r strings are structurally equivalent.

Header padding for cipher_count/ext_count may differ between servers
(production unpadded vs spec-compliant zero-padded), but the body
(ciphers, extensions, signature algorithms) and TLS version + ALPN
must match exactly.
"""
a = parse_ja4r(actual)
e = parse_ja4r(expected)
assert a["tls_version"] == e["tls_version"], (
f"TLS version mismatch: actual={a['tls_version']} expected={e['tls_version']}"
)
assert a["alpn"] == e["alpn"], f"ALPN mismatch: actual={a['alpn']} expected={e['alpn']}"
assert a["ciphers"] == e["ciphers"], (
f"Cipher list mismatch:\nactual: {a['ciphers']}\nexpected: {e['ciphers']}"
)
assert a["extensions"] == e["extensions"], (
f"Extension list mismatch:\nactual: {a['extensions']}\nexpected: {e['extensions']}"
)
assert a["sig_algs"] == e["sig_algs"], (
f"Signature algorithm list mismatch:\nactual: {a['sig_algs']}\nexpected: {e['sig_algs']}"
)
Loading
Loading