From 04233cff671dbb658e6fdcdbc624571a53d7cbac Mon Sep 17 00:00:00 2001 From: hechen-eng Date: Fri, 8 May 2026 11:05:05 -0700 Subject: [PATCH 1/3] TEL-575: do not transition state for 407 --- pkg/sip/inbound.go | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 29f77b25..683f206e 100644 --- a/pkg/sip/inbound.go +++ b/pkg/sip/inbound.go @@ -158,7 +158,13 @@ func (s *Server) getInvite(sipCallID string) *inProgressInvite { return is } -func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Request, tx sip.ServerTransaction, from, username, password string) (ok bool) { +// handleInviteAuth performs SIP digest authentication on an inbound INVITE. +// The challenge return value distinguishes the normal digest handshake (where we +// just sent the initial 407 with no credentials yet provided and expect the +// client to retry) from a hard auth failure. Callers should treat +// (ok=false, challenge=true) as non-terminal so it doesn't end up recorded as +// a finalized error state. +func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Request, tx sip.ServerTransaction, from, username, password string) (ok bool, challenge bool) { log = log.WithValues( "username", username, "passwordHash", hashPassword(password), @@ -170,7 +176,7 @@ func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Re if username == "" || password == "" { log.Debugw("Skipping authentication - no credentials provided") - return true + return true, false } if s.conf.HideInboundPort { @@ -205,7 +211,7 @@ func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Re res.AppendHeader(sip.NewHeader("Proxy-Authenticate", inviteState.challenge.String())) _ = tx.Respond(res) log.Infow("No Proxy header found. Sending 407 Unauthorized response with Proxy-Authenticate header") - return false + return false, true } log.Debugw("Found Proxy-Authorization header, parsing credentials") @@ -215,7 +221,7 @@ func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Re "headerValue", h.Value(), ) _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) - return false + return false, false } // Set credURI and credUsername in logger early to avoid repetitive logging @@ -230,7 +236,7 @@ func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Re "receivedUsername", cred.Username, ) _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Unauthorized", nil)) - return false + return false, false } // Check if we have a valid challenge state @@ -240,7 +246,7 @@ func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Re "expectedRealm", UserAgent, ) _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) - return false + return false, false } log.Debugw("Computing digest response", @@ -259,7 +265,7 @@ func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Re if err != nil { log.Warnw("Failed to compute digest response", err) _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) - return false + return false, false } log.Debugw("Digest computation completed", @@ -274,11 +280,11 @@ func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Re "receivedResponse", cred.Response, ) _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Unauthorized", nil)) - return false + return false, false } log.Infow("SIP invite authentication successful") - return true + return true, false } func (s *Server) onInvite(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { @@ -457,12 +463,19 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE tryingTime = time.Now() } s.getCallInfo(cc.ID()).countInvite(log, req) - if !s.handleInviteAuth(tid, log, req, tx, from.User, r.Username, r.Password) { + if ok, challenge := s.handleInviteAuth(tid, log, req, tx, from.User, r.Username, r.Password); !ok { // Store (call-ID + from tag) to (to tag) mapping s.cmu.Lock() s.provisionalInvites.Add([2]string{cc.SIPCallID(), string(cc.Tag())}, cc.ID()) s.cmu.Unlock() cmon.InviteErrorShort(stats.ClientError("unauthorized")) + if challenge { + // We just sent the initial 407 challenge; the client is expected to + // retry with digest credentials. This is part of the normal SIP auth + // handshake, not a finalized error, so suppress the deferred state + // transition to avoid recording a 0-duration call. + state = nil + } // handleInviteAuth will generate the SIP Response as needed return psrpc.NewErrorf(psrpc.PermissionDenied, "invalid credentials were provided") } From 21b35754741f64c3c6eea85c8a96aa384a07741d Mon Sep 17 00:00:00 2001 From: hechen-eng Date: Fri, 8 May 2026 13:23:34 -0700 Subject: [PATCH 2/3] update call status with SCS_ERROR if second invite is not received in time --- pkg/sip/inbound.go | 34 ++++++++++++++++++++++++++++++---- pkg/sip/server.go | 6 ++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 683f206e..5fdc6f8e 100644 --- a/pkg/sip/inbound.go +++ b/pkg/sip/inbound.go @@ -66,6 +66,7 @@ const ( inviteOKRetryAttempts = 5 inviteOKRetryAttemptsNoACK = 2 inviteOkAckLateTimeout = inviteOkRetryIntervalMax + authChallengeTimeout = 30 * time.Second ) var allowHeader = sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE") @@ -158,6 +159,24 @@ func (s *Server) getInvite(sipCallID string) *inProgressInvite { return is } +// scheduleAuthChallengeTimeout finalizes st as SCS_ERROR after authChallengeTimeout +// unless authResolved is set to true (by a follow-up INVITE) before the timer fires. +func (i *inProgressInvite) scheduleAuthChallengeTimeout(st *CallState, log logger.Logger) { + i.authResolved.Store(false) + time.AfterFunc(authChallengeTimeout, func() { + if !i.authResolved.CompareAndSwap(false, true) { + return + } + log.Infow("auth challenge timed out without authenticated retry; finalizing call as error", + "sipCallID", i.sipCallID, "timeout", authChallengeTimeout) + st.Update(context.Background(), func(info *livekit.SIPCallInfo) { + info.CallStatus = livekit.SIPCallStatus_SCS_ERROR + info.Error = "auth challenge issued, no authenticated retry received" + info.EndedAtNs = time.Now().UnixNano() + }) + }) +} + // handleInviteAuth performs SIP digest authentication on an inbound INVITE. // The challenge return value distinguishes the normal digest handshake (where we // just sent the initial 407 with no credentials yet provided and expect the @@ -462,6 +481,14 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE cc.Processing() tryingTime = time.Now() } + sipCallID := "" + if h := req.CallID(); h != nil { + sipCallID = h.Value() + } + inviteState := s.getInvite(sipCallID) + // New INVITE supersedes any pending 407-challenge timer for this Call-ID. + inviteState.authResolved.Store(true) + s.getCallInfo(cc.ID()).countInvite(log, req) if ok, challenge := s.handleInviteAuth(tid, log, req, tx, from.User, r.Username, r.Password); !ok { // Store (call-ID + from tag) to (to tag) mapping @@ -470,10 +497,9 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE s.cmu.Unlock() cmon.InviteErrorShort(stats.ClientError("unauthorized")) if challenge { - // We just sent the initial 407 challenge; the client is expected to - // retry with digest credentials. This is part of the normal SIP auth - // handshake, not a finalized error, so suppress the deferred state - // transition to avoid recording a 0-duration call. + // 407 sent: defer finalization to the timer or the next INVITE, + // not the deferred handler at the top of processInvite. + inviteState.scheduleAuthChallengeTimeout(state, log) state = nil } // handleInviteAuth will generate the SIP Response as needed diff --git a/pkg/sip/server.go b/pkg/sip/server.go index 9b3c3384..7116cba7 100644 --- a/pkg/sip/server.go +++ b/pkg/sip/server.go @@ -24,6 +24,7 @@ import ( "net" "net/netip" "sync" + "sync/atomic" "time" "github.com/frostbyte73/core" @@ -167,8 +168,9 @@ type Server struct { } type inProgressInvite struct { - sipCallID string - challenge digest.Challenge + sipCallID string + challenge digest.Challenge + authResolved atomic.Bool } type ServerOption func(s *Server) From 87acc8069ea33a341d399673db16b76ce8f056b9 Mon Sep 17 00:00:00 2001 From: hechen-eng Date: Fri, 8 May 2026 18:06:43 -0700 Subject: [PATCH 3/3] update --- pkg/sip/inbound.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 5fdc6f8e..2cab93df 100644 --- a/pkg/sip/inbound.go +++ b/pkg/sip/inbound.go @@ -163,6 +163,7 @@ func (s *Server) getInvite(sipCallID string) *inProgressInvite { // unless authResolved is set to true (by a follow-up INVITE) before the timer fires. func (i *inProgressInvite) scheduleAuthChallengeTimeout(st *CallState, log logger.Logger) { i.authResolved.Store(false) + challengedAt := time.Now() time.AfterFunc(authChallengeTimeout, func() { if !i.authResolved.CompareAndSwap(false, true) { return @@ -172,7 +173,8 @@ func (i *inProgressInvite) scheduleAuthChallengeTimeout(st *CallState, log logge st.Update(context.Background(), func(info *livekit.SIPCallInfo) { info.CallStatus = livekit.SIPCallStatus_SCS_ERROR info.Error = "auth challenge issued, no authenticated retry received" - info.EndedAtNs = time.Now().UnixNano() + // EndedAtNs reflects when the call effectively ended. + info.EndedAtNs = challengedAt.UnixNano() }) }) }