723 lines
20 KiB
Go
723 lines
20 KiB
Go
package live
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
|
log "github.com/sirupsen/logrus"
|
|
xproxy "golang.org/x/net/proxy"
|
|
)
|
|
|
|
const (
|
|
defaultSidebandAPIBaseURL = "wss://api.openai.com/v1"
|
|
sessionLifetime = time.Hour
|
|
)
|
|
|
|
var (
|
|
callIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
|
sidebandUpgrader = websocket.Upgrader{
|
|
ReadBufferSize: 4096,
|
|
WriteBufferSize: 4096,
|
|
CheckOrigin: func(*http.Request) bool {
|
|
return true
|
|
},
|
|
}
|
|
)
|
|
|
|
type liveSession struct {
|
|
callID string
|
|
authID string
|
|
model string
|
|
ownerPrincipal string
|
|
ownerProvider string
|
|
clientSecretPrincipal string
|
|
homeSelection *auth.HomeDispatchSelection
|
|
media mediaRelaySession
|
|
resources *liveSessionResources
|
|
token uint64
|
|
}
|
|
|
|
type liveSessionResources struct {
|
|
mu sync.Mutex
|
|
closed bool
|
|
closers []func() error
|
|
}
|
|
|
|
type storedSession struct {
|
|
session liveSession
|
|
claimed bool
|
|
timer *time.Timer
|
|
}
|
|
|
|
type sessionStore struct {
|
|
mu sync.Mutex
|
|
next uint64
|
|
lifetime time.Duration
|
|
sessions map[string]*storedSession
|
|
}
|
|
|
|
type sessionClaim int
|
|
|
|
const (
|
|
sessionClaimMissing sessionClaim = iota
|
|
sessionClaimBusy
|
|
sessionClaimAcquired
|
|
)
|
|
|
|
func newSessionStore() *sessionStore {
|
|
return &sessionStore{
|
|
lifetime: sessionLifetime,
|
|
sessions: make(map[string]*storedSession),
|
|
}
|
|
}
|
|
|
|
func (s *sessionStore) put(callID string, session liveSession) liveSession {
|
|
if s == nil || !callIDPattern.MatchString(callID) {
|
|
endLiveSession(session, "invalid_call_id")
|
|
return liveSession{}
|
|
}
|
|
|
|
if session.resources == nil {
|
|
session.resources = &liveSessionResources{}
|
|
}
|
|
s.mu.Lock()
|
|
s.next++
|
|
session.callID = callID
|
|
session.token = s.next
|
|
previous := s.sessions[callID]
|
|
entry := &storedSession{session: session}
|
|
entry.timer = time.AfterFunc(s.expiryDuration(), func() {
|
|
s.expire(callID, session.token)
|
|
})
|
|
s.sessions[callID] = entry
|
|
s.mu.Unlock()
|
|
|
|
if previous != nil {
|
|
if previous.timer != nil {
|
|
previous.timer.Stop()
|
|
}
|
|
if previous.session.resources != nil && previous.session.resources != session.resources {
|
|
previous.session.resources.close()
|
|
}
|
|
if previous.session.media != nil && previous.session.media != session.media {
|
|
if errClose := previous.session.media.CloseWithReason("session_replaced"); errClose != nil {
|
|
log.WithError(errClose).Debug("codex live media: close replaced session")
|
|
}
|
|
}
|
|
if previous.session.homeSelection != session.homeSelection {
|
|
endHomeSelection(previous.session, "session_replaced")
|
|
}
|
|
}
|
|
return session
|
|
}
|
|
|
|
func (s *sessionStore) claim(callID string) (liveSession, sessionClaim) {
|
|
if s == nil || !callIDPattern.MatchString(callID) {
|
|
return liveSession{}, sessionClaimMissing
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
entry := s.sessions[callID]
|
|
if entry == nil {
|
|
return liveSession{}, sessionClaimMissing
|
|
}
|
|
if entry.claimed {
|
|
return liveSession{}, sessionClaimBusy
|
|
}
|
|
entry.claimed = true
|
|
if entry.timer != nil {
|
|
entry.timer.Stop()
|
|
entry.timer = nil
|
|
}
|
|
return entry.session, sessionClaimAcquired
|
|
}
|
|
|
|
func (s *sessionStore) release(session liveSession) {
|
|
if s == nil || session.callID == "" {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
entry := s.sessions[session.callID]
|
|
if entry == nil || entry.session.token != session.token || !entry.claimed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
entry.claimed = false
|
|
entry.timer = time.AfterFunc(s.expiryDuration(), func() {
|
|
s.expire(session.callID, session.token)
|
|
})
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *sessionStore) complete(session liveSession, reason string) {
|
|
if s == nil || session.callID == "" {
|
|
endLiveSession(session, reason)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
entry := s.sessions[session.callID]
|
|
if entry == nil || entry.session.token != session.token {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
delete(s.sessions, session.callID)
|
|
if entry.timer != nil {
|
|
entry.timer.Stop()
|
|
}
|
|
s.mu.Unlock()
|
|
endLiveSession(entry.session, reason)
|
|
}
|
|
|
|
func (s *sessionStore) closeAll(reason string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
entries := make([]*storedSession, 0, len(s.sessions))
|
|
for callID, entry := range s.sessions {
|
|
delete(s.sessions, callID)
|
|
if entry.timer != nil {
|
|
entry.timer.Stop()
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
s.mu.Unlock()
|
|
for _, entry := range entries {
|
|
endLiveSession(entry.session, reason)
|
|
}
|
|
}
|
|
|
|
func (s *sessionStore) expiryDuration() time.Duration {
|
|
if s.lifetime > 0 {
|
|
return s.lifetime
|
|
}
|
|
return sessionLifetime
|
|
}
|
|
|
|
func (s *sessionStore) expire(callID string, token uint64) {
|
|
s.mu.Lock()
|
|
entry := s.sessions[callID]
|
|
if entry == nil || entry.session.token != token || entry.claimed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
delete(s.sessions, callID)
|
|
s.mu.Unlock()
|
|
endLiveSession(entry.session, "session_expired")
|
|
}
|
|
|
|
func (s *sessionStore) peek(callID string) (liveSession, bool) {
|
|
if s == nil {
|
|
return liveSession{}, false
|
|
}
|
|
s.mu.Lock()
|
|
entry := s.sessions[callID]
|
|
s.mu.Unlock()
|
|
if entry == nil {
|
|
return liveSession{}, false
|
|
}
|
|
return entry.session, true
|
|
}
|
|
|
|
func endLiveSession(session liveSession, reason string) {
|
|
if session.resources != nil {
|
|
session.resources.close()
|
|
}
|
|
if session.media != nil {
|
|
if errClose := session.media.CloseWithReason(reason); errClose != nil {
|
|
log.WithError(errClose).Debug("codex live media: close stored session")
|
|
}
|
|
}
|
|
endHomeSelection(session, reason)
|
|
}
|
|
|
|
func endHomeSelection(session liveSession, reason string) {
|
|
if session.homeSelection != nil {
|
|
session.homeSelection.End(reason)
|
|
}
|
|
}
|
|
|
|
func (r *liveSessionResources) add(closers ...func() error) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
if !r.closed {
|
|
r.closers = append(r.closers, closers...)
|
|
r.mu.Unlock()
|
|
return
|
|
}
|
|
r.mu.Unlock()
|
|
closeSessionResources(closers)
|
|
}
|
|
|
|
func (r *liveSessionResources) close() {
|
|
if r == nil {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
if r.closed {
|
|
r.mu.Unlock()
|
|
return
|
|
}
|
|
r.closed = true
|
|
closers := r.closers
|
|
r.closers = nil
|
|
r.mu.Unlock()
|
|
closeSessionResources(closers)
|
|
}
|
|
|
|
func closeSessionResources(closers []func() error) {
|
|
for _, closer := range closers {
|
|
if closer == nil {
|
|
continue
|
|
}
|
|
if errClose := closer(); errClose != nil && !isNormalWebsocketClose(errClose) {
|
|
log.WithError(errClose).Debug("codex live: close session resource")
|
|
}
|
|
}
|
|
}
|
|
|
|
type sidebandStyle int
|
|
|
|
const (
|
|
sidebandFrameless sidebandStyle = iota
|
|
sidebandRealtimeCalls
|
|
sidebandRealtimeQuery
|
|
)
|
|
|
|
// HandleSideband relays live session sideband WebSocket frames bidirectionally.
|
|
func (h *Handler) HandleSideband(c *gin.Context) {
|
|
if h == nil || h.authManager == nil || h.sessions == nil {
|
|
writeLiveError(c, http.StatusServiceUnavailable, "Codex live sideband unavailable")
|
|
return
|
|
}
|
|
runtimeConfig := h.currentConfig()
|
|
if !websocket.IsWebSocketUpgrade(c.Request) {
|
|
c.Header("Upgrade", "websocket")
|
|
writeLiveError(c, http.StatusUpgradeRequired, "WebSocket upgrade required")
|
|
return
|
|
}
|
|
|
|
style, callID, ok := sidebandTarget(c)
|
|
if !ok {
|
|
writeLiveError(c, http.StatusBadRequest, "Invalid Codex live call ID")
|
|
return
|
|
}
|
|
session, claim := h.sessions.claim(callID)
|
|
switch claim {
|
|
case sessionClaimBusy:
|
|
writeLiveError(c, http.StatusConflict, "Codex live session already joining")
|
|
return
|
|
case sessionClaimAcquired:
|
|
default:
|
|
writeLiveError(c, http.StatusNotFound, "Codex live session not found")
|
|
return
|
|
}
|
|
if principal, hasClientSecret := c.Get(ClientSecretPrincipalContextKey); hasClientSecret {
|
|
principalValue, _ := principal.(string)
|
|
if session.clientSecretPrincipal == "" || principalValue != session.clientSecretPrincipal {
|
|
h.sessions.release(session)
|
|
writeRealtimeError(c, http.StatusForbidden, "Realtime client secret is not valid for this call", "invalid_request_error", "realtime_client_secret_scope_mismatch")
|
|
return
|
|
}
|
|
} else if ownerPrincipal, ownerProvider := requestOwner(c); session.ownerPrincipal != "" && (ownerPrincipal != session.ownerPrincipal || ownerProvider != session.ownerProvider) {
|
|
h.sessions.release(session)
|
|
writeRealtimeError(c, http.StatusForbidden, "Realtime call belongs to another API principal", "invalid_request_error", "realtime_call_scope_mismatch")
|
|
return
|
|
}
|
|
consumeSession := false
|
|
defer func() {
|
|
if consumeSession {
|
|
h.sessions.complete(session, "session_closed")
|
|
return
|
|
}
|
|
h.sessions.release(session)
|
|
}()
|
|
|
|
ctx := context.WithValue(c.Request.Context(), "gin", c)
|
|
ctx = coreexecutor.WithDownstreamWebsocket(ctx)
|
|
var selection *auth.HomeDispatchSelection
|
|
var selected *auth.Auth
|
|
var errSelect error
|
|
if session.homeSelection != nil {
|
|
if !session.homeSelection.Active() {
|
|
consumeSession = true
|
|
writeLiveError(c, http.StatusServiceUnavailable, "Codex live Home selection unavailable")
|
|
return
|
|
}
|
|
selection = session.homeSelection
|
|
selected = selection.CloneAuth()
|
|
} else {
|
|
selectionOpts := coreexecutor.Options{
|
|
Headers: liveSelectionHeaders(c),
|
|
Metadata: map[string]any{
|
|
coreexecutor.PinnedAuthMetadataKey: session.authID,
|
|
coreexecutor.ExecutionSessionMetadataKey: callID,
|
|
},
|
|
}
|
|
selection, selected, errSelect = h.selectOAuth(ctx, session.model, selectionOpts)
|
|
}
|
|
if errSelect != nil {
|
|
writeSelectionError(c, errSelect)
|
|
return
|
|
}
|
|
if selected == nil {
|
|
writeLiveError(c, http.StatusServiceUnavailable, "Codex auth unavailable")
|
|
return
|
|
}
|
|
|
|
if selection != nil {
|
|
attemptCtx, releaseAttempt, errAttempt := selection.AttemptContext(ctx)
|
|
if errAttempt != nil {
|
|
consumeSession = true
|
|
writeLiveError(c, http.StatusServiceUnavailable, errAttempt.Error())
|
|
return
|
|
}
|
|
ctx = attemptCtx
|
|
defer releaseAttempt()
|
|
}
|
|
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
|
|
|
upstreamURL := buildSidebandURL(h.sidebandAPIBaseURL, style, callID)
|
|
upstreamHTTPURL := websocketHTTPURL(upstreamURL)
|
|
dialUpstream := func(current *auth.Auth) (*websocket.Conn, *http.Response, error) {
|
|
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, upstreamHTTPURL, nil)
|
|
if errRequest != nil {
|
|
return nil, nil, errRequest
|
|
}
|
|
req.Header = protocolHeaders(c.Request.Header)
|
|
setAccountHeader(req.Header, current)
|
|
if errPrepare := h.authManager.PrepareHttpRequest(ctx, current, req); errPrepare != nil {
|
|
return nil, nil, errPrepare
|
|
}
|
|
authType, authValue := current.AccountInfo()
|
|
helps.RecordAPIWebsocketRequest(ctx, runtimeConfig, helps.UpstreamRequestLog{
|
|
URL: upstreamURL,
|
|
Method: "WEBSOCKET",
|
|
Headers: headersForLogging(req.Header),
|
|
Provider: "codex",
|
|
AuthID: current.ID,
|
|
AuthLabel: current.Label,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
dialer := newProxyAwareSidebandDialer(runtimeConfig, current)
|
|
dialer.Subprotocols = websocket.Subprotocols(c.Request)
|
|
return dialer.DialContext(ctx, upstreamURL, req.Header)
|
|
}
|
|
|
|
upstream, handshakeResponse, errDial := dialUpstream(selected)
|
|
if errDial != nil && selection != nil && handshakeResponse != nil && handshakeResponse.StatusCode == http.StatusUnauthorized {
|
|
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", session.model)
|
|
helps.RecordAPIWebsocketHandshake(ctx, runtimeConfig, handshakeResponse.StatusCode, callResponseHeaders(handshakeResponse.Header))
|
|
if handshakeResponse.Body != nil {
|
|
if errClose := handshakeResponse.Body.Close(); errClose != nil {
|
|
log.Errorf("codex live sideband: close unauthorized handshake body error: %v", errClose)
|
|
}
|
|
}
|
|
refreshed, didRefresh, errRefresh := h.authManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected)
|
|
if errRefresh != nil {
|
|
writeSelectionError(c, errRefresh)
|
|
return
|
|
}
|
|
if !didRefresh || refreshed == nil {
|
|
writeLiveError(c, http.StatusUnauthorized, "Codex credential unauthorized")
|
|
return
|
|
}
|
|
selected = refreshed
|
|
logging.SetGinCPATraceID(c, selected.EnsureIndex())
|
|
upstream, handshakeResponse, errDial = dialUpstream(selected)
|
|
if errDial != nil && handshakeResponse != nil && handshakeResponse.StatusCode == http.StatusUnauthorized {
|
|
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", session.model)
|
|
}
|
|
}
|
|
if errDial != nil {
|
|
handleSidebandDialError(c, ctx, runtimeConfig, handshakeResponse, errDial)
|
|
return
|
|
}
|
|
if handshakeResponse != nil {
|
|
helps.RecordAPIWebsocketHandshake(ctx, runtimeConfig, handshakeResponse.StatusCode, callResponseHeaders(handshakeResponse.Header))
|
|
if handshakeResponse.Body != nil {
|
|
if errClose := handshakeResponse.Body.Close(); errClose != nil {
|
|
log.Errorf("codex live sideband: close handshake response body error: %v", errClose)
|
|
}
|
|
}
|
|
}
|
|
|
|
closeUpstream := websocketCloseFunc("upstream", upstream)
|
|
if selection != nil {
|
|
if errBind := selection.Bind(closeUpstream); errBind != nil {
|
|
consumeSession = true
|
|
writeLiveError(c, http.StatusServiceUnavailable, errBind.Error())
|
|
return
|
|
}
|
|
} else {
|
|
defer func() { _ = closeUpstream() }()
|
|
}
|
|
|
|
upgradeHeaders := make(http.Header)
|
|
if subprotocol := upstream.Subprotocol(); subprotocol != "" {
|
|
upgradeHeaders.Set("Sec-WebSocket-Protocol", subprotocol)
|
|
}
|
|
downstream, errUpgrade := sidebandUpgrader.Upgrade(c.Writer, c.Request, upgradeHeaders)
|
|
if errUpgrade != nil {
|
|
_ = closeUpstream()
|
|
return
|
|
}
|
|
closeDownstream := websocketCloseFunc("downstream", downstream)
|
|
if selection != nil {
|
|
if errBind := selection.Bind(closeDownstream); errBind != nil {
|
|
consumeSession = true
|
|
return
|
|
}
|
|
} else {
|
|
defer func() { _ = closeDownstream() }()
|
|
}
|
|
if session.resources != nil {
|
|
session.resources.add(closeUpstream, closeDownstream)
|
|
}
|
|
consumeSession = true
|
|
|
|
if errRelay := relayWebsockets(downstream, upstream); errRelay != nil && !isNormalWebsocketClose(errRelay) {
|
|
helps.RecordAPIWebsocketError(ctx, runtimeConfig, "relay", errRelay)
|
|
log.WithError(errRelay).Debug("codex live sideband relay closed")
|
|
}
|
|
}
|
|
|
|
func sidebandTarget(c *gin.Context) (sidebandStyle, string, bool) {
|
|
if c == nil || c.Request == nil || c.Request.URL == nil {
|
|
return sidebandFrameless, "", false
|
|
}
|
|
if callID := strings.TrimSpace(c.Param("call_id")); callID != "" {
|
|
style := sidebandFrameless
|
|
if strings.Contains(c.Request.URL.Path, "/realtime/calls/") {
|
|
style = sidebandRealtimeCalls
|
|
}
|
|
return style, callID, callIDPattern.MatchString(callID)
|
|
}
|
|
callID := strings.TrimSpace(c.Query("call_id"))
|
|
return sidebandRealtimeQuery, callID, callIDPattern.MatchString(callID)
|
|
}
|
|
|
|
func buildSidebandURL(baseURL string, style sidebandStyle, callID string) string {
|
|
root := strings.TrimRight(baseURL, "/")
|
|
switch style {
|
|
case sidebandRealtimeCalls:
|
|
return root + "/realtime/calls/" + callID
|
|
case sidebandRealtimeQuery:
|
|
return root + "/realtime?intent=quicksilver&call_id=" + url.QueryEscape(callID)
|
|
default:
|
|
return root + "/live/" + callID
|
|
}
|
|
}
|
|
|
|
func websocketHTTPURL(rawURL string) string {
|
|
parsed, errParse := url.Parse(rawURL)
|
|
if errParse != nil {
|
|
return rawURL
|
|
}
|
|
switch strings.ToLower(parsed.Scheme) {
|
|
case "ws":
|
|
parsed.Scheme = "http"
|
|
case "wss":
|
|
parsed.Scheme = "https"
|
|
}
|
|
return parsed.String()
|
|
}
|
|
|
|
func callIDFromLocation(location string) string {
|
|
location = strings.TrimSpace(location)
|
|
if callIDPattern.MatchString(location) {
|
|
return location
|
|
}
|
|
parsed, errParse := url.Parse(location)
|
|
if errParse != nil {
|
|
return ""
|
|
}
|
|
if callID := strings.TrimSpace(parsed.Query().Get("call_id")); callIDPattern.MatchString(callID) {
|
|
return callID
|
|
}
|
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
callID := parts[len(parts)-1]
|
|
previous := parts[len(parts)-2]
|
|
if !callIDPattern.MatchString(callID) || (previous != "live" && previous != "calls") {
|
|
return ""
|
|
}
|
|
return callID
|
|
}
|
|
|
|
func handleSidebandDialError(c *gin.Context, ctx context.Context, cfg *config.Config, response *http.Response, errDial error) {
|
|
status := clienterror.HTTPStatusFromErrorOr(errDial, http.StatusBadGateway)
|
|
if response != nil {
|
|
if response.StatusCode > 0 {
|
|
status = response.StatusCode
|
|
}
|
|
copyRealtimeHandshakeHeaders(c.Writer.Header(), response.Header)
|
|
helps.RecordAPIWebsocketHandshake(ctx, cfg, response.StatusCode, callResponseHeaders(response.Header))
|
|
if response.Body != nil {
|
|
if errClose := response.Body.Close(); errClose != nil {
|
|
log.Errorf("codex live sideband: close rejected handshake body error: %v", errClose)
|
|
}
|
|
}
|
|
}
|
|
helps.RecordAPIWebsocketError(ctx, cfg, "dial", errDial)
|
|
writeLiveError(c, status, "Codex live sideband upstream unavailable")
|
|
}
|
|
|
|
func websocketCloseFunc(name string, conn *websocket.Conn) func() error {
|
|
var once sync.Once
|
|
var closeErr error
|
|
return func() error {
|
|
once.Do(func() {
|
|
closeErr = conn.Close()
|
|
if closeErr != nil && !isNormalWebsocketClose(closeErr) {
|
|
log.Debugf("codex live sideband: close %s websocket error: %v", name, closeErr)
|
|
}
|
|
})
|
|
return closeErr
|
|
}
|
|
}
|
|
|
|
func relayWebsockets(downstream, upstream *websocket.Conn) error {
|
|
results := make(chan error, 2)
|
|
go func() { results <- copyWebsocket(upstream, downstream) }()
|
|
go func() { results <- copyWebsocket(downstream, upstream) }()
|
|
|
|
firstErr := <-results
|
|
closeCode, closeReason := websocketCloseDetails(firstErr)
|
|
payload := websocket.FormatCloseMessage(closeCode, closeReason)
|
|
_ = downstream.WriteControl(websocket.CloseMessage, payload, time.Time{})
|
|
_ = upstream.WriteControl(websocket.CloseMessage, payload, time.Time{})
|
|
_ = downstream.Close()
|
|
_ = upstream.Close()
|
|
<-results
|
|
return firstErr
|
|
}
|
|
|
|
func copyWebsocket(destination, source *websocket.Conn) error {
|
|
for {
|
|
messageType, reader, errReader := source.NextReader()
|
|
if errReader != nil {
|
|
return errReader
|
|
}
|
|
writer, errWriter := destination.NextWriter(messageType)
|
|
if errWriter != nil {
|
|
return errWriter
|
|
}
|
|
_, errCopy := io.Copy(writer, reader)
|
|
errClose := writer.Close()
|
|
if errCopy != nil {
|
|
return errCopy
|
|
}
|
|
if errClose != nil {
|
|
return errClose
|
|
}
|
|
}
|
|
}
|
|
|
|
func websocketCloseDetails(err error) (int, string) {
|
|
var closeErr *websocket.CloseError
|
|
if errors.As(err, &closeErr) {
|
|
switch closeErr.Code {
|
|
case websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure, websocket.CloseTLSHandshake:
|
|
return websocket.CloseNormalClosure, ""
|
|
default:
|
|
return closeErr.Code, closeErr.Text
|
|
}
|
|
}
|
|
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
|
return websocket.CloseNormalClosure, ""
|
|
}
|
|
return websocket.CloseInternalServerErr, "relay closed"
|
|
}
|
|
|
|
func isNormalWebsocketClose(err error) bool {
|
|
if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
|
return true
|
|
}
|
|
return websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived)
|
|
}
|
|
|
|
func newProxyAwareSidebandDialer(cfg *config.Config, selected *auth.Auth) *websocket.Dialer {
|
|
return newSidebandDialer(proxyURLForAuth(cfg, selected))
|
|
}
|
|
|
|
func proxyURLForAuth(cfg *config.Config, selected *auth.Auth) string {
|
|
if selected != nil && strings.TrimSpace(selected.ProxyURL) != "" {
|
|
return strings.TrimSpace(selected.ProxyURL)
|
|
}
|
|
if cfg != nil {
|
|
return strings.TrimSpace(cfg.ProxyURL)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func newSidebandDialer(proxyURL string) *websocket.Dialer {
|
|
dialer := &websocket.Dialer{Proxy: http.ProxyFromEnvironment}
|
|
if strings.TrimSpace(proxyURL) == "" {
|
|
return dialer
|
|
}
|
|
|
|
setting, errParse := proxyutil.Parse(proxyURL)
|
|
if errParse != nil {
|
|
log.Errorf("codex live sideband: %v", errParse)
|
|
return dialer
|
|
}
|
|
switch setting.Mode {
|
|
case proxyutil.ModeDirect:
|
|
dialer.Proxy = nil
|
|
return dialer
|
|
case proxyutil.ModeProxy:
|
|
default:
|
|
return dialer
|
|
}
|
|
|
|
switch setting.URL.Scheme {
|
|
case "socks5", "socks5h":
|
|
var proxyAuth *xproxy.Auth
|
|
if setting.URL.User != nil {
|
|
username := setting.URL.User.Username()
|
|
password, _ := setting.URL.User.Password()
|
|
proxyAuth = &xproxy.Auth{User: username, Password: password}
|
|
}
|
|
socksDialer, errSOCKS5 := xproxy.SOCKS5("tcp", setting.URL.Host, proxyAuth, xproxy.Direct)
|
|
if errSOCKS5 != nil {
|
|
log.Errorf("codex live sideband: create SOCKS5 dialer failed: %v", errSOCKS5)
|
|
return dialer
|
|
}
|
|
dialer.Proxy = nil
|
|
if contextDialer, ok := socksDialer.(xproxy.ContextDialer); ok {
|
|
dialer.NetDialContext = contextDialer.DialContext
|
|
} else {
|
|
dialer.NetDialContext = func(_ context.Context, network, address string) (net.Conn, error) {
|
|
return socksDialer.Dial(network, address)
|
|
}
|
|
}
|
|
case "http", "https":
|
|
dialer.Proxy = http.ProxyURL(setting.URL)
|
|
default:
|
|
log.Errorf("codex live sideband: unsupported proxy scheme: %s", setting.URL.Scheme)
|
|
}
|
|
return dialer
|
|
}
|