I'm attempting to connect to a websockets server (websockify) through a reverse proxy on IIS. The IIS and websockets server reside on the same physical server (Windows Server 2012 R2, IIS 8.5, ARR 3, Websockets enabled). I've seen a few questions about this and it's suggested this should work with IIS 8 and ARR 3, but no actual solutions as yet. I have some experience with http/https reverse proxies in IIS, but this is my first attempt working with websockets.
For example:
The original url: ws://10.2.1.10/websockify
The reverse proxy needs to translate this to: ws://10.2.1.10:5901/websockify
Overly general sample rule in web.config:
<rewrite>
<rules>
<rule name="WS reverse proxy" stopProcessing="true">
<match url="(.*)" />
<conditions> <add input="{CACHE_URL}" pattern="^(.+)://" />
</conditions>
<action type="Rewrite" url="{C:1}://10.2.1.10:5901/websockify"/>
</rule>
</rules>
</rewrite>
Per the Failed Request Trace, the url appears to be translated, but for some reason it doesn't reach the websocket server at 10.2.1.10:5901.
The end goal is to incorporate noVNC/websockify to provide browser based client access to multiple VNC servers on the network. Any help understanding how to reverse proxy the websockets is appreciated.
I had been trying to accomplish this same thing on IIS 8.5 with ARR 3.0, and eventually found the problem. According to Microsoft's Erez Benari, this is possible:
WebSocket support requires the WebSocket feature to be installed on IIS, but does not require any other configuration or action. Install the feature using the Server Manager Add Roles and Features, and once that is complete, ARR 3.0 will handle the requests appropriately.
As a test, I set up a Node.js server for WebSocket:
const WebSocketServer = require('ws'); const wss = new WebSocketServer({ port: 3011 }); function sendWSMessage(msg) { wss.clients.forEach((client) => { client.send(msg); }); } setInterval(function() { sendWSMessage('hello client'); }, 3000);
Along with a simple test page:
var websock = new WebSocket('ws://localhost:3011'); websock.onmessage = function (event) { console.log(event.data); }; websock.onopen = function (event) { websock.send("hello server"); };
Then, I set up an ARR reverse proxy on my local machine, with the following in a web.config file of a "wstest" directory on localhost:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="WebSocketTestRule" stopProcessing="true">
<match url=".*" />
<conditions>
<add input="{CACHE_URL}" pattern="^(.+)://" />
</conditions>
<action type="Rewrite" url="{C:1}://localhost:3011/" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>
This should forward all traffic for //localhost/wstest
to a Node.js server on port 3011. The Node server works when I directly connect to it via ws://localhost:3011
. When I try to connect through the proxy via ws://localhost/wstest
, the request makes it through to the Node.js server, the upgrade occurs, and the connection is made.
Chrome sends:
GET ws://localhost/wstest HTTP/1.1 Host: localhost Connection: Upgrade Pragma: no-cache Cache-Control: no-cache Upgrade: websocket Origin: file:// Sec-WebSocket-Version: 13 User-Agent: Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.84 Safari/537.36 Accept-Encoding: gzip, deflate, sdch Accept-Language: en-US,en;q=0.8 Sec-WebSocket-Key: 4ufu8nAOj7cKndASs4EX9w== Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
The Node.js server receives:
cache-control: no-cache connection: upgrade pragma: no-cache upgrade: Websocket accept-encoding: gzip, deflate, sdch accept-language: en-US,en;q=0.8 host: localhost:3011 max-forwards: 10 user-agent: Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.84 Safari/537.36 origin: file:// sec-websocket-version: 13 sec-websocket-key: fBkTwAS9d/unXYKDE3+Jjg== sec-websocket-extensions: permessage-deflate; client_max_window_bits x-original-url: /wstest x-forwarded-for: [::1]:54499 x-arr-log-id: a0b27458-9231-491d-b74b-07ae5a01c300
The Node.js server responds with:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-Websocket-Accept: yep8mgQACAc93oGIk8Azde4WSXk= Sec-WebSocket-Extensions: permessage-deflate
And finally Chrome receives:
HTTP/1.1 101 Switching Protocols Upgrade: Websocket Sec-WebSocket-Accept: CBSM8dzuDoDG0OrJC28nIqaw/sI= Sec-WebSocket-Extensions: permessage-deflate X-Powered-By: ARR/3.0 Connection: Upgrade X-Powered-By: ASP.NET Date: Fri, 10 Jun 2016 21:16:16 GMT EndTime: 17:16:16.148 ReceivedBytes: 0 SentBytes: 0
So now they are connected. This all looks good, the only noticeable difference being that the Sec-WebSocket-Key and Sec-WebSocket-Accept is changed in both directions by either IIS or the ARR proxy.
But... no WebSocket frames ever make it through the proxy! When Chrome receives positive feedback on its upgrade request, it sends its WebSocket message frame, and it is then sitting and waiting for messages from the server. The Node.js server sends its frames, and no error occurs, but they are never received by Chrome. The message that Chrome sent is never received by Node.js. It appears that ARR/IIS is dropping the WebSocket frames in both directions.
Notice how Chrome is telling the server that it supports the permessage-deflate extension, which is a WebSocket extension for per-message compression. The server is responding that it also supports permessage-deflate, so when they browser and server send their messages to each other, they use this compression extension. HOWEVER, the guy in the middle, ARR, apparently does NOT support this compression! By turning off support for permessage-deflate on the server, the actual WebSocket frames can now pass through the proxy flawlessly:
const wss = new WebSocketServer({ port: 3011, perMessageDeflate: false });
I think the issue is that ARR 3.0 does not support the Sec-Websocket-Extensions
header, so it is allowing the header to simply pass through. But allowing this header to be negotiated between the client and the server is wrong, because ARR is not involved in the negotiation and has no way of telling the two parties that it does not support passing compressed messages. Hopefully someday, ARR will be able to properly handle extensions by negotiating between itself and the client, and then doing a separate negotiation between itself and the server. As it stands now, it simply has the client and server negotiating with each other, which results in this error.