From 8b001daf0abcaf76dd09d7a0776ba13b63d6f331 Mon Sep 17 00:00:00 2001 From: uk0 Date: Wed, 8 Apr 2026 15:55:56 +0800 Subject: [PATCH] add WebRTC and IPCAM wire formats, encrypt shim header, simulate DTLS Security fixes: - Encrypt shim header inside AES-GCM (eliminates 0x5C10ADED fingerprint) - XOR magic with session-derived mask when AES disabled - Unified encode/decode path via wire.go New wire formats: - webrtc: SRTP-style 24B RTP header with 0xBEDE extensions (abs-send-time, transport-cc), 10B auth tag, compound RTCP SR+SDES, STUN with FINGERPRINT - ipcam: H.264/RTP surveillance camera simulation with FU-A fragmentation, STAP-A SPS/PPS, GOP state machine (I/P frames) DTLS handshake simulation: - Fake DTLS 1.2 ClientHello/ServerHello/ChangeCipherSpec/Finished - Realistic cipher suites, use_srtp extension, supported_groups DPI score improvement: 34.6 -> 70.4 (webrtc+AES mode) --- crypto.go | 138 +++++++++++++++++ dtls.go | 407 +++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 167 +++++++------------- wire.go | 149 ++++++++++++++++++ wire_ipcam.go | 404 ++++++++++++++++++++++++++++++++++++++++++++++++ wire_webrtc.go | 226 +++++++++++++++++++++++++++ 6 files changed, 1380 insertions(+), 111 deletions(-) create mode 100644 crypto.go create mode 100644 dtls.go create mode 100644 wire.go create mode 100644 wire_ipcam.go create mode 100644 wire_webrtc.go diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..96773be --- /dev/null +++ b/crypto.go @@ -0,0 +1,138 @@ +package main + +import ( + "crypto/aes" + "crypto/cipher" + cryptoRand "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "strings" +) + +type aeadBox2 struct{ aead cipher.AEAD } + +func newAEAD2(hexKey string) (*aeadBox2, error) { + if strings.TrimSpace(hexKey) == "" { + return nil, nil + } + kb, err := hex.DecodeString(hexKey) + if err != nil { + return nil, err + } + switch len(kb) { + case 16, 24, 32: + default: + return nil, fmt.Errorf("aes key must be 16/24/32 bytes") + } + block, err := aes.NewCipher(kb) + if err != nil { + return nil, err + } + a, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &aeadBox2{aead: a}, nil +} + +func (b *aeadBox2) seal(p []byte) (out []byte, nonce []byte, err error) { + if b == nil { + return p, nil, nil + } + nonce = make([]byte, b.aead.NonceSize()) + if _, err = cryptoRand.Read(nonce); err != nil { + return nil, nil, err + } + c := b.aead.Seal(nil, nonce, p, nil) + return c, nonce, nil +} + +func (b *aeadBox2) open(nonce, c []byte) ([]byte, error) { + if b == nil { + return c, nil + } + return b.aead.Open(nil, nonce, c, nil) +} + +const nonceLen = 12 + +// magicMask derives a 4-byte XOR mask from the session ID so the magic constant +// never appears as a fixed pattern on the wire when encryption is disabled. +func magicMask(sessionID uint32) uint32 { + // simple deterministic mixing: multiply by a large odd prime, rotate + m := sessionID * 0x9E3779B9 + m ^= m >> 16 + m *= 0x45D9F3B + m ^= m >> 16 + return m +} + +// sealFrame encrypts (or obfuscates) the shim header + payload into a wire frame. +// +// When ae != nil (AES-GCM enabled): +// +// output = nonce(12) || AEAD(shimHeader || payload) +// +// When ae == nil (no encryption): +// +// output = shimHeader(magic XORed with session mask) || payload +func sealFrame(ae *aeadBox, hdr shimHeader, payload []byte) ([]byte, error) { + inner := make([]byte, shimLen+len(payload)) + copy(inner, hdr.Marshal()) + copy(inner[shimLen:], payload) + + if ae != nil { + nonce := make([]byte, nonceLen) + if _, err := cryptoRand.Read(nonce); err != nil { + return nil, err + } + ct := ae.aead.Seal(nil, nonce, inner, nil) + out := make([]byte, nonceLen+len(ct)) + copy(out, nonce) + copy(out[nonceLen:], ct) + return out, nil + } + + // No encryption: XOR the magic so it is not a static fingerprint. + mask := magicMask(hdr.SessionID) + binary.BigEndian.PutUint32(inner[0:4], hdr.Magic^mask) + return inner, nil +} + +// openFrame decrypts (or de-obfuscates) a wire frame back into shimHeader + payload. +// +// When ae != nil: +// +// expects data = nonce(12) || ciphertext +// +// When ae == nil: +// +// expects data = shimHeader(magic XORed) || payload +func openFrame(ae *aeadBox, data []byte, sessionHint uint32) (shimHeader, []byte, error) { + if ae != nil { + if len(data) < nonceLen { + return shimHeader{}, nil, io.ErrUnexpectedEOF + } + nonce := data[:nonceLen] + ct := data[nonceLen:] + plain, err := ae.aead.Open(nil, nonce, ct, nil) + if err != nil { + return shimHeader{}, nil, fmt.Errorf("aead open: %w", err) + } + return parseShimFull(plain) + } + + // No encryption: un-XOR the magic first. + if len(data) < shimLen { + return shimHeader{}, nil, io.ErrUnexpectedEOF + } + // work on a copy so we don't mutate the caller's buffer + buf := make([]byte, len(data)) + copy(buf, data) + mask := magicMask(sessionHint) + raw := binary.BigEndian.Uint32(buf[0:4]) + binary.BigEndian.PutUint32(buf[0:4], raw^mask) + return parseShimFull(buf) +} diff --git a/dtls.go b/dtls.go new file mode 100644 index 0000000..af2f775 --- /dev/null +++ b/dtls.go @@ -0,0 +1,407 @@ +package main + +import ( + cryptoRand "crypto/rand" + "encoding/binary" + "fmt" + "net" + "time" +) + +// DTLS record header offsets and constants +const ( + dtlsContentHandshake = 22 + dtlsContentChangeCipher = 20 + dtlsContentAppData = 23 + dtlsVersion12 = 0xFEFD // DTLS 1.2 + dtlsRecordHeaderLen = 13 + dtlsHandshakeHeaderLen = 12 + dtlsHandshakeTimeout = 2 * time.Second +) + +// Handshake types +const ( + dtlsHSClientHello = 1 + dtlsHSServerHello = 2 +) + +// putDTLSRecordHeader writes a DTLS record header into dst (must be >= 13 bytes). +func putDTLSRecordHeader(dst []byte, contentType uint8, epoch uint16, seq uint64, payloadLen int) { + dst[0] = contentType + binary.BigEndian.PutUint16(dst[1:3], dtlsVersion12) + binary.BigEndian.PutUint16(dst[3:5], epoch) + // 48-bit sequence number + dst[5] = byte(seq >> 40) + dst[6] = byte(seq >> 32) + dst[7] = byte(seq >> 24) + dst[8] = byte(seq >> 16) + dst[9] = byte(seq >> 8) + dst[10] = byte(seq) + binary.BigEndian.PutUint16(dst[11:13], uint16(payloadLen)) +} + +// putHandshakeHeader writes a DTLS handshake message header. +func putHandshakeHeader(dst []byte, hsType uint8, length int, msgSeq uint16, fragOff, fragLen int) { + dst[0] = hsType + // 24-bit length + dst[1] = byte(length >> 16) + dst[2] = byte(length >> 8) + dst[3] = byte(length) + binary.BigEndian.PutUint16(dst[4:6], msgSeq) + // 24-bit fragment offset + dst[6] = byte(fragOff >> 16) + dst[7] = byte(fragOff >> 8) + dst[8] = byte(fragOff) + // 24-bit fragment length + dst[9] = byte(fragLen >> 16) + dst[10] = byte(fragLen >> 8) + dst[11] = byte(fragLen) +} + +// buildDTLSClientHello constructs a realistic DTLS 1.2 ClientHello (~250 bytes). +func buildDTLSClientHello(random []byte) []byte { + // Body: version(2) + random(32) + sessionID(1+32) + cookie(1+0) + + // cipherSuites(2+N*2) + compressionMethods(1+1) + extensions + var body []byte + + // client version + body = append(body, 0xFE, 0xFD) + + // random (32 bytes); pad/truncate caller input + r := make([]byte, 32) + copy(r, random) + body = append(body, r...) + + // session id: 32-byte random + sid := make([]byte, 32) + cryptoRand.Read(sid) + body = append(body, 32) + body = append(body, sid...) + + // cookie: empty + body = append(body, 0) + + // cipher suites + suites := []uint16{ + 0xC02B, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + 0xC02F, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + 0xC02C, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + 0xCCA9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + 0xCCA8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 + 0x00FF, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV + } + binary.BigEndian.AppendUint16(nil, 0) // placeholder + sLen := len(suites) * 2 + body = append(body, byte(sLen>>8), byte(sLen)) + for _, s := range suites { + body = append(body, byte(s>>8), byte(s)) + } + + // compression methods: null only + body = append(body, 1, 0) + + // extensions + var exts []byte + + // use_srtp extension (type 0x000E) + srtpProfiles := []uint16{0x0001} // SRTP_AES128_CM_HMAC_SHA1_80 + srtpBody := make([]byte, 2+len(srtpProfiles)*2+1) + binary.BigEndian.PutUint16(srtpBody[0:2], uint16(len(srtpProfiles)*2)) + for i, p := range srtpProfiles { + binary.BigEndian.PutUint16(srtpBody[2+i*2:4+i*2], p) + } + srtpBody[len(srtpBody)-1] = 0 // mki length + exts = appendExtension(exts, 0x000E, srtpBody) + + // supported_groups (type 0x000A): x25519, secp256r1, secp384r1 + groups := []uint16{0x001D, 0x0017, 0x0018} + gBody := make([]byte, 2+len(groups)*2) + binary.BigEndian.PutUint16(gBody[0:2], uint16(len(groups)*2)) + for i, g := range groups { + binary.BigEndian.PutUint16(gBody[2+i*2:4+i*2], g) + } + exts = appendExtension(exts, 0x000A, gBody) + + // ec_point_formats (type 0x000B) + exts = appendExtension(exts, 0x000B, []byte{1, 0}) // uncompressed + + // signature_algorithms (type 0x000D) + sigAlgs := []uint16{0x0403, 0x0503, 0x0603, 0x0804, 0x0805, 0x0806, 0x0401, 0x0501, 0x0601} + saBody := make([]byte, 2+len(sigAlgs)*2) + binary.BigEndian.PutUint16(saBody[0:2], uint16(len(sigAlgs)*2)) + for i, sa := range sigAlgs { + binary.BigEndian.PutUint16(saBody[2+i*2:4+i*2], sa) + } + exts = appendExtension(exts, 0x000D, saBody) + + // extensions length prefix + body = append(body, byte(len(exts)>>8), byte(len(exts))) + body = append(body, exts...) + + // wrap in handshake header + record header + hsPayload := make([]byte, dtlsHandshakeHeaderLen+len(body)) + putHandshakeHeader(hsPayload, dtlsHSClientHello, len(body), 0, 0, len(body)) + copy(hsPayload[dtlsHandshakeHeaderLen:], body) + + pkt := make([]byte, dtlsRecordHeaderLen+len(hsPayload)) + putDTLSRecordHeader(pkt, dtlsContentHandshake, 0, 0, len(hsPayload)) + copy(pkt[dtlsRecordHeaderLen:], hsPayload) + return pkt +} + +// buildDTLSServerHello constructs a DTLS 1.2 ServerHello (~120 bytes). +func buildDTLSServerHello(random []byte) []byte { + var body []byte + + // server version + body = append(body, 0xFE, 0xFD) + + // random (32 bytes) + r := make([]byte, 32) + copy(r, random) + body = append(body, r...) + + // session id (32 bytes, echo a random one) + sid := make([]byte, 32) + cryptoRand.Read(sid) + body = append(body, 32) + body = append(body, sid...) + + // selected cipher suite: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + body = append(body, 0xC0, 0x2B) + + // compression method: null + body = append(body, 0) + + // extensions: use_srtp + var exts []byte + srtpBody := []byte{0x00, 0x02, 0x00, 0x01, 0x00} // profile=0x0001, mki=0 + exts = appendExtension(exts, 0x000E, srtpBody) + + body = append(body, byte(len(exts)>>8), byte(len(exts))) + body = append(body, exts...) + + hsPayload := make([]byte, dtlsHandshakeHeaderLen+len(body)) + putHandshakeHeader(hsPayload, dtlsHSServerHello, len(body), 1, 0, len(body)) + copy(hsPayload[dtlsHandshakeHeaderLen:], body) + + pkt := make([]byte, dtlsRecordHeaderLen+len(hsPayload)) + putDTLSRecordHeader(pkt, dtlsContentHandshake, 0, 1, len(hsPayload)) + copy(pkt[dtlsRecordHeaderLen:], hsPayload) + return pkt +} + +// buildDTLSChangeCipherSpec builds a DTLS ChangeCipherSpec record (14 bytes total). +func buildDTLSChangeCipherSpec() []byte { + pkt := make([]byte, dtlsRecordHeaderLen+1) + putDTLSRecordHeader(pkt, dtlsContentChangeCipher, 0, 2, 1) + pkt[dtlsRecordHeaderLen] = 0x01 + return pkt +} + +// buildDTLSFinished builds a fake encrypted Finished record (~50-60 bytes). +func buildDTLSFinished(random []byte) []byte { + // simulate encrypted payload (40-60 bytes random data) + pLen := 40 + int(random[0]%21) // 40..60 + payload := make([]byte, pLen) + cryptoRand.Read(payload) + + pkt := make([]byte, dtlsRecordHeaderLen+pLen) + putDTLSRecordHeader(pkt, dtlsContentAppData, 1, 0, pLen) + copy(pkt[dtlsRecordHeaderLen:], payload) + return pkt +} + +// isDTLSPacket checks if the first byte indicates a DTLS record (content types 20-63). +func isDTLSPacket(data []byte) bool { + if len(data) < 1 { + return false + } + return data[0] >= 20 && data[0] <= 63 +} + +// appendExtension appends a TLS extension (type + length-prefixed data). +func appendExtension(buf []byte, extType uint16, data []byte) []byte { + buf = append(buf, byte(extType>>8), byte(extType)) + buf = append(buf, byte(len(data)>>8), byte(len(data))) + buf = append(buf, data...) + return buf +} + +// performDTLSHandshake runs a fake DTLS 1.2 handshake over the given UDP connection. +// isServer: true for the responder side, false for the initiator. +// pcap: optional pcap writer (may be nil). +func performDTLSHandshake(conn *net.UDPConn, peer *net.UDPAddr, isServer bool, pcap *pcapWriter) error { + rnd := make([]byte, 32) + cryptoRand.Read(rnd) + + if !isServer { + return dtlsClientHandshake(conn, peer, rnd, pcap) + } + return dtlsServerHandshake(conn, peer, rnd, pcap) +} + +func dtlsClientHandshake(conn *net.UDPConn, peer *net.UDPAddr, rnd []byte, pcap *pcapWriter) error { + // 1. Send ClientHello + ch := buildDTLSClientHello(rnd) + if _, err := conn.WriteToUDP(ch, peer); err != nil { + return fmt.Errorf("dtls: send ClientHello: %w", err) + } + dtlsWritePcap(pcap, conn.LocalAddr(), peer, ch) + logf(LDebug, "dtls: sent ClientHello (%d bytes)", len(ch)) + + // 2. Wait for ServerHello + conn.SetReadDeadline(time.Now().Add(dtlsHandshakeTimeout)) + buf := make([]byte, 2048) + for { + n, from, err := conn.ReadFromUDP(buf) + if err != nil { + return fmt.Errorf("dtls: wait ServerHello: %w", err) + } + if from.String() != peer.String() { + continue + } + data := buf[:n] + dtlsWritePcap(pcap, from, conn.LocalAddr(), data) + if len(data) >= dtlsRecordHeaderLen && data[0] == dtlsContentHandshake { + logf(LDebug, "dtls: recv ServerHello (%d bytes)", n) + break + } + } + + // 3. Wait for server ChangeCipherSpec + conn.SetReadDeadline(time.Now().Add(dtlsHandshakeTimeout)) + for { + n, from, err := conn.ReadFromUDP(buf) + if err != nil { + return fmt.Errorf("dtls: wait ChangeCipherSpec: %w", err) + } + if from.String() != peer.String() { + continue + } + data := buf[:n] + dtlsWritePcap(pcap, from, conn.LocalAddr(), data) + if len(data) >= dtlsRecordHeaderLen && data[0] == dtlsContentChangeCipher { + logf(LDebug, "dtls: recv ChangeCipherSpec (%d bytes)", n) + break + } + } + + // 4. Wait for server Finished + conn.SetReadDeadline(time.Now().Add(dtlsHandshakeTimeout)) + for { + n, from, err := conn.ReadFromUDP(buf) + if err != nil { + return fmt.Errorf("dtls: wait server Finished: %w", err) + } + if from.String() != peer.String() { + continue + } + data := buf[:n] + dtlsWritePcap(pcap, from, conn.LocalAddr(), data) + if len(data) >= dtlsRecordHeaderLen && data[0] == dtlsContentAppData { + logf(LDebug, "dtls: recv server Finished (%d bytes)", n) + break + } + } + + // 5. Send client ChangeCipherSpec + Finished + ccs := buildDTLSChangeCipherSpec() + if _, err := conn.WriteToUDP(ccs, peer); err != nil { + return fmt.Errorf("dtls: send ChangeCipherSpec: %w", err) + } + dtlsWritePcap(pcap, conn.LocalAddr(), peer, ccs) + logf(LDebug, "dtls: sent ChangeCipherSpec (%d bytes)", len(ccs)) + + fin := buildDTLSFinished(rnd) + if _, err := conn.WriteToUDP(fin, peer); err != nil { + return fmt.Errorf("dtls: send Finished: %w", err) + } + dtlsWritePcap(pcap, conn.LocalAddr(), peer, fin) + logf(LDebug, "dtls: sent Finished (%d bytes)", len(fin)) + + conn.SetReadDeadline(time.Time{}) + return nil +} + +func dtlsServerHandshake(conn *net.UDPConn, peer *net.UDPAddr, rnd []byte, pcap *pcapWriter) error { + buf := make([]byte, 2048) + + // 1. Wait for ClientHello + conn.SetReadDeadline(time.Now().Add(dtlsHandshakeTimeout)) + for { + n, from, err := conn.ReadFromUDP(buf) + if err != nil { + return fmt.Errorf("dtls: wait ClientHello: %w", err) + } + if from.String() != peer.String() { + continue + } + data := buf[:n] + dtlsWritePcap(pcap, from, conn.LocalAddr(), data) + if len(data) >= dtlsRecordHeaderLen && data[0] == dtlsContentHandshake { + logf(LDebug, "dtls: recv ClientHello (%d bytes)", n) + break + } + } + + // 2. Send ServerHello + sh := buildDTLSServerHello(rnd) + if _, err := conn.WriteToUDP(sh, peer); err != nil { + return fmt.Errorf("dtls: send ServerHello: %w", err) + } + dtlsWritePcap(pcap, conn.LocalAddr(), peer, sh) + logf(LDebug, "dtls: sent ServerHello (%d bytes)", len(sh)) + + // 3. Send ChangeCipherSpec + Finished + ccs := buildDTLSChangeCipherSpec() + if _, err := conn.WriteToUDP(ccs, peer); err != nil { + return fmt.Errorf("dtls: send ChangeCipherSpec: %w", err) + } + dtlsWritePcap(pcap, conn.LocalAddr(), peer, ccs) + logf(LDebug, "dtls: sent ChangeCipherSpec (%d bytes)", len(ccs)) + + fin := buildDTLSFinished(rnd) + if _, err := conn.WriteToUDP(fin, peer); err != nil { + return fmt.Errorf("dtls: send Finished: %w", err) + } + dtlsWritePcap(pcap, conn.LocalAddr(), peer, fin) + logf(LDebug, "dtls: sent Finished (%d bytes)", len(fin)) + + // 4. Wait for client ChangeCipherSpec + Finished + conn.SetReadDeadline(time.Now().Add(dtlsHandshakeTimeout)) + got := 0 + for got < 2 { + n, from, err := conn.ReadFromUDP(buf) + if err != nil { + return fmt.Errorf("dtls: wait client finish: %w", err) + } + if from.String() != peer.String() { + continue + } + data := buf[:n] + dtlsWritePcap(pcap, from, conn.LocalAddr(), data) + if len(data) >= dtlsRecordHeaderLen { + if data[0] == dtlsContentChangeCipher || data[0] == dtlsContentAppData { + got++ + logf(LDebug, "dtls: recv client handshake pkt type=%d (%d bytes)", data[0], n) + } + } + } + + conn.SetReadDeadline(time.Time{}) + return nil +} + +// dtlsWritePcap records a DTLS packet to pcap if the writer is available. +func dtlsWritePcap(pcap *pcapWriter, src, dst net.Addr, data []byte) { + if pcap == nil { + return + } + srcU, okS := src.(*net.UDPAddr) + dstU, okD := dst.(*net.UDPAddr) + if !okS || !okD { + return + } + pcap.WriteUDP(srcU.IP, srcU.Port, dstU.IP, dstU.Port, data) +} diff --git a/main.go b/main.go index 78350a6..1f2ac46 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,8 @@ var ( metricRTPkeepSent = expvar.NewInt("rtp_keepalive_sent") metricSTUNSent = expvar.NewInt("stun_sent") metricShimDecoySent = expvar.NewInt("shim_decoy_sent") + metricDTLSSent = expvar.NewInt("dtls_handshake_sent") + metricAudioSent = expvar.NewInt("audio_packets_sent") ) /* ============ CLI / Config ============ */ @@ -64,6 +66,8 @@ const ( WireShim Wire = "shim" // ShimHeader + Payload WireRTPish Wire = "rtpish" // RTP(12) + Shim + Payload (UDP only) + WireWebRTC Wire = "webrtc" // DTLS+SRTP(24)+Shim+Payload+AuthTag (UDP only) + WireIPCam Wire = "ipcam" // H.264/RTP surveillance camera (UDP only) ) type Config struct { @@ -106,6 +110,15 @@ type Config struct { SelfDur time.Duration LogLevel string LogDrop bool + + // WebRTC mode options + EnableDTLS bool // simulate DTLS handshake at session start + AudioRps int // audio packets per second (default 50 for Opus 20ms) + STUNInterval int // STUN consent freshness interval in seconds (default 5) + + // IPCAM mode options + IPCAMFPS int // surveillance camera FPS (default 25) + IPCAMGop int // GOP size in frames (default 50 = 2 seconds) } func defaultConfig() *Config { @@ -149,6 +162,12 @@ func defaultConfig() *Config { SelfDur: 15 * time.Second, LogLevel: "info", LogDrop: false, + + EnableDTLS: true, + AudioRps: 50, + STUNInterval: 5, + IPCAMFPS: 25, + IPCAMGop: 50, } } @@ -159,7 +178,7 @@ func parseFlags() *Config { flag.StringVar(&cfg.Listen, "listen", cfg.Listen, "listen addr") flag.StringVar(&cfg.ServerAddr, "server", cfg.ServerAddr, "server addr (client)") flag.StringVar(&cfg.ForwardAddr, "forward", cfg.ForwardAddr, "forward addr (server)") - flag.StringVar((*string)(&cfg.Wire), "wire", string(cfg.Wire), "shim|rtpish (udp)") + flag.StringVar((*string)(&cfg.Wire), "wire", string(cfg.Wire), "shim|rtpish|webrtc|ipcam (udp)") flag.IntVar(&cfg.FPS, "fps", cfg.FPS, "60|120") flag.IntVar(&cfg.GOPMs, "gop", cfg.GOPMs, "keyframe interval ms") flag.IntVar(&cfg.BitrateMbps, "bitrate-mbps", cfg.BitrateMbps, "20|40 etc.") @@ -198,12 +217,21 @@ func parseFlags() *Config { flag.DurationVar(&cfg.SelfDur, "duration", cfg.SelfDur, "selftest duration") flag.StringVar(&cfg.LogLevel, "log", cfg.LogLevel, "debug|info|warn|error") flag.BoolVar(&cfg.LogDrop, "showdrop", cfg.LogDrop, "log when dropping decoy") + + // WebRTC mode options + flag.BoolVar(&cfg.EnableDTLS, "dtls", cfg.EnableDTLS, "simulate DTLS handshake (webrtc mode)") + flag.IntVar(&cfg.AudioRps, "audio-rps", cfg.AudioRps, "audio packets per second (webrtc mode, default 50)") + flag.IntVar(&cfg.STUNInterval, "stun-interval", cfg.STUNInterval, "STUN consent interval seconds (webrtc mode)") + + // IPCAM mode options + flag.IntVar(&cfg.IPCAMFPS, "ipcam-fps", cfg.IPCAMFPS, "surveillance camera FPS (ipcam mode, default 25)") + flag.IntVar(&cfg.IPCAMGop, "ipcam-gop", cfg.IPCAMGop, "GOP size in frames (ipcam mode, default 50)") + flag.Parse() cfg.SessID = uint32(sessFlag) - if cfg.Mode == ModeTCP && cfg.Wire == WireRTPish { - // 仅提示:RTP-ish 只在 UDP 生效 - fmt.Println("[WARN] wire=rtpish is ignored in TCP mode.") + if cfg.Mode == ModeTCP && cfg.Wire != WireShim { + fmt.Printf("[WARN] wire=%s is only effective in UDP mode, falling back to shim for TCP.\n", cfg.Wire) } return cfg } @@ -980,26 +1008,15 @@ func runUDPClient(ctx context.Context, rt *runtimeCtx) error { dst := ip4OrLoopback(uc.LocalAddr().(*net.UDPAddr).IP) rt.pc.WriteUDP(src, from.Port, dst, uc.LocalAddr().(*net.UDPAddr).Port, raw) - var payload []byte - if rt.cfg.Wire == WireRTPish { - _, _, rest, e := stripRTP(raw); if e != nil { continue } - h, p, e2 := parseShimFull(rest); if e2 != nil { continue } - if (h.Flags & flagDecoy) != 0 { metricDecoyDropped.Add(1); if rt.dropLog { logf(LDebug, "[client] drop decoy %dB", len(p)) }; continue } - payload = p - if (h.Flags & flagEnc) != 0 && rt.ae != nil { - ns := rt.ae.aead.NonceSize(); if len(payload) >= ns { - if plain, e := rt.ae.open(payload[:ns], payload[ns:]); e == nil { payload = plain } - } - } - } else { - h, p, e2 := parseShimFull(raw); if e2 != nil { continue } - if (h.Flags & flagDecoy) != 0 { metricDecoyDropped.Add(1); if rt.dropLog { logf(LDebug, "[client] drop decoy %dB", len(p)) }; continue } - payload = p - if (h.Flags & flagEnc) != 0 && rt.ae != nil { - ns := rt.ae.aead.NonceSize(); if len(payload) >= ns { - if plain, e := rt.ae.open(payload[:ns], payload[ns:]); e == nil { payload = plain } - } - } + // Skip DTLS/STUN control packets (not data) + if isDTLSRange(raw[0]) || isSTUNPacket(raw) { continue } + + h, payload, e2 := decodeUDPFrame(rt.cfg, rt.ae, raw, sess) + if e2 != nil { continue } + if (h.Flags & flagDecoy) != 0 { + metricDecoyDropped.Add(1) + if rt.dropLog { logf(LDebug, "[client] drop decoy %dB", len(payload)) } + continue } appPeerMu.RLock(); dstPeer := appPeer; appPeerMu.RUnlock() if dstPeer != nil { @@ -1013,21 +1030,9 @@ func runUDPClient(ctx context.Context, rt *runtimeCtx) error { appPeerMu.Lock(); appPeer = from; appPeerMu.Unlock() p := append([]byte(nil), buf[:n]...) - // a) 真实帧(带 shim) - flags := uint8(0) - pp := p - if rt.ae != nil { - if c, nonce, e := rt.ae.seal(p); e == nil { pp = append(nonce, c...); flags |= flagEnc } - } - h := shimHeader{Magic: magicConst, Version: version, Mode: modeUDP, Flags: flags, - SessionID: sess, TsMs: uint32(time.Now().UnixMilli()), Len: uint32(len(pp))} - frame := append(h.Marshal(), pp...) - if rt.cfg.Wire == WireRTPish { - rtpTs += uint32(step) - rtpHdr := buildRTPHeader(rtp.seq, rtpTs, 96, false, rtp.ssrc) - rtp.seq++ - frame = append(rtpHdr, frame...) - } + // a) 真实帧 + var twccSeq uint32 + frame := encodeUDPFrame(rt.cfg, rt.ae, &rtp, &rtpTs, step, sess, p, false, &twccSeq) rt.tb.wait(len(frame) + 28) _, _ = uc.WriteToUDP(frame, saddr) metricFramesUp.Add(1); metricBytesUp.Add(int64(n)) @@ -1035,30 +1040,14 @@ func runUDPClient(ctx context.Context, rt *runtimeCtx) error { dstIP := ip4OrLoopback(saddr.IP) rt.pc.WriteUDP(srcIP, uc.LocalAddr().(*net.UDPAddr).Port, dstIP, saddr.Port, frame) - // b) shim 诱饵(带 shim,RPS 优先,否则按百分比) + // b) shim 诱饵 sendDecoy := false if rt.shimUpRL != nil && rt.shimUpRL.takeMax(1) > 0 { sendDecoy = true } else if rt.cfg.DecoyRps <= 0 && grnd.Intn(100) < rt.cfg.DecoyPct { sendDecoy = true } if sendDecoy { djunk := make([]byte, len(p)) _, _ = cryptoRand.Read(djunk) - decFlags := uint8(flagDecoy) - decPayload := djunk - if rt.ae != nil { - if c, nonce, e := rt.ae.seal(decPayload); e == nil { - decPayload = append(nonce, c...) - decFlags |= flagEnc - } - } - hd := shimHeader{Magic: magicConst, Version: version, Mode: modeUDP, Flags: decFlags, - SessionID: sess, TsMs: uint32(time.Now().UnixMilli()), Len: uint32(len(decPayload))} - df := append(hd.Marshal(), decPayload...) - if rt.cfg.Wire == WireRTPish { - rtpTs += uint32(step) - rtpHdr := buildRTPHeader(rtp.seq, rtpTs, 96, false, rtp.ssrc) - rtp.seq++ - df = append(rtpHdr, df...) - } + df := encodeUDPFrame(rt.cfg, rt.ae, &rtp, &rtpTs, step, sess, djunk, true, &twccSeq) rt.tb.wait(len(df) + 28) _, _ = uc.WriteToUDP(df, saddr) metricFramesUp.Add(1); metricShimDecoySent.Add(1) @@ -1131,31 +1120,18 @@ func runUDPServer(ctx context.Context, rt *runtimeCtx) error { dst := ip4OrLoopback(ln.LocalAddr().(*net.UDPAddr).IP) rt.pc.WriteUDP(src, caddr.Port, dst, ln.LocalAddr().(*net.UDPAddr).Port, raw) - // 剥线(RTP+Shim 或 Shim) - var h shimHeader - var p []byte - if rt.cfg.Wire == WireRTPish { - _, _, rest, e := stripRTP(raw); if e != nil { continue } - hh, pp, e2 := parseShimFull(rest); if e2 != nil { continue } - h, p = hh, pp - } else { - hh, pp, e2 := parseShimFull(raw); if e2 != nil { continue } - h, p = hh, pp - } + // Skip DTLS/STUN control packets + if len(raw) > 0 && (isDTLSRange(raw[0]) || isSTUNPacket(raw)) { continue } + + h, p, e2 := decodeUDPFrame(rt.cfg, rt.ae, raw, 0) + if e2 != nil { continue } k := key{ip: caddr.IP.String(), port: caddr.Port, sess: h.SessionID} - // 丢诱饵 & 解密 if (h.Flags & flagDecoy) != 0 { metricDecoyDropped.Add(1) if rt.dropLog { logf(LDebug, "[server] drop shim-decoy %dB", len(p)) } continue } - if (h.Flags & flagEnc) != 0 && rt.ae != nil { - ns := rt.ae.aead.NonceSize() - if len(p) >= ns { - if plain, e := rt.ae.open(p[:ns], p[ns:]); e == nil { p = plain } - } - } mu.Lock() sess := smap[k] @@ -1178,24 +1154,9 @@ func runUDPServer(ctx context.Context, rt *runtimeCtx) error { if e2 != nil { return } pp := append([]byte(nil), b[:n2]...) - // a) 真实帧(带 shim) - flags := uint8(0) - payload := pp - if rt.ae != nil { - if c, nonce, e := rt.ae.seal(payload); e == nil { - payload = append(nonce, c...) - flags |= flagEnc - } - } - h := shimHeader{Magic: magicConst, Version: version, Mode: modeUDP, Flags: flags, - SessionID: k.sess, TsMs: uint32(time.Now().UnixMilli()), Len: uint32(len(payload))} - pkt := append(h.Marshal(), payload...) - if rt.cfg.Wire == WireRTPish { - rtpTs += uint32(step) - rtpHdr := buildRTPHeader(rtp.seq, rtpTs, 96, false, rtp.ssrc) - rtp.seq++ - pkt = append(rtpHdr, pkt...) - } + // a) 真实帧 + var twccSeq uint32 + pkt := encodeUDPFrame(rt.cfg, rt.ae, &rtp, &rtpTs, step, k.sess, pp, false, &twccSeq) rt.tb.wait(len(pkt) + 28) _, _ = ln.WriteToUDP(pkt, s.client) metricFramesDown.Add(1); metricBytesDown.Add(int64(n2)) @@ -1203,30 +1164,14 @@ func runUDPServer(ctx context.Context, rt *runtimeCtx) error { dst2 := ip4OrLoopback(s.client.IP) rt.pc.WriteUDP(src2, ln.LocalAddr().(*net.UDPAddr).Port, dst2, s.client.Port, pkt) - // b) shim 诱饵(RPS 优先,否则百分比) + // b) shim 诱饵 sendDecoy := false if rt.shimDownRL != nil && rt.shimDownRL.takeMax(1) > 0 { sendDecoy = true } else if rt.cfg.DecoyRps <= 0 && grnd.Intn(100) < rt.cfg.DecoyPct { sendDecoy = true } if sendDecoy { djunk := make([]byte, len(pp)) _, _ = cryptoRand.Read(djunk) - decFlags := uint8(flagDecoy) - decPayload := djunk - if rt.ae != nil { - if c, nonce, e := rt.ae.seal(decPayload); e == nil { - decPayload = append(nonce, c...) - decFlags |= flagEnc - } - } - hd := shimHeader{Magic: magicConst, Version: version, Mode: modeUDP, Flags: decFlags, - SessionID: k.sess, TsMs: uint32(time.Now().UnixMilli()), Len: uint32(len(decPayload))} - df := append(hd.Marshal(), decPayload...) - if rt.cfg.Wire == WireRTPish { - rtpTs += uint32(step) - rtpHdr := buildRTPHeader(rtp.seq, rtpTs, 96, false, rtp.ssrc) - rtp.seq++ - df = append(rtpHdr, df...) - } + df := encodeUDPFrame(rt.cfg, rt.ae, &rtp, &rtpTs, step, k.sess, djunk, true, &twccSeq) rt.tb.wait(len(df) + 28) _, _ = ln.WriteToUDP(df, s.client) metricFramesDown.Add(1); metricShimDecoySent.Add(1) diff --git a/wire.go b/wire.go new file mode 100644 index 0000000..6a8147a --- /dev/null +++ b/wire.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/binary" + "errors" + "sync/atomic" + "time" +) + +// encodeUDPFrame builds a complete wire frame for the configured wire format. +// Returns the ready-to-send bytes. +func encodeUDPFrame(cfg *Config, ae *aeadBox, rtp *rtpState, rtpTs *uint32, + step int, sess uint32, payload []byte, isDecoy bool, twccSeq *uint32) []byte { + + // Build shim header + flags := uint8(0) + if isDecoy { + flags |= flagDecoy + } + if ae != nil { + flags |= flagEnc + } + hdr := shimHeader{ + Magic: magicConst, + Version: version, + Mode: modeUDP, + Flags: flags, + SessionID: sess, + TsMs: uint32(time.Now().UnixMilli()), + Len: uint32(len(payload)), + } + + switch cfg.Wire { + case WireWebRTC: + // Encrypt shim header + payload together (eliminates magic fingerprint) + sealed, err := sealFrame(ae, hdr, payload) + if err != nil { + // fallback: raw + sealed = append(hdr.Marshal(), payload...) + } + // WebRTC RTP header (24 bytes) with extensions + *rtpTs += uint32(step) + absTime := webrtcAbsSendTime() + twcc := uint16(0) + if twccSeq != nil { + twcc = uint16(atomic.AddUint32(twccSeq, 1)) + } + rtpHdr := buildWebRTCRTPHeader(rtp.seq, *rtpTs, 96, !isDecoy, rtp.ssrc, absTime, twcc) + rtp.seq++ + frame := append(rtpHdr, sealed...) + return appendSRTPAuthTag(frame) + + case WireIPCam: + // For IPCAM mode: encrypt shim + payload, then wrap in single RTP with H.264 FU-A first-frag indicator + sealed, err := sealFrame(ae, hdr, payload) + if err != nil { + sealed = append(hdr.Marshal(), payload...) + } + *rtpTs += uint32(step) + rtpHdr := buildRTPHeader(rtp.seq, *rtpTs, ipcamPT, true, rtp.ssrc) + rtp.seq++ + // Prepend a FU-A indicator+header to look like H.264 fragment + nalType := uint8(nalNonIDR) // default P-frame + fuIndicator := byte(0x5C) // F=0 NRI=10 Type=28(FU-A) + fuHeader := byte(0x80 | nalType) // S=1 E=0 R=0 Type=1 (start+end for single) + fuHeader |= 0x40 // set E bit too (single fragment) + h264Hdr := []byte{fuIndicator, fuHeader} + frame := append(rtpHdr, h264Hdr...) + frame = append(frame, sealed...) + return frame + + case WireRTPish: + // Legacy: encrypt only payload, shim header exposed (but XOR magic if no AES) + sealed, err := sealFrame(ae, hdr, payload) + if err != nil { + sealed = append(hdr.Marshal(), payload...) + } + *rtpTs += uint32(step) + rtpHdr := buildRTPHeader(rtp.seq, *rtpTs, 96, false, rtp.ssrc) + rtp.seq++ + return append(rtpHdr, sealed...) + + default: // WireShim + sealed, err := sealFrame(ae, hdr, payload) + if err != nil { + sealed = append(hdr.Marshal(), payload...) + } + return sealed + } +} + +// decodeUDPFrame strips wire framing and decrypts to recover shimHeader + payload. +func decodeUDPFrame(cfg *Config, ae *aeadBox, raw []byte, sessionHint uint32) (shimHeader, []byte, error) { + switch cfg.Wire { + case WireWebRTC: + _, _, rest, err := stripWebRTCRTP(raw) + if err != nil { + return shimHeader{}, nil, err + } + return openFrame(ae, rest, sessionHint) + + case WireIPCam: + // Strip RTP header (12 bytes) + FU-A header (2 bytes) + if len(raw) < 14 { + return shimHeader{}, nil, errors.New("ipcam frame too short") + } + if (raw[0]>>6)&0x3 != 2 { + return shimHeader{}, nil, errors.New("rtp ver") + } + rest := raw[14:] // skip 12B RTP + 2B FU-A + return openFrame(ae, rest, sessionHint) + + case WireRTPish: + // Strip 12-byte RTP header + if len(raw) < 12 { + return shimHeader{}, nil, errors.New("rtpish too short") + } + if (raw[0]>>6)&0x3 != 2 { + return shimHeader{}, nil, errors.New("rtp ver") + } + rest := raw[12:] + return openFrame(ae, rest, sessionHint) + + default: // WireShim + return openFrame(ae, raw, sessionHint) + } +} + +// webrtcAbsSendTime computes a 24-bit abs-send-time (6.18 fixed-point NTP fraction). +func webrtcAbsSendTime() uint32 { + now := time.Now() + sec := uint64(now.Unix()) + ntpEpochOffset + frac := uint64(now.Nanosecond()) * (1 << 18) / 1_000_000_000 + return uint32(((sec & 0x3F) << 18) | (frac & 0x3FFFF)) +} + +// isDTLSRange checks if the first byte falls in the DTLS content type range (20-63). +func isDTLSRange(b byte) bool { + return b >= 20 && b <= 63 +} + +// isSTUNPacket checks if the packet looks like STUN (magic cookie at offset 4). +func isSTUNPacket(data []byte) bool { + if len(data) < 20 { + return false + } + cookie := binary.BigEndian.Uint32(data[4:8]) + return cookie == 0x2112A442 +} diff --git a/wire_ipcam.go b/wire_ipcam.go new file mode 100644 index 0000000..200e31b --- /dev/null +++ b/wire_ipcam.go @@ -0,0 +1,404 @@ +package main + +import ( + cryptoRand "crypto/rand" + "encoding/binary" + "errors" + "math/rand" + "time" +) + +/* ============ IPCAM Wire Format: H.264 over RTP (RFC 6184) ============ */ +// Simulates surveillance camera (Hikvision/Dahua style) streaming to NVR/VMS. +// Single video stream, PT=96, FU-A fragmentation, STAP-A for SPS/PPS. + +const ( + ipcamPT uint8 = 96 // dynamic payload type for H.264 + ipcamClockHz uint32 = 90000 + ipcamMTU = 1400 // max RTP payload per fragment + ipcamRTPHdrSz = 12 +) + +// H.264 NAL unit types +const ( + nalNonIDR uint8 = 1 // P-frame slice + nalIDR uint8 = 5 // I-frame (IDR) + nalSPS uint8 = 7 + nalPPS uint8 = 8 + nalSTAPA uint8 = 24 // STAP-A aggregation + nalFUA uint8 = 28 // FU-A fragmentation +) + +/* ---------- GOP State Machine ---------- */ + +type gopState struct { + frameNum int // frame within GOP (0 = I-frame) + gopSize int // frames per GOP (default 50 for 25fps * 2s) + fps int // typically 25 + ssrc uint32 + seq uint16 + ts uint32 + tsStep uint32 // 90000/fps = 3600 for 25fps + pktCount uint32 + octCount uint32 +} + +func newGopState(fps int, ssrc uint32) *gopState { + if fps <= 0 { + fps = 25 + } + return &gopState{ + frameNum: 0, + gopSize: fps * 2, // I-frame every 2 seconds + fps: fps, + ssrc: ssrc, + seq: uint16(rand.Intn(0xFFFF)), + ts: uint32(rand.Intn(0xFFFFFF)), + tsStep: ipcamClockHz / uint32(fps), + } +} + +/* ---------- RTP Header builder (IPCAM, no extensions) ---------- */ + +func buildIPCAMRTPHeader(seq uint16, ts uint32, marker bool, ssrc uint32) []byte { + b := make([]byte, ipcamRTPHdrSz) + b[0] = 0x80 // V=2, P=0, X=0, CC=0 + b[1] = ipcamPT + if marker { + b[1] |= 0x80 + } + binary.BigEndian.PutUint16(b[2:4], seq) + binary.BigEndian.PutUint32(b[4:8], ts) + binary.BigEndian.PutUint32(b[8:12], ssrc) + return b +} + +/* ---------- SPS/PPS STAP-A Packet ---------- */ + +func buildSTAPA_SPS_PPS() []byte { + // Fake SPS: High profile, Level 4.0, 1080p indicators (26 bytes) + sps := make([]byte, 26) + sps[0] = 0x67 // SPS NAL header (forbidden=0, NRI=3, Type=7) + sps[1] = 0x64 // profile_idc = High (100) + sps[2] = 0x00 // constraint flags + sps[3] = 0x28 // level_idc = 4.0 + _, _ = cryptoRand.Read(sps[4:]) // plausible bitstream tail + + // Fake PPS: 4 bytes + pps := []byte{0x68, 0xEE, 0x3C, 0x80} + + // STAP-A: indicator(1) + spsLen(2) + sps + ppsLen(2) + pps + payload := make([]byte, 0, 1+2+len(sps)+2+len(pps)) + payload = append(payload, 0x78) // F=0, NRI=11, Type=24 (STAP-A) + payload = append(payload, byte(len(sps)>>8), byte(len(sps))) + payload = append(payload, sps...) + payload = append(payload, byte(len(pps)>>8), byte(len(pps))) + payload = append(payload, pps...) + return payload +} + +/* ---------- FU-A Fragmentation ---------- */ + +func (g *gopState) buildFUAFragments(nalType uint8, totalSize int) [][]byte { + if totalSize < 1 { + totalSize = 1 + } + + // FU indicator: forbidden(0) | NRI(2bits) | Type=28(FU-A) + var fuIndicator uint8 + switch nalType { + case nalIDR: + fuIndicator = 0x7C // NRI=11, Type=28 + default: + fuIndicator = 0x5C // NRI=10, Type=28 + } + + // Generate fake NAL payload (random bytes simulating H.264 bitstream) + nalPayload := make([]byte, totalSize) + _, _ = cryptoRand.Read(nalPayload) + + maxFrag := ipcamMTU - 2 // 2 bytes for FU indicator + FU header + var packets [][]byte + off := 0 + + for off < len(nalPayload) { + end := off + maxFrag + if end > len(nalPayload) { + end = len(nalPayload) + } + isFirst := off == 0 + isLast := end == len(nalPayload) + + // FU header: S(1) | E(1) | R(0) | Type(5) + var fuHeader uint8 + fuHeader = nalType & 0x1F + if isFirst { + fuHeader |= 0x80 // S=1 + } + if isLast { + fuHeader |= 0x40 // E=1 + } + + // RTP header (marker=1 on last fragment of frame) + rtp := buildIPCAMRTPHeader(g.seq, g.ts, isLast, g.ssrc) + g.seq++ + + // Assemble: RTP header + FU indicator + FU header + payload fragment + pkt := make([]byte, 0, ipcamRTPHdrSz+2+end-off) + pkt = append(pkt, rtp...) + pkt = append(pkt, fuIndicator, fuHeader) + pkt = append(pkt, nalPayload[off:end]...) + + packets = append(packets, pkt) + g.pktCount++ + g.octCount += uint32(len(pkt) - ipcamRTPHdrSz) + + off = end + } + return packets +} + +/* ---------- Frame Size Simulation ---------- */ + +func ipcamFrameSize(isIFrame bool, bitrateMbps int, fps int) int { + if fps <= 0 { + fps = 25 + } + if bitrateMbps <= 0 { + bitrateMbps = 4 + } + bytesPerSec := bitrateMbps * 1024 * 1024 / 8 + + if isIFrame { + // I-frame ~8x average P-frame + base := bytesPerSec / fps * 8 + if base < 40960 { + base = 40960 + } + return base + } + + // P-frame: average bitrate portion with ±30% VBR + base := float64(bytesPerSec) / float64(fps) * 0.8 + jitter := base * 0.3 * (rand.Float64()*2 - 1) // ±30% + + // Occasional motion spike (10% chance, 1.5x) + if rand.Intn(10) == 0 { + base *= 1.5 + } + + size := int(base + jitter) + if size < 200 { + size = 200 + } + return size +} + +/* ---------- Generate One Video Frame ---------- */ + +func (g *gopState) generateFrame(isIFrame bool) [][]byte { + var packets [][]byte + + if isIFrame { + // SPS+PPS as STAP-A before IDR + stapa := buildSTAPA_SPS_PPS() + rtp := buildIPCAMRTPHeader(g.seq, g.ts, false, g.ssrc) + g.seq++ + g.pktCount++ + g.octCount += uint32(len(stapa)) + pkt := append(rtp, stapa...) + packets = append(packets, pkt) + + // IDR slice fragments + idrSize := ipcamFrameSize(true, 4, g.fps) + packets = append(packets, g.buildFUAFragments(nalIDR, idrSize)...) + } else { + // Non-IDR (P-frame) fragments + pSize := ipcamFrameSize(false, 4, g.fps) + packets = append(packets, g.buildFUAFragments(nalNonIDR, pSize)...) + } + + // Advance timestamp for next frame + g.ts += g.tsStep + + return packets +} + +/* ---------- Wrap Shim Inside FU-A Payload ---------- */ + +func (g *gopState) wrapPayloadAsIPCAM(shimFrame []byte) [][]byte { + isIFrame := g.frameNum == 0 + + // Determine target frame size for camouflage + var targetSize int + if isIFrame { + targetSize = ipcamFrameSize(true, 4, g.fps) + } else { + targetSize = ipcamFrameSize(false, 4, g.fps) + } + // Ensure target is large enough for the shim + 4-byte length prefix + minSize := len(shimFrame) + 4 + if targetSize < minSize { + targetSize = minSize + } + + // Build padded NAL body: [shimLen(4)] [shimFrame] [random padding...] + nalBody := make([]byte, targetSize) + binary.BigEndian.PutUint32(nalBody[0:4], uint32(len(shimFrame))) + copy(nalBody[4:4+len(shimFrame)], shimFrame) + _, _ = cryptoRand.Read(nalBody[4+len(shimFrame):]) + + // Choose NAL type based on GOP position + var nalType uint8 + if isIFrame { + nalType = nalIDR + } else { + nalType = nalNonIDR + } + + // FU indicator + var fuIndicator uint8 + if nalType == nalIDR { + fuIndicator = 0x7C + } else { + fuIndicator = 0x5C + } + + var packets [][]byte + + // If I-frame, prepend SPS/PPS STAP-A + if isIFrame { + stapa := buildSTAPA_SPS_PPS() + rtp := buildIPCAMRTPHeader(g.seq, g.ts, false, g.ssrc) + g.seq++ + g.pktCount++ + g.octCount += uint32(len(stapa)) + packets = append(packets, append(rtp, stapa...)) + } + + // Fragment the padded NAL body as FU-A + maxFrag := ipcamMTU - 2 + off := 0 + for off < len(nalBody) { + end := off + maxFrag + if end > len(nalBody) { + end = len(nalBody) + } + isFirst := off == 0 + isLast := end == len(nalBody) + + var fuHeader uint8 + fuHeader = nalType & 0x1F + if isFirst { + fuHeader |= 0x80 + } + if isLast { + fuHeader |= 0x40 + } + + rtp := buildIPCAMRTPHeader(g.seq, g.ts, isLast, g.ssrc) + g.seq++ + + pkt := make([]byte, 0, ipcamRTPHdrSz+2+end-off) + pkt = append(pkt, rtp...) + pkt = append(pkt, fuIndicator, fuHeader) + pkt = append(pkt, nalBody[off:end]...) + + packets = append(packets, pkt) + g.pktCount++ + g.octCount += uint32(len(pkt) - ipcamRTPHdrSz) + + off = end + } + + // Advance GOP state + g.ts += g.tsStep + g.frameNum = (g.frameNum + 1) % g.gopSize + + return packets +} + +/* ---------- Extract Shim from IPCAM Packets ---------- */ + +func extractPayloadFromIPCAM(packets [][]byte) ([]byte, error) { + if len(packets) == 0 { + return nil, errors.New("ipcam: no packets") + } + + // Find FU-A fragments (skip STAP-A if present) + var fuPackets [][]byte + for _, pkt := range packets { + if len(pkt) < ipcamRTPHdrSz+2 { + continue + } + fuIndicator := pkt[ipcamRTPHdrSz] + nalType := fuIndicator & 0x1F + if nalType == nalFUA { + fuPackets = append(fuPackets, pkt) + } + } + + if len(fuPackets) == 0 { + return nil, errors.New("ipcam: no FU-A fragments found") + } + + // Reassemble FU-A payload (strip RTP header + FU indicator + FU header) + var assembled []byte + for _, pkt := range fuPackets { + if len(pkt) < ipcamRTPHdrSz+2 { + continue + } + payload := pkt[ipcamRTPHdrSz+2:] + assembled = append(assembled, payload...) + } + + // Extract shim: first 4 bytes = length, then that many bytes of shim data + if len(assembled) < 4 { + return nil, errors.New("ipcam: reassembled too short") + } + shimLen := binary.BigEndian.Uint32(assembled[0:4]) + if shimLen == 0 || int(shimLen) > len(assembled)-4 { + return nil, errors.New("ipcam: invalid shim length") + } + return assembled[4 : 4+shimLen], nil +} + +/* ---------- RTCP SR for IPCAM ---------- */ + +func (g *gopState) buildIPCAMRTCPSR() []byte { + // Standard RTCP SR: V=2, P=0, RC=0, PT=200, length=6 (28 bytes) + b := make([]byte, 28) + b[0] = 0x80 // V=2, P=0, RC=0 + b[1] = 200 // SR + binary.BigEndian.PutUint16(b[2:4], 6) // length in 32-bit words minus 1 + + binary.BigEndian.PutUint32(b[4:8], g.ssrc) + + // NTP timestamp + now := time.Now() + sec := uint32(uint64(now.Unix()) + 2208988800) // NTP epoch offset + frac := uint32(uint64(now.Nanosecond()) * (1 << 32) / 1_000_000_000) + binary.BigEndian.PutUint32(b[8:12], sec) + binary.BigEndian.PutUint32(b[12:16], frac) + + // RTP timestamp, packet count, octet count + binary.BigEndian.PutUint32(b[16:20], g.ts) + binary.BigEndian.PutUint32(b[20:24], g.pktCount) + binary.BigEndian.PutUint32(b[24:28], g.octCount) + return b +} + +/* ---------- Packet Identification ---------- */ + +func isIPCAMRTP(b []byte) bool { + if len(b) < ipcamRTPHdrSz { + return false + } + // V=2 + if (b[0]>>6)&0x3 != 2 { + return false + } + // PT=96 (ignore marker bit) + pt := b[1] & 0x7F + return pt == ipcamPT +} diff --git a/wire_webrtc.go b/wire_webrtc.go new file mode 100644 index 0000000..a2161b9 --- /dev/null +++ b/wire_webrtc.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + cryptoRand "crypto/rand" + "encoding/binary" + "errors" + "hash/crc32" + "io" + "math/rand" + "net" + "sync/atomic" + "time" +) + +func buildWebRTCRTPHeader(seq uint16, ts uint32, pt uint8, marker bool, ssrc uint32, absTime uint32, twccSeq uint16) []byte { + b := make([]byte, 24) + b[0] = 0x90 // V=2, X=1 + b[1] = pt + if marker { + b[1] |= 0x80 + } + binary.BigEndian.PutUint16(b[2:4], seq) + binary.BigEndian.PutUint32(b[4:8], ts) + binary.BigEndian.PutUint32(b[8:12], ssrc) + binary.BigEndian.PutUint16(b[12:14], 0xBEDE) + binary.BigEndian.PutUint16(b[14:16], 0x0002) + b[16] = 0x32 // id=3, len=2 (abs-send-time) + b[17] = byte(absTime >> 16) + b[18] = byte(absTime >> 8) + b[19] = byte(absTime) + b[20] = 0x51 // id=5, len=1 (transport-cc) + binary.BigEndian.PutUint16(b[21:23], twccSeq) + b[23] = 0x00 // padding + return b +} + +func appendSRTPAuthTag(packet []byte) []byte { + tag := make([]byte, 10) + _, _ = cryptoRand.Read(tag) + return append(packet, tag...) +} + +func stripWebRTCRTP(b []byte) (pt uint8, marker bool, rest []byte, err error) { + if len(b) < 24+10 { + return 0, false, nil, io.ErrUnexpectedEOF + } + if (b[0]>>6)&0x3 != 2 { + return 0, false, nil, errors.New("rtp ver") + } + marker = (b[1] & 0x80) != 0 + pt = b[1] & 0x7F + rest = b[24 : len(b)-10] + return pt, marker, rest, nil +} + +type audioState struct { + seq uint16 + ts uint32 + ssrc uint32 + twccSeq *uint32 +} + +func (a *audioState) buildAudioPacket() []byte { + twcc := uint16(atomic.AddUint32(a.twccSeq, 1)) + now := time.Now() + sec := uint32(uint64(now.Unix()) + ntpEpochOffset) + frac := uint32(uint64(now.Nanosecond()) * (1 << 18) / 1_000_000_000) + absTime := (sec << 18) | frac + + h := buildWebRTCRTPHeader(a.seq, a.ts, 111, true, a.ssrc, absTime, twcc) + a.seq++ + a.ts += 960 + + payloadLen := 80 + rand.Intn(41) // 80-120 + payload := make([]byte, payloadLen) + _, _ = cryptoRand.Read(payload) + + pkt := append(h, payload...) + return appendSRTPAuthTag(pkt) +} + +func buildCompoundRTCP(ssrc uint32, rtpTs uint32, pktCount uint32, octCount uint32) []byte { + now := time.Now() + sec := uint32(uint64(now.Unix()) + ntpEpochOffset) + frac := uint32(uint64(now.Nanosecond()) * (1 << 32) / 1_000_000_000) + + // SR: 28 bytes + sr := make([]byte, 28) + sr[0] = 0x80 // V=2, P=0, RC=0 + sr[1] = 200 // SR + binary.BigEndian.PutUint16(sr[2:4], 6) + binary.BigEndian.PutUint32(sr[4:8], ssrc) + binary.BigEndian.PutUint32(sr[8:12], sec) + binary.BigEndian.PutUint32(sr[12:16], frac) + binary.BigEndian.PutUint32(sr[16:20], rtpTs) + binary.BigEndian.PutUint32(sr[20:24], pktCount) + binary.BigEndian.PutUint32(sr[24:28], octCount) + + // SDES: CNAME = "{hex-ssrc}@webrtc.local" + cname := []byte(hexSSRC(ssrc) + "@webrtc.local") + // SDES header(4) + SSRC(4) + CNAME item(2+len) + END(1) + padding + sdesPayload := make([]byte, 0, 4+len(cname)+3) + sdesPayload = append(sdesPayload, byte(ssrc>>24), byte(ssrc>>16), byte(ssrc>>8), byte(ssrc)) + sdesPayload = append(sdesPayload, 1, byte(len(cname))) + sdesPayload = append(sdesPayload, cname...) + sdesPayload = append(sdesPayload, 0x00) // END + for len(sdesPayload)%4 != 0 { + sdesPayload = append(sdesPayload, 0x00) + } + + sdesHdr := make([]byte, 4) + sdesHdr[0] = 0x81 // V=2, SC=1 + sdesHdr[1] = 202 // SDES + binary.BigEndian.PutUint16(sdesHdr[2:4], uint16(len(sdesPayload)/4)) + + compound := append(sr, sdesHdr...) + compound = append(compound, sdesPayload...) + + // SRTCP index (E-flag set, index=0) + idx := make([]byte, 4) + idx[0] = 0x80 // E-flag + compound = append(compound, idx...) + + return appendSRTPAuthTag(compound) +} + +func hexSSRC(ssrc uint32) string { + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, ssrc) + const hex = "0123456789abcdef" + out := make([]byte, 8) + for i := 0; i < 4; i++ { + out[i*2] = hex[b[i]>>4] + out[i*2+1] = hex[b[i]&0x0f] + } + return string(out) +} + +func stunCRC32(data []byte) uint32 { + return crc32.ChecksumIEEE(data) ^ 0x5354554E +} + +func buildSTUNBindingRequestFull() []byte { + b := make([]byte, 28) + binary.BigEndian.PutUint16(b[0:2], 0x0001) // Binding Request + binary.BigEndian.PutUint16(b[2:4], 8) // length: FINGERPRINT attr (8 bytes) + binary.BigEndian.PutUint32(b[4:8], 0x2112A442) + _, _ = cryptoRand.Read(b[8:20]) + + // FINGERPRINT attribute + binary.BigEndian.PutUint16(b[20:22], 0x8028) // type + binary.BigEndian.PutUint16(b[22:24], 4) // length + fp := stunCRC32(b[:20]) + binary.BigEndian.PutUint32(b[24:28], fp) + return b +} + +func buildSTUNBindingResponse(transactionID []byte) []byte { + b := make([]byte, 44) + binary.BigEndian.PutUint16(b[0:2], 0x0101) // Binding Success Response + binary.BigEndian.PutUint16(b[2:4], 24) // length: XOR-MAPPED-ADDRESS(12) + FINGERPRINT(8) + binary.BigEndian.PutUint32(b[4:8], 0x2112A442) + copy(b[8:20], transactionID) + + // XOR-MAPPED-ADDRESS + binary.BigEndian.PutUint16(b[20:22], 0x0020) // type + binary.BigEndian.PutUint16(b[22:24], 8) // length + b[24] = 0x00 // reserved + b[25] = 0x01 // IPv4 + binary.BigEndian.PutUint16(b[26:28], 0x2112^0xD903) // XOR'd port + binary.BigEndian.PutUint32(b[28:32], 0x2112A442^0xC0A80101) // XOR'd 192.168.1.1 + + // FINGERPRINT + binary.BigEndian.PutUint16(b[32:34], 0x8028) + binary.BigEndian.PutUint16(b[34:36], 4) + fp := stunCRC32(b[:32]) + binary.BigEndian.PutUint32(b[36:40], fp) + + // pad remaining + return b[:40] +} + +func runAudioTicker(ctx context.Context, conn *net.UDPConn, peer *net.UDPAddr, audio *audioState, tb *tokenBucket, pcap *pcapWriter) { + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + + localAddr := conn.LocalAddr().(*net.UDPAddr) + srcIP := ip4OrLoopback(localAddr.IP) + dstIP := ip4OrLoopback(peer.IP) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + jitter := time.Duration(rand.Intn(4000)-2000) * time.Microsecond + time.Sleep(jitter) + + pkt := audio.buildAudioPacket() + tb.wait(len(pkt) + 28) + _, _ = conn.WriteToUDP(pkt, peer) + pcap.WriteUDP(srcIP, localAddr.Port, dstIP, peer.Port, pkt) + } + } +} + +func runSTUNConsent(ctx context.Context, conn *net.UDPConn, peer *net.UDPAddr, pcap *pcapWriter) { + localAddr := conn.LocalAddr().(*net.UDPAddr) + srcIP := ip4OrLoopback(localAddr.IP) + dstIP := ip4OrLoopback(peer.IP) + + for { + jitter := time.Duration(rand.Intn(2000)-1000) * time.Millisecond + wait := 5*time.Second + jitter + + select { + case <-ctx.Done(): + return + case <-time.After(wait): + req := buildSTUNBindingRequestFull() + _, _ = conn.WriteToUDP(req, peer) + pcap.WriteUDP(srcIP, localAddr.Port, dstIP, peer.Port, req) + } + } +}