548 lines
17 KiB
Go
548 lines
17 KiB
Go
package live
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/pion/ice/v4"
|
|
"github.com/pion/sdp/v3"
|
|
"github.com/pion/stun/v3"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/net/proxy"
|
|
)
|
|
|
|
const (
|
|
maxUpstreamICECandidates = 64
|
|
maxProxiedTCPCandidates = 16
|
|
maxUnauthenticatedTCPConns = 4
|
|
maxInitialSTUNFrameSize = 4096
|
|
stunMessageHeaderSize = 20
|
|
)
|
|
|
|
var nonRoutableProxyTargetPrefixes = []netip.Prefix{
|
|
netip.MustParsePrefix("0.0.0.0/8"),
|
|
netip.MustParsePrefix("10.0.0.0/8"),
|
|
netip.MustParsePrefix("100.64.0.0/10"),
|
|
netip.MustParsePrefix("127.0.0.0/8"),
|
|
netip.MustParsePrefix("169.254.0.0/16"),
|
|
netip.MustParsePrefix("172.16.0.0/12"),
|
|
netip.MustParsePrefix("192.0.0.0/24"),
|
|
netip.MustParsePrefix("192.0.2.0/24"),
|
|
netip.MustParsePrefix("192.88.99.0/24"),
|
|
netip.MustParsePrefix("192.168.0.0/16"),
|
|
netip.MustParsePrefix("198.18.0.0/15"),
|
|
netip.MustParsePrefix("198.51.100.0/24"),
|
|
netip.MustParsePrefix("203.0.113.0/24"),
|
|
netip.MustParsePrefix("224.0.0.0/4"),
|
|
netip.MustParsePrefix("240.0.0.0/4"),
|
|
netip.MustParsePrefix("::/96"),
|
|
netip.MustParsePrefix("::ffff:0:0:0/96"),
|
|
netip.MustParsePrefix("64:ff9b::/96"),
|
|
netip.MustParsePrefix("64:ff9b:1::/48"),
|
|
netip.MustParsePrefix("100::/64"),
|
|
netip.MustParsePrefix("2001::/23"),
|
|
netip.MustParsePrefix("2001:db8::/32"),
|
|
netip.MustParsePrefix("2002::/16"),
|
|
netip.MustParsePrefix("3fff::/20"),
|
|
netip.MustParsePrefix("5f00::/16"),
|
|
netip.MustParsePrefix("fc00::/7"),
|
|
netip.MustParsePrefix("fe80::/10"),
|
|
netip.MustParsePrefix("fec0::/10"),
|
|
netip.MustParsePrefix("ff00::/8"),
|
|
}
|
|
|
|
type iceCredentials struct {
|
|
ufrag string
|
|
password string
|
|
}
|
|
|
|
type tcpCandidateTunnel struct {
|
|
listener net.Listener
|
|
target netip.AddrPort
|
|
dialer proxy.ContextDialer
|
|
expectedUser string
|
|
remotePassword string
|
|
|
|
mu sync.Mutex
|
|
closed bool
|
|
claimed bool
|
|
connections map[net.Conn]struct{}
|
|
validationSlots chan struct{}
|
|
onForwardingStarted func()
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
type tcpCandidatePlan struct {
|
|
mediaIndex int
|
|
attributeIndex int
|
|
fields []string
|
|
target netip.AddrPort
|
|
}
|
|
|
|
func prepareProxiedUpstreamAnswer(answer, localOffer string, dialer proxy.ContextDialer) (string, []*tcpCandidateTunnel, error) {
|
|
if dialer == nil {
|
|
return "", nil, errors.New("Codex live TCP proxy dialer is unavailable")
|
|
}
|
|
var remoteDescription sdp.SessionDescription
|
|
if errUnmarshal := remoteDescription.UnmarshalString(answer); errUnmarshal != nil {
|
|
return "", nil, fmt.Errorf("parse upstream WebRTC answer for TCP proxy: %w", errUnmarshal)
|
|
}
|
|
var localDescription sdp.SessionDescription
|
|
if errUnmarshal := localDescription.UnmarshalString(localOffer); errUnmarshal != nil {
|
|
return "", nil, fmt.Errorf("parse upstream WebRTC offer for TCP proxy: %w", errUnmarshal)
|
|
}
|
|
remoteCredentials, errCredentials := bundledICECredentials(&remoteDescription)
|
|
if errCredentials != nil {
|
|
return "", nil, fmt.Errorf("read upstream WebRTC answer ICE credentials: %w", errCredentials)
|
|
}
|
|
localCredentials, errCredentials := bundledICECredentials(&localDescription)
|
|
if errCredentials != nil {
|
|
return "", nil, fmt.Errorf("read upstream WebRTC offer ICE credentials: %w", errCredentials)
|
|
}
|
|
|
|
plans := make([]tcpCandidatePlan, 0, 4)
|
|
candidateCount := 0
|
|
for mediaIndex, media := range remoteDescription.MediaDescriptions {
|
|
if media == nil {
|
|
continue
|
|
}
|
|
filtered := make([]sdp.Attribute, 0, len(media.Attributes))
|
|
for attributeIndex := range media.Attributes {
|
|
attribute := media.Attributes[attributeIndex]
|
|
if !attribute.IsICECandidate() {
|
|
filtered = append(filtered, attribute)
|
|
continue
|
|
}
|
|
candidateCount++
|
|
if candidateCount > maxUpstreamICECandidates {
|
|
return "", nil, fmt.Errorf("upstream WebRTC answer exceeds the %d candidate limit", maxUpstreamICECandidates)
|
|
}
|
|
plan, keep, errCandidate := proxiedTCPCandidatePlan(attribute.Value)
|
|
if errCandidate != nil {
|
|
return "", nil, errCandidate
|
|
}
|
|
if !keep {
|
|
continue
|
|
}
|
|
if len(plans) >= maxProxiedTCPCandidates {
|
|
return "", nil, fmt.Errorf("upstream WebRTC answer exceeds the %d TCP candidate proxy limit", maxProxiedTCPCandidates)
|
|
}
|
|
plan.mediaIndex = mediaIndex
|
|
plan.attributeIndex = len(filtered)
|
|
filtered = append(filtered, attribute)
|
|
plans = append(plans, plan)
|
|
}
|
|
media.Attributes = filtered
|
|
}
|
|
if len(plans) == 0 {
|
|
return "", nil, errors.New("upstream WebRTC answer has no supported public TCP passive candidate on port 443")
|
|
}
|
|
|
|
expectedUser := remoteCredentials.ufrag + ":" + localCredentials.ufrag
|
|
tunnels := make([]*tcpCandidateTunnel, 0, len(plans))
|
|
closeTunnels := func() {
|
|
for _, tunnel := range tunnels {
|
|
if errClose := tunnel.Close(); errClose != nil {
|
|
log.WithError(errClose).Debug("codex live TCP proxy: close candidate tunnel after setup error")
|
|
}
|
|
}
|
|
}
|
|
for _, plan := range plans {
|
|
tunnel, errTunnel := newTCPCandidateTunnel(plan.target, dialer, expectedUser, remoteCredentials.password)
|
|
if errTunnel != nil {
|
|
closeTunnels()
|
|
return "", nil, errTunnel
|
|
}
|
|
tunnels = append(tunnels, tunnel)
|
|
listenerAddress, ok := tunnel.listener.Addr().(*net.TCPAddr)
|
|
if !ok || listenerAddress.IP == nil {
|
|
closeTunnels()
|
|
return "", nil, errors.New("Codex live TCP proxy listener returned an invalid address")
|
|
}
|
|
fields := append([]string(nil), plan.fields...)
|
|
fields[4] = listenerAddress.IP.String()
|
|
fields[5] = strconv.Itoa(listenerAddress.Port)
|
|
remoteDescription.MediaDescriptions[plan.mediaIndex].Attributes[plan.attributeIndex].Value = strings.Join(fields, " ")
|
|
}
|
|
|
|
rewritten, errMarshal := remoteDescription.Marshal()
|
|
if errMarshal != nil {
|
|
closeTunnels()
|
|
return "", nil, fmt.Errorf("marshal proxied upstream WebRTC answer: %w", errMarshal)
|
|
}
|
|
return string(rewritten), tunnels, nil
|
|
}
|
|
|
|
func proxiedTCPCandidatePlan(rawCandidate string) (tcpCandidatePlan, bool, error) {
|
|
trimmed := strings.TrimSpace(rawCandidate)
|
|
candidate, errCandidate := ice.UnmarshalCandidate(trimmed)
|
|
if errCandidate != nil {
|
|
return tcpCandidatePlan{}, false, fmt.Errorf("parse upstream WebRTC candidate: %w", errCandidate)
|
|
}
|
|
if candidate.NetworkType() != ice.NetworkTypeTCP4 && candidate.NetworkType() != ice.NetworkTypeTCP6 {
|
|
return tcpCandidatePlan{}, false, nil
|
|
}
|
|
if candidate.TCPType() != ice.TCPTypePassive {
|
|
return tcpCandidatePlan{}, false, nil
|
|
}
|
|
if candidate.Component() != uint16(ice.ComponentRTP) || candidate.Type() != ice.CandidateTypeHost {
|
|
return tcpCandidatePlan{}, false, nil
|
|
}
|
|
if candidate.Port() != 443 {
|
|
return tcpCandidatePlan{}, false, fmt.Errorf("upstream WebRTC TCP proxy candidate uses disallowed port %d", candidate.Port())
|
|
}
|
|
address, errAddress := netip.ParseAddr(candidate.Address())
|
|
if errAddress != nil {
|
|
return tcpCandidatePlan{}, false, errors.New("upstream WebRTC TCP proxy candidate address must be an IP")
|
|
}
|
|
address = address.Unmap()
|
|
if !isPublicProxyTarget(address) {
|
|
return tcpCandidatePlan{}, false, errors.New("upstream WebRTC TCP proxy candidate address must be globally routable")
|
|
}
|
|
fields := strings.Fields(trimmed)
|
|
if len(fields) < 8 {
|
|
return tcpCandidatePlan{}, false, errors.New("upstream WebRTC TCP proxy candidate is malformed")
|
|
}
|
|
return tcpCandidatePlan{
|
|
fields: fields,
|
|
target: netip.AddrPortFrom(address, uint16(candidate.Port())),
|
|
}, true, nil
|
|
}
|
|
|
|
func isPublicProxyTarget(address netip.Addr) bool {
|
|
if !address.IsValid() || !address.IsGlobalUnicast() || address.IsUnspecified() || address.IsLoopback() ||
|
|
address.IsPrivate() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() || address.IsMulticast() {
|
|
return false
|
|
}
|
|
for _, prefix := range nonRoutableProxyTargetPrefixes {
|
|
if prefix.Contains(address) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func bundledICECredentials(description *sdp.SessionDescription) (iceCredentials, error) {
|
|
if description == nil {
|
|
return iceCredentials{}, errors.New("SDP is unavailable")
|
|
}
|
|
sessionUfrag, _ := description.Attribute("ice-ufrag")
|
|
sessionPassword, _ := description.Attribute("ice-pwd")
|
|
var selected iceCredentials
|
|
for _, media := range description.MediaDescriptions {
|
|
if media == nil {
|
|
continue
|
|
}
|
|
ufrag := sessionUfrag
|
|
if mediaUfrag, ok := media.Attribute("ice-ufrag"); ok {
|
|
ufrag = mediaUfrag
|
|
}
|
|
password := sessionPassword
|
|
if mediaPassword, ok := media.Attribute("ice-pwd"); ok {
|
|
password = mediaPassword
|
|
}
|
|
ufrag = strings.TrimSpace(ufrag)
|
|
password = strings.TrimSpace(password)
|
|
if ufrag == "" && password == "" {
|
|
continue
|
|
}
|
|
if ufrag == "" || password == "" {
|
|
return iceCredentials{}, errors.New("SDP contains incomplete ICE credentials")
|
|
}
|
|
current := iceCredentials{ufrag: ufrag, password: password}
|
|
if selected.ufrag == "" {
|
|
selected = current
|
|
continue
|
|
}
|
|
if selected != current {
|
|
return iceCredentials{}, errors.New("SDP contains inconsistent bundled ICE credentials")
|
|
}
|
|
}
|
|
if selected.ufrag == "" {
|
|
selected = iceCredentials{ufrag: strings.TrimSpace(sessionUfrag), password: strings.TrimSpace(sessionPassword)}
|
|
}
|
|
if selected.ufrag == "" || selected.password == "" {
|
|
return iceCredentials{}, errors.New("SDP is missing ICE credentials")
|
|
}
|
|
return selected, nil
|
|
}
|
|
|
|
func closeCandidateTunnels(tunnels []*tcpCandidateTunnel) error {
|
|
var closeErrors []error
|
|
for _, tunnel := range tunnels {
|
|
if errClose := tunnel.Close(); errClose != nil {
|
|
closeErrors = append(closeErrors, errClose)
|
|
}
|
|
}
|
|
return errors.Join(closeErrors...)
|
|
}
|
|
|
|
func newTCPCandidateTunnel(target netip.AddrPort, dialer proxy.ContextDialer, expectedUser, remotePassword string) (*tcpCandidateTunnel, error) {
|
|
if !isPublicProxyTarget(target.Addr()) || target.Port() != 443 {
|
|
return nil, errors.New("Codex live TCP proxy target is not allowed")
|
|
}
|
|
if dialer == nil || strings.TrimSpace(expectedUser) == "" || strings.TrimSpace(remotePassword) == "" {
|
|
return nil, errors.New("Codex live TCP proxy tunnel configuration is incomplete")
|
|
}
|
|
network := "tcp4"
|
|
listenAddress := "127.0.0.1:0"
|
|
if target.Addr().Is6() {
|
|
network = "tcp6"
|
|
listenAddress = "[::1]:0"
|
|
}
|
|
listener, errListen := net.Listen(network, listenAddress)
|
|
if errListen != nil {
|
|
return nil, fmt.Errorf("listen for Codex live TCP proxy candidate: %w", errListen)
|
|
}
|
|
tunnelContext, cancelTunnel := context.WithCancel(context.Background())
|
|
tunnel := &tcpCandidateTunnel{
|
|
listener: listener,
|
|
target: target,
|
|
dialer: dialer,
|
|
expectedUser: expectedUser,
|
|
remotePassword: remotePassword,
|
|
connections: make(map[net.Conn]struct{}),
|
|
validationSlots: make(chan struct{}, maxUnauthenticatedTCPConns),
|
|
ctx: tunnelContext,
|
|
cancel: cancelTunnel,
|
|
}
|
|
go tunnel.accept()
|
|
return tunnel, nil
|
|
}
|
|
|
|
func (t *tcpCandidateTunnel) accept() {
|
|
for {
|
|
connection, errAccept := t.listener.Accept()
|
|
if errAccept != nil {
|
|
if !errors.Is(errAccept, net.ErrClosed) {
|
|
log.WithError(errAccept).Warn("codex live TCP proxy: accept candidate connection failed")
|
|
}
|
|
return
|
|
}
|
|
if !t.trackConnection(connection) {
|
|
if errClose := connection.Close(); errClose != nil {
|
|
log.WithError(errClose).Debug("codex live TCP proxy: close connection after tunnel shutdown")
|
|
}
|
|
return
|
|
}
|
|
select {
|
|
case t.validationSlots <- struct{}{}:
|
|
go func() {
|
|
defer func() { <-t.validationSlots }()
|
|
t.handleConnection(connection)
|
|
}()
|
|
default:
|
|
t.untrackAndClose(connection)
|
|
log.Warn("codex live TCP proxy: rejected excess unauthenticated candidate connection")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *tcpCandidateTunnel) handleConnection(client net.Conn) {
|
|
firstFrame, errValidate := readValidatedICEBindingFrame(client, t.expectedUser, t.remotePassword)
|
|
if errValidate != nil {
|
|
t.untrackAndClose(client)
|
|
log.WithError(errValidate).Warn("codex live TCP proxy: rejected unauthenticated candidate connection")
|
|
return
|
|
}
|
|
if !t.claim() {
|
|
t.untrackAndClose(client)
|
|
return
|
|
}
|
|
if errClose := t.listener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
|
log.WithError(errClose).Debug("codex live TCP proxy: close claimed candidate listener")
|
|
}
|
|
upstream, errDial := t.dialer.DialContext(t.ctx, "tcp", t.target.String())
|
|
if errDial != nil {
|
|
t.untrackAndClose(client)
|
|
log.WithError(errDial).Warn("codex live TCP proxy: connect fixed upstream candidate failed")
|
|
return
|
|
}
|
|
if !t.trackConnection(upstream) {
|
|
if errClose := upstream.Close(); errClose != nil {
|
|
log.WithError(errClose).Debug("codex live TCP proxy: close upstream after tunnel shutdown")
|
|
}
|
|
t.untrackAndClose(client)
|
|
return
|
|
}
|
|
if errWrite := writeAll(upstream, firstFrame); errWrite != nil {
|
|
t.untrackAndClose(upstream)
|
|
t.untrackAndClose(client)
|
|
log.WithError(errWrite).Warn("codex live TCP proxy: forward authenticated ICE frame failed")
|
|
return
|
|
}
|
|
t.notifyForwardingStarted()
|
|
|
|
copyDone := make(chan struct{}, 2)
|
|
copyConnection := func(destination, source net.Conn) {
|
|
_, _ = io.Copy(destination, source)
|
|
copyDone <- struct{}{}
|
|
}
|
|
go copyConnection(upstream, client)
|
|
go copyConnection(client, upstream)
|
|
<-copyDone
|
|
t.untrackAndClose(upstream)
|
|
t.untrackAndClose(client)
|
|
<-copyDone
|
|
}
|
|
|
|
func (t *tcpCandidateTunnel) setForwardingStartedHandler(handler func()) {
|
|
if t == nil {
|
|
return
|
|
}
|
|
t.mu.Lock()
|
|
t.onForwardingStarted = handler
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
func (t *tcpCandidateTunnel) notifyForwardingStarted() {
|
|
if t == nil {
|
|
return
|
|
}
|
|
t.mu.Lock()
|
|
handler := t.onForwardingStarted
|
|
t.mu.Unlock()
|
|
if handler != nil {
|
|
handler()
|
|
}
|
|
}
|
|
|
|
func (t *tcpCandidateTunnel) trackConnection(connection net.Conn) bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if t.closed {
|
|
return false
|
|
}
|
|
t.connections[connection] = struct{}{}
|
|
return true
|
|
}
|
|
|
|
func (t *tcpCandidateTunnel) untrackAndClose(connection net.Conn) {
|
|
if connection == nil {
|
|
return
|
|
}
|
|
t.mu.Lock()
|
|
delete(t.connections, connection)
|
|
t.mu.Unlock()
|
|
if errClose := connection.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
|
log.WithError(errClose).Debug("codex live TCP proxy: close tunnel connection")
|
|
}
|
|
}
|
|
|
|
func (t *tcpCandidateTunnel) claim() bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if t.closed || t.claimed {
|
|
return false
|
|
}
|
|
t.claimed = true
|
|
return true
|
|
}
|
|
|
|
func (t *tcpCandidateTunnel) Close() error {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
t.mu.Lock()
|
|
if t.closed {
|
|
t.mu.Unlock()
|
|
return nil
|
|
}
|
|
t.closed = true
|
|
cancel := t.cancel
|
|
connections := make([]net.Conn, 0, len(t.connections))
|
|
for connection := range t.connections {
|
|
connections = append(connections, connection)
|
|
}
|
|
t.connections = make(map[net.Conn]struct{})
|
|
t.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
|
|
var closeErrors []error
|
|
if t.listener != nil {
|
|
if errClose := t.listener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
|
closeErrors = append(closeErrors, errClose)
|
|
}
|
|
}
|
|
for _, connection := range connections {
|
|
if errClose := connection.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
|
closeErrors = append(closeErrors, errClose)
|
|
}
|
|
}
|
|
return errors.Join(closeErrors...)
|
|
}
|
|
|
|
func readValidatedICEBindingFrame(connection io.Reader, expectedUser, remotePassword string) ([]byte, error) {
|
|
var header [2]byte
|
|
if _, errRead := io.ReadFull(connection, header[:]); errRead != nil {
|
|
return nil, fmt.Errorf("read ICE-TCP frame header: %w", errRead)
|
|
}
|
|
frameSize := int(binary.BigEndian.Uint16(header[:]))
|
|
if frameSize < stunMessageHeaderSize || frameSize > maxInitialSTUNFrameSize {
|
|
return nil, fmt.Errorf("invalid initial ICE-TCP STUN frame size %d", frameSize)
|
|
}
|
|
payload := make([]byte, frameSize)
|
|
if _, errRead := io.ReadFull(connection, payload); errRead != nil {
|
|
return nil, fmt.Errorf("read ICE-TCP STUN frame: %w", errRead)
|
|
}
|
|
message := stun.NewWithOptions(stun.WithStrict(true))
|
|
if errDecode := stun.Decode(payload, message); errDecode != nil {
|
|
return nil, fmt.Errorf("decode initial ICE-TCP STUN message: %w", errDecode)
|
|
}
|
|
if len(payload) != stunMessageHeaderSize+int(message.Length) {
|
|
return nil, errors.New("initial ICE-TCP STUN message contains trailing data")
|
|
}
|
|
if message.Type != stun.BindingRequest {
|
|
return nil, fmt.Errorf("initial ICE-TCP STUN message has unexpected type %s", message.Type)
|
|
}
|
|
var username stun.Username
|
|
if errUsername := username.GetFrom(message); errUsername != nil {
|
|
return nil, fmt.Errorf("read initial ICE-TCP STUN username: %w", errUsername)
|
|
}
|
|
if string(username) != expectedUser {
|
|
return nil, errors.New("initial ICE-TCP STUN username does not match the media session")
|
|
}
|
|
if errIntegrity := stun.NewShortTermIntegrity(remotePassword).Check(message); errIntegrity != nil {
|
|
return nil, fmt.Errorf("verify initial ICE-TCP STUN integrity: %w", errIntegrity)
|
|
}
|
|
if errFingerprint := stun.Fingerprint.Check(message); errFingerprint != nil {
|
|
return nil, fmt.Errorf("verify initial ICE-TCP STUN fingerprint: %w", errFingerprint)
|
|
}
|
|
frame := make([]byte, len(header)+len(payload))
|
|
copy(frame, header[:])
|
|
copy(frame[len(header):], payload)
|
|
return frame, nil
|
|
}
|
|
|
|
func writeAll(writer io.Writer, data []byte) error {
|
|
for len(data) > 0 {
|
|
written, errWrite := writer.Write(data)
|
|
if errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if written <= 0 {
|
|
return io.ErrShortWrite
|
|
}
|
|
data = data[written:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func proxyScheme(rawProxyURL string) string {
|
|
trimmed := strings.TrimSpace(rawProxyURL)
|
|
if index := strings.Index(trimmed, "://"); index > 0 {
|
|
return strings.ToLower(trimmed[:index])
|
|
}
|
|
return "proxy"
|
|
}
|