diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 29f77b25..2cab93df 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,7 +159,33 @@ 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) { +// 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) + challengedAt := time.Now() + 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" + // EndedAtNs reflects when the call effectively ended. + info.EndedAtNs = challengedAt.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 +// 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 +197,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 +232,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 +242,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 +257,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 +267,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 +286,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 +301,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) { @@ -456,13 +483,27 @@ 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 !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 { + // 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 return psrpc.NewErrorf(psrpc.PermissionDenied, "invalid credentials were provided") } 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)