Files
oko_public/frame/network.go
T
2026-09-09 21:44:05 -05:00

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)
}