17 January 2018

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

Configuring LinuxDevelopment for iOSRuby on RailsDevelopment for AndroidC#
В этой статье я хочу рассказать, о моей реализации мессенджера с двойным шифрованием сообщений на основе 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
Tags:безопасностьшифрование данных.netrubyxamarinopenvpnpowerdnsubuntusoaprest
Hubs: Configuring Linux Development for iOS Ruby on Rails Development for Android C#
0
1.6k 1
Leave a comment
Popular right now
Ruby on Rails разработчик
from 100,000 ₽FrogogoМоскваRemote job
Бэкенд-разработчик (Ruby on Rails)
from 120,000 ₽FunBoxМоскваRemote job
Ruby on Rails Developer
from 90,000 to 180,000 ₽FlatstackRemote job
Senior Backend-разработчик (Ruby on Rails)
from 200,000 to 325,000 ₽HoodiesRemote job
Ruby on Rails разработчик
from 100,000 to 250,000 ₽PactRemote job