Вызов расширения SIP-реализация Node.js

Я пытаюсь создать свою собственную базовую реализацию SIP.

В настоящее время я просто пытаюсь вызвать на другой телефон для первоначального тестирования, пока я работаю над структурой своей программы. Как видно из кода, после успешной регистрации (благодаря этому ответу) я отправляю INVITE запрос (как показано выше). Однако я не получаю ответа 180 RINGING, чего, согласно RFC, мне и следует ожидать. Я пытался использовать как добавочный номер, так и имя пользователя SIP, но безрезультатно. Действительно ли мне нужен SDP, чтобы звонить другому добавочному номеру? Может ли проблема заключаться не в приведенном выше SIP-сообщении, а, возможно, в другом месте моей реализации?

Вот полный фрагмент кода для справки:

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.info(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.info(Parser.parse(res));
                        var cseq = Parser.parse(res).headers.CSeq
                        if (cseq.includes("REGISTER")){
                            console.info("REGISTERED")

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

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

                    this.on("NOTIFY", (res) => {
                        //console.info(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 => {
        
    })
});

ОБНОВЛЯТЬ Изучив захват SIP Wireshark, я заметил, что получаю 401 Unauthorized SIP-сообщение после отправки вышеуказанного INVITE запроса, хотя я успешно зарегистрировался. Это происходит только после отправки запроса INVITE. Нужно ли мне также включать заголовок аутентификации в мой запрос?

Требует ли Asterisk чего-то другого? Кроме того, я наблюдаю постоянную повторную передачу одного и того же NOTIFY SIP-сообщения, даже после отправки 200 OK. Хотя это может быть неуместно, я подумал, что было бы полезно упомянуть об этом.

Вот загрузка моего захвата Wireshark без отправки сообщения INVITE. Вот захват с INVITE SIP-сообщением. Я не беспокоюсь о том, что люди используют его для входа в мою АТС, так как она будет перемещена на другой сервер, получит новый IP и полностью перенастроится в ближайшее время, так что получайте удовольствие.

Обновление 2

После некоторых предложений теперь я отправляю заголовок Authorization только после того, как получу 401 Unauthorized. Я обнаружил, что значение cseq можно использовать для дифференциации ответов 401. После изменения этого я получаю новый ответ, 482 Loop Detected

Обновление 3 Я заметил ошибку в своем INVITE sip-сообщении. После изменения Request-URI на sip:[email protected]:6111 я все еще не получаю никаких изменений. Вот как выглядит мое второе сообщение INVITE после добавления заголовка Authentication

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"

Я получаю этот ответ в ответ

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

Привет! Неясно, касается ли ваш вопрос исходящего звонка, входящего звонка или того и другого. И это немного сбивает с толку, потому что у вас много вопросов! Можно переписать и указать свой точный вопрос. Ответить будет проще!

AymericM 03.06.2023 11:30

Ваш первый INVITE обычно не будет иметь заголовка авторизации. Затем вы получаете новый 401 или 407. Затем вы отправляете второй INVITE с заголовком авторизации. (Тот же идентификатор вызова и увеличение cseq, как и для РЕГИСТРА). После этого можно ожидать 180 звон!

AymericM 04.06.2023 14:37

Круто спасибо за помощь. Есть ли способ, которым я могу понять нашу контекстуализацию, для чего предназначен ответ 401? В настоящее время 401 запускает часть авторизации REGISTER. Есть ли заголовок или значение, отправленное обратно, которое могло бы помочь мне увидеть, что 401 является ответом на приглашение, которое я отправил? После некоторого поиска, для этого ли предназначен параметр cseq?

Nik Hendricks 04.06.2023 20:32

После некоторых изменений я получаю новый ответ. Я получаю 482 Loop Detected Я не уверен, что это шаг вперед, но, похоже, это так. Кто-нибудь знает, что может быть причиной этого?

Nik Hendricks 04.06.2023 21:09

Чтобы сопоставить 401 с запросом, вы в основном сравниваете ветку Via. 482 Обнаружена петля означает, что сервер пересылает INVITE самому себе, что, вероятно, означает, что вы используете неправильный домен запроса-uri (первая строка INVITE): сервер не распознает себя.

AymericM 05.06.2023 00:22

Итак, что бы я использовал в запросе uri вместо IP-адреса серверов АТС? Буду ли я использовать IP-адрес расширения вместо этого? предоставляет ли SIP механизм для просмотра зарегистрированных клиентов, если это так?

Nik Hendricks 05.06.2023 02:25

Итак, вы говорите, что я могу сопоставить запрос с параметром branch заголовка VIA? Но в настоящее время я регенерирую его с каждым запросом. Должен ли я создавать новый только для каждого диалога? Думаю, я бы сохранил это в массиве, а предыдущий запрос соответствовал 401-му. Если это так. Наверное, я неправильно понимаю, для чего на самом деле используется CSEQ.

Nik Hendricks 05.06.2023 07:41

ветвь используется для сопоставления ответа с запросом (новый для каждой транзакции). Call-id и увеличивающаяся cseq используются для того, чтобы рассматривать 2 INVITE как часть одного и того же диалога. Кроме того, вам необходимо учитывать параметры тега To tag и From tag, чтобы эти ПРИГЛАШЕНИЯ рассматривались как часть одного и того же диалога SIP!

AymericM 05.06.2023 10:53

@AymericM Спасибо за вашу помощь. Если вы ответите здесь, я могу пометить его как отвеченный. Хотя ваши знания проницательны, я все еще не понимаю, какой request-uri мне следует использовать для запроса на приглашение, если я не должен использовать свой URI АТС Asterisk? чтобы избежать обнаружения петли 482. Я чувствую, что очень близок к тому, чтобы заставить это работать благодаря вашей помощи.

Nik Hendricks 05.06.2023 14:57

Итак, URI запроса, который я сейчас использую для приглашений, sip:${extension}@${asterisk_ip}:${port}, но я все еще получаю 482 Loop Detected Я действительно не уверен, что я делаю неправильно

Nik Hendricks 06.06.2023 05:59
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
В настоящее время производительность загрузки веб-сайта имеет решающее значение не только для удобства пользователей, но и для ранжирования в...
Безумие обратных вызовов в javascript [JS]
Безумие обратных вызовов в javascript [JS]
Здравствуйте! Юный падаван 🚀. Присоединяйся ко мне, чтобы разобраться в одной из самых запутанных концепций, когда вы начинаете изучать мир...
Система управления парковками с использованием HTML, CSS и JavaScript
Система управления парковками с использованием HTML, CSS и JavaScript
Веб-сайт по управлению парковками был создан с использованием HTML, CSS и JavaScript. Это простой сайт, ничего вычурного. Основная цель -...
JavaScript Вопросы с множественным выбором и ответы
JavaScript Вопросы с множественным выбором и ответы
Если вы ищете платформу, которая предоставляет вам бесплатный тест JavaScript MCQ (Multiple Choice Questions With Answers) для оценки ваших знаний,...
2
10
87
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Как вы и предложили, давайте попробуем составить ответ со всеми правилами, которые вы должны соблюдать для INVITE.

При обмене SIP-сообщениями возникает несколько тем. Среди них давайте обсудим эти конкретные элементы:

  • Через филиал

Для любого нового запроса требуется новая ветвь Via. Он должен быть случайным и всегда начинается с волшебного файла cookie z9hG4bK. Он всегда будет копироваться в SIP-ответе на этот запрос. Только повторные передачи SIP-сообщений будут иметь то же самое.

  • Аутентификация

Первоначальный REGISTER и начальный INVITE, используя очень простую реализацию Digest, будут отправлены без каких-либо заголовков авторизации или прокси-авторизации.

После 401 или 407 требуется аутентификация. Новый REGISTER (или INVITE) будет отправлен с заголовком Authorization или Proxy-Authorization.

  • Call-ID и CSeq

Когда вы запускаете приложение, вы создаете Call-ID для начального REGISTER. Любой новый REGISTER для аутентификации или обновления регистрации будет использовать тот же Call-ID и будет иметь увеличенный CSeq. Это упорядочивает все последовательные транзакции и помогает серверу следить за потоком. Это обязательная операция.

Когда вы начинаете новый вызов, вы создаете новый Call-ID для первоначального INVITE. Любое SIP-сообщение в том же диалоговом окне SIP (тот же вызов) должно повторно использовать один и тот же Call-ID и будет иметь увеличенный CSeq. Опять же, это помогает заказывать на удаленной стороне и является обязательным.

  • В тег и из тега

Тег From помогает сопоставить все запросы в одном и том же диалоговом окне SIP (один и тот же вызов). Он остается неизменным в пределах вызова и определяется инициатором вызова.

Тег To помогает сопоставить все запросы в одном и том же диалоговом окне SIP (один и тот же вызов). Он остается неизменным в пределах вызова и определяется/добавляется получателем вызова. Это известно только тогда, когда удаленный пользователь сначала отправляет ответ 1xx (например, 180 Ringing) или ответ 2xx (например, 200 Ok).

  • Запрос-URI

Когда сервер получает (и принимает) запрос, ему необходимо решить, будет ли он переадресован другому оператору или будет обрабатываться самостоятельно. Чтобы было ясно, пользователи Verizon не находятся в базе данных AT&T, поэтому, если звонок от AT&T к Verizon поступает на AT&T, его необходимо перенаправить в Verizon.

То же самое происходит в Request-URI с доменом SIP URI. Если он не распознается, используется DNS, и запрос перенаправляется. Однако, если запрос с использованием DNS снова получен на том же сервере, мы можем обнаружить петлю и отклонить ее с ошибкой 482 Loop Detected.

Вы можете попытаться удалить порт из своего Request-URI или исправить конфигурацию звездочки, чтобы правильно направить ваш запрос. Не знаю достаточно о звездочке, чтобы дать лучший намек.

ПРИМЕЧАНИЕ. Возможно, в будущих вопросах о stackoverflow будет лучше указать более конкретные вопросы. Периметр был слишком велик в этом вопросе. Спасибо.

Большое спасибо еще раз, AymericM. Моя проблема заключалась во многих вещах. Но ваше объяснение помогло мне лучше понять это. Проблема, вызвавшая это, оказалась в неправильном подсчете значения cseq. Спасибо!

Nik Hendricks 07.06.2023 00:11

Другие вопросы по теме