Kamailio saves multiple contacts for same websocket client from same web broswer tab like for every reload

This is a very common issue with WebRTC/WebSocket clients (like SIP.js). I can see exactly why this is happening from your logs.

The Diagnosis

The issue is not a bug in your Kamailio script, but rather how the Web Browser and SIP.js behave during a page reload combined with how standard SIP registration works.

Here is the smoking gun in your log:

  1. Instance: [not set]: This is the most critical part. Your client is not sending a +sip.instance (GRUU) parameter.
  2. Randomized Contact URIs:
    • Contact 1: sip:se1tpl2q@...
    • Contact 2: sip:q2t78kmk@...

What happens during a Browser Refresh:

  1. New Connection: The browser kills the old WebSocket and opens a new one (evidenced by the Source Port changing from 63817 to 63853).
  2. New Identity: SIP.js (by default) generates a fresh, random User Part and Domain Part for the Contact URI every time the JavaScript initializes.
  3. Kamailio’s Logic: Kamailio looks at the usrloc table. It sees a registration coming in. Because the Contact URI is completely different (se1tpl2q vs q2t78kmk) and there is no “Instance ID” to link them, Kamailio assumes this is a new device and appends it to the list.

Solution 1: The “Correct” Fix (Client-Side)

You need to enforce RFC 5626 (Managing Client-Initiated Connections).

You must configure SIP.js to send a persistent instanceId. Since browsers clear memory on reload, you must generate a UUID once, store it in the browser’s localStorage, and pass it to the SIP.js UserAgent options every time the page loads.

How to fix in SIP.js (JavaScript):

JavaScript

// 1. Get existing Instance ID from LocalStorage or generate a new one
let instanceId = localStorage.getItem('sip_instance_id');
if (!instanceId) {
    // Generate a new UUID if one doesn't exist
    instanceId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
        return v.toString(16);
    });
    localStorage.setItem('sip_instance_id', instanceId);
}

// 2. Pass it to the UserAgent configuration
const userAgent = new UserAgent({
    uri: UserAgent.makeURI("sip:hello@yourdomain.com"),
    transportOptions: { server: "wss://your-kamailio-server:443" },
    // THIS IS THE FIX:
    instanceId: instanceId, 
    // ... other options
});

What changes in Kamailio: Once you do this, the logs will show Instance: [some-uuid-string]. Kamailio will recognize that the new Register comes from the same instance as the old one and will overwrite the old contact instead of adding a new one.


Solution 2: The Server-Side Workaround (Kamailio Script)

If you cannot change the client code, you must handle this in kamailio.cfg. You can force Kamailio to only keep one contact per user, or specifically limit WebSocket contacts.

Option A: Limit Contacts to 1 (Aggressive) In your route[REGISTRAR] block, modify the save() function. Passing 1 as the max_contacts parameter (depends on your registrar module version, usually via specific flags or max_contacts param).

The easiest way using the registrar module is setting the max_contacts global parameter or per call.

Bash

# In your main configuration parameters
modparam("registrar", "max_contacts", 1)

Warning: This prevents the user from registering a Desktop Phone and a Web Client at the same time.

Option B: Remove old WebSocket contacts manually (Surgical) Before saving the new registration, check if the transport is WebSocket and if the user already has contacts. This is complex to do cleanly.

Option C: Short Registration Timer Force WebSocket clients to have a very short expiration time (e.g., 60 seconds). This way, “ghost” contacts disappear quickly.

Bash

route[REGISTRAR] {
    # ... inside your registration logic ...
    if (proto == WS || proto == WSS) {
        # Force strict expiration interval for WebSockets
        # to clear out stale connections faster
        if (!is_present_hf("Expires") || $(hdr(Expires){s.int}) > 120) {
            remove_hf("Expires");
            append_hf("Expires: 120\r\n");
        }
    }
    
    save("location");
}

Recommendation

Implement Solution 1 (Client-Side Instance ID). It is the standard-compliant way to handle SIP over WebSockets. Without it, you are fighting against the protocol design.

The same happen for pjsip android app in websocket mode?

Yes, absolutely. The exact same behavior happens with PJSIP on Android (and iOS) when using WebSockets (or even TCP/TLS), but usually for slightly different reasons than a browser refresh.

In fact, on mobile, this issue can be more aggressive and harder to troubleshoot because of network switching (WiFi ↔ 4G/5G) and the operating system putting apps to sleep.

Why it happens on PJSIP (Android)

While a browser “refresh” triggers the issue in SIP.js, the following triggers it in an Android App:

  1. Network Switching (The #1 Cause):
    • When the user leaves the office (WiFi) and switches to 5G, the IP address changes.
    • The OS forces the WebSocket to disconnect and reconnect.
    • The PJSIP stack creates a new WebSocket connection (new source port).
    • If the app does not send the same Instance ID, Kamailio sees: New Source IP + New Source Port = New Contact. It adds it to the list, leaving the old “dead” WiFi contact sitting there until it expires.
  2. App Restart / Backgrounding:
    • If Android kills the app service to save battery and then restarts it (or the user force-closes it), PJSIP re-initializes.
    • If the developer did not explicitly save the Instance ID in the Android SharedPreferences and reload it, PJSIP might generate a random new Instance ID on every startup.

How to validate if your PJSIP app is doing this

Look at your Kamailio logs for the Android registration.

The “Bad” Scenario (Duplicates will occur):

Plaintext

# Log snippet
Contact: <sip:user@IP:Port;transport=ws>
Instance: [not set]  <-- THE PROBLEM
Reg-Id: 0

Result: Every time the network flakes or app restarts, you get a new contact.

The “Good” Scenario (PJSIP configured correctly):

Plaintext

# Log snippet
Contact: <sip:user@IP:Port;transport=ws;+sip.instance="<urn:uuid:1234-FIXED-UUID>">
Instance: <urn:uuid:1234-FIXED-UUID>
Reg-Id: 1

Result: Kamailio sees the UUID matches an existing record and updates the old contact (updating the socket/IP) instead of creating a ghost duplicate.

How to fix it (For the Android Developer)

If you have access to the Android source code, you must enforce RFC 5626 (SIP Outbound) in the PJSIP configuration.

In PJSIP (Java/Kotlin wrapper), you need to set the Contact parameters explicitly in the AccountConfig.

Pseudocode for the fix:

  1. Generate UUID Once: On the very first app launch, generate a UUID.
  2. Save It: Store it in SharedPreferences. Never change it.
  3. Inject into PJSIP:

Java

// Inside your PJSIP Account Configuration setup
AccountConfig accCfg = new AccountConfig();
accCfg.setIdUri("sip:user@domain.com");

// 1. Get your persistent UUID from storage
String myDeviceUUID = sharedPreferences.getString("sip_instance_id", generateNewUUID());

// 2. Configure PJSIP to send this Instance ID + Reg-ID
AccountSipConfig sipConfig = accCfg.getSipConfig();

// This tells PJSIP to use RFC 5626 Outbound
sipConfig.setInstanceId(myDeviceUUID); 
sipConfig.setContactUriParams(";transport=ws"); // Ensure transport is explicit if needed

// 3. Enable Registrar support for Path/Outbound (optional but recommended for Kamailio)
accCfg.getRegConfig().setRegisterOnAdd(true);

How to fix it in Kamailio (Server Side)

If you cannot update the Android app, you have to clean up the mess in Kamailio.

Since mobile devices (Android) using WebSockets are prone to “unclean” disconnections (they just disappear without sending a SIP REGISTER Expires: 0), you should use the max_contacts logic specifically for them.

Add this to your route[REGISTRAR]:

Code snippet

route[REGISTRAR] {
    
    # ... authentication checks ...

    # Identify Mobile/WebSocket clients
    if (proto == WS || proto == WSS) {
        
        # Option A: Brutal fix - Allow only 1 device per user for WebSockets
        # This automatically removes the old contact if a new one arrives.
        save("location", "0x04"); # 0x04 flag restricts to 1 contact (check your registrar module docs)
        
        # OR use modparam if running older Kamailio:
        # modparam("registrar", "max_contacts", 1) 
        
        exit;
    }

    # Standard save for other UDP/TCP phones
    save("location");
}

Note: The save() flags vary slightly by Kamailio version. In recent versions, you can pass limit parameters or use reg_limit module logic.

Leave a Reply