230 lines
6.8 KiB
Go
230 lines
6.8 KiB
Go
package frame
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"bytes"
|
||
|
|
"crypto/hmac"
|
||
|
|
"encoding/binary"
|
||
|
|
"encoding/gob"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log"
|
||
|
|
"net"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Network timeouts for a single TCP hop: the auth handshake and the clip
|
||
|
|
// transfer must both complete within their deadlines or the connection is
|
||
|
|
// dropped (fail-closed).
|
||
|
|
const (
|
||
|
|
authTimeout = 10 * time.Second
|
||
|
|
ClipReadTimeout = 30 * time.Second
|
||
|
|
ClipWriteTimeout = 30 * time.Second
|
||
|
|
)
|
||
|
|
|
||
|
|
// maxAuthLine bounds the length of the auth token line, so a peer that never
|
||
|
|
// sends a newline cannot grow a buffer forever (the read deadline still
|
||
|
|
// applies).
|
||
|
|
const maxAuthLine = 4096
|
||
|
|
|
||
|
|
// Wire framing for clips: every gob-encoded Clip is sent as a 4-byte
|
||
|
|
// big-endian length followed by that many payload bytes. The receiving side
|
||
|
|
// refuses any length above MaxWireBytes before allocating, so a single
|
||
|
|
// crafted message cannot force a multi-gigabyte allocation on the decode
|
||
|
|
// path.
|
||
|
|
const (
|
||
|
|
wireHeaderSize = 4
|
||
|
|
MaxClipWireSize = MaxClipBytes + (16 << 20) // 80 MiB: pixel data + guids/timestamps/detections + gob overhead
|
||
|
|
)
|
||
|
|
|
||
|
|
// authToken is the shared secret required on every hop. An empty token is
|
||
|
|
// never usable: receivers fail closed rather than accepting unauthenticated
|
||
|
|
// connections, and senders require a configured token to write the handshake.
|
||
|
|
var (
|
||
|
|
authMu sync.RWMutex
|
||
|
|
authToken string
|
||
|
|
)
|
||
|
|
|
||
|
|
// SetAuthToken configures the shared token required by every send and receive
|
||
|
|
// on this process. The token is trimmed of surrounding whitespace so a
|
||
|
|
// config line with a stray newline keeps matching the handshake.
|
||
|
|
func SetAuthToken(tok string) {
|
||
|
|
authMu.Lock()
|
||
|
|
defer authMu.Unlock()
|
||
|
|
authToken = strings.TrimSpace(tok)
|
||
|
|
}
|
||
|
|
|
||
|
|
func getAuthToken() string {
|
||
|
|
authMu.RLock()
|
||
|
|
defer authMu.RUnlock()
|
||
|
|
return authToken
|
||
|
|
}
|
||
|
|
|
||
|
|
func isValidHostPort(s string) bool {
|
||
|
|
host, port, err := net.SplitHostPort(s)
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if host == "" {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
p, err := strconv.Atoi(port)
|
||
|
|
return err == nil && p >= 1 && p <= 65535
|
||
|
|
}
|
||
|
|
|
||
|
|
// writeAuthToken emits the auth line to conn. It returns an error when no
|
||
|
|
// token is configured so a misconfigured sender never silently transmits an
|
||
|
|
// unauthenticated clip.
|
||
|
|
func writeAuthToken(conn net.Conn) error {
|
||
|
|
tok := getAuthToken()
|
||
|
|
if tok == "" {
|
||
|
|
return errors.New("auth: no token configured (fail-closed)")
|
||
|
|
}
|
||
|
|
if err := conn.SetWriteDeadline(time.Now().Add(authTimeout)); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_, err := fmt.Fprintf(conn, "%s\n", tok)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// readAuthToken consumes the auth line from conn and returns a reader for the
|
||
|
|
// gob stream that follows. A missing, truncated, or mismatched token fails
|
||
|
|
// the connection (constant-time comparison), and an unconfigured token fails
|
||
|
|
// closed rather than accepting the peer silently.
|
||
|
|
func readAuthToken(conn net.Conn) (io.Reader, error) {
|
||
|
|
tok := getAuthToken()
|
||
|
|
if tok == "" {
|
||
|
|
return nil, errors.New("auth: no token configured on receiver (fail-closed)")
|
||
|
|
}
|
||
|
|
if err := conn.SetReadDeadline(time.Now().Add(authTimeout)); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
br := bufio.NewReader(conn)
|
||
|
|
|
||
|
|
line, err := readAuthLine(br)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("auth handshake read: %w", err)
|
||
|
|
}
|
||
|
|
if !hmac.Equal([]byte(strings.TrimSpace(line)), []byte(tok)) {
|
||
|
|
return nil, errors.New("authenticate: token mismatch")
|
||
|
|
}
|
||
|
|
return io.MultiReader(br, conn), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// readAuthLine reads a single '\n'-terminated line, bounding how many bytes
|
||
|
|
// are consumed so a hostile peer cannot stream data without a newline.
|
||
|
|
func readAuthLine(br *bufio.Reader) (string, error) {
|
||
|
|
var sb strings.Builder
|
||
|
|
for {
|
||
|
|
b, err := br.ReadByte()
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if b == '\n' {
|
||
|
|
return sb.String(), nil
|
||
|
|
}
|
||
|
|
if sb.Len() >= maxAuthLine {
|
||
|
|
return "", errors.New("auth handshake line exceeds limit")
|
||
|
|
}
|
||
|
|
sb.WriteByte(b)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// writeWireClip encodes clp as a length-prefixed gob stream to w. The length
|
||
|
|
// prefix lets the receiver bound allocation before decoding.
|
||
|
|
func writeWireClip(w io.Writer, clp *Clip) (int64, error) {
|
||
|
|
var buf bytes.Buffer
|
||
|
|
if err := gob.NewEncoder(&buf).Encode(clp); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
if buf.Len() > MaxClipWireSize {
|
||
|
|
return 0, fmt.Errorf("clip wire encoding of %d bytes exceeds limit of %d", buf.Len(), MaxClipWireSize)
|
||
|
|
}
|
||
|
|
var hdr [wireHeaderSize]byte
|
||
|
|
binary.BigEndian.PutUint32(hdr[:], uint32(buf.Len()))
|
||
|
|
if _, err := w.Write(hdr[:]); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
n, err := w.Write(buf.Bytes())
|
||
|
|
return int64(wireHeaderSize) + int64(n), err
|
||
|
|
}
|
||
|
|
|
||
|
|
// DecodeWireClip reads a length-prefixed Clip produced by writeWireClip. The
|
||
|
|
// declared length is validated against MaxClipWireSize before any allocation,
|
||
|
|
// so a hostile peer cannot force a huge single allocation. Validation of the
|
||
|
|
// Clip's structure is the caller's job (see Clip.Validate).
|
||
|
|
func DecodeWireClip(r io.Reader) (Clip, error) {
|
||
|
|
var hdr [wireHeaderSize]byte
|
||
|
|
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||
|
|
return Clip{}, err
|
||
|
|
}
|
||
|
|
n := binary.BigEndian.Uint32(hdr[:])
|
||
|
|
if n > MaxClipWireSize {
|
||
|
|
return Clip{}, fmt.Errorf("clip wire length %d exceeds limit of %d", n, MaxClipWireSize)
|
||
|
|
}
|
||
|
|
payload := make([]byte, n)
|
||
|
|
if _, err := io.ReadFull(r, payload); err != nil {
|
||
|
|
return Clip{}, err
|
||
|
|
}
|
||
|
|
var clp Clip
|
||
|
|
if err := gob.NewDecoder(bytes.NewReader(payload)).Decode(&clp); err != nil {
|
||
|
|
return Clip{}, err
|
||
|
|
}
|
||
|
|
return clp, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Send delivers the clip to target: auth handshake, then a length-prefixed
|
||
|
|
// gob encode within a write deadline.
|
||
|
|
func (clp *Clip) Send(target string) error {
|
||
|
|
if !isValidHostPort(target) {
|
||
|
|
return fmt.Errorf("invalid send address: %s", target)
|
||
|
|
}
|
||
|
|
|
||
|
|
conn, err := net.DialTimeout("tcp", target, 10*time.Second)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("dial %s: %w", target, err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
|
||
|
|
if err := writeAuthToken(conn); err != nil {
|
||
|
|
return fmt.Errorf("auth write to %s: %w", target, err)
|
||
|
|
}
|
||
|
|
if err := conn.SetWriteDeadline(time.Now().Add(ClipWriteTimeout)); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if _, err := writeWireClip(conn, clp); err != nil {
|
||
|
|
return fmt.Errorf("encode to %s: %w", target, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SendClipRetry attempts to deliver clp to target up to attempts times, with a
|
||
|
|
// backoff that doubles from 100ms up to a 500ms cap. The leaf services use it
|
||
|
|
// so a transient downstream outage (restart, network blip) does not silently
|
||
|
|
// lose footage.
|
||
|
|
func (clp *Clip) SendClipRetry(target string, attempts int) error {
|
||
|
|
if attempts < 1 {
|
||
|
|
attempts = 1
|
||
|
|
}
|
||
|
|
|
||
|
|
var lastErr error
|
||
|
|
for i := 1; i <= attempts; i++ {
|
||
|
|
if err := clp.Send(target); err != nil {
|
||
|
|
lastErr = err
|
||
|
|
delay := 100 * time.Millisecond * (1 << (i - 1))
|
||
|
|
if delay > 500*time.Millisecond {
|
||
|
|
delay = 500 * time.Millisecond
|
||
|
|
}
|
||
|
|
log.Printf("send to %s failed (attempt %d/%d): %v; retrying in %s",
|
||
|
|
target, i, attempts, err, delay)
|
||
|
|
time.Sleep(delay)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return fmt.Errorf("send to %s failed after %d attempts: %w", target, attempts, lastErr)
|
||
|
|
}
|