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 prefixThis 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.
| File | Written by | On by default | Also needs | One line is |
|---|---|---|---|---|
{log_directory}/eventbridge.log | agent and proxy | yes, logging.eventbridge_log.enabled | a capture backend that produces fingerprints | one fingerprint observation |
{log_directory}/access.log | proxy only | no, needs logging.file_logging_enabled | a Unix build with the proxy feature | one 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
}'
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.
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 boundaryA 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.
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
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 emptyThe 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.logcarries"event_type":"fingerprint"lines -
/healthanswersOKon127.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:
-
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 withoutCAP_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 assched_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.
-
Is traffic reaching the capture interface at all?
Next Steps
- JA4+ Fingerprinting - Learn about fingerprint analysis
- Configuration Reference - Tune your settings