Search code examples
javascriptprotocolssiprfc

Ringing an extension Node.js SIP implementation


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:[email protected]:6111 I still get no changes. Here is what my second INVITE message looks like after I add the Authentication header

INVITE sip:[email protected]:6111 SIP/2.0
Via: SIP/2.0/UDP 192.168.1.2:6111;branch=z9hG4bK8777308828561X2
From: <sip:[email protected]>;tag=z9hG4bK1286583240470X2
To: <sip:[email protected]>
Call-ID: [email protected]
CSeq: 1 INVITE
Contact: <sip:[email protected]:6111>
Max-Forwards: 70
User-Agent: Node.js SIP Library
Content-Length: 0
Authorization: Digest username="Rob", realm="asterisk", nonce="1e5f4517", uri="sip:[email protected]: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:[email protected]>;tag=z9hG4bK1286583240470X2
To: <sip:[email protected]>;tag=as3e8a4d9d
Call-ID: [email protected]
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

Solution

  • 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.