Реализация асинхронной защищенной системы связи на основе TCP сокетов и центрального OpenVPN сервера

    В этой статье я хочу рассказать, о моей реализации мессенджера с двойным шифрованием сообщений на основе tcp сокетов, OpenVPN сервера, PowerDNS сервера.
    Суть задачи — обеспечить обмен сообщениями через интернет минуя NAT между клиентами на различных платформах (IOS, Android, Windows, Linux). Также необходимо обеспечить безопасность передаваемых данных.

    Моя система состоит из:
    • OpenVPN сервера
    • PowerDNS сервера
    • Прокси. В моем случае это написанный мною на Ruby on Rails Web сервис с реализацией протокола SOAP. То есть на этом SOAP сервере я описываю механизм выполнения неких действий, потом делаю SOAP запрос с клиентского терминала, сервер выполняет некие действия, например апдейт зоны PowerDNS, и возвращает терминалу ответ — успешно или неуспешно. Очень удобно.
    • непосредственно клиентские терминалы.


    На клиентских терминалах поднимается tcp сокет который слушает определенный порт на предмет входящих сообщений. Если сообщение пришло — сокет выводит его в терминал. В самом сообщении содержится юзернейм отправителя. Для отправки сообщений также открывается tcp сокет клиента.
    Сокет открывается непосредственно с терминалом удаленного клиента. Следовательно в моем случае взаимодействие идет в режиме клиент-клиент.
    Поиск клиентов происходит по юзернейму. Привязку юзернейма и IP адреса хранит у себя PowerDNS сервер.
    PowerDNS – представляет собой высокопроизводительный DNS-сервер, написанный на C++ и лицензируемый под лицензией GPL. Разработка ведется в рамках поддержки Unix-систем; Windows-системы более не поддерживаются.
    Сервер разработан в голландской компании PowerDNS.com Бертом Хубертом и поддерживается сообществом свободного программного обеспечения.
    PowerDNS использует гибкую архитектуру хранения/доступа к данным, которая может получать DNS информацию с любого источника данных. Это включает в себя файлы, файлы зон BIND, реляционные базы данных или директории LDAP.
    PowerDNS по умолчанию настроен на обслуживание запросов из БД.
    После выхода версии 2.9.20 программное обеспечение распространяется в виде двух компонентов – (Authoritative) Server (авторитетный DNS) и Recursor (рекурсивный DNS).
    Выбор данного решения обусловлен тем, что PowerDNS умеет работать с базой данных без подключения дополнительных модулей, а также более высокой скоростью работы в сравнение с другими свободно распространяемыми решениями.
    Для моих целей мне достаточно было только авторитетного модуля, рекурсор я ставить не стал.

    Клиентские терминалы взаимодействуют со всеми внутренними компонентами через SOAP Gateway.
    Логика работы следующая — клиент включает программу, происходит выполнение SOAP метода на апдейт зоны на PowerDNS сервере. Если клиент хочет с кем-то связаться, он вводит или выбирает в списке соответствующий username, выполняется SOAP метод на получение IP адреса из базы DNS, и происходит подключение к удаленному клиенту по IP адресу.
    У меня есть написанные готовые клиенты для IOS, Android, Windows. При их написании я использовал фреймворк xamarin. Очень удобно, потребовались лишь небольшие изменения кода для перевода приложения под другую платформу.

    Далее я представлю коды сокетов клиента и сервера, которые у меня используются на клиентских терминалах. Здесь приведены коды для IOS. Для Android и Windows будут почти такие. Разница только в различных типах элементов (кнопок, текстовых блоков и т д)

    Код tcp socket сервера
        public class GlobalFunction
        {
    
            public static void writeLOG(string loggg)
            {
                //Размышления над прошлым могут служить руководством для будущего
                string path = @"bin\logfile.log";
                string time = DateTime.Now.ToString("hh:mm:ss");
                string date = DateTime.Now.ToString("yyyy.MM.dd");
                string logging = date + " " + time + " " + loggg;
    
                using (StreamWriter sw = File.AppendText(path))
                    {
                        sw.WriteLine(logging);
                    }
            }
    
            public static void writeLOGdebug(string loggg)
            {
                try
                {
                    //Размышления над прошлым могут служить руководством для будущего
                    string path = @"bin\logfile.log";
                    string time = DateTime.Now.ToString("hh:mm:ss");
                    string date = DateTime.Now.ToString("yyyy.MM.dd");
                    string logging = date + " " + time + " " + loggg;
    
                    using (StreamWriter sw = File.AppendText(path))
                    {
                        sw.WriteLine(logging);
                    }
                }
                catch (Exception exc) { }
            }
        }
    
        public class Globals
        {
            public static IPAddress localip = "192.168.88.23";
            public static int _localServerPort = 19991;
            public const int _maxMessage = 100;
            public static string _LocalUserName = "375297770001";
    
            public struct MessBuffer 
            {
                public string usernameLocal;
                public string usernamePeer;
                public string message;
            }
    
    
            public static List<MessBuffer> MessagesBase = new List<MessBuffer>();
            public static List<MessBuffer> MessagesBaseSelected = new List<MessBuffer>();
    
        }
    
        public class StateObject
        {
            // Client  socket.  
            public Socket workSocket = null;
            // Size of receive buffer.  
            public const int BufferSize = 1024;
            // Receive buffer.  
            public byte[] buffer = new byte[BufferSize];
            // Received data string.  
            public StringBuilder sb = new StringBuilder();
        }
    
        public partial class ViewController : UIViewController
        {
    
            public static ManualResetEvent allDone = new ManualResetEvent(false);
            public void startLocalServer()
            {
                //IPHostEntry ipHost = Dns.GetHostEntry(_serverHost);
                //IPAddress ipAddress = ipHost.AddressList[0];
                IPAddress ipAddress = Globals.localip;
                IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, Globals._localServerPort);
                Socket socket = new Socket(ipAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
                socket.Bind(ipEndPoint);
                socket.Listen(1000);
                GlobalFunction.writeLOGdebug("Local Server has been started on IP: " + ipEndPoint);
                while (true)
                {
                    try
                    {
                        // Set the event to nonsignaled state.  
                        allDone.Reset();
    
                        // Start an asynchronous socket to listen for connections.  
                        socket.BeginAccept(
                            new AsyncCallback(AcceptCallback),
                            socket);
    
                        // Wait until a connection is made before continuing.  
                        allDone.WaitOne();
                    }
                    catch (Exception exp) { GlobalFunction.writeLOGdebug("Error. Failed startLocalServer() method:  " + Convert.ToString(exp)); }
                }
    
            }
    
            public void AcceptCallback(IAsyncResult ar)
            {
                // Signal the main thread to continue.  
                allDone.Set();
    
                // Get the socket that handles the client request.  
                Socket listener = (Socket)ar.AsyncState;
                Socket handler = listener.EndAccept(ar);
    
                // Create the state object.  
                StateObject state = new StateObject();
                state.workSocket = handler;
                handler.BeginReceive(state.buffer, 0, StateObject.BufferSize, 0,
                    new AsyncCallback(ReadCallback), state);
            }
    
            public void ReadCallback(IAsyncResult ar)
            {
                String content = String.Empty;
    
                // Retrieve the state object and the handler socket  
                // from the asynchronous state object.  
                StateObject state = (StateObject)ar.AsyncState;
                Socket handler = state.workSocket;
    
                // Read data from the client socket.   
                int bytesRead = handler.EndReceive(ar);
    
                if (bytesRead > 0)
                {
                    // There  might be more data, so store the data received so far.  
                    state.sb.Append(Encoding.UTF8.GetString(
                        state.buffer, 0, bytesRead));
    
                    // Check for end-of-file tag. If it is not there, read   
                    // more data.  
                    content = state.sb.ToString();
                    if (content.IndexOf("<EOF>") > -1)
                    {
                        // All the data has been read from the   
                        // client. Display it on the console.  
    
                        string[] bfd = content.Split(new char[] { '|' }, StringSplitOptions.None);
                        string decrypt = MasterEncryption.MasterDecrypt(bfd[0]);
    
                        string[] bab = decrypt.Split(new char[] { '~' }, StringSplitOptions.None);
                        Globals.MessBuffer Bf = new Globals.MessBuffer();
                        Bf.message = bab[2];
                        Bf.usernamePeer = bab[0];
                        Bf.usernameLocal = bab[1];
                        string upchat_m = "[" + bab[1] + "]# " + bab[2];
    
    
    
                        this.InvokeOnMainThread(delegate {
                            frm.messageField1.InsertText(Environment.NewLine + "[" + bab[1] + "]# " + bab[2]);
                              
                        });
    
                        //if (Ok != null) { Ok(this, upchat_m); }
                        Globals.MessagesBase.Add(Bf);
                        //GlobalFunction.writeLOGdebug("Received message: " + content);
    
                        // Echo the data back to the client.  
                        //Send(handler, content);
                    }
                    else
                    {
                        handler.BeginReceive(state.buffer, 0, StateObject.BufferSize, 0,
                        new AsyncCallback(ReadCallback), state);
                    }
                }
            }
    }
    




    Сервер запускается в отдельном потоке, например вот так:

        public static class Program
        {
            private static Thread _serverLocalThread;
            /// <summary>
            /// The main entry point for the application.
            /// </summary>
            /// 
            [STAThread]
            static void Main()
            {
                _serverLocalThread = new Thread(GlobalFunction.startLocalServer);
                _serverLocalThread.IsBackground = true;
                _serverLocalThread.Start();
    
                Application.EnableVisualStyles();
                Application.SetCompatibleTextRenderingDefault(false);
                Application.Run(new Form1());
            }
        }
    


    Код tcp socket клиента
            public override void DidReceiveMemoryWarning()
            {
                base.DidReceiveMemoryWarning();
                // Release any cached data, images, etc that aren't in use.
            }
    
            private void connectToRemotePeer(IPAddress ipAddress)
            {
                try
                {
                    IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, Globals._localServerPort);
                    _serverSocketClientRemote = new Socket(ipAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
                    _serverSocketClientRemote.Connect(ipEndPoint);
    
                    GlobalFunction.writeLOGdebug("We connected to:  " + ipEndPoint);
                }
                catch (Exception exc) { GlobalFunction.writeLOGdebug("Error. Failed connectToRemotePeer(string iphost) method:  " + Convert.ToString(exc)); }
            }
    
            private void sendDataToPeer(string textMessage)
            {
                try
                {
                    byte[] buffer = Encoding.UTF8.GetBytes(textMessage);
                    int bytesSent = _serverSocketClientRemote.Send(buffer);
    
                    GlobalFunction.writeLOGdebug("Sended data: " + textMessage);
                }
                catch (Exception exc) { GlobalFunction.writeLOGdebug("Error. Failed sendDataToPeer(string testMessage) method:  " + Convert.ToString(exc)); }
            }
    
            private void Client_listner()
            {
                try
                {
                    while (_serverSocketClientRemote.Connected)
                    {
                        byte[] buffer = new byte[8196];
                        int bytesRec = _serverSocketClientRemote.Receive(buffer);
                        string data = Encoding.UTF8.GetString(buffer, 0, bytesRec);
                        //string data1 = encryption.Decrypt(data);
                        if (data.Contains("#updatechat"))
                        {
                            //UpdateChat(data);
                            GlobalFunction.writeLOGdebug("Chat updated with: " + data);
    
                            continue;
                        }
                    }
                }
                catch (Exception exc) { GlobalFunction.writeLOGdebug("Error. Failed Client_listner() method:  " + Convert.ToString(exc)); }
            }
    
            private void sendMessage()
            {
                try
                {
                    connectToRemotePeer(Globals._peerRemoteServer);
                    _RemoteclientThread = new Thread(Client_listner);
                    _RemoteclientThread.IsBackground = true;
                    _RemoteclientThread.Start();
    
                    string data = inputTextBox.Text;
    
                    Globals.MessBuffer ba = new Globals.MessBuffer();
                    ba.usernameLocal = Globals._LocalUserName;
                    ba.usernamePeer = Globals._peerRemoteUsername;
                    ba.message = data;
                    Globals.MessagesBase.Add(ba);
    
    
                    if (string.IsNullOrEmpty(data)) return;
                    string datatopeer = Globals._peerRemoteUsername + "~" + Globals._LocalUserName + "~" + data;
                    string datatopeerEncrypted = MasterEncryption.MasterEncrypt(datatopeer);
                    sendDataToPeer(datatopeerEncrypted + "|<EOF>");
                    addLineToChat(data, Globals._LocalUserName);
                    inputTextBox.Text = string.Empty;
                }
                catch (Exception exp) { GlobalFunction.writeLOGdebug(Convert.ToString(exp)); }
            }
    
            private void addLineToChat(string msg, string username)
            {
                messageField1.InsertText(Environment.NewLine + "[" + username + "]# " + msg);
            }
    
            public void addFromServer(string msg, string username)
            {
                messageField1.InsertText(Environment.NewLine + "[" + username + "]# " + msg);
            }
    
            private void listBox1_Click()
            {
                messageField1.Text = "";
                Globals.MessagesBaseSelected.Clear();
                GlobalFunction.ReloadLocalBufferForSelected();
    
                for (int i = 0; i < Globals.MessagesBaseSelected.Count; i++)
                {
                    messageField1.InsertText(Environment.NewLine + "[" + Globals.MessagesBaseSelected[i].usernameLocal + "]# " + Globals.MessagesBaseSelected[i].message);
                }
    
                Globals._peerRemoteServer = GlobalFunction.getPEERIPbySOAPRequest(Globals._peerRemoteUsername);
                 
                string Name = Globals._LocalUserName;
                GlobalFunction.writeLOGdebug("Local name parameter listBox1_DoubleClick: " + Name);
                connectToRemotePeer(Globals._peerRemoteServer);
                _RemoteclientThread = new Thread(Client_listner);
                _RemoteclientThread.IsBackground = true;
                _RemoteclientThread.Start();
            }
            public static ViewController Form;
    
    



    OpenVPN сервер необходим чтобы преодолеть NAT, а также для дополнительной защиты данных.
    В случае с OpenVPN, мы имеем некую адресацию внутри тоннеля, по которой могут связываться клиенты.
    Процесс установки OpenVPN сервера описывать не буду, так как на эту тему есть много информации. Приведу только мою конфигурацию. Она полностью оптимизирована для моих целей и полностью рабочая (скопировал с рабочего сервера как есть, без изменений, только IP в конфигурации клиента поменял на фейковый, вместо 1.1.1.1 нужно указывать IP адрес Вашего OpenVPN сервера).
    OpenVPN проводит все сетевые операции через TCP или UDP транспорт. В общем случае предпочтительным является UDP по той причине, что через туннель проходит трафик сетевого уровня и выше по OSI, если используется TUN соединение, или трафик канального уровня и выше, если используется TAP. Это значит, что OpenVPN для клиента выступает протоколом канального или даже физического уровня, а значит, надежность передачи данных может обеспечиваться вышестоящими по OSI уровнями, если это необходимо. Именно поэтому протокол UDP по своей концепции наиболее близок к OpenVPN, т.к. он, как и протоколы канального и физического уровней, не обеспечивает надежность соединения, передавая эту инициативу более высоким уровням. Если же настроить туннель на работу по ТСР, сервер в типичном случае будет получать ТСР-сегменты OpenVPN, которые содержат другие ТСР-сегменты от клиента. В результате в цепи получается двойная проверка на целостность информации, что совершенно не имеет смысла, т.к. надежность не повышается, а скорости соединения и пинга снижаются.

    Конфигурация моего OpenVPN сервера
    port 8443
    proto udp
    dev tun

    cd /etc/openvpn
    persist-key
    persist-tun
    tls-server
    tls-timeout 120

    #certificates and encryption
    dh /etc/openvpn/keys/dh2048.pem
    ca /etc/openvpn/keys/ca.crt
    cert /etc/openvpn/keys/server.crt
    key /etc/openvpn/keys/server.key
    tls-auth /etc/openvpn/keys/ta.key 0
    auth SHA512
    cipher AES-256-CBC
    duplicate-cn
    client-to-client

    #DHCP information
    server 10.0.141.0 255.255.255.0
    topology subnet
    max-clients 250
    route 10.0.141.0 255.255.255.0

    #any
    mssfix
    float
    comp-lzo
    mute 20

    #log and security
    user nobody
    group nogroup
    keepalive 10 120
    status /var/log/openvpn/openvpn-status.log
    log-append /var/log/openvpn/openvpn.log
    client-config-dir /etc/openvpn/ccd
    verb 3


    Конфигурация моего OpenVPN клиента
    client
    dev tun
    proto udp
    remote 1.1.1.1 8443
    tls-client
    #ca ca.crt
    #cert client1.crt
    #key client1.key
    key-direction 1
    #tls-auth ta.key 1
    auth SHA512
    cipher AES-256-CBC
    remote-cert-tls server
    comp-lzo
    tun-mtu 1500
    mssfix
    ping-restart 180
    persist-key
    persist-tun
    auth-nocache
    verb 3

    Жду комментарии по поводу данной конфигурации OpenVPN.

    Все запросы от клиентских терминалов у меня выполняются на тоннельный адрес на стороне OpenVPN сервера, где настроен соответствующий проброс портов и необходимые доступы.
    Вот пример:
    iptables -A INPUT -i ens160 -p tcp -m tcp --dport 8443 -j ACCEPT
    iptables -A INPUT -i ens160 -p udp -m udp --dport 8443 -j ACCEPT
    iptables -A INPUT -m state --state ESTABLISHED -j ACCEPT
    iptables -A INPUT -i ens192  -j ACCEPT
    iptables -P INPUT DROP
    iptables -t nat -A PREROUTING -p tcp -m tcp -d 10.0.141.1 --dport 443 -j DNAT --to-destination 10.24.184.179:443
    #iptables -t nat -A PREROUTING -p udp -m udp -d 10.0.141.1 --dport 53 -j DNAT --to-destination 10.24.214.124:53
    
    


    ДНС сервер у меня служит своеобразным хранилищем привязок IP-username, и не только.
    Также приведу здесь конфигурацию моего PowerDNS сервера. Скажу только, что в плане безопасности она не очень оптимизирована, можно было бы разрешить только соответствующие адреса, но тоже полностью рабочая. Скопировал с рабочего сервера, только заменив логины/пароли/адреса на фейковые.

    Конфигурация моего PowerDNS authoritative сервера
    launch=gmysql
    gmysql-host=10.24.214.131
    gmysql-user=powerdns
    gmysql-password=password
    gmysql-dbname=powerdns
    gmysql-dnssec=yes
    allow-axfr-ips=0.0.0.0/0
    allow-dnsupdate-from=0.0.0.0/0
    allow-notify-from=0.0.0.0/0
    api=yes
    api-key=1234567890
    #api-logfile=/var/log/pdns.log
    api-readonly=no
    cache-ttl=2000
    daemon=yes
    default-soa-mail=dbuynovskiy.spectrum.by
    disable-axfr=yes
    guardian=yes
    local-address=0.0.0.0
    local-port=53
    log-dns-details=yes
    log-dns-queries=yes
    logging-facility=0
    loglevel=7
    master=yes
    dnsupdate=yes
    max-tcp-connections=512
    receiver-threads=4
    retrieval-threads=4
    reuseport=yes
    setgid=pdns
    setuid=pdns
    signing-threads=8
    slave=no
    slave-cycle-interval=60
    version-string=powerdns
    webserver=yes
    webserver-address=0.0.0.0
    webserver-allow-from=0.0.0.0/0,10.10.71.0/24,10.10.55.4/32
    webserver-password=1234567890
    webserver-port=7777
    webserver-print-arguments=yes
    write-pid=yes



    Для обновления зон PowerDNS я использовал встроенный REST API. Написал на Ruby следующую процедуру:
    Метод обновления зоны на PowerDNS через REST
      def updatezone(username,ipaddress)
        p data = {"rrsets":  [  {"name":  "#{username}.spectrum.loc.", "type":  "A", "ttl":  86400, "changetype":  "REPLACE",  "records":   [  {"content":  ipaddress, "disabled":  false }  ]  }  ]  }
    
        url = 'http://10.24.214.124:7777/api/v1/servers/localhost/zones/spectrum.loc.'
        uri = URI.parse(url)
        http = Net::HTTP.new(uri.host, uri.port)
        req = Net::HTTP::Patch.new(uri.request_uri)
        req["X-API-Key"]="1234567890"
        req.body = data.to_json
        p "fd"
        p response = http.request(req)
        p content = response.body
      end
    


    Этот метод я выполняю при запуске клиента. То есть происходит апдейт IP адреса, соответствующего конкретному пользователю. Напоминаю, подобные запросы у меня выполняет SOAP Gateway, написанный на Ruby.

    Сама передаваемая между сокетами информация у меня шифруется с помощью алгоритма AES, но не по отдельному паролю, а по случайно выбираемому паролю из списка, что обеспечивает практически абсолютную защиту, даже при наличии у атакующего бесконечных вычислительных ресурсов. Конечно чем длиннее список, тем лучше.
    У меня есть метод для проведения такого шифрования/дешифровки.

    В дополнение хочу оставить здесь пример моей процедуры на c# для выполнения SOAP запросов.
    Может кому-то пригодится. Во всяком случае я долго добивался чтобы SOAP запросы выполнялись на разных платформах. Сперва использовал Service Reference на Windows, но для Xamarin под другие платформы его нету. А эта методика работает везде. Во всяком случае я ее тестировал на IOS, Android и Windows

    Пример выполнения SOAP запроса на c#
           public static void registerSession2()
            {
                try
                {
                    CallWebServiceUpdateLocation();
                    writeLOG("Session registered on SoapGW.");
                }
                catch (Exception exc)
                {
                    writeLOG("Error. Failed GlobalFunction.registerSession2() method:  " + Convert.ToString(exc));
                }
            }
    
            private static HttpWebRequest CreateWebRequest(string url, string action)
            {
                HttpWebRequest webRequest = (HttpWebRequest)WebRequest.Create(url);
                webRequest.Headers.Add("SOAPAction", action);
                webRequest.ContentType = "text/xml;charset=\"utf-8\"";
                webRequest.Accept = "text/xml";
                webRequest.Method = "POST";
                return webRequest;
            }
    
            private static XmlDocument CreateSoapEnvelope()
            {
                XmlDocument soapEnvelopeDocument = new XmlDocument();
    
                string xml = System.String.Format(@"
    
    <soapenv:Envelope xmlns:xsi=""http://www.w3.org/2001/XMLSchema-instance"" xmlns:xsd=""http://www.w3.org/2001/XMLSchema"" xmlns:soapenv=""http://schemas.xmlsoap.org/soap/envelope/"" xmlns:spec=""https://spectrum.master"">
    <soapenv:Header/>
    <soapenv:Body>
    <master_update_location soapenv:encodingStyle=""http://schemas.xmlsoap.org/soap/encoding/"">
    <ipaddress xsi:type=""xsd:string"">{0}</ipaddress>
    <username xsi:type=""xsd:string"">{1}</username>
    </master_update_location>
    </soapenv:Body>
    </soapenv:Envelope>
    
    ", Convert.ToString(Globals.localip), Globals._LocalUserName);
    
                soapEnvelopeDocument.LoadXml(xml);
                return soapEnvelopeDocument;
            }
    
            public static void CallWebServiceUpdateLocation()
            {
                var _url = "http://10.0.141.1/master/action";
                var _action = "master_update_location";
    
                XmlDocument soapEnvelopeXml = CreateSoapEnvelope();
                HttpWebRequest webRequest = CreateWebRequest(_url, _action);
                InsertSoapEnvelopeIntoWebRequest(soapEnvelopeXml, webRequest);
    
                // begin async call to web request.
                IAsyncResult asyncResult = webRequest.BeginGetResponse(null, null);
    
                // suspend this thread until call is complete. You might want to
                // do something usefull here like update your UI.
                asyncResult.AsyncWaitHandle.WaitOne();
    
                // get the response from the completed web request.
                string soapResult;
                using (WebResponse webResponse = webRequest.EndGetResponse(asyncResult))
                {
                    using (StreamReader rd = new StreamReader(webResponse.GetResponseStream()))
                    {
                        soapResult = rd.ReadToEnd();
                    }
                    //Console.Write(soapResult);
                }
            }
    
            private static void InsertSoapEnvelopeIntoWebRequest(XmlDocument soapEnvelopeXml, HttpWebRequest webRequest)
            {
                using (Stream stream = webRequest.GetRequestStream())
                {
                    soapEnvelopeXml.Save(stream);
                }
            }
    



    P.S. Всем спасибо за внимание! Надеюсь, что кому-нибудь будет полезна если не идея, то хотя бы примеры процедур. Пишите в комментариях свои мысли по поводу такой реализации обмена сообщениями.

    Немного литературы:
    1. Полезная статья. Сам ее использовал. Правда предложенная в ней конфигурация мне не подошла, но процесс установки очень подробный — Настройка OpenVPN сервера на Ubuntu 16.04
    2. Установка PowerDNS
    3. Подробно о Xamarin
    Поделиться публикацией
    Ммм, длинные выходные!
    Самое время просмотреть заказы на Фрилансим.
    Мне повезёт!
    Реклама
    Комментарии 0

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