WebSocket connection to 'wss://api.example.com/ws' failed: Error during WebSocket handshake: Unexpected response code: 404

1/11/2020

I am attempting to set up websockets with TLS within Google Kubernetes Engine and Istio.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: example-back-end
spec:
  hosts:
    - "api-dev.example.dev"
  gateways:
    - istio-system/example-gateway
  http:
    - match:
        - uri:
            prefix: /worker
      route:
        - destination:
            host: worker
            port:
              number: 5001
    - match:
        - uri:
            prefix: /
      route:
        - destination:
            host: back-end
            port:
              number: 5000
    - match:
        - uri:
            prefix: /ws
      route:
        - destination:
            host: service-websocket
            port:
              number: 8080
      websocketUpgrade: true

I've mounted the tls cert and key into my websocket service container. (The same one I'm using for the api.example.com).

apiVersion: v1
kind: Service
metadata:
  name: service-websocket
spec:
  selector:
    app: service-websocket
  ports:
  - port: 8080
    targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: service-websocket
spec:
  selector:
    matchLabels:
      app: service-websocket
  template:
    metadata:
      labels:
        app: service-websocket
    spec:
      volumes:
        - name: example-certificate
          secret:
            secretName: example-certificate
      containers:
      - name: service-websocket
        image: gcr.io/example-project/service-websocket:latest
        resources:
          limits:
            memory: "128Mi"
            cpu: "500m"
        ports:
        - containerPort: 8080
        volumeMounts:
          - name: example-certificate
            mountPath: /var/secrets/tls

This is the websocket server which uses the ws node package.

// web server
const https = require("https");
const config = require("./config");
const fs = require("fs");


const server = https.createServer({
  cert: fs.readFileSync(config.TLS_CERT),
  key: fs.readFileSync(config.TLS_KEY)
});

// websocket

const WebSocket = require("ws");
const url = require("url");

const wss = new WebSocket.Server({ noServer: true });

wss.on("connection", function connection(ws, req) {
  const parameters = url.parse(req.url, true);


  ws.on("message", function incoming(message) {
    wss.clients.forEach(client => {
      const msg = {
        msg: "hello world from server" 
      };
      client.send(JSON.stringify(msg));
    });
  });

  const msg = {
    msg: "something"
  };

  ws.send(JSON.stringify(msg));
});

wss.on("error", () => console.log("error"));

server.on("upgrade", function upgrade(request, socket, head) {
  const pathname = url.parse(request.url).pathname;

  if (pathname === "/ws") {
    wss.handleUpgrade(request, socket, head, function done(ws) {
      wss.emit("connection", ws, request);
    });
  } else {
    socket.destroy();
  }
});

server.listen(8080);

From the frontend, I'm initializing Websocket:

const ws = new WebSocket(`wss://api.example.com/ws`);

However, I am getting the error:

WebSocket connection to 'wss://api.example.com/ws' failed: Error during WebSocket handshake: Unexpected response code: 404

Everything seems to work fine locally within my docker-compose setup. But can't seem to figure out how to make this work on GKE + Istio.

UPDATE 1/15/20

I changed the virtual service route order. Previously, /ws was after /. But now I am getting a 503 error from the frontend.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: example-back-end
spec:
  hosts:
    - "api-dev.example.dev"
  gateways:
    - istio-system/example-gateway
  http:
    - match:
        - uri:
            prefix: /ws
      route:
        - destination:
            host: service-websocket
            port:
              number: 443
      websocketUpgrade: true

    - match:
        - uri:
            prefix: /worker
      route:
        - destination:
            host: worker
            port:
              number: 5001

    - match:
        - uri:
            prefix: /
      route:
        - destination:
            host: back-end
            port:
              number: 5000
-- travisluong
google-kubernetes-engine
istio
node.js
websocket

1 Answer

2/28/2020

The main issue was that TLS termination was happening at the gateway instead of the service layer. Istio has a tls passthrough mode which allows end to end encryption of the websocket connection. The solution involves several parts.

  1. Create a CNAME record to point ws.dev.example.dev to dev.example.dev. This is key as URI prefix matching isn't supported, hence why the /ws didn't work before.
  2. Configure TLS passthrough at the gateway and SNI host at the virtual service.
  3. Modify the frontend such that ws.dev.example.dev is used when connecting to the websocket service.
  4. Mount the tls cert and key into the websocket service. (We copied an existing cert stored as a Kubernetes secret)
  5. Modify the websocket service to use the cert.

gateway.yaml

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: example-gateway
  namespace: istio-system
  labels:
    app: ingressgateway
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 443
      protocol: HTTPS
      name: https-ws
    tls:
      mode: PASSTHROUGH
    hosts:
    - "ws.dev.example.dev"

service-websocket-virtual-service.yaml

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: service-websocket
spec:
  hosts:
    - "ws.dev.example.dev"
  gateways:
    - istio-system/example-gateway
  tls:
    - match:
        - port: 443
          sni_hosts:
            - "ws.dev.example.dev"
      route:
        - destination:
            host: service-websocket
            port:
              number: 443

service-websocket.yaml

apiVersion: v1
kind: Service
metadata:
  name: service-websocket
spec:
  selector:
    app: service-websocket
  ports:
  - port: 443
    targetPort: 443
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: service-websocket
spec:
  selector:
    matchLabels:
      app: service-websocket
  template:
    metadata:
      labels:
        app: service-websocket
    spec:
      volumes:
        - name: example-dev-certificate
          secret:
            secretName: example-dev-certificate
      containers:
      - name: service-websocket
        image: gcr.io/example-dev/service-websocket:latest
        resources:
          limits:
            memory: "128Mi"
            cpu: "500m"
        ports:
        - containerPort: 443
        volumeMounts:
          - name: example-dev-certificate
            mountPath: /var/secrets/tls

service-websocket node server

const https = require("https");
const config = require("./config");
const fs = require("fs");

const server = https.createServer({
  cert: fs.readFileSync(config.TLS_CERT),
  key: fs.readFileSync(config.TLS_KEY)
});

// websocket

const WebSocket = require("ws");
const url = require("url");

const wss = new WebSocket.Server({ noServer: true });

wss.on("connection", function connection(ws, req) {
  const parameters = url.parse(req.url, true);

  ws.ideaRoom = { id: parseInt(parameters.query.ideaId) };

  console.log("ws.ideaRoom", ws.ideaRoom);

  ws.on("message", function incoming(message) {
    console.log("received: %s", message);
    console.log("wss.clients", wss.clients);
    wss.clients.forEach(client => {
      const msg = {
        msg: "hello world from server " + ws.ideaRoom.id
      };
      client.send(JSON.stringify(msg));
    });
  });

  const msg = {
    msg: "something"
  };

  ws.send(JSON.stringify(msg));
});

wss.on("error", () => console.log("error"));

server.on("upgrade", function upgrade(request, socket, head) {
  const pathname = url.parse(request.url).pathname;
  console.log("pathname", pathname);
  if (pathname === "/") {
    wss.handleUpgrade(request, socket, head, function done(ws) {
      wss.emit("connection", ws, request);
    });
  } else {
    socket.destroy();
  }
});

server.listen(443);

frontend code

const ws = new WebSocket(`wss://ws.dev.example.dev:443?ideaId=${idea.id}`);
ws.onopen = () => {
  ws.send("hello world from client: " + idea.id);
};

ws.onerror = error => {
  console.error(error);
};

ws.onmessage = e => {
  console.log('e.data', e.data);
  const idea_comment = JSON.parse(e.data);
  console.log("idea_comment", idea_comment);
};
-- travisluong
Source: StackOverflow