Web UI, WebSocket и проблема самоподписанных сертификатов

    Доброго времени суток всем!

    В этой заметке я постараюсь рассмотреть проблематику зашифрованного соединения с Web-сервером (HTTPS) и такого же соединения с Comet-сервером с использованием WebSocket (WSS) при использовании самоподписанных сертификатов, а так же варианты для решения данной проблемы.

    В данный момент времени я занимаюсь разработкой Web UI к некоему продукту компании, который будет использоваться внутри клиентских корпоративных сетей.

    Изначальные условия: доступ к Web UI прямо по IP адресу сервера, желательно использование стандартных портов 80/443, чтобы облегчить деплоймент, ОС сервера — Windows Server 2012.

    Comet-сервер нужен нам для организации связи между неким сервисом, который формирует отчеты по заданным параметрам, и непосредственно Web UI, через который клиент отправляет запрос, получает статус о выполнении и завершении задания, а так же для передачи разных сервисных сообщений о событиях (например — пользователь вошел в систему, данные пользователя были изменены администратором и т.п.).

    Для Comet-сервера и бэкенда Web UI мы используем Node.JS.

    Вообще опыта использования Node.JS у меня особого нет, но как оказалось подобные задачи делать на нем весьма удобно — не вдаваясь в детали на само написание первой версии с глобальным бродкастом сообщений между всеми участниками без «комнат» или «каналов» у меня ушло буквально пару часов. И большая куча времени на инвестигирование модулей Node.JS, на которых можно реализовать WebSocket сервер (Socket.IO, WS) — тут время ушло на исследование стыковки C# серверного компонента и Comet-сервера. Socket.IO нам не подошел — в версии > 1 там реализовано много «поверх», решили по разным причинам использовать более нативную реализацию и взяли модуль WS.

    Скажу сразу, что Comet-сервер имеет два интерфейса: первый это обычный HTTP интерфейс (внутренний с авторизацией запросов), по которому происходит авторизация пользователей и компонентов, отправка команд серверу; второй это непосредственно WebSocket интерфейс (WS), к которому подключаются авторизованные пользователи и компоненты для получения сообщений о событиях в системе.

    В связке HTTP+WS все естественно работает на «ура» и никаких проблем, но по заданию нам нужен SSL, и соответственно HTTPS+WSS.

    Создать самоподписанный сертификат при помощи OpenSSL дело несложное, прикрутить к Web UI тоже буквально пара строчек, к интерфейсу Comet тоже просто.

    И вот тут началось самое интересное.

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

    Итак, мы идем на тестовый Web UI с эмуляцией клиента по адресу 127.0.0.1, подтверждаем сертификат, далаем попытку соединения по адресу wss://127.0.0.1:4433/ — и получаем ошибку соединения. Потому что для адреса 127.0.0.1 и порта 4433 сертификат надо подтверждать второй раз. Браузер при этом ну никаких дополнительных сообщений о том, что сертификат нужно подтвердить, не выдает. Просто молча сбрасывает запрос и все, он даже до сервера не доходит.

    Из четырех тестируемых браузеров — IE, Firefox, Opera, Chrome — второй раз не спрашивают подтверждения на другой порт по тому же адресу Chrome и Opera, IE и Firefox запрос молча обрубают с выдачей ошибки в консоль.

    Поскольку скорее всего у клиента будет IE (все-таки Windows это массовая ОС у корпоративных клиентов), то это большая проблема.

    Решение 1


    Самое простое решение, которое приходит в голову — это проксировать запросы. В Linux я бы поставил Nginx и проксировал через него, но у нас Windows. Взял уже имеющийся у меня Apache 2.2, настроил SSL и дал вот такую директиву:

    ProxyPass /wss/ ws://127.0.0.1:8095/
    ProxyPass / http://127.0.0.1:8080/
    

    То есть запросы на /wss/ пробрасываем к WS интерфейсу, все остальное — на Web UI.

    Не заработало. Гугление и прочее выдало что надо Apache 2.4 с модулем mod_proxy_wstunnel. Поставил, подключил — все прекрасно, Web UI выдает запрос один раз, WSS соединение устанавливается без проблем.

    Но Apache в данном получается все-таки лишним компонентом, и несколько портит производительность, которую дает асинхронное приложение на Node.JS.

    Решил попробовать написать прокси-сервер на Node.JS, не вышло. Если вкратце, то одновременные запросы на любой один порт с поднятием WS и HTTPS интерфейсов HTTPS интерфейс в упор не ловит запросов по WSS протоколу. Совсем. Проксировать отдельно HTTPS на внутренний HTTP и отдельно WSS на внутренний WS проксирует, а вот поймать урл при запросе wss://127.0.0.1/wss/ — никак.

    Хорошо, вернемся к первому варианту и двум портам с подтверждением сертификатов.

    В Node.JS WSS сервер создается на основе HTTPS-сервера и умеет отдавать некий контент при прямом запросе 127.0.0.1:4433, чем я и решил воспользоваться:

    var ws = require('ws');
    var https = require('https');
    var fs = require('fs');
    
    var processRequest = function( req, res ) {
        res.statusCode = 200;
        res.end('Hello, world!' + "\n");
    };
    
    var options = {
        key: fs.readFileSync(PathToKey),
        cert: fs.readFileSync(PathToCert)
    };
    
    var cometApp = https.createServer(options, processRequest).listen(4433, function() {
        console.log('Comet server [WSS] on *:4433');
    });
    
    var cometServerOptions = {
        server: cometApp,
        verifyClient: function(request) {
    //      Some client verifying
            return true;
        }
    };
    
    var cometServer = new ws.Server(cometServerOptions);
    
    cometServer.on('connection', function(socket) {
        socket.on('message', function(data) {
            console.log('New message received');
            }
        });
    });
    

    Ради смеха решил попробовать iframe. В iframe выдается предупреждение… И никаких кнопок для пользователя чтобы подтвердить сертификат. Да и в любом случае я никак не могу отследить, подтвердил там чего пользователь или нет, чтобы потом установить соединение. Прописать в техтребованиях, чтобы пользователь обязательно сходил на два адреса? Тоже как-то некрасиво… AJAX-запрос на другой порт по тому же адресу вообще не работает из-за внутренней безопасности — для Javascript это два разных сайта.

    Решение 2


    Оказалось несколько бредовым, но весьма рабочим. Итак, нам надо два раза подтвердить сертификат. А что если мы попросим пользователя сначала сходить на HTTPS интерфейс WSS-сервера, а потом после подтверждения сделаем редирект на основной WebUI?

    var ws = require('ws');
    var https = require('https');
    var fs = require('fs');
    
    var processRequest = function( req, res ) {
        res.setHeader("Location", redirectUrl);
        res.statusCode = 302;
        res.end();
    };
    
    
    var options = {
        key: fs.readFileSync(PathToKey),
        cert: fs.readFileSync(PathToCert)
    };
    
    var cometApp = https.createServer(options, processRequest).listen(4433, function() {
        console.log('Comet server [WSS] on *:4433');
    });
    
    var cometServerOptions = {
        server: cometApp,
        verifyClient: function(request) {
    //      Some client verifying
            return true;
        }
    };
    
    var cometServer = new ws.Server(cometServerOptions);
    
    cometServer.on('connection', function(socket) {
        socket.on('message', function(data) {
            console.log('New message received');
            }
        });
    });
    

    Работает! При запросе 127.0.0.1:4433/ меня теперь просит подтвердить сертификат, потом автоматически перебрасывает в Web UI 127.0.0.1, где тоже просит подтвердить сертификат, после чего связка HTTPS на одном + WSS на другом порту прекрасно работает и больше ничего не спрашивает.

    NB! Выше я ничего не написал про доменные имена. Конечно, их можно использовать, чтобы получить нормальные сертификаты, но у меня есть определенные ограничения в виде корпоративной сети, из которой далеко не всегда есть доступ к центру сертификации, выдавшему сертификат. Да и требовать заводить доменные имена во внутренней сети для продакшена в моем случае это лишняя головная боль. Еще один сервер для Web UI на Linux, например, с проксей в виде Nginx в силу разных причин мы тоже не рассматриваем.

    Выводы


    Для решения данной проблемы нашлось два пути:

    1. Проксирование с прямого адреса по части url (при помощи Apache или Nginx)
    2. Редирект с HTTPS-интерфейса WS-сервера на основной Web UI с подтверждением сертификатов два раза

    Буду рад любым комментариям, может есть еще какие решения.

    UPDATE

    для Node.JS все-таки получилось сделать проксирование WS запросов (я брал node-http-proxy) по части урла (спасибо xdenser) средствами самого Node.JS на событие Upgrade, причем проксирование оптимально добавить прямо в http-сервер Web UI, чтобы не плодить компоненты, примерно таким образом:

    var httpProxy = require('http-proxy');
    
    var proxy = httpProxy.createProxyServer({});
    
    function proxyWebsocketResponse(req, res, head) {
        try {
            var hostname = req.headers.host.split(":")[0];
            var pathname = url.parse(req.url).pathname;
    
            if (pathname === config.comet.websocket.path) {
                var options = {
                    target: 'ws://' + config.comet.websocket.host + ':' + config.comet.websocket.port + '/',
                    ws: true
                };
    
                proxy.ws(req, res, head, options);
    
                proxy.on('error', function(e) {
                    console.log('WebSocket error: ' + e.message);
                    res.end();
                });
    
            } else {
                res.statusCode = 501;
                res.end('Not Implemented');
            }
        } catch (e) {
            console.log('Error: ' + e.message);
        }
    }
    
    httpServer.addListener('upgrade', proxyWebsocketResponse);
    
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 17

      +2
      Решение — все на одном порту и апгрейд https до wss.
      С TSL не пробовал но HTTP -> WS socket.io так и делает.
        0
        а различать запросы как? у меня там еще и HTTP-сервер сбоку для приема команд.

        честно не понимаю, можно схему словами описать?
          +1
          ну в URL есть же не только адрес, порт но и путь.
          в socket.io клиент стучится на http://address:port/socket.io
          и просит апгрейд протокола до WS
            0
            а чем это изменит запрос подтверждения сертификата в нашем случае?

            браузер молча срубит запрос, и все. или я чего не понимаю опять.

            да, и по-моему есть некое недопонимание архитектуры. компонентов два.

            первый — бэкенд Web UI (443)

            второй — WSS Comet-сервер (4433) + HTTP сервер (8090) для внутренних запросов

            и никак их на один порт не повесишь
              0
              повторюсь я не пробовал с TLS, но по идее на момент апгрейда протокола, безопасное соединение уже установлено и сертификат подтверждать не нужно.

              Повесить на один порт с помощью NodeJS можно, я думаю.
              Естественно для WebUI это будет прокси, точнее больше похоже на проброс порта.
              Вот тут, например, обсуждается вариант. И то не самый лучший. Можно не делать редиректы, просто пайпить и все.
                0
                в socket.io клиент стучится на http://address:port/socket.io

                тут я должен бы пояснить, что в javascript инициализация вида

                var ws = new Websocket('http://address:port/'); 
                

                свалится с ошибкой, потому что надо делать

                var ws = new Websocket('ws://address:port/);
                

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

                var ws = new Websocket('wss://address:port/');
                

                на момент апгрейда протокола, безопасное соединение уже установлено

                я так понимаю, мы все-таки про прокси? так вот смотрите, если я основную страницу открыл грубо говоря https://127.0.0.1/ и стучусь в wss://127.0.0.1/wss/, то в бэкенде код прокси вида

                var proxy = httpProxy.createProxyServer({});
                
                http.createServer(function(req, res) {
                    var hostname = req.headers.host.split(":")[0];
                    var pathname = url.parse(req.url).pathname;
                
                    var options = {};
                
                    if (pathname == '/wss/') {
                        options.target = 'ws://' + httpHost + ':' + wsPort + '/';
                        options.ws = true;
                        proxy.ws(req, res, options);
                    } else {
                        options.target = 'http://' + httpHost + ':' + httpPort + '/';
                        proxy.web(req, res, options);
                
                    }
                
                }).listen(proxyPort);
                

                в упор не видит WS запроса, потому что это все-таки не HTTP запрос, я так понимаю:

                    GET /demo HTTP/1.1
                    Upgrade: WebSocket
                    Connection: Upgrade
                    Sec-WebSocket-Key2: 4 @1 46546xW%0l 1 5
                    Host: example.com
                    Sec-WebSocket-Key1: 12998 5 Y3 1 .P00
                    Origin: http://example.com
                    WebSocket-Protocol: sample
                

                я об этом вобщем и написал в статье…
                  +1
                  т.е. callback на http.createServer не вызывается для этого запроса?
                  а что же происходит с соединением?
                  вообще это явно HTTP запрос, он не может его просто сбрасывать.
                  если не может обработать должен выдать код ошибки.
                    0
                    именно.

                    http-сервер его просто не обрабатывает (не ловит) почему-то.
                      0
                      Да это так и должно быть — смотри мой коммент ниже со ссылкой на доки ноды. Нужно слушать событие 'upgrade'.
                      Пришлось пойти посмотреть в исходники engine.io, чтоб понять, что он делает. Потому что там это точно работает.
                    0
                    Еще для информации

                    Event: 'upgrade'#

                    function (request, socket, head) { }

                    Emitted each time a client requests a http upgrade. If this event isn't listened for, then clients requesting an upgrade will have their connections closed.
                      0
                      я не совсем корректный пример кода привел в прокси-сервере, не до конца инвестигировал. у нас все-таки https-сервер, завтра попробую еще раз помучать тему прокси на Node.JS.

                      я видимо с http еще плюнул на эту идею и не стал дальше смотреть.
                        0
                        бинго! вариант с прокси на Node.JS все-таки получился! надо было действительно проксировать на upgrade, а не на непосредственно запрос, спасибо.

                        а здесь принято поправить пост и дописать некий Update про третий вариант с кодом?

                        вот код прокси, если интересно:

                        app.js

                        var
                            http = require('https'),
                            https = require('https'),
                            url = require('url'),
                            httpProxy = require('http-proxy'),
                            fs = require('fs');
                        
                        var config = require('./config/config.json');
                        
                        var proxy = httpProxy.createProxyServer({});
                        
                        function proxyHttpResponse(req, res) {
                            var hostname = req.headers.host.split(":")[0];
                            var pathname = url.parse(req.url).pathname;
                        
                            try {
                                var options = {
                                    target: 'http://' + config.http.host + ':' + config.http.port + '/'
                                };
                                proxy.web(req, res, options);
                            } catch (e) {
                                console.log('Error: ' + e.message);
                            }
                        }
                        
                        function proxyWebsocketResponse(req, res) {
                            var hostname = req.headers.host.split(":")[0];
                            var pathname = url.parse(req.url).pathname;
                        
                            if (pathname === '/ws/') {
                                try {
                                    var options = {
                                        target: 'ws://' + config.websocket.host + ':' + config.websocket.port + '/',
                                        ws: true
                                    };
                                    proxy.ws(req, res, options);
                                } catch (e) {
                                    console.log('Error: ' + e.message);
                                }
                            } else {
                                res.statusCode = 501;
                                res.end('Not Implemented');
                            }
                        }
                        
                        
                        if (config.server.ssl) {
                            var options = {
                                port: config.server.sslPort,
                                key: fs.readFileSync(config.server.sslKey),
                                cert: fs.readFileSync(config.server.sslCert)
                            };
                        
                            var server = https.createServer(options, proxyHttpResponse).listen(config.server.sslPort, function () {
                                console.log('Proxy server (SSL) started on *:' + config.server.sslPort);
                            });
                        } else {
                            var server = http.createServer(proxyHttpResponse).listen(config.server.port, function () {
                                console.log('Proxy server started on *:' + config.server.port);
                            });
                        }
                        
                        server.addListener('upgrade', proxyWebsocketResponse);
                        

                        config.json

                        {
                            "debug": true,
                        
                            "server": {
                                "ssl": true,
                                "port": 80,
                                "sslPort": 443,
                                "sslKey"  : "cert/server.key",
                                "sslCert" : "cert/server.pem"
                            },
                        
                            "http": {
                                "port": 8080,
                                "host": "127.0.0.1"
                            },
                        
                            "websocket": {
                                "port": 8095,
                                "host": "127.0.0.1"
                            }
                        }
            +5
            Если использование внутрикорпаротивное, то просто ставим всем клиентам сертификат центра сертификации (CA), которым подписан ключ веб-сервера и все будет работать «из коробки»
              0
              кстати да. а сертификат собственного такого же CA прокатит в этом случае?
              +1
              Так все-таки, в чем была проблема с nginx под виндой?
                0
                я его года четыре назад пробовал — падал постоянно и глючило его. может конечно что и изменилось.
                  0

                Only users with full accounts can post comments. Log in, please.