Пишем свой мессенджер P2P

70af33808f784de395d274f13a2c630a.png


На фоне обсуждения будущего интернет мессенджеров и прочтения статьи «Почему ваш любимый мессенджер должен умереть», решил поделиться своим опытом создания P2P приложения для общения независимо от сторонних серверов. Точнее — это просто заготовка, передающая одно сообщение от клиента серверу, дальнейшее расширение функционала зависит только от Вашей фантазии.
В этой публикации мы напишем 3 простых приложения для связи P2P из любой точки Земного шара — клиент, сервер и сигнальный сервер.

Нам понадобится:
— один сервер с белым статическим IP адресом;
— 2 компьютера за NAT с типом соединения Full Cone NAT (либо 1 компьютер с 2-мя виртуальными машинами);
— STUN-сервер.

Full Cone NAT — это такой тип преобразования сетевых адресов, при котором существует однозначная трансляция между парами «внутренний адрес: внутренний порт» и «публичный адрес: публичный порт».

Вот, что мы можем прочесть о STUN-сервере на Wiki:

«Существуют протоколы, использующие пакеты UDP для передачи голоса, изображения или текста по IP-сетям. К сожалению, если обе общающиеся стороны находятся за NAT«ом, соединение не может быть установлено обычным способом. Именно здесь STUN и оказывается полезным. Он позволяет клиенту, находящемуся за сервером трансляции адресов (или за несколькими такими серверами), определить свой внешний IP-адрес, способ трансляции адреса и порта во внешней сети, связанный с определённым внутренним номером порта.»


При решении задачи использовались следующие питоновские модули: socket, twisted, stun, sqlite3, os, sys.

Для обмена данными, как между Сервером и Клиентом, так и между Сервером, Клиентом и Сигнальным Сервером — используется UDP протокол.

В общих чертах механизм функционирования выглядит так:

Сервер <-> STUN сервер
Клиент <-> STUN сервер

Сервер <-> Сигнальный Сервер
Клиент <-> Сигнальный Сервер

Клиент → Сервер

1. Клиент, находясь за NAT с типом соединения Full Cone NAT, отправляет сообщение на STUN сервер, получает ответ в виде своего внешнего IP и открытого PORT;

2. Сервер, находясь за NAT с типом соединения Full Cone NAT, отправляет сообщение на STUN сервер, получает ответ в виде своего внешнего IP и открытого PORT;

При этом, Клиенту и Серверу известен внешний (белый) IP и PORT Сигнального Сервера;

3. Сервер отправляет на Сигнальный Сервер данные о своих внешних IP и PORT, Сигнальный Сервер их сохраняет;

4. Клиент отправляет на Сигнальный Сервер данные о своих внешних IP и PORT и id_destination искомого Сервера, для которого ожидает его внешний IP, PORT.

Сигнальный Сервер их сохраняет, осуществляет поиск по базе, используя id_destination и, в ответ, отдает найденную информацию в виде строки: 'id_host, name_host, ip_host, port_host';

5. Клиент принимает найденную информацию, разбивает по разделителю и, используя (ip_host, port_host), отправляет сообщение Серверу.

Приложения написаны на Python версии 2.7, протестированы под Debian 7.7.

Создадим файл server.py с содержимым:

server.py
# -*- coding: utf-8 -*-
#SERVER 

from socket import *
import sys
import stun

def sigserver_exch():
# СЕРВЕР <-> СИГНАЛЬНЫЙ СЕРВЕР
# СЕРВЕР <- КЛИЕНТ

# СЕРВЕР - отправляет запрос на СИГНАЛЬНЫЙ СЕРВЕР с белым статическим IP со своими данными о текущих значениях IP и PORT. Принимает запрос от КЛИЕНТА.

    #Внешний IP и PORT СИГНАЛЬНОГО СЕРВЕРА:
        v_sig_host = 'XX.XX.XX.XX'
        v_sig_port = XXXX

    #id этого КЛИЕНТА, имя этого КЛИЕНТА, id искомого СЕРВЕРА
        v_id_client = 'id_server_1002'
        v_name_client = 'name_server_2'
        v_id_server = 'none'

    #IP и PORT этого КЛИЕНТА
        v_ip_localhost = 'XX.XX.XX.XX'
        v_port_localhost = XXXX

    udp_socket = ''
    try:
        #Получаем текущий внешний IP и PORT при помощи утилиты STUN
        nat_type, external_ip, external_port = stun.get_ip_info()

        #Присваиваем переменным белый IP и PORT сигнального сервера для отправки запроса
        host_sigserver = v_sig_host
        port_sigserver = v_sig_port
        addr_sigserv = (host_sigserver,port_sigserver)

        #Заполняем словарь данными для отправки на СИГНАЛЬНЫЙ СЕРВЕР: 
        #текущий id + имя + текущий внешний IP и PORT,
        #и id_dest - укажем 'none'
        #В качестве id можно использовать хеш случайного числа + соль
        data_out = v_id_client + ',' + v_name_client + ',' + external_ip + ',' + str(external_port) + ',' + v_id_server

        #Создадим сокет с атрибутами: 
        #использовать пространство интернет адресов (AF_INET), 
        #передавать данные в виде отдельных сообщений
        udp_socket = socket(AF_INET, SOCK_DGRAM)

        #Присвоим переменным свой локальный IP и свободный PORT для получения информации
        host = v_ip_localhost
        port = v_port_localhost
        addr = (host,port)

        #Свяжем сокет с локальными IP и PORT
        udp_socket.bind(addr)      
        print('socket binding')

        #Отправим сообщение на СИГНАЛЬНЫЙ СЕРВЕР
        udp_socket.sendto(data_out,addr_sigserv)

        while True:
        #Если первый элемент списка - 'sigserv' (сообщение от СИГНАЛЬНОГО СЕРВЕРА),
        #печатаем сообщение с полученными данными 
        #Иначе - печатаем сообщение 'Message from CLIENT!'
            data_in = udp_socket.recvfrom(1024)
            if data_in[0] == 'sigserv':
                print('signal server data: ', data_in)
            else:
                print('Message from CLIENT!')

        #Закрываем сокет
        udp_socket.close()
    
    except:
        print('exit!')
        sys.exit(1)

    finally:
        if udp_socket <> ''
            udp_socket.close()

sigserver_exch()




Заполним соответствующие поля разделов: «Внешний IP и PORT СИГНАЛЬНОГО СЕРВЕРА» и «IP и PORT этого КЛИЕНТА».

Создадим файл client.py с содержимым:

client.py
# -*- coding: utf-8 -*-
# CLIENT

from socket import *
import sys
import stun

def sigserver_exch():
# КЛИЕНТ <-> СИГНАЛЬНЫЙ СЕРВЕР
# КЛИЕНТ -> СЕРВЕР

# КЛИЕНТ - отправляет запрос на СИГНАЛЬНЫЙ СЕРВЕР с белым IP
# для получения текущих значений IP и PORT СЕРВЕРА за NAT для подключения к нему.
 
    #Внешний IP и PORT СИГНАЛЬНОГО СЕРВЕРА:
    v_sig_host = 'XX.XX.XX.XX'
    v_sig_port = XXXX

    #id этого КЛИЕНТА, имя этого КЛИЕНТА, id искомого СЕРВЕРА
    v_id_client = 'id_client_1001'
    v_name_client = 'name_client_1'
    v_id_server = 'id_server_1002'

    #IP и PORT этого КЛИЕНТА
    v_ip_localhost = 'XX.XX.XX.XX'
    v_port_localhost = XXXX

    udp_socket = ''
    
    try:
        #Получаем текущий внешний IP и PORT при помощи утилиты STUN
        nat_type, external_ip, external_port = stun.get_ip_info()

        #Присваиваем переменным белый IP и PORT сигнального сервера для отправки запроса
        host_sigserver = v_sig_host
        port_sigserver = v_sig_port
        addr_sigserv = (host_sigserver,port_sigserver)

        #Заполняем словарь данными для отправки на СИГНАЛЬНЫЙ СЕРВЕР: 
        #текущий id + имя + текущий внешний IP и PORT,
        #и id_dest - id известного сервера с которым хотим связаться.
        #В качестве id можно использовать хеш случайного числа + соль
        data_out = v_id_client + ',' + v_name_client + ',' + external_ip + ',' + str(external_port) + ',' + v_id_server

        #Создадим сокет с атрибутами: 
        #использовать пространство интернет адресов (AF_INET), 
        #передавать данные в виде отдельных сообщений
        udp_socket = socket(AF_INET, SOCK_DGRAM)

        #Присвоим переменным свой локальный IP и свободный PORT для получения информации
        host = v_ip_localhost
        port = v_port_localhost
        addr = (host,port)

        #Свяжем сокет с локальными IP и PORT
        udp_socket.bind(addr)

        #Отправим сообщение на СИГНАЛЬНЫЙ СЕРВЕР
        udp_socket.sendto(data_out, addr_sigserv)

        while True:
        #Если первый элемент списка - 'sigserv' (сообщение от СИГНАЛЬНОГО СЕРВЕРА),
        #печатаем сообщение с полученными данными и отправляем сообщение 
        #'Hello, SERVER!' на сервер по указанному в сообщении адресу.
        data_in = udp_socket.recvfrom(1024)
        data_0 = data_in[0]
        data_p = data_0.split(",")
        if data_p[0] == 'sigserv':
            print('signal server data: ', data_p)
            udp_socket.sendto('Hello, SERVER!',(data_p[3],int(data_p[4])))
        else:
            print("No, it is not Rio de Janeiro!")
        udp_socket.close()
    
    except:
        print ('Exit!')
        sys.exit(1)
    
    finally:
        if udp_socket <> ''
            udp_socket.close()

sigserver_exch()



Заполним соответствующие поля разделов: «Внешний IP и PORT СИГНАЛЬНОГО СЕРВЕРА» и «IP и PORT этого КЛИЕНТА».

Создадим файл signal_server.py с содержимым:

signal_server.py
# -*- coding: utf-8 -*-
# SIGNAL SERVER

#Twisted - управляемая событиями(event) структура
#Событиями управляют функции – event handler
#Цикл обработки событий отслеживает события и запускает соответствующие event handler
#Работа цикла лежит на объекте reactor из модуля twisted.internet

from twisted.internet import reactor
from twisted.internet.protocol import DatagramProtocol
import sys, os
import sqlite3

class Query_processing_server(DatagramProtocol):
# СИГНАЛЬНЫЙ СЕРВЕР <-> КЛИЕНТ
# КЛИЕНТ -> СЕРВЕР
# либо
# СИГНАЛЬНЫЙ СЕРВЕР <-> СЕРВЕР

# СИГНАЛЬНЫЙ СЕРВЕР - принимает запросы от КЛИЕНТА и СЕРВЕРА
# сохраняет их текущие значения IP и PORT 
# (если отсутствуют - создает новые + имя и идентификатор)
# и выдает IP и PORT СЕРВЕРА запрошенного КЛИЕНТОМ.

    def datagramReceived(self, data, addr_out):
        conn = ''
        try:
            #Разбиваем полученные данные по разделителю (,) [id_host,name_host,external_ip,external_port,id_dest]
            #id_dest - искомый id сервера
            data = data.split(",")
                
            #Запрос на указание пути к файлу БД sqlite3, при отсутствии будет создана новая БД по указанному пути:
            path_to_db = raw_input('Enter name db. For example: "/home/user/new_db.db": ')
            path_to_db = os.path.join(path_to_db)
            #Создать соединение с БД
            conn = sqlite3.connect(path_to_db)
            #Преобразовывать байтстроку в юникод
            conn.text_factory = str
            #Создаем объект курсора
            c = conn.cursor()
            #Создаем таблицу соответствия для хостов
            c.execute('''CREATE TABLE IF NOT EXISTS compliance_table ("id_host" text UNIQUE, "name_host" text, "ip_host" text, \
                "port_host" text)''')

            #Добавляем новый хост, если еще не создан
            #Обновляем данные ip, port для существующего хоста
            c.execute('INSERT OR IGNORE INTO compliance_table VALUES (?, ?, ?, ?);', data[0:len(data)-1])
            #Сохраняем изменения
            conn.commit()
            c.execute('SELECT * FROM compliance_table')

            #Поиск данных о сервере по его id
            c.execute('''SELECT id_host, name_host, ip_host, port_host from compliance_table WHERE id_host=?''', (data[len(data)-1],))
            cf = c.fetchone()
            if cf == None:
                print ('Server_id not found!')
            else:
                #transport.write - отправка сообщения с данными: id_host, name_host, ip_host, port_host и меткой sigserver
                lst = 'sigserv' + ',' + cf[0] + ',' + cf[1] + ',' + cf[2] + ','  + cf[3]
                self.transport.write(str(lst), addr_out)
            #Закрываем соединение       
            conn.close()
        except:
            print ('Exit!')
            sys.exit(1)
        finally:
            if conn <> ''
                conn.close()
reactor.listenUDP(9102, Query_processing_server())
print('reactor run!')
reactor.run()


Готово!

Порядок запуска приложения следующий:
— signal_server.py
— server.py
— client.py

© Habrahabr.ru