2

I am attempting to create my own barebones SIP implementation.

Currently, I am just trying to ring another phone for initial testing purposes, while I work on the structure of my program. As you can see in the code, after successfully registering (thanks to this answer), I send an INVITE request (as shown above). However, I am not receiving a 180 RINGING response, which, according to the RFC, is what I should expect. I have tried using both the extension number and the SIP user's name, but to no avail. Do I actually need SDP to ring another extension? Could the issue lie not in the above SIP message, but possibly elsewhere in my implementation?

Here is the complete code snippet for reference:

const dgram = require("dgram");
const crypto = require("crypto");

const asteriskDOMAIN = "";
const asteriskIP = "";
const asteriskPort = "";
const clientIP = "";
const clientPort = "";
const username = "";
const password = "";
let callId;

const generateBranch = () => {
  const branchId = Math.floor(Math.random() * 10000000000000);
  return `z9hG4bK${branchId}X2`;
};

const generateCallid = () => {
  const branchId = Math.floor(Math.random() * 10000000000000);
  return `${branchId}`;
};

const Parser = {
    parse: (message) => {
        const lines = message.split('\r\n');
        const firstLine = lines.shift();
        const isResponse = firstLine.startsWith('SIP');
      
        if (isResponse) {
          // Parse SIP response
          const [protocol, statusCode, statusText] = firstLine.split(' ');
      
          const headers = {};
          let index = 0;
      
          // Parse headers
          while (index < lines.length && lines[index] !== '') {
            const line = lines[index];
            const colonIndex = line.indexOf(':');
            if (colonIndex !== -1) {
              const headerName = line.substr(0, colonIndex).trim();
              const headerValue = line.substr(colonIndex + 1).trim();
              if (headers[headerName]) {
                // If header name already exists, convert it to an array
                if (Array.isArray(headers[headerName])) {
                  headers[headerName].push(headerValue);
                } else {
                  headers[headerName] = [headers[headerName], headerValue];
                }
              } else {
                headers[headerName] = headerValue;
              }
            }
            index++;
          }
      
          // Parse message body if it exists
          const body = lines.slice(index + 1).join('\r\n');
      
          return {
            isResponse: true,
            protocol,
            statusCode: parseInt(statusCode),
            statusText,
            headers,
            body,
          };
        } else {
          // Parse SIP request
          const [method, requestUri, protocol] = firstLine.split(' ');
      
          const headers = {};
          let index = 0;
      
          // Parse headers
          while (index < lines.length && lines[index] !== '') {
            const line = lines[index];
            const colonIndex = line.indexOf(':');
            if (colonIndex !== -1) {
              const headerName = line.substr(0, colonIndex).trim();
              const headerValue = line.substr(colonIndex + 1).trim();
              if (headers[headerName]) {
                // If header name already exists, convert it to an array
                if (Array.isArray(headers[headerName])) {
                  headers[headerName].push(headerValue);
                } else {
                  headers[headerName] = [headers[headerName], headerValue];
                }
              } else {
                headers[headerName] = headerValue;
              }
            }
            index++;
          }
      
          // Parse message body if it exists
          const body = lines.slice(index + 1).join('\r\n');
      
          return {
            isResponse: false,
            method,
            requestUri,
            protocol,
            headers,
            body,
          };
        }
    },

    getResponseType: (message) => {
        var response = message.split("\r\n")[0];
        if(response.split(" ")[0].includes("SIP/2.0")){
            return response.split(" ")[1];
        }else{
            return response.split(" ")[0];
        }
        return response;
    }
}

class Builder{
    constructor(context){
        this.context = context;
        return this;
    }

    register(props){
        if(props.realm && props.nonce && props.realm != "" && props.nonce != ""){
            return {
                'Via': `SIP/2.0/UDP ${clientIP}:${clientPort};branch=${generateBranch()}`,
                'From': `<sip:${this.context.username}@${this.context.ip}>;tag=${generateBranch()}`,
                'To': `<sip:${this.context.username}@${this.context.ip}>`,
                'Call-ID': `${this.context.callId}@${clientIP}`,
                'CSeq': `${this.context.cseq_count['REGISTER']} REGISTER`,
                'Contact': `<sip:${this.context.username}@${clientIP}:${clientPort}>`,
                'Max-Forwards': '70',
                'Expires': '3600',
                'User-Agent': 'Node.js SIP Library',
                'Content-Length': '0',
                'Authorization': `Digest username="${this.context.username}", realm="${props.realm}", nonce="${props.nonce}", uri="sip:${this.context.ip}:${this.context.port}", response="${this.DigestResponse(this.context.username, this.context.password, this.context.realm, this.context.nonce, "REGISTER", `sip:${this.context.ip}:${this.context.port}`)}"`
            }
        }else{
            return {
                'Via': `SIP/2.0/UDP ${clientIP}:${clientPort};branch=${generateBranch()}`,
                'From': `<sip:${this.context.username}@${this.context.ip}>;tag=${generateBranch()}`,
                'To': `<sip:${this.context.username}@${this.context.ip}>`,
                'Call-ID': `${this.context.callId}@${clientIP}`,
                'CSeq': `${this.context.cseq_count['REGISTER']} REGISTER`,
                'Contact': `<sip:${this.context.username}@${clientIP}:${clientPort}>`,
                'Max-Forwards': '70',
                'Expires': '3600',
                'User-Agent': 'Node.js SIP Library',
                'Content-Length': '0'
            }
        }
    }

    invite(props) {
        if(props.realm && props.nonce && props.realm != "" && props.nonce != ""){
            return {
              'Via': `SIP/2.0/UDP ${clientIP}:${clientPort};branch=${generateBranch()}`,
              'From': `<sip:${this.context.username}@${this.context.ip}>;tag=${generateBranch()}`,
              'To': `<sip:${props.extension}@${this.context.ip}>`,
              'Call-ID': `${this.context.callId}@${clientIP}`,
              'CSeq': `${this.context.cseq_count['INVITE']} INVITE`,
              'Contact': `<sip:${this.context.username}@${clientIP}:${clientPort}>`,
              'Max-Forwards': '70',
              'Expires': '3600',
              'User-Agent': 'Node.js SIP Library',
              'Content-Length': '0',
              'Authorization': `Digest username="${this.context.username}", realm="${props.realm}", nonce="${props.nonce}", uri="sip:${this.context.ip}:${this.context.port}", response="${this.DigestResponse(this.context.username, this.context.password, this.context.realm, this.context.nonce, "INVITE", `sip:${this.context.ip}:${this.context.port}`)}"`
            };
        }else{
            return {
                'Via': `SIP/2.0/UDP ${clientIP}:${clientPort};branch=${generateBranch()}`,
                'From': `<sip:${this.context.username}@${this.context.ip}>;tag=${generateBranch()}`,
                'To': `<sip:${props.extension}@${this.context.ip}>`,
                'Call-ID': `${this.context.callId}@${clientIP}`,
                'CSeq': `${this.context.cseq_count['INVITE']} INVITE`,
                'Contact': `<sip:${this.context.username}@${clientIP}:${clientPort}>`,
                'Max-Forwards': '70',
                'Expires': '3600',
                'User-Agent': 'Node.js SIP Library',
                'Content-Length': '0',
              };
        }
    }

    ack(){

    }

    BuildResponse(type, props){
        var map = {
            "REGISTER": this.register(props),
            "INVITE": this.invite(props),
            "ACK": this.ack(props),
        }
        return this.JsonToSip(type, map[type]);
    }

    JsonToSip(type, props){
        var request = `${type} sip:${asteriskDOMAIN}:${asteriskPort} SIP/2.0\r\n`;
        for(let prop in props){
            request += `${prop}: ${props[prop]}\r\n`;
        }
        request += `\r\n`;
        return request;
    }

    DigestResponse(username, password, realm, nonce, method, uri) {
        const ha1 = crypto.createHash("md5")
          .update(`${username}:${realm}:${password}`)
          .digest("hex");
      
        const ha2 = crypto.createHash("md5")
          .update(`${method}:${uri}`)
          .digest("hex");
      
        const response = crypto.createHash("md5")
          .update(`${ha1}:${nonce}:${ha2}`)
          .digest("hex");
        return response;
      }
}

class SIP{
    constructor(ip, port, username, password){
        this.ip = ip;
        this.port = port;
        this.username = username;
        this.password = password;
        this.Generator = new Builder(this);
        this.Socket = dgram.createSocket("udp4");
        this.callId = generateCallid();
        this.Events = [];
        this.cseq_count = {REGISTER: 1, INVITE: 1, ACK: 1}
        return this;
    }

    send(message){
        return new Promise(resolve => {
            this.Socket.send(message, 0, message.length, this.port, this.ip, (error) => {
                if(error){
                    resolve({context: this, 'error': error})
                } else {
                    resolve({context: this, 'success':'success'});
                }
            })
        })
    }

    registerEvent(event, callback){
        this.Events.push({event: event, callback: callback});
    }

    listen(){
        this.Socket.on("message", (message) => {
            var response = message.toString();
            var type = Parser.getResponseType(response);
            if(this.Events.length > 0){
                this.Events.forEach(event => {
                    if(event.event == type){
                        event.callback(response);
                    }
                })
            }
        })
    }

    on(event, callback){
        this.Events.push({event: event, callback: callback});
    }

    start(){
        return new Promise(resolve => {
            this.listen();
            var test = this.Generator.BuildResponse("REGISTER", {})
            this.send(test).then(response => {
                if(!response.error){
                    this.on("401", (res) => {
                        var cseq = Parser.parse(res).headers.CSeq;
                        console.log(cseq);
                    
                        if(cseq == "1 REGISTER"){
                            const authenticateHeader = res.match(/WWW-Authenticate:.*realm="([^"]+)".*nonce="([^"]+)"/i);
                            if (authenticateHeader) {
                                this.realm = authenticateHeader[1];
                                this.nonce = authenticateHeader[2];                     
                                const registerRequestWithAuth = this.Generator.BuildResponse("REGISTER", {realm: this.realm, nonce: this.nonce});
                                this.send(registerRequestWithAuth).then(res => {
                                    
                                })
                            }
                        }else if (cseq == "1 INVITE"){
                            const authenticateHeader = res.match(/WWW-Authenticate:.*realm="([^"]+)".*nonce="([^"]+)"/i);
                            if (authenticateHeader) {
                                this.realm = authenticateHeader[1];
                                this.nonce = authenticateHeader[2];                     
                                const registerRequestWithAuth = this.Generator.BuildResponse("INVITE", {extension:"420", realm: this.realm, nonce: this.nonce});
                                this.send(registerRequestWithAuth).then(res => {
                                    
                                })
                            }
                        }
                    })

                    this.on("200", (res) => {
                        //console.log(Parser.parse(res));
                        var cseq = Parser.parse(res).headers.CSeq
                        if(cseq.includes("REGISTER")){
                            console.log("REGISTERED")

                        }else if(cseq.includes("INVITE")){
                            console.log("INVITED")
                        }
                        this.cseq_count[cseq.split(" ")[1]] = this.cseq_count[cseq.split(" ")[1]] + 1;
                        resolve({context: this, 'success':'success'})
                    })

                    this.on("INVITE", (res) => {
                        //console.log(res);
                    })

                    this.on("NOTIFY", (res) => {
                        //console.log(res);
                        this.send('SIP/2.0 200 OK\r\n\r\n')
                    })

                } else {
                    resolve({context: this, 'error': res.error})
                }
            })
        })
    }
}

new SIP(asteriskIP, asteriskPort, username, password).start().then(res => {
    var invite_request = res.context.Generator.BuildResponse("INVITE", {extension: "420"});
    res.context.send(invite_request).then(res => {
        
    })
});

UPDATE Upon examining the Wireshark SIP capture, I noticed that I am receiving a 401 Unauthorized SIP message after sending the above INVITE request, even though I have successfully registered. This only occurs after sending the INVITE request. Do I also need to include the authentication header in my request?

Does Asterisk require something different? Additionally, I am observing constant retransmission of the same NOTIFY SIP message, even after sending 200 OK. Although this might be irrelevant, I thought it would be helpful to mention it.

Here is a download for my Wireshark capture without sending the INVITE message. Here is a capture with the INVITE SIP message I'm not worried about people using it to log in to my PBX as it will be moved to another server and get a new IP and reconfigured entirely soon anyways so have fun.

Update 2

After some suggestions, Now I only send the Authorization header after I get a 401 Unauthorized. I found that the cseq value can be used to differentiate 401 responses. After changing this I receive a new response, 482 Loop Detected

Update 3 I noticed an error in my INVITE sip message. After changing the Request-URI to sip:420@64.227.16.15:6111 I still get no changes. Here is what my second INVITE message looks like after I add the Authentication header

INVITE sip:420@64.227.16.15:6111 SIP/2.0
Via: SIP/2.0/UDP 192.168.1.2:6111;branch=z9hG4bK8777308828561X2
From: <sip:Rob@64.227.16.15>;tag=z9hG4bK1286583240470X2
To: <sip:420@64.227.16.15>
Call-ID: 4551011395608@192.168.1.2
CSeq: 1 INVITE
Contact: <sip:Rob@192.168.1.2:6111>
Max-Forwards: 70
User-Agent: Node.js SIP Library
Content-Length: 0
Authorization: Digest username="Rob", realm="asterisk", nonce="1e5f4517", uri="sip:420@64.227.16.15:6111", response="85d378163c5e059ac3c9ee293d5e69d3"

I get this response in return

SIP/2.0 482 (Loop Detected)
Via: SIP/2.0/UDP 192.168.1.2:6111;branch=z9hG4bK8777308828561X2;received=72.172.213.173;rport=41390
From: <sip:Rob@64.227.16.15>;tag=z9hG4bK1286583240470X2
To: <sip:420@64.227.16.15>;tag=as3e8a4d9d
Call-ID: 4551011395608@192.168.1.2
CSeq: 1 INVITE
Server: Asterisk PBX 18.14.0~dfsg+~cs6.12.40431414-1
Allow: INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, SUBSCRIBE, NOTIFY, INFO, PUBLISH, MESSAGE
Supported: replaces
Content-Length: 0
Nik Hendricks
  • 244
  • 2
  • 6
  • 29
  • 2
    Hello! It's not clear if your question is about an outgoing call, and incoming call, or both. And it's a bit confusing because you have many questions! May be rewrite and list your exact question. It will be easier to make an answer! – AymericM Jun 03 '23 at 09:30
  • 2
    Your first INVITE will usually have no authorization header. Then, you receive a new 401 or 407. Then, you send a second INVITE with the authorization header. (Same call-id, and increasing cseq like for the REGISTER). After this, you can expect the 180 ringing! – AymericM Jun 04 '23 at 12:37
  • 1
    Awesome thank you for the help. Is there any way that I can figure out our contextualize what the 401 response is for? Currently, 401 triggers the REGISTER authorizations part. Is there a header or value sent back that could help me see that the 401 is a response to the invite I sent?. After doing some looking is this what the `cseq` parameter is for? – Nik Hendricks Jun 04 '23 at 18:32
  • 1
    After some changes, I am receiving a new response now. I am getting a `482 Loop Detected` I'm not sure if this is a step forward but it seems to be. Does anyone know what could be causing this? – Nik Hendricks Jun 04 '23 at 19:09
  • To match a 401 with a request, you mainly compare the *branch* of the Via. *482 Loop detected* means the server is forwarding the INVITE to himself, which probably means you use a wrong request-uri (first line of INVITE) domain: the server do not recognize himself. – AymericM Jun 04 '23 at 22:22
  • So what would i use in the request uri instead of the PBX servers IP? Would i be using the extensions IP there instead? does SIP provide a mechanism to see registered clients if that is the case? – Nik Hendricks Jun 05 '23 at 00:25
  • So your saying that I can match a request with the `branch` parameter of the `VIA` header? But currently, I regenerate it with every request. Should I only be generating a new one per dialogue? I guess i would store this in an array and the previous request to match 401's. If this is the case. I guess i'm misunderstanding what `CSEQ` is actually used for. – Nik Hendricks Jun 05 '23 at 05:41
  • 1
    **branch** is used to match a response with a request (New for every **transaction**). **Call-id** and increasing **cseq** are used to consider 2 INVITE as part of the same **dialog**. Additionally, you need to consider **To tag** and **From tag** parameters to have those INVITE being considered as part of the same **SIP dialog**! – AymericM Jun 05 '23 at 08:53
  • @AymericM Thank you for all of your help. If you would make an answer here I can mark it as answered. While your knowledge is insightful I am still confused what request-uri should I use for the invite request if I am not supposed to use my asterisk PBX URI? to avoid 482 loop detected. I feel i am very close to getting this working thanks to your help. – Nik Hendricks Jun 05 '23 at 12:57
  • So the request URI i am using now for invites is `sip:${extension}@${asterisk_ip}:${port}` but i am still getting a `482 Loop Detected` I really am not sure what i am doing wrong – Nik Hendricks Jun 06 '23 at 03:59

1 Answers1

1

As you proposed, let's try to make an answer with all rules that you must follow for INVITE.

There are several topics involved when exchanging SIP message. Among them, let's discuss those specific elements:

  • Via branch

Any new request needs a new Via branch. It needs to be random and always starts with the magic cookie z9hG4bK. It will always be copied in the SIP response for this request. Only retransmissions of SIP messages will have the same.

  • Authentication

The initial REGISTER and the initial INVITE, using very basic implementation of Digest, will be sent without any Authorization or Proxy-Authorization header

After a 401 or a 407, Authentication is required. A new REGISTER (or INVITE) will be sent with the Authorization or Proxy-Authorization header.

  • Call-ID and CSeq

When you start an application, you create a Call-ID for the initial REGISTER. Any new REGISTER, either for authentication or refreshing registration, will use the same Call-ID and will have an increased CSeq. This is ordering all successive transactions and help the server to follow the flow. It's a mandatory operation.

When you start a new call, you create a new Call-ID for the initial INVITE. Any SIP message within the same SIP dialog (same call) needs to re-use the same Call-ID and will have an increased CSeq. Again, this helps ordering on remote side and is mandatory.

  • To tag and From tag

The From tag helps to match all requests within the same SIP dialog (same call). It remains the same within call and is defined by the call initiator.

The To tag helps to match all requests within the same SIP dialog (same call). It remains the same within call and is defined/added by the call receiver. It's only known when a remote user first send a 1xx answer (like 180 Ringing) or 2xx answer (like 200 Ok).

  • Request-URI

When a server receive (and accept) a request, it needs to decide if it will forward to another operator or handle himself. To make it clear, Verizon users are not in the database of AT&T, so if a call from AT&T to Verizon arrive on AT&T, it needs to forward to Verizon.

The same is happening in Request-URI with the domain of the SIP URI. If it's not recognize, DNS is used, and request is forwarded. However, if the request, using DNS is received again on same server, we can detect a loop and reject with 482 Loop Detected

You may try to remove port from your Request-URI, or fix your asterisk config to route correctly your request. Don't know enough on asterisk to give a better hint.

NOTE: might be better to be more specific in your future questions on stackoverflow. The perimeter was a bit too large on this question. Thanks.

AymericM
  • 1,645
  • 13
  • 13
  • Thank you so much once again AymericM. My issue was a lot of things. But your explantation helped me understand it in a better way. The problem causing this turned out to be the `cseq` value not counting correctly. Thank you! – Nik Hendricks Jun 06 '23 at 22:11