Move cert noise calibration into doppelganger scout

Instead of a separate cert_probe.go that duplicates the scout's TLS
connection logic, measure the cert chain size directly from the same
HTTPS connections the scout already makes.

Changes:
- Extend ScoutConnResult with payloadLen field
- Add Write interception to ScoutConn for handshake boundary detection
- Scout.learn() now computes cert size (sum of ApplicationData between
  CCS and first client Write) alongside inter-record durations
- Ganger aggregates cert sizes across raids and exposes NoiseParams()
  via atomic pointer for lock-free reads from proxy goroutines
- Proxy reads NoiseParams from Ganger on each handshake instead of
  probing at startup
- Remove cert_probe.go, disk cache, and related config options
  (noise-cache-path, noise-cache-ttl, noise-probe-count)

Falls back to legacy 2500-4700 range until the first scout raid
completes (typically within 1-2 seconds of startup).
This commit is contained in:
Alexey Dolotov
2026-03-27 16:34:42 +03:00
parent 80213ad35d
commit 9dfd992c1d
12 changed files with 177 additions and 363 deletions
-261
View File
@@ -1,261 +0,0 @@
package fake
import (
"crypto/tls"
"encoding/binary"
"encoding/json"
"fmt"
"net"
"os"
"sync"
"time"
)
const (
probeDialTimeout = 10 * time.Second
probeHandshakeTimeout = 10 * time.Second
defaultProbeCount = 15
defaultCacheTTL = 24 * time.Hour
tlsTypeChangeCipherSpec = 0x14
tlsTypeApplicationData = 0x17
)
// CertProbeResult holds the measured encrypted handshake size.
type CertProbeResult struct {
Mean int `json:"mean"`
Jitter int `json:"jitter"`
}
// CertProbeCache is the on-disk format for cached probe results.
type CertProbeCache struct {
Hostname string `json:"hostname"`
Port int `json:"port"`
Mean int `json:"mean"`
Jitter int `json:"jitter"`
ProbedAt time.Time `json:"probed_at"`
}
// LoadCachedProbe reads a cached probe result from path. Returns the result
// and true if the cache exists, matches hostname:port, and is younger than ttl.
// Otherwise returns zero value and false.
func LoadCachedProbe(path, hostname string, port int, ttl time.Duration) (CertProbeResult, bool) {
if ttl <= 0 {
ttl = defaultCacheTTL
}
data, err := os.ReadFile(path)
if err != nil {
return CertProbeResult{}, false
}
var cache CertProbeCache
if err := json.Unmarshal(data, &cache); err != nil {
return CertProbeResult{}, false
}
if cache.Hostname != hostname || cache.Port != port {
return CertProbeResult{}, false
}
if time.Since(cache.ProbedAt) > ttl {
return CertProbeResult{}, false
}
if cache.Mean <= 0 {
return CertProbeResult{}, false
}
return CertProbeResult{Mean: cache.Mean, Jitter: cache.Jitter}, true
}
// SaveCachedProbe writes a probe result to path as JSON.
func SaveCachedProbe(path, hostname string, port int, result CertProbeResult) error {
cache := CertProbeCache{
Hostname: hostname,
Port: port,
Mean: result.Mean,
Jitter: result.Jitter,
ProbedAt: time.Now(),
}
data, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644) //nolint: gosec
}
// ProbeCertSize connects to hostname:port via TLS multiple times and measures
// the total ApplicationData payload bytes sent by the server during the
// handshake (between ChangeCipherSpec and the first application-level data).
// This corresponds to EncryptedExtensions + Certificate + CertificateVerify +
// Finished in TLS 1.3, which is what the FakeTLS noise must mimic.
func ProbeCertSize(hostname string, port int, count int) (CertProbeResult, error) {
if count <= 0 {
count = defaultProbeCount
}
addr := net.JoinHostPort(hostname, fmt.Sprintf("%d", port))
sizes := make([]int, 0, count)
for i := 0; i < count; i++ {
size, err := probeSingle(addr, hostname)
if err != nil {
if len(sizes) > 0 {
break // use what we have
}
return CertProbeResult{}, fmt.Errorf("probe %d failed: %w", i, err)
}
sizes = append(sizes, size)
}
if len(sizes) == 0 {
return CertProbeResult{}, fmt.Errorf("no successful probes")
}
// Calculate mean and jitter (max deviation from mean).
sum := 0
for _, s := range sizes {
sum += s
}
mean := sum / len(sizes)
maxDev := 0
for _, s := range sizes {
d := s - mean
if d < 0 {
d = -d
}
if d > maxDev {
maxDev = d
}
}
// Ensure minimum jitter of 100 bytes for variability.
if maxDev < 100 {
maxDev = 100
}
return CertProbeResult{Mean: mean, Jitter: maxDev}, nil
}
// probeSingle does one TLS handshake and measures ApplicationData bytes
// received during the handshake.
func probeSingle(addr, hostname string) (int, error) {
rawConn, err := net.DialTimeout("tcp", addr, probeDialTimeout)
if err != nil {
return 0, err
}
defer rawConn.Close() //nolint: errcheck
capture := &recordCapture{conn: rawConn}
tlsConn := tls.Client(capture, &tls.Config{
ServerName: hostname,
MinVersion: tls.VersionTLS12,
})
tlsConn.SetDeadline(time.Now().Add(probeHandshakeTimeout)) //nolint: errcheck
if err := tlsConn.Handshake(); err != nil {
return 0, err
}
tlsConn.Close() //nolint: errcheck
return capture.appDataBytes, nil
}
// recordCapture wraps a net.Conn and parses the raw TLS record stream to
// measure ApplicationData payload sizes sent by the server during handshake.
// It tracks record boundaries by maintaining a state machine over Read calls.
type recordCapture struct {
conn net.Conn
mu sync.Mutex
appDataBytes int
seenCCS bool
done bool
// Record boundary tracking for the read side.
readRemaining int // bytes left in current record payload
readHeaderBuf [5]byte
readHeaderPos int
}
func (rc *recordCapture) Read(p []byte) (int, error) {
n, err := rc.conn.Read(p)
if n > 0 && !rc.done {
rc.mu.Lock()
rc.parseReadBytes(p[:n])
rc.mu.Unlock()
}
return n, err
}
func (rc *recordCapture) parseReadBytes(data []byte) {
for len(data) > 0 {
if rc.readRemaining > 0 {
// Consuming payload of current record.
consume := rc.readRemaining
if consume > len(data) {
consume = len(data)
}
rc.readRemaining -= consume
data = data[consume:]
continue
}
// Accumulate header bytes (5 bytes per record).
need := 5 - rc.readHeaderPos
if need > len(data) {
need = len(data)
}
copy(rc.readHeaderBuf[rc.readHeaderPos:], data[:need])
rc.readHeaderPos += need
data = data[need:]
if rc.readHeaderPos < 5 {
return // incomplete header
}
// Full header available.
recordType := rc.readHeaderBuf[0]
payloadLen := int(binary.BigEndian.Uint16(rc.readHeaderBuf[3:5]))
rc.readHeaderPos = 0
rc.readRemaining = payloadLen
if recordType == tlsTypeChangeCipherSpec {
rc.seenCCS = true
} else if recordType == tlsTypeApplicationData && rc.seenCCS {
rc.appDataBytes += payloadLen
}
}
}
func (rc *recordCapture) Write(p []byte) (int, error) {
// After client writes post-CCS data, server handshake records are done.
if rc.seenCCS && rc.appDataBytes > 0 {
rc.done = true
}
return rc.conn.Write(p)
}
func (rc *recordCapture) Close() error { return rc.conn.Close() }
func (rc *recordCapture) LocalAddr() net.Addr { return rc.conn.LocalAddr() }
func (rc *recordCapture) RemoteAddr() net.Addr { return rc.conn.RemoteAddr() }
func (rc *recordCapture) SetDeadline(t time.Time) error { return rc.conn.SetDeadline(t) }
func (rc *recordCapture) SetReadDeadline(t time.Time) error { return rc.conn.SetReadDeadline(t) }
func (rc *recordCapture) SetWriteDeadline(t time.Time) error { return rc.conn.SetWriteDeadline(t) }
// Ensure recordCapture implements net.Conn.
var _ net.Conn = (*recordCapture)(nil)