Files
mrambossekandClaude Fable 5 ce1aaa332a
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
server: send granted traffic from the address the session actually used
fmr binds two IPv4 addresses. connFor picked whichever socket of the right
family came first in the bind list, so a downtrain for a session established on
.150 went out from .151 — and every packet was dropped by the client's NAT,
which has no mapping for that pair. tcpdump on the server showed all 50 leaving;
the client saw none. Read as "100% downstream loss", which is the worst kind of
wrong: a confident measurement of something that never happened.

Sessions now record which of our own bound addresses received their traffic, and
granted sends (and delayed echo) go back out through that socket. The fallback
to a family match is kept for the case where nothing has been received yet, and
the test pins both paths — a single-homed lab can never reproduce this.

Also: the client-side halves of the same work — anonymizer (core-privacy), local
run archive with retention (core-archive), upload client, and the app's settings
and history screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:45:43 +02:00

112 lines
3.4 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package tcpecho
import (
"encoding/binary"
"strings"
"testing"
)
// buildClientHello assembles a minimal but valid TLS ClientHello record for
// tests: TLS1.2 legacy version, the given ciphers, and extensions SNI, ALPN
// (h2), supported_versions (1.3), signature_algorithms (0x0403).
func buildClientHello(ciphers []uint16) []byte {
u16 := func(v uint16) []byte { b := make([]byte, 2); binary.BigEndian.PutUint16(b, v); return b }
var body []byte
body = append(body, u16(0x0303)...) // client_version TLS1.2
body = append(body, make([]byte, 32)...) // random
body = append(body, 0) // session_id len 0
// cipher suites
cs := []byte{}
for _, c := range ciphers {
cs = append(cs, u16(c)...)
}
body = append(body, u16(uint16(len(cs)))...)
body = append(body, cs...)
body = append(body, 1, 0) // compression: 1 method, null
// extensions
var exts []byte
addExt := func(typ uint16, data []byte) {
exts = append(exts, u16(typ)...)
exts = append(exts, u16(uint16(len(data)))...)
exts = append(exts, data...)
}
// SNI: server_name_list -> host_name "x"
sni := append(u16(3), 0) // list len 3, name_type host_name(0)
sni = append(sni, u16(1)...) // name len 1
sni = append(sni, 'x')
addExt(0x0000, sni)
// ALPN: protocol_name_list -> "h2"
alpn := append(u16(3), 2, 'h', '2') // list len 3, strlen 2, "h2"
addExt(0x0010, alpn)
// supported_versions: list len 2, 0x0304
addExt(0x002b, append([]byte{2}, u16(0x0304)...))
// signature_algorithms: list len 2, 0x0403
addExt(0x000d, append(u16(2), u16(0x0403)...))
body = append(body, u16(uint16(len(exts)))...)
body = append(body, exts...)
// handshake header
hs := []byte{0x01, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
hs = append(hs, body...)
// record header
rec := []byte{0x16, 0x03, 0x01, byte(len(hs) >> 8), byte(len(hs))}
return append(rec, hs...)
}
func TestParseAndJA4(t *testing.T) {
rec := buildClientHello([]uint16{0x1301, 0x1302})
h, ok := parseClientHello(rec)
if !ok {
t.Fatal("parse failed")
}
if len(h.cipherSuites) != 2 || !h.hasSNI || len(h.alpns) != 1 || h.alpns[0] != "h2" {
t.Fatalf("parsed fields wrong: %+v", h)
}
if len(h.supportedVersions) != 1 || h.supportedVersions[0] != 0x0304 {
t.Fatalf("supported_versions: %v", h.supportedVersions)
}
got := ja4(h)
// _a: t + 13 (supported_versions 1.3) + d (SNI) + 02 ciphers + 04 exts + h2
wantA := "t13d0204h2"
parts := strings.Split(got, "_")
if len(parts) != 3 {
t.Fatalf("JA4 not 3 parts: %s", got)
}
if parts[0] != wantA {
t.Fatalf("JA4_a = %s, want %s (full %s)", parts[0], wantA, got)
}
if len(parts[1]) != 12 || len(parts[2]) != 12 {
t.Fatalf("JA4 hash parts not 12 hex: %s", got)
}
// determinism
if ja4(h) != got {
t.Fatal("JA4 not deterministic")
}
}
func TestJA4GREASEExcluded(t *testing.T) {
// Same hello but with a GREASE cipher inserted; cipher count and _b hash
// must be identical to the non-GREASE version.
base := ja4(mustParse(t, buildClientHello([]uint16{0x1301, 0x1302})))
withGrease := ja4(mustParse(t, buildClientHello([]uint16{0x0a0a, 0x1301, 0x1302})))
if base != withGrease {
t.Fatalf("GREASE changed JA4:\n base=%s\n grease=%s", base, withGrease)
}
}
func mustParse(t *testing.T, rec []byte) *clientHello {
t.Helper()
h, ok := parseClientHello(rec)
if !ok {
t.Fatal("parse failed")
}
return h
}