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

Речь об «автоматической» пробросе порта, через технологию UPnP, без использования «стандартной» библиотеки NATUPnPLib.

О том, в силу чего был выбран такой непростой путь и почему он все-таки непростой — читайте ниже.

Пролог


Работая над своим проектом (игровой проект), я четко осознавал, что рано или поздно придется подойти в плотную к вопросу сервера и связки с оным. Стоит отметить, что он был в планах. Но я помнил свой опыт работы с выделенным сервером, для того же minecraft'a, был готов к тому что меня ждет определенная «боль», в особенности, если я попытаюсь продвинуть свой продукт в массы.

Коротко о выделенном сервере Minecraft'a
У многих людей, которые пытались запустить свой выделенный сервер, была одна общая проблема, они имея роутер, сами могли к себе подключиться, а любой человек из вне — нет. Понятно дело, что такие вопросы решались пробросом порта в настройках роутера, но как показывала практика (а так же куча видео на ютубе), что это было сложно для людей.

Из всех этих вещей вытекло основополагающие требования для моего сервера.
  1. Пользователь должен делать минимум движений для запуска сервера и начала игры.
  2. Решение должно работать «из коробки» на любой машине под Windows (поддержка Unix систем пока что не в планах).

По сути это означало следующие цели:
  1. Проброс порта должен происходить без вмешательства человека.
  2. Решение будет написано на C# в двух архитектрурах x64 и x32 (приоритет на x64, в связи с тем, что вероятно потребуется «много» памяти).

Иллюзия решения


Определившись с языком, первым же делом отправился в гугл, узнать если ли уже готовые решения. И действительно, было сразу же найдено «решение», предлагалось использовать библиотеку NATUPnPLib, и сразу же пример ее использования:
NATUPNPLib.UPnPNATClass upnpnat = new NATUPNPLib.UPnPNATClass();
NATUPNPLib.IStaticPortMappingCollection mappings = upnpnat.StaticPortMappingCollection;
//Добавление порта
mappings.Add(12345, "UDP", 12345, "localIp", true, "Some name");
//Удаление порта
mappings.Remove(12345, "UDP");

Возрадовавшись сему, я поспешил опробовать полученную «зверюшку». В конечном итоге на одной машине мне удалось, получить желаемый результат. Однако, когда я уже совсем возрадовался, я решил «чуток потестировать» (вообще как показывает практика, это полезное действие), и запустил скомпиленый код на соседней машине (в одной локалке, с одним роутером «во главе») и тут меня ждало первое разочарование.

Увы, upnpnat.StaticPortMappingCollection — возвращал null, что бы я не делал. Вместе с этим пришел «отчет» от другого человека, которого я так же попросил протестировать, его ответ был так же грустен, у него данная библиотека вообще не разрешалась (вероятно была не зарегистрирована в системе, по какой-то причине или запрещена, или еще как, но суть в том что она не подхватывалась).

«Отсутствие результата, тоже результат» — так гласит одна хитрая мудрость. Печальный результат, дал мне понимание, что если я оставлю эту библиотеку, то подобные же ошибки будут у конечного потребителя, а значит мне придется готовится принимать поток «добра». Что мне, почему-то совсем не хотелось.

Поиск пути


В расстроенных чувствах пошел гуглить замену NATUPnPLib. Но как оказалось, почти все готовые решения были по сути обертками над этой библиотекой. Ради эксперимента они были попробованы, но поскольку в основе NATUPnPLib, то все заканчивалось как и ранее.

Это сильно огорчало. Однако глядя на такие продукты как например uTorrent, и видя что он успешно пробрасывает порт, я решил пойти научным хитрым путем. Идея проста как котик. Я знаю, что от машины до роутера передается некая команда или часть, в которой должно быть как-то сказано, что роутер должен сделать. Оставалось дело за малым, «посмотреть в лицо» этой команде или набору команд.

Первой же ссылкой в гугле, на запрос о сетевом сниффере был Wireshark. Дальше начав гуглить, что он умеет попалась вот эта статья от товарища sinist3r за что ему большое спасибо. В статье в достаточной степени описано, как и что делать, потому подробно на этом останавливаться не стану.

Анализ сетевого трафика


Запустив Wireshark и сделав проброс порта с библиотекой NATUPnPLib (с той машины на которой все работало), получаем что-то в таком духе:
Общий результат


Итак, что мы имеем:
  • Кучу сетевой информации
  • Знаем ip адрес машины, с которой отправляем
  • Знаем ip адрес других машин

Настроим фильтр так, что остался запрос только от машины с которой проводим тест (столбец Source ip 150-й), а так же что бы в столбце назначения не было других машин (ip 200).

Смотрим на полученный список и видим какую-то интересную вещь, а именно мультикаст группа и протокол SSDP, и на нее посылается такое сообщение:
Мультикаст группа


Это уже интересно. Идем в гугл, и смотрим, что это за мультикаст группа, и… драматическая пауза… первым же запросом убиваем двух маленьких пушистых и ушастых существ, гугл выдает такую ссылку.
Примечание
Почему я решил, что адрес на который идет запрос — мультикастовый, напомню, что адреса начинающие с 224.0.0.0 и заканчивающиеся 239.255.255.255 — это класс D, который и был зарезервирован для мультикастовых групп. Подробнее можно посмотреть тут.

Радостно потираю лапки, как муха, из буквально маленькой статьи на Википедии, узнаю, что используется протокол UDP, посылается сообщение обнаружение, которое было приведено в скрине выше. И все говорит, о том что я иду правильным путем.

От теории к практике


Имея под рукой запрос, протокол по которому обращаемся и адрес, я решил попробовать сделать небольшую консольную программку, для того что бы протестеровать, как оно будет, и будет ли вообще, работать.

Итак, такой запрос надо отправить в мультикаст группу, на порт 1900. Роутер услышав запрос, ответит, машине с которой пришел запрос, с неким ответом.
M-SEARCH * HTTP/1.1\r\n
HOST:239.255.255.250:1900\r\n
MAN:\«ssdp:discover\»\r\n
ST:upnp:rootdevice\r\n
MX:3\r\n\r\n

Подробнее, что есть что, можно посмотреть тут.

Пишем примерно такой код:
Образец кода
IPEndPoint MulticastEndPoint = new IPEndPoint(IPAddress.Parse("239.255.255.250"), 1900);//Адрес мультикаст группы с портом 1900

IPEndPoint LocalEndPoint = new IPEndPoint(GetLocalAdress(), 0);//Мой собственный метод для получения локального IP-a с любым свободным портом
//Чуть позже поясню почему использовал этот метод, а так же приведу его код

Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

//Делаем магические настройки сокета
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
socket.Bind(LocalEndPoint);//мапим сокет к ранее полученому локальной конечной точки
socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(MulticastEndPoint.Address, IPAddress.Any));
socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 2);
socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true);

string searchString = "M-SEARCH * HTTP/1.1\r\nHOST:239.255.255.250:1900\r\nMAN:\"ssdp:discover\"\r\nST:upnp:rootdevice\r\nMX:3\r\n\r\n";

byte[] data = Encoding.UTF8.GetBytes(searchString);

socket.SendTo(data, data.Length, SocketFlags.None, MulticastEndPoint);
socket.SendTo(data, data.Length, SocketFlags.None, MulticastEndPoint);
socket.SendTo(data, data.Length, SocketFlags.None, MulticastEndPoint);

//А дальше часть касаемая получения ответа, сразу предупреждаю слабонервным не смотреть!

byte[] ReceiveBuffer = new byte[64000];

int ReceivedBytes = 0;

int repeatCount = 10;
while (repeatCount>0)
{
	if (socket.Available > 0)
	{
		ReceivedBytes = socket.Receive(ReceiveBuffer, SocketFlags.None);

		if (ReceivedBytes > 0)
		{
			Console.WriteLine(Encoding.UTF8.GetString(ReceiveBuffer, 0, ReceivedBytes));
		}
	}
	else
	{
		repeatCount--;
		Thread.Sleep(100);//Такой ужас сделан, по той причине, что роутер может не сразу ответить, а подождать некоторое время
	}
}
socket.Close();


Вот что в итоге получиться
Результат запроса


Нас интересует подчеркнутая строка, именно по ней в дальнейшем и будет происходить общение с роутером. (Как я это понял? В том же Wireshark'e указал в источнике ip-машины с которого шел запрос, и в качестве назначения — ip-роутера, и увидел кучу http запросов)
Примечание
Прежде всего хотел бы обратить внимание на то, что посыл осуществляется трижды, связано это с тем, что на некоторых машинах (у меня это одна из трех), первый пакет «теряется», а по сути вообще не отправляется (при наблюдении в Wireshark'e нету даже намека, на то что пакет отсылается). О подобных вещах читал на просторах интернета, но решения окромя «перейдите на TCP» или «делайте несколько запросов» не обнаружил.

Примечание
Почему использую такую конструкцию:
IPEndPoint LocalEndPoint = new IPEndPoint(GetLocalAdress(), 0);

А не IPAddress.Any, в следствии вот этого ответа

Теперь не много о функции GetLocalAdress. Обычно, предлагают использовать такой или подобный код
Dns.GetHostEntry(Dns.GetHostName()).AddressList.FirstOrDefault(ip => ip.AddressFamily == AddressFamily.InterNetwork);

Однако, если у вас на машине стоит VirtualBox или скажем Tunngle, или что-то подобное, что ставит свой адаптер, то в таком случае, указанный выше код, вернет адрес этого самого адаптера. Что «не есть хорошо», и потому надо либо как-то по названиям пытаться обрезать «левые» адреса, либо как предлагаю я:
Пример кода
private static IPAddress GetLocalAdress()
{
	NetworkInterface[] networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();

	foreach (NetworkInterface network in networkInterfaces)
	{

		IPInterfaceProperties properties = network.GetIPProperties();

		if (properties.GatewayAddresses.Count == 0)//вся магия вот в этой строке
			continue;

		foreach (IPAddressInformation address in properties.UnicastAddresses)
		{

			if (address.Address.AddressFamily != AddressFamily.InterNetwork)
				continue;

			if (IPAddress.IsLoopback(address.Address))
				continue;

			return address.Address;
		}
	}
	return default(IPAddress);
}


Конец уж близок


Итак, почти все у нас есть. Можно перейти к финальной стадии, а именно, попробовать на практике пробросить порт и его закрыть.

Опущу моменты как я в Wireshark'e, наблюдал какие команды ходят и куда, ранее достаточно подробно писал, дальнейший поиск достаточно прост, учитывая, что все общение с роутером уже идет через HTTP.

Получив на предыдущей стадии, путь для запроса информации о роутере, сделаем это. Сразу оговорюсь, при HTTP запросах, обязательно указывать UserAgent = «Microsoft-Windows/6.1 UpnP/1.0»; (естесвенно учитывая реальную версию Windows).

В моем случае GET-запрос, надо послать по этому адресу:
 http://192.168.0.1:46382/rootDesc.xml


В полученном, огромном ответе, (да-да, вот в этой огромной простыне текста), нас интересует тег controlURL, у которого serviceType равен urn:schemas-upnp-org:service:WANIPConnection:1.

Примечание
WANPPPConnection (ADSL modems) and WANIPConnection (IP routers)

Подробнее, можно почитать тут, в том числе и о командах для добавления или удаления портов

В моем случае получено значение «/ctl/IPConn». Дописываем его к адресу роутера, в итоге получаем такое:
http://192.168.0.1:46382/ctl/IPConn


Теперь соберем тело запроса, в нем должно быть:
  • NewRemoteHost //оставляем пустым
  • NewExternalPort //внешний порт
  • NewProtocol //протокол (TCP/UDP)
  • NewInternalPort //внутренний порт
  • NewInternalClient //ip «на который» открываем
  • NewEnabled //включен или выключен
  • NewPortMappingDescription //описание
  • NewLeaseDuration //продолжительность жизни, 0 — навсегда

Собрав, я получил такое тело (Форматировано для улучшения чтения):
Тело запроса
<?xml version=\"1.0\"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV=\"http://schemas.xmlsoap.org/soap/envelope/\" SOAP-ENV:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">
	<SOAP-ENV:Body><m:AddPortMapping xmlns:m=\"urn:schemas-upnp-org:service:WANIPConnection:1\">
		<NewRemoteHost xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"string\"></NewRemoteHost>
		<NewExternalPort xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"ui2\">25565</NewExternalPort>
		<NewProtocol xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"string\">TCP</NewProtocol>
		<NewInternalPort xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"ui2\">25565</NewInternalPort>
		<NewInternalClient xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"string\">192.168.0.150</NewInternalClient>
		<NewEnabled xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"boolean\">1</NewEnabled>
		<NewPortMappingDescription xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"string\">Test Open Port and say hi, habrahabr</NewPortMappingDescription>
		<NewLeaseDuration xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"ui4\">0</NewLeaseDuration>
	</m:AddPortMapping>
</SOAP-ENV:Body></SOAP-ENV:Envelope>


Выполнив запрос, и если все правильно указано, то получим подобный результат
Результат



Аналогично, но проще делается для удаления порта, там всего два важных параметра — это протокол и порт, подчеркну, внешний порт.

Заключение


Вот так вот, простое желание сделать для пользователя «проще», вылилось в целую эпопею и статью. Надеюсь, статья кому-либо поможет избежать тех определенных трудностей, с которыми я столкнулся.

Всем спасибо, кто прочитал, если имеются какие-то дополнения, пишите, дополню статью.

Комментарии (13)


  1. igor_suhorukov
    11.08.2015 13:20

    рассматривали возможность использования ipv6 тунелирования и teredo?


    1. Orygeunik
      11.08.2015 15:56
      -1

      Честно скажу нет.

      Касаемо IPv6 думал посмотреть в его сторону, но потом глянул статистику, а так же предложения провайдеров с IPv6, а так же учитывая тот факт, что даже у меня у самого нет на «внешку» IPv6 решил отложить рассмотрения этого вопроса, на другое время.


      1. icCE
        11.08.2015 16:52
        +1

        Вам в помощь tunnelbroker.net

        Бесплатно, без смс :)


        1. DjPhoeniX
          11.08.2015 23:31

          Лучше sixxs.net. Бесплатно, без СМС, но есть туннели в России. К тому же умеет динамические адреса и выдаёт подсети. Вот тут о настройке писал: habrahabr.ru/post/203376.


          1. ivan386
            12.08.2015 15:56

            Зачем вообще эти брокеры когда есть192.88.99.1 6to4. Если ip за NAT не меняется то всё оилично работает.


    1. ValdikSS
      15.08.2015 16:00

      Microsoft, кстати, постепенно отказывается от Teredo. Сервер для Windows 7, например, выключили пару месяцев назад.


  1. ComodoHacker
    11.08.2015 15:22
    +6

    А почитать доки по протоколу UPnP это не путь настоящего джедая?


    1. icCE
      11.08.2015 16:54

      Кстати, а под linux есть решения upnp и nat-pmp итд? Просто в свое время гуглил искал и все было печально.


      1. YourChief
        12.08.2015 00:45

        Есть готовые утилиты miniupnpc (cli) и gupnp-tools (gui).


  1. kacang
    12.08.2015 06:05
    +1

    Ох уж этот UPnP. Одни порты юзерам открывают, а другие reverse туннель пробрасывают.


  1. robux
    12.08.2015 10:22
    +1

    Правильно ли я понял работу UPnP:
    1) создаём магический udp-сокет
    2) прикрепляем к нему хитро полученный локальный IP
    3) шлём 3 раза с этого хитрого-магического udp-сокета широковещательное (на адрес 239.255.255.250:1900) сообщение-обнаружение
    4) роутер отзывается, при этом мы узнаём его ip-адрес, tcp-порт, html/xml-скрипт
    5) шлём get-запросы по http на скрипт роутера, при этом ставя UA как «Microsoft-Windows/6.1 UpnP/1.0»
    6) через http сначала открываем порт, потом обмениваемся трафиком, потом закрываем
    Что я упустил?

    p.s. Жалко что у вас 6й пункт не расписан, т.е. сами http-запросы и ответы, суть протокола UPnP.
    Но статья ценна, безусловно.


    1. tipok
      12.08.2015 14:33

      Там HTTP-over-UDP, в общем-то через тот-самый мультикаст-канал.


    1. Orygeunik
      12.08.2015 21:39

      Спасибо, за оценку, и тому факту, что статья для кого-то оказалось ценной или полезной.

      Касаемо Вашего понимая — да, так.

      Касаемо 6-го пункта, не стал подробно описывать, потому как в статье под спойлером лежит пример запроса.

      Пример открытия порта
      public static void AddPort(string webPath, int port, string protocol, string locIP, string desc)
      {
      	//webPath=http://192.168.0.1:46382/ctl/IPConn
      	HttpWebRequest request = (HttpWebRequest)WebRequest.Create(webPath);
      
      	request.Method = "POST";
      	request.Headers.Add("Cache-Control", "no-cache");
      	request.Headers.Add("Pragma", "no-cache");
      	request.Headers.Add("SOAPAction", "\"urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping\"");
      
      	//request.Connection = "Close";
      	request.ContentType = "text/xml; charset=\"utf-8\"";
      	request.UserAgent = "Microsoft-Windows/6.1 UPnP/1.0";
      
      	string query = "<?xml version=\"1.0\"?><SOAP-ENV:Envelope xmlns:SOAP-ENV=\"http://schemas.xmlsoap.org/soap/envelope/\" SOAP-ENV:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><SOAP-ENV:Body><m:AddPortMapping xmlns:m=\"urn:schemas-upnp-org:service:WANIPConnection:1\"><NewRemoteHost xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"string\"></NewRemoteHost><NewExternalPort xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"ui2\">"+port+"</NewExternalPort><NewProtocol xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"string\">"+protocol+"</NewProtocol><NewInternalPort xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"ui2\">"+port+"</NewInternalPort><NewInternalClient xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"string\">"+locIP+"</NewInternalClient><NewEnabled xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"boolean\">1</NewEnabled><NewPortMappingDescription xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"string\">"+desc+"</NewPortMappingDescription><NewLeaseDuration xmlns:dt=\"urn:schemas-microsoft-com:datatypes\" dt:dt=\"ui4\">0</NewLeaseDuration></m:AddPortMapping></SOAP-ENV:Body></SOAP-ENV:Envelope>";
      	
      	byte[] data = Encoding.UTF8.GetBytes(query);
      
      	request.ContentLength = data.Length;
      
      	using (Stream stream = request.GetRequestStream())
      	{
      		stream.Write(data, 0, data.Length);
      	}
      	string response = String.Empty;
      	WebResponse response = request.GetResponse();
      	using (StreamReader sr = new StreamReader(response.GetResponseStream()))
      	{
      		response = sr.ReadToEnd();
      	}
      }