Skip to main content

Testing Guide

This guide shows how to manually test and validate Synapse's features.

Prerequisites​

Install required tools:

apt-get update
apt-get install -y curl tcpdump tshark netcat-openbsd bind9-dnsutils jq

Start Synapse with Logging​

Enable Debug Logging​

# Option 1: Environment variable
export RUST_LOG=debug
./target/release/synapse --config config.yaml

# Option 2: Specific module logging
export RUST_LOG=synapse::tcp_fingerprint=debug,synapse::ja4_plus=debug,synapse::access_log=debug
./target/release/synapse --config config.yaml

# Option 3: Log to file
./target/release/synapse --config config.yaml 2>&1 | tee synapse.log

Watch Logs in Real-Time​

Fingerprints have a file of their own, and at debug level they also appear in the console log, so there are two streams to watch:

# Startup, errors and feature decisions - the stdout log captured above
tail -f synapse.log

# Fingerprints, one JSON object per line
tail -f /var/log/synapse/eventbridge.log | jq .

The fingerprint stream is on by default and is written by an agent and a proxy alike. /var/log/synapse is logging.log_directory; see JA4+ Fingerprinting for the switch and the path.

TCP_FP is not a log prefix

This page used to filter the console log with grep -E "TCP_FP|JA4|Fingerprint". Two of those three alternatives work: with RUST_LOG=debug set, as above, the proxy logs lines beginning JA4:, JA4T:, JA4H:, JA4S: and JA4X:, and other components log messages containing Fingerprint. TCP_FP matches nothing, though - no log line carries that prefix. (TCP_FP_KEY_SIZE and similar names do exist in the source, but they are eBPF compile-time constants, not log output.)

Treat the console as a debugging aid and eventbridge.log as the record: the console lines appear only at debug level, carry one fingerprint type each, and are not JSON.

Testing TCP Fingerprinting (JA4T)​

Test IPv4 TCP Fingerprinting​

# Simple HTTPS request (generates TCP SYN)
curl -v https://localhost:8443/

# The JA4T fingerprint, as recorded
tail -f /var/log/synapse/eventbridge.log | \
jq 'select(.event_type == "fingerprint" and .ja4t) | {src_ip, src_port, ja4t}'

Test IPv6 TCP Fingerprinting​

# Test with IPv6 localhost
curl -v -6 https://[::1]:8443/

# Test with global IPv6 address (if available)
curl -v -6 https://[2001:db8::1]:8443/

# A v6 source address lands in the same field as a v4 one
tail -f /var/log/synapse/eventbridge.log | \
jq 'select(.event_type == "fingerprint" and (.src_ip | contains(":"))) | {src_ip, ja4t}'

Capture TCP Options with tcpdump​

# Capture and display TCP SYN packets with options
sudo tcpdump -i any -nn -vv 'tcp[tcpflags] & tcp-syn != 0' port 8443

# Output shows:
# - TTL (from IP header)
# - Window size
# - MSS option (kind=2)
# - Window scale option (kind=3)
# - SACK permitted (kind=4)
# - Timestamps (kind=8)

JA4T Format​

The JA4T format is: {window_size}_{tcp_options}_{mss}_{window_scale}

Example: 65535_2-4-8-1-3_1460_7

  • Window size: 65535
  • TCP options: MSS(2), SACK(4), Timestamps(8), NOP(1), Window Scale(3)
  • MSS value: 1460
  • Window scale: 7

Testing HTTP Fingerprinting (JA4H)​

Basic HTTP Request​

# Simple GET request
curl -v https://localhost:8443/test

# Expected JA4H format: {method}{version}{cookie}{referer}{count}{lang}_{headers_hash}_{cookie_names_hash}_{cookie_values_hash}
# Example: ge11nr05n_a1b2c3d4e5f6_000000000000_000000000000

Test with Custom Headers​

# With cookies
curl -v -H "Cookie: session=abc123; user=john" \
https://localhost:8443/test

# With referer
curl -v -H "Referer: https://example.com" \
https://localhost:8443/test

# With language
curl -v -H "Accept-Language: en-US,en;q=0.9" \
https://localhost:8443/test

# With multiple headers
curl -v \
-H "Cookie: session=abc123" \
-H "Referer: https://example.com" \
-H "Accept-Language: en-US" \
-H "User-Agent: CustomAgent/1.0" \
-H "Accept: application/json" \
https://localhost:8443/test

# Expected JA4H: ge11cr08enus_<hash>_<cookie_names_hash>_<cookie_values_hash>

Compare Different Browsers​

# Simulate Chrome
curl -v -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
-H "Accept: text/html,application/xhtml+xml" \
-H "Accept-Language: en-US,en;q=0.9" \
-H "Accept-Encoding: gzip, deflate, br" \
https://localhost:8443/

# Simulate Firefox
curl -v -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0" \
-H "Accept: text/html,application/xhtml+xml" \
-H "Accept-Language: en-US,en;q=0.5" \
-H "Accept-Encoding: gzip, deflate, br" \
https://localhost:8443/

# Compare JA4H hashes - they should be different

Testing TLS Client Fingerprinting (JA4)​

Test Default curl​

# Default TLS settings
curl -v https://localhost:8443/

# Check curl's TLS library
curl --version | grep -E "OpenSSL|GnuTLS|NSS"

Test with Different TLS Versions​

# Force TLS 1.2
curl -v --tlsv1.2 --tls-max 1.2 https://localhost:8443/

# Force TLS 1.3
curl -v --tlsv1.3 https://localhost:8443/

# Different fingerprints should be generated

Capture TLS ClientHello with tshark​

# Capture TLS handshake
sudo tshark -i any -f "port 8443" -Y "tls.handshake.type == 1" -V

# Look for:
# - TLS version
# - Cipher suites
# - Extensions
# - ALPN values

Viewing Fingerprints​

Fingerprints are readable from two files, and the two are not interchangeable.

FileWritten byOn by defaultAlso needsOne line is
{log_directory}/eventbridge.logagent and proxyyes, logging.eventbridge_log.enableda capture backend that produces fingerprintsone fingerprint observation
{log_directory}/access.logproxy onlyno, needs logging.file_logging_enableda Unix build with the proxy featureone HTTP request, fingerprints among its fields

The "also needs" column is the part that catches people out: the file appears as soon as the switch is on, whether or not anything is producing fingerprints to put in it. Three backends do produce them, and only the first needs BPF - the periodic collectors that poll the BPF maps, the Linux AF_PACKET capture path, and the Windows NDIS path. So an empty file means "no backend is feeding it", not "BPF is missing".

log_directory defaults to /var/log/synapse on Linux and C:\ProgramData\Gen0Sec\Synapse\logs on Windows.

The fingerprint stream​

Each line is one event carrying an event_type discriminator, and the fingerprint values sit at the top level - not nested under tls or http:

tail -f /var/log/synapse/eventbridge.log | jq 'select(.event_type == "fingerprint")'
{
"event_type": "fingerprint",
"timestamp": "2025-10-29T12:34:56.789Z",
"src_ip": "192.168.1.100",
"src_port": 54321,
"dst_ip": "10.0.0.5",
"dst_port": 8443,
"ja4": "t13d1516h2_8daaf6152771_b186095e22b6",
"ja4t": "65535_2-4-8-1-3_1460_7",
"ja4h": "ge11cr08enus_a1b2c3d4e5f6_123456789abc_def012345678"
}

Only timestamp, src_ip, src_port, dst_ip and dst_port are always present. Every fingerprint field is omitted when that fingerprint was not observed, so select on the field rather than assuming it is there:

EB=/var/log/synapse/eventbridge.log

# Unique JA4 values, most frequent first
jq -r 'select(.event_type == "fingerprint" and .ja4) | .ja4' "$EB" |
sort | uniq -c | sort -rn

# Unique JA4T values
jq -r 'select(.event_type == "fingerprint" and .ja4t) | .ja4t' "$EB" |
sort | uniq -c | sort -rn

# Correlate source address with fingerprints
jq -r 'select(.event_type == "fingerprint")
| [.src_ip, .ja4t // "-", .ja4h // "-"] | @tsv' "$EB"

The proxy access log​

A proxy also records fingerprints per request, but only with file logging on:

# /etc/synapse/config.yaml
logging:
file_logging_enabled: true # default false - without it there is no access.log

The fingerprints live under fingerprints, and the client address under network:

tail -f /var/log/synapse/access.log | jq -R -r 'fromjson? | select(.http) | {
timestamp: .timestamp,
src_ip: .network.src_ip,
ja4: .fingerprints.ja4,
ja4h: .fingerprints.ja4h,
ja4t: .fingerprints.ja4t,
method: .http.method,
path: .http.path
}'
With file logging off, the record goes to the console

access.log exists only with logging.file_logging_enabled: true, but the record is emitted either way. With file logging off it goes to the console on an appender of its own, printed raw - one JSON object per line, with no timestamp or level prefix.

What breaks a naive jq is the company it keeps: the console also carries ordinary tracing lines, which are not JSON, so jq stops at the first of them with a parse error. Filter the stream instead of feeding it whole:

# Non-JSON lines are skipped rather than fatal
./target/release/synapse --config config.yaml 2>&1 |
jq -R 'fromjson? | select(.http) | {src_ip: .network.src_ip, ja4: .fingerprints.ja4}'

The console target is stderr unless synapse is in daemon mode, which is why the 2>&1 matters. Setting file_logging_enabled: true gets the records into access.log away from the tracing lines, which is why the recipes above read the file - but keep the -R 'fromjson?' guard there too: the proxy writes its own access lines to that same path through a second appender, and nothing guarantees every one of them is JSON.

The per-request ja4h, ja4s and ja4x values are available nowhere else on a default proxy: eventbridge.log carries fingerprint observations, not request correlation.

Three field paths this page used to give do not exist

client_ip, tls.ja4 and http.ja4h are not fields of an access-log record. A record's top level is event_type, schema_version, timestamp, request_id, http, network, tls, response, error, remediation, geoip, upstream, performance, fingerprints and trace. Every JA4 value is under fingerprints, and the client address is network.src_ip; tls.ja4t exists too and duplicates fingerprints.ja4t, but there is no tls.ja4.

A jq filter naming a field that does not exist prints null for every line instead of failing, which is how the wrong paths survived on this page for so long.

Testing Health Endpoints​

Health Check​

# The default address is 127.0.0.1:9180
curl http://127.0.0.1:9180/health
# OK

# HEAD request (for load balancers)
curl -I http://127.0.0.1:9180/health

The body is the string OK, not a JSON object. proxy.internal_services is enabled by default on port 9180, bound to 127.0.0.1.

bind_ip is not the exposure boundary

A proxy also publishes /health on its public listeners. Unless your config already defines an internal_paths entry for /health, synapse adds one pointing at the internal server, and the router consults internal_paths before the upstream table and without regard to hostname. So curl -k https://<proxy-host>/health answers OK from any machine that can reach the proxy at all.

bind_ip: 127.0.0.1 restricts only direct access to port 9180; it is not what decides whether the path is served publicly.

On a v1 upstreams file you can take the path back: internal_paths is a key of the upstreams file, not of config.yaml, and an entry there for /health replaces the default one. Putting internal_paths: in config.yaml does nothing except add it to the Unused config options in <path> warning at startup.

On a v2 upstreams file there is no override at all. The v2 reader does not carry internal_paths through, so the default /health entry stands whatever the file says. On v2, network controls in front of the proxy are the only lever.

The internal server is a proxy surface

It is started only when proxy.internal_services.enabled is true, synapse is not in agent mode, and the build is a Unix build with the proxy feature - so a Windows build, which ships without that feature, has no internal server either. An agent serves no /health at all, and setting the key changes nothing there. If you are testing fingerprinting on an agent, there is no endpoint to call; read eventbridge.log.

Counting Fingerprints​

There is no fingerprint statistics endpoint

This page used to document GET /health/tcp_fingerprint_stats, returning a body with syn_stats.total_syns, unique_fingerprints and a fingerprints array. No such route exists. The internal server registers /health, /metrics, /cgi-bin/captcha/verify, /cert/expiration, /cert/expiration/{domain}, /cert/renew/{domain}, the opt-in config-stream path and the ACME challenge directory, and nothing else; tcp_fingerprint_stats appears in no source file. The request answers 404, and /metrics carries no fingerprint metric either.

The statistics themselves are real - only the endpoint was imaginary. With the fingerprint stream on and BPF loaded, synapse logs the whole aggregate to the console at debug level, every 60 seconds, as one JSON object:

# Default config: the line goes to the console
RUST_LOG=debug ./target/release/synapse --config config.yaml 2>&1 |
grep --line-buffered 'TCP Fingerprint Stats JSON:'

# With logging.file_logging_enabled: true, it goes to the application log instead
grep 'TCP Fingerprint Stats JSON:' /var/log/synapse/app.log

That object is the shape this page used to publish, with syn_stats.total_syns, syn_stats.unique_fingerprints, total_unique_fingerprints and a fingerprints array whose entries carry key.{src_ip,src_port,fingerprint} and data.{packet_count,ttl,mss,window_size,window_scale}. It just never had a URL.

EB=/var/log/synapse/eventbridge.log

# Observations recorded
grep -c '"event_type":"fingerprint"' "$EB"

# Distinct JA4T values
jq -r 'select(.event_type == "fingerprint" and .ja4t) | .ja4t' "$EB" | sort -u | wc -l

# Everything from one source address
jq 'select(.event_type == "fingerprint" and .src_ip == "192.168.1.100")' "$EB"

# Everything from an IPv6 prefix
jq 'select(.event_type == "fingerprint" and (.src_ip | startswith("2001:")))' "$EB"

Testing with Multiple Clients​

Generate Load​

# Script to test with multiple clients
for i in {1..10}; do
curl -v https://localhost:8443/test?client=$i &
done
wait

# Count the fingerprints those requests produced
jq -r 'select(.event_type == "fingerprint" and .ja4t) | .ja4t' \
/var/log/synapse/eventbridge.log | sort -u | wc -l

Test IPv4 and IPv6 Simultaneously​

# Terminal 1: IPv4 requests
while true; do
curl -4 -s https://localhost:8443/ > /dev/null
sleep 1
done

# Terminal 2: IPv6 requests
while true; do
curl -6 -s https://[::1]:8443/ > /dev/null
sleep 1
done

# Terminal 3: Monitor fingerprints, split by address family
while sleep 2; do
clear
jq -r 'select(.event_type == "fingerprint")
| if (.src_ip | contains(":")) then "v6" else "v4" end' \
/var/log/synapse/eventbridge.log | sort | uniq -c
done

Debugging Tools​

Check BPF Maps​

# List the maps synapse loaded (requires root)
sudo bpftool map show | grep -E "tcp_fingerprint|tcp_syn_stats|banned"

# Dump one, by the id from that listing
sudo bpftool map dump id <id>

Monitor Kernel Logs​

# XDP and BPF load failures surface here
sudo dmesg -T | grep -iE "xdp|bpf"
trace_pipe stays empty

The eBPF programs contain no bpf_printk, so nothing from synapse ever reaches /sys/kernel/debug/tracing/trace_pipe. The recipe this page used to give piped it through grep TCP_FP, which could not match.

Check XDP Attachment​

# Should show synapse's programs. TC/TCX attachments list as `sched_cls`, not `xdp`,
# so matching only on "xdp" misses a host where just the overlay is attached
sudo bpftool prog show | grep -iE 'xdp|sched_cls'

# Check which interface
ip link show | grep -B 1 "prog/xdp"

Performance Testing​

# Load test with ab (ApacheBench)
ab -n 1000 -c 10 https://localhost:8443/

# Monitor CPU usage during collection
pidstat -p $(pgrep synapse) 1 10

Quick Test Script​

#!/bin/bash
# quick_test.sh - Comprehensive test script

echo "=== Testing TCP Fingerprinting (JA4T) ==="
echo "IPv4 test..."
curl -4 -s https://localhost:8443/ > /dev/null && echo "✓ IPv4 request sent"

echo "IPv6 test..."
curl -6 -s https://[::1]:8443/ > /dev/null && echo "✓ IPv6 request sent"

echo ""
echo "=== Testing HTTP Fingerprinting (JA4H) ==="
curl -s -H "Cookie: test=value" -H "Referer: https://test.com" https://localhost:8443/ > /dev/null
echo "✓ Request with headers sent"

EB=/var/log/synapse/eventbridge.log

echo ""
echo "=== Counting Fingerprints ==="
COUNT=$(jq -r 'select(.event_type == "fingerprint" and .ja4t) | .ja4t' "$EB" | sort -u | wc -l)
echo "✓ Distinct JA4T values: $COUNT"

echo ""
echo "=== Recent Fingerprints ==="
jq -c 'select(.event_type == "fingerprint") | {src_ip, ja4t, ja4h}' "$EB" | tail -3

echo ""
echo "=== Access Log Sample (proxy with file_logging_enabled only) ==="
if [ -r /var/log/synapse/access.log ]; then
tail -5 /var/log/synapse/access.log |
jq -R -r 'fromjson? | select(.http) | {ja4t: .fingerprints.ja4t, ja4h: .fingerprints.ja4h, src_ip: .network.src_ip}'
else
echo "- no access.log: logging.file_logging_enabled is off by default, and only a proxy writes one"
fi

Make executable and run:

chmod +x quick_test.sh
./quick_test.sh

Validation Checklist​

After testing, verify:

  • JA4T fingerprints appear in eventbridge.log
  • JA4H fingerprints change with different headers
  • JA4 fingerprints change with different TLS settings
  • IPv4 and IPv6 both generate fingerprints
  • BPF maps contain entries
  • No errors in synapse logs
  • eventbridge.log carries "event_type":"fingerprint" lines
  • /health answers OK on 127.0.0.1:9180 (a proxy only)
  • Different clients generate different fingerprints
  • Same client generates consistent fingerprints

Common Issues​

No fingerprints appearing​

# Check XDP is enabled
./target/release/synapse --config config.yaml 2>&1 | grep -i xdp

# Verify interface is correct
ip link show

# Check BPF program is loaded. TC/TCX attachments list as `sched_cls`, not `xdp`
sudo bpftool prog list | grep -iE 'xdp|sched_cls'

IPv6 not working​

# Enable IPv6
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=0
sudo sysctl -w net.ipv6.conf.default.disable_ipv6=0

# Test connectivity
ping6 -c 3 ::1

No fingerprint records​

There is no tcp_fingerprinting section, and no per-connection threshold to lower: logging.tcp_fingerprint and its min_packet_count were removed from the schema. The fingerprint stream has one switch, and it is on by default:

# /etc/synapse/config.yaml
logging:
eventbridge_log:
enabled: true # the default

If the stream is empty, the switch is not the thing to adjust. Check, in this order:

  1. Is a capture backend actually producing fingerprints? The BPF-fed collectors are gated on a loaded program as well as on the switch, so with firewall.mode: none, in a container without CAP_BPF, or on a build without BPF they contribute nothing. sudo bpftool prog show | grep -iE 'xdp|sched_cls' says whether a program is loaded (synapse attaches TC/TCX programs as well as XDP, and those list as sched_cls).

    A missing BPF program is not on its own an explanation for an empty file: a Linux AF_PACKET build and a Windows agent both write fingerprints without any BPF at all. If you are on one of those and the file is empty, this is a fault, not the expected outcome.

  2. Is traffic reaching the capture interface at all?

Next Steps​