-
Notifications
You must be signed in to change notification settings - Fork 109
Reuse the same ID for both auth-less and auth-ful INVITEs #488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b720467
0bc3d6e
0634af3
3fa9108
6906ea9
ef60152
6155ab5
1a0eb73
de0faf0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ import ( | |
|
|
||
| msdk "github.com/livekit/media-sdk" | ||
| "github.com/livekit/protocol/rpc" | ||
| "github.com/livekit/protocol/utils" | ||
|
|
||
| "github.com/frostbyte73/core" | ||
| "github.com/icholy/digest" | ||
|
|
@@ -64,6 +65,8 @@ const ( | |
| inviteOKRetryAttempts = 5 | ||
| inviteOKRetryAttemptsNoACK = 2 | ||
| inviteOkAckLateTimeout = inviteOkRetryIntervalMax | ||
|
|
||
| inviteCredentialValidity = 60 * time.Minute // Allow reuse of credentials for 1h | ||
| ) | ||
|
|
||
| var errNoACK = errors.New("no ACK received for 200 OK") | ||
|
|
@@ -134,23 +137,44 @@ func (s *Server) getCallInfo(id string) *inboundCallInfo { | |
| return c | ||
| } | ||
|
|
||
| func (s *Server) getInvite(sipCallID string) *inProgressInvite { | ||
| s.imu.Lock() | ||
| defer s.imu.Unlock() | ||
| for i := range s.inProgressInvites { | ||
| if s.inProgressInvites[i].sipCallID == sipCallID { | ||
| return s.inProgressInvites[i] | ||
| func (s *Server) cleanupInvites() { | ||
| ticker := time.NewTicker(5 * time.Minute) // Periodic cleanup every 5 minutes | ||
| defer ticker.Stop() | ||
| for { | ||
| select { | ||
| case <-s.closing.Watch(): | ||
| return | ||
| case <-ticker.C: | ||
| s.imu.Lock() | ||
| for it := s.inviteTimeoutQueue.IterateRemoveAfter(inviteCredentialValidity); it.Next(); { | ||
| key := it.Item().Value | ||
| delete(s.inProgressInvites, *key) | ||
| } | ||
| s.imu.Unlock() | ||
| } | ||
| } | ||
| if len(s.inProgressInvites) >= digestLimit { | ||
| s.inProgressInvites = s.inProgressInvites[1:] | ||
alexlivekit marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| func (s *Server) getInvite(sipCallID, toTag, fromTag string) *inProgressInvite { | ||
| key := dialogKey{ | ||
| sipCallID: sipCallID, | ||
| toTag: toTag, | ||
| fromTag: fromTag, | ||
| } | ||
| s.imu.Lock() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A common practice to avoid lock contention is to use s.imu.RLock()
is, ok := s.inProgressInvites[key]
s.imu.RUnlock()
if ok {
return is
}
s.imu.Lock()
defer s.imu.Unlock()
is, ok := s.inProgressInvites[key]
if ok {
return is
}
// ... the rest ...This allows multiple readers to get the invite state without blocking each other. Also notice that we redo the check after getting a write lock - other routine might create the state earlier. |
||
| defer s.imu.Unlock() | ||
| is, ok := s.inProgressInvites[key] | ||
| if ok { | ||
| return is | ||
| } | ||
| is := &inProgressInvite{sipCallID: sipCallID} | ||
| s.inProgressInvites = append(s.inProgressInvites, is) | ||
| is = &inProgressInvite{sipCallID: sipCallID} | ||
| s.inProgressInvites[key] = is | ||
| s.inviteTimeoutQueue.Reset(&utils.TimeoutQueueItem[*dialogKey]{Value: &key}) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like we do not reset the cache expiry time if we have a cache hit. Is it intentional? |
||
|
|
||
| return is | ||
| } | ||
|
|
||
| func (s *Server) handleInviteAuth(log logger.Logger, req *sip.Request, tx sip.ServerTransaction, from, username, password string) (ok bool) { | ||
| func (s *Server) handleInviteAuth(log logger.Logger, req *sip.Request, tx sip.ServerTransaction, from, username, password string, inviteState *inProgressInvite) (ok bool) { | ||
| log = log.WithValues( | ||
| "username", username, | ||
| "passwordHash", hashPassword(password), | ||
|
|
@@ -178,8 +202,6 @@ func (s *Server) handleInviteAuth(log logger.Logger, req *sip.Request, tx sip.Se | |
| } | ||
| ci := s.getCallInfo(sipCallID) | ||
| ci.countInvite(log, req) | ||
| inviteState := s.getInvite(sipCallID) | ||
| log = log.WithValues("inviteStateSipCallID", sipCallID) | ||
alexlivekit marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| h := req.GetHeader("Proxy-Authorization") | ||
| if h == nil { | ||
|
|
@@ -220,7 +242,6 @@ func (s *Server) handleInviteAuth(log logger.Logger, req *sip.Request, tx sip.Se | |
| // Check if we have a valid challenge state | ||
| if inviteState.challenge.Realm == "" { | ||
| log.Warnw("No challenge state found for authentication attempt", errors.New("missing challenge state"), | ||
| "sipCallID", sipCallID, | ||
alexlivekit marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "expectedRealm", UserAgent, | ||
| ) | ||
| _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) | ||
|
|
@@ -295,18 +316,18 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE | |
| s.log.Errorw("cannot parse source IP", err, "fromIP", src) | ||
| return psrpc.NewError(psrpc.MalformedRequest, errors.Wrap(err, "cannot parse source IP")) | ||
| } | ||
| callID := lksip.NewCallID() | ||
| sipCallID := legCallIDFromReq(req) | ||
| tr := callTransportFromReq(req) | ||
| legTr := legTransportFromReq(req) | ||
| log := s.log.WithValues( | ||
| "callID", callID, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The one log line that will not have |
||
| "sipCallID", sipCallID, | ||
| "fromIP", src.Addr(), | ||
| "toIP", req.Destination(), | ||
| "transport", tr, | ||
| ) | ||
|
|
||
| var call *inboundCall | ||
| cc := s.newInbound(log, LocalTag(callID), s.ContactURI(legTr), req, tx, func(headers map[string]string) map[string]string { | ||
| cc := s.newInbound(log, s.ContactURI(legTr), req, tx, func(headers map[string]string) map[string]string { | ||
| c := call | ||
| if c == nil || len(c.attrsToHdr) == 0 { | ||
| return headers | ||
|
|
@@ -319,17 +340,43 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE | |
| }) | ||
| log = LoggerWithParams(log, cc) | ||
| log = LoggerWithHeaders(log, cc) | ||
| cc.log = log | ||
| log.Infow("processing invite") | ||
alexlivekit marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if err := cc.ValidateInvite(); err != nil { | ||
| log.Errorw("invalid invite", err) | ||
| if s.conf.HideInboundPort { | ||
| cc.Drop() | ||
| } else { | ||
| cc.RespondAndDrop(sip.StatusBadRequest, "Bad request") | ||
| } | ||
| return psrpc.NewError(psrpc.InvalidArgument, errors.Wrap(err, "invite validation failed")) | ||
| } | ||
|
|
||
| // Establish ID | ||
| fromTag, _ := req.From().Params.Get("tag") // always exists, via ValidateInvite() check | ||
| toParams := req.To().Params // To() always exists, via ValidateInvite() check | ||
| if toParams == nil { | ||
| toParams = sip.NewParams() | ||
| req.To().Params = toParams | ||
| } | ||
| toTag, ok := toParams.Get("tag") | ||
| if !ok { | ||
| // No to-tag on the invite means we need to generate one per RFC 3261 section 12. | ||
| // Generate a new to-tag early, to make sure both INVITES have the same ID. | ||
| toTag = utils.NewGuid("") | ||
| toParams.Add("tag", toTag) | ||
| } | ||
| inviteProgress := s.getInvite(sipCallID, toTag, fromTag) | ||
| callID := inviteProgress.lkCallID | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No locking here. I assume our re-invite handling will catch a duplicate invite if it arrives at the same time? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's right - locking is done in |
||
| if callID == "" { | ||
| callID = lksip.NewCallID() | ||
| inviteProgress.lkCallID = callID | ||
| } | ||
| cc.id = LocalTag(callID) | ||
|
|
||
| log = log.WithValues("callID", callID) | ||
| cc.log = log | ||
| log.Infow("processing invite") | ||
|
|
||
| ctx, span := tracer.Start(ctx, "Server.onInvite") | ||
| defer span.End() | ||
|
|
||
|
|
@@ -352,12 +399,6 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE | |
| cc.Processing() | ||
| } | ||
|
|
||
| // Extract SIP Call ID directly from the request | ||
| sipCallID := "" | ||
| if h := req.CallID(); h != nil { | ||
| sipCallID = h.Value() | ||
| } | ||
|
|
||
| callInfo := &rpc.SIPCall{ | ||
| LkCallId: callID, | ||
| SipCallId: sipCallID, | ||
|
|
@@ -421,7 +462,7 @@ func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retE | |
| // We will send password request anyway, so might as well signal that the progress is made. | ||
| cc.Processing() | ||
| } | ||
| if !s.handleInviteAuth(log, req, tx, from.User, r.Username, r.Password) { | ||
| if !s.handleInviteAuth(log, req, tx, from.User, r.Username, r.Password, inviteProgress) { | ||
| cmon.InviteErrorShort("unauthorized") | ||
| // handleInviteAuth will generate the SIP Response as needed | ||
| return psrpc.NewErrorf(psrpc.PermissionDenied, "invalid credentials were provided") | ||
|
|
@@ -1293,11 +1334,10 @@ func (c *inboundCall) transferCall(ctx context.Context, transferTo string, heade | |
|
|
||
| } | ||
|
|
||
| func (s *Server) newInbound(log logger.Logger, id LocalTag, contact URI, invite *sip.Request, inviteTx sip.ServerTransaction, getHeaders setHeadersFunc) *sipInbound { | ||
| func (s *Server) newInbound(log logger.Logger, contact URI, invite *sip.Request, inviteTx sip.ServerTransaction, getHeaders setHeadersFunc) *sipInbound { | ||
| c := &sipInbound{ | ||
| log: log, | ||
| s: s, | ||
| id: id, | ||
| invite: invite, | ||
| inviteTx: inviteTx, | ||
| legTr: legTransportFromReq(invite), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -828,7 +828,7 @@ authLoop: | |
| if err != nil { | ||
| return nil, fmt.Errorf("invalid challenge %q: %w", challengeStr, err) | ||
| } | ||
| toHeader := resp.To() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that we're doing the right validation on the inbound side, the outbound side E2E test caught this error! |
||
| toHeader = resp.To() | ||
| if toHeader == nil { | ||
| return nil, errors.New("no 'To' header on Response") | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -24,6 +24,7 @@ import ( | |||||
| "net" | ||||||
| "net/netip" | ||||||
| "sync" | ||||||
| "sync/atomic" | ||||||
| "time" | ||||||
|
|
||||||
| "github.com/frostbyte73/core" | ||||||
|
|
@@ -35,6 +36,7 @@ import ( | |||||
| "github.com/livekit/protocol/livekit" | ||||||
| "github.com/livekit/protocol/logger" | ||||||
| "github.com/livekit/protocol/rpc" | ||||||
| "github.com/livekit/protocol/utils" | ||||||
| "github.com/livekit/sipgo" | ||||||
| "github.com/livekit/sipgo/sip" | ||||||
|
|
||||||
|
|
@@ -43,8 +45,7 @@ import ( | |||||
| ) | ||||||
|
|
||||||
| const ( | ||||||
| UserAgent = "LiveKit" | ||||||
| digestLimit = 500 | ||||||
| UserAgent = "LiveKit" | ||||||
| ) | ||||||
|
|
||||||
| const ( | ||||||
|
|
@@ -123,6 +124,12 @@ type Handler interface { | |||||
| OnSessionEnd(ctx context.Context, callIdentifier *CallIdentifier, callInfo *livekit.SIPCallInfo, reason string) | ||||||
| } | ||||||
|
|
||||||
| type dialogKey struct { | ||||||
| sipCallID string | ||||||
| toTag string | ||||||
| fromTag string | ||||||
| } | ||||||
|
|
||||||
| type Server struct { | ||||||
| log logger.Logger | ||||||
| mon *stats.Monitor | ||||||
|
|
@@ -132,8 +139,10 @@ type Server struct { | |||||
| sipListeners []io.Closer | ||||||
| sipUnhandled RequestHandler | ||||||
|
|
||||||
| imu sync.Mutex | ||||||
| inProgressInvites []*inProgressInvite | ||||||
| imu sync.Mutex | ||||||
| inProgressInvites map[dialogKey]*inProgressInvite | ||||||
| inviteTimeoutQueue utils.TimeoutQueue[*dialogKey] | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
We probably don't need a pointer here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a convention in Go called a Mutex hat, which implies But, the queue implementation already has a mutex internally. So might be worth moving it out of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, this is neat, thank you for poinitng it out! |
||||||
| isCleanupTaskRunning atomic.Bool | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seem unused |
||||||
|
|
||||||
| closing core.Fuse | ||||||
| cmu sync.RWMutex | ||||||
|
|
@@ -155,20 +164,22 @@ type Server struct { | |||||
| type inProgressInvite struct { | ||||||
| sipCallID string | ||||||
| challenge digest.Challenge | ||||||
| lkCallID string // SCL_* LiveKit call ID assigned to this dialog | ||||||
| } | ||||||
|
|
||||||
| func NewServer(region string, conf *config.Config, log logger.Logger, mon *stats.Monitor, getIOClient GetIOInfoClient) *Server { | ||||||
| if log == nil { | ||||||
| log = logger.GetLogger() | ||||||
| } | ||||||
| s := &Server{ | ||||||
| log: log, | ||||||
| conf: conf, | ||||||
| region: region, | ||||||
| mon: mon, | ||||||
| getIOClient: getIOClient, | ||||||
| activeCalls: make(map[RemoteTag]*inboundCall), | ||||||
| byLocal: make(map[LocalTag]*inboundCall), | ||||||
| log: log, | ||||||
| conf: conf, | ||||||
| region: region, | ||||||
| mon: mon, | ||||||
| getIOClient: getIOClient, | ||||||
| inProgressInvites: make(map[dialogKey]*inProgressInvite), | ||||||
| activeCalls: make(map[RemoteTag]*inboundCall), | ||||||
| byLocal: make(map[LocalTag]*inboundCall), | ||||||
| } | ||||||
| s.infos.byCallID = expirable.NewLRU[string, *inboundCallInfo](maxCallCache, nil, callCacheTTL) | ||||||
| s.initMediaRes() | ||||||
|
|
@@ -309,6 +320,9 @@ func (s *Server) Start(agent *sipgo.UserAgent, sc *ServiceConfig, tlsConf *tls.C | |||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Start the cleanup task | ||||||
| go s.cleanupInvites() | ||||||
|
|
||||||
| return nil | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure this is smart, might want to extend this to max_call_duration or something.
Also, keep in mind that this is per Call-ID for now, so new calls would still need re-auth.