Inbound SIP dispatch returns DispatchNoRuleDrop despite verified matching trunk/rule (self-hosted) — call rings forever, no final SIP response ever sent

Environment:

  • livekit-server 1.9.2 (self-hosted, Docker, host networking)
  • livekit-sip v1.6.0 (self-hosted, Docker, same host)
  • Redis shared between both services (localhost:6379), confirmed matching
    config on both sides
  • SIP provider: Telnyx, IP-authenticated Connection (no registration),
    Italian DID

Symptom:
Every real inbound call from Telnyx is silently swallowed. livekit-sip logs “Rejecting inbound flood” (case DispatchNoRuleDrop in pkg/sip/inbound.go) even though the trunk is correctly matched (sipTrunk present in the log) and the trunk/dispatch rule configuration is verified correct via the API AND directly in Redis (raw HGET on sip_inbound_trunk / sip_dispatch_rule hashes matches the API output exactly, byte for byte).

Critical detail found via packet capture (tcpdump on port 5060):
livekit-sip sends 100 Trying + 180 Ringing BEFORE evaluating the dispatch rule (StartRinging() is called before DispatchCall() in handleInvite).
When dispatch evaluation then returns DispatchNoRuleDrop, the code calls cc.Drop() — which does NOT send any SIP response at all. No 486, no BYE, nothing. The call just rings forever from the caller’s perspective; the
“status”: 486 field in the JSON log is purely an internal bookkeeping value, not something actually transmitted. In our testing the call kept ringing until we manually hung up (confirmed via tcpdump — CANCEL only
appears after we hang up, ~30s+ into the call, never triggered by our side).

What I’ve ruled out so far (fresh test call after each change):

  1. Number format (with/without leading “+”) — fixed and verified against the actual “To” user seen in the SIP INVITE.
  2. Incomplete IP allowlist — expanded allowed_addresses to all 12 documented Telnyx signaling IPs across all regions. Confirmed both a US (192.76.120.10) and EU (185.246.41.140, host sip.telnyx.eu) signaling
    IP hit our trunk correctly (sipTrunk resolved) and both still get DispatchNoRuleDrop.
  3. Stale in-memory state in livekit-sip — restarted the container (new nodeID confirmed), no change.
  4. Stale in-memory state in livekit-server — restarted that container too (workers re-registered automatically within seconds), no change.
  5. Ambiguous match between two dispatch rules sharing the same room_prefix on different trunks — temporarily deleted the unrelated trunk’s dispatch rule, no change.
  6. Dispatch rule type — tried both SIPDispatchRuleIndividual and SIPDispatchRuleDirect, identical rejection in both cases.
  7. Update vs. create code path — suspected that UpdateSIPInboundTrunk might not refresh whatever internal index the live dispatch matcher uses (as opposed to the raw hash we could read directly). Deleted and recreated both the trunk and the dispatch rule from scratch (create only, no update calls at all) with a brand new trunk ID — identical rejection.
  8. Directly inspected Redis (HGET sip_inbound_trunk <id> / HGET sip_dispatch_rule <id>) immediately before a test call: the raw stored protobuf matches the trunk numbers, allowed_addresses, dispatch rule
    trunk_ids/inbound_numbers/room_config.agents exactly as reported by the admin API. No discrepancy between storage and what the API reports.

Enabled debug logging (note: the correct config key is the nested logging: {level: debug}, not a top-level log_level key from older examples, which is silently ignored) to confirm the exact code path is
consistently case DispatchNoRuleDrop in inbound.go, meaning livekit-server’s dispatch evaluation itself returns “no matching rule, drop” for this call, despite everything we can inspect (API + Redis)
being correct.

So, my questions are:

  1. Is there a known cause for DispatchNoRuleDrop being returned when the trunk/dispatch rule configuration is verifiably correct in both the API and the underlying Redis storage?
  2. Separately from the root cause: is cc.Drop() on DispatchNoRuleDrop intended to skip sending any final SIP response even after a 180 Ringing was already sent? That seems like a bug in its own right regardless of why dispatch evaluation fails — it leaves the caller’s phone ringing indefinitely instead of failing fast.

Happy to share full debug logs, a redacted pcap, or run additional diagnostics live if that helps track this down.

Thanks all

Your DispatchNoRuleDrop result actually narrows this down more than it looks, and I think it rules out most of what you have already tested.

The result code tells you auth already succeeded

DispatchNoRuleDrop is a member of DispatchResult, which is the enum for the dispatch rule evaluation stage. Auth has its own separate enum, AuthResult, with its own failure values:

  • AuthResult: AuthNotFound, AuthDrop, AuthPassword, AuthAccept, AuthQuotaExceeded, AuthNoTrunkFound
  • DispatchResult: DispatchAccept, DispatchRequestPin, DispatchNoRuleReject, DispatchNoRuleDrop, DispatchServiceUnavailable

(both listed here: sip package - github.com/livekit/sip/pkg/sip - Go Packages)

So landing in the DispatchNoRuleDrop branch rather than one of the Auth* branches means the trunk was found and authenticated. Your IP allowlist expansion, the + prefix testing against trunk numbers, the delete-and-recreate cycle, the raw HGET on sip_inbound_trunk: all of that verified a stage that had already passed. The failure is entirely at the dispatch rule matching stage.

Worth confirming against the other reports, because they look identical in the logs but are a different failure. In livekit/sip#520 the server logs No SIP trunk matched for auth from service/ioservice_sip.go immediately before the flood rejection, because Twilio was putting the call SID in the To user instead of the DID. If your livekit-server logs do not contain that line, you are not in that case, and trunk-level config is not your problem.

Most likely cause: inbound_numbers filters the caller, not the callee

You mention verifying inbound_numbers on the dispatch rule. That field does not mean “DIDs this rule serves”. On SIPDispatchRuleInfo, when inbound_numbers is populated the rule only accepts calls placed from numbers in that list, and a caller whose number is absent is rejected. It filters the From header.

If you populated it with your Italian DID, which is the natural reading of the name and is what most people do, then no real caller will ever match, the rule drops out of the candidate set, and evaluation returns “no rule”. That produces exactly your symptom while leaving the trunk match, the IP allowlist, the Redis contents and the API output all looking perfectly correct, because they are correct.

There are actually two separate caller-side number filters, and both are easy to conflate with the DID field:

Field Object Matches on
numbers inbound trunk called number (your DID)
allowed_numbers inbound trunk calling number
inbound_numbers dispatch rule calling number

Filling either of the bottom two with your DID silently kills every call. Check both.

Fastest test: create a dispatch rule with no inbound_numbers at all and empty trunk_ids (empty matches every trunk), remove allowed_numbers from the trunk, delete or disable every other rule, and place one call. If it connects, that is your answer.

Field semantics are documented here: https://docs.livekit.io/reference/telephony/sip-api.md

Other things to check at the rule stage

Wildcard rules you have not accounted for. You deleted the rule on the unrelated trunk, but a rule with empty trunk_ids applies to every trunk, so it still participates in conflict detection even though it was not created for yours. The protocol package validates the whole rule set (ValidateDispatchRulesIter, MatchDispatchRuleIter), and a conflicting set can fail matching rather than picking a winner. Run lk sip dispatch list unfiltered and look at every rule, not just the ones you think are relevant.

Compare against LiveKit’s parsed values, not the pcap. In the processing invite log line, toUser and fromUser are what LiveKit extracted, and those strings are what matching actually consumes. Diff them against your trunk numbers and against any caller-side filter. If Telnyx presents Italian callers in national format on some calls and E.164 on others, a caller-side filter passes intermittently and looks like flakiness rather than config.

Version skew. livekit-server 1.9.2 against livekit-sip v1.6.0 is a wide gap, and the matching logic lives in livekit-server via the livekit/protocol sip package, not in livekit-sip. Since the SIP side is the newer of the two, aligning them (upgrading the server especially) removes a variable that is very hard to see from either side’s logs.

Explicit agent dispatch. You have room_config.agents set. That cannot cause DispatchNoRuleDrop, so it is not the current problem, but see livekit/sip#401, where named agents in a dispatch rule did not dispatch while unnamed agents worked. If you clear the number filters and calls start connecting but the agent never joins, that is the next thing to look at.

On question 2: the silent drop is intentional, and your objection still holds

Yes, cc.Drop() sending no final response is deliberate. The enum comment on DispatchNoRuleDrop says the call is silently dropped, as distinct from DispatchNoRuleReject, which exists specifically to reject with an error. The intent is anti-enumeration: a port that answers unmatched INVITEs with 404 or 486 confirms to a scanner that a SIP service lives there, so unmatched traffic gets nothing.

That said, your point stands. StartRinging() running before DispatchCall() means 100 and 180 have already gone out by the time the drop decision is made. The port is already disclosed, so the silence buys no stealth, and the only remaining effect is a legitimate caller ringing until their own timer expires. Silent before any response would be defensible. Silent after a 180 is the worst of both.

Your report is stronger than the existing ones because of the pcap distinction, which nobody else has documented. In livekit/sip#407 the caller genuinely does receive 486 Busy Here on the flood-protection path, so a 486 is transmitted there. Yours shows DispatchNoRuleDrop transmitting nothing while logging a status: 486 that never leaves the box. Two code paths, same log shape, different wire behaviour, and one of them makes the status field actively misleading during triage.

That seems worth filing on livekit/sip as its own issue with the pcap attached, independent of whatever the config root cause turns out to be. Two concrete asks:

  1. Send a final response once a provisional has already gone out, or make the drop decision before StartRinging().
  2. Stop labelling this reason: "flood". Several people (livekit/sip#362, #400, #477, #520) have burned days chasing rate limits for what is really a config mismatch. A distinct reason string for the no-rule case would have saved me most of my test matrix.