2023-06-05

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



No comments:

Post a Comment