[WebSocket][Django][Ngnix] WebSockt fails on HTTPs, while it works via http (connection failed)

Hi,
I have an issue in django App;
Web Socket fails over https connection (please see attached screenshots of the console errors), while it works perfectly locally, with http;

I would appreciate your help, and also, advices on how to test web socket connection programmatically, so that I will not have regressions like this, in the near future :slight_smile:
Thank you!

I have a similar issue; Locally with http, ws works perfectly;
In https, I receive connection interrupted
routing.py

# chat/routing.py
from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
]

The chat is managed via:

urlpatterns = [
    path("me", views.user_chats, name="user_chats"),
    path("<int:room_id>", views.room, name="room"),
    path("<int:room_id>/upload", views.upload_file, name="upload_file"),
]

The chat are server via https://taxcoder.cz/chat/2

The ngnix configuration is:

server {
    listen 80;
    server_name app.taxcoder.cz www.app.taxcoder.cz;

    # Redirect all HTTP requests to HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name app.taxcoder.cz www.app.taxcoder.cz;

    # SSL certificate files
    ssl_certificate /etc/letsencrypt/live/app.taxcoder.cz/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.taxcoder.cz/privkey.pem;

    # WebSocket configuration
    location /ws/chat/ {
        proxy_pass https://web:8000/chat/;  # Ensure your backend is running on HTTPS if using https
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Optional WebSocket timeout settings
        proxy_read_timeout 86400;
        proxy_send_timeout 86400;
        proxy_connect_timeout 86400;
        proxy_buffering off;
    }

    # Other location blocks
    location / {
        proxy_pass http://web:8000;  # Internal communication over HTTPS
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Static files configuration
    location /static/ {
        alias /app/static/;  # Adjust the alias to your static file location
    }
}

Locally, it works, while when being in https, I receive the error to being closed


Script is:

  <script>
    const roomId = JSON.parse(document.getElementById('room-id').textContent);
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const chatSocket = new WebSocket(protocol + '//' + window.location.host + '/ws/chat/' + roomId + '/');


    function scrollToLatestMessage() {
      const chatLog = document.getElementById('chat-log');
      chatLog.scrollTop = chatLog.scrollHeight;
    }

    document.addEventListener('DOMContentLoaded', function () {
      scrollToLatestMessage();
      updateInvoiceButtonVisibility(); // Check invoice button visibility on load
    });

    chatSocket.onmessage = function (e) {
      const data = JSON.parse(e.data);
      const senderName = `${data.first_name} ${data.last_name}`;
      let messageElement;
      if (data.message != null) {
        messageElement = `<div><strong>${senderName}:</strong> ${data.message}</div>`;
      } else {
        let filename = data.file.split('/').pop();
        let shortenedFilename;
        if (filename.length > 15) {
          shortenedFilename = `${filename.substring(0, 5)}...${filename.substring(filename.length - 8)}`;
        } else {
          shortenedFilename = filename;
        }
        messageElement = `<div><strong>${senderName}:</strong> <a href="${data.file}">File: ${shortenedFilename}</a></div>`;
      }
      const chatLog = document.getElementById('chat-log');
      chatLog.innerHTML += messageElement;

      scrollToLatestMessage();
      updateInvoiceButtonVisibility();
    };

    chatSocket.onopen = function () {
      console.log('WebSocket connection established');
    };
    
    chatSocket.onerror = function (error) {
      console.error('WebSocket error:', error);
x    };
    
    chatSocket.onclose = function (event) {
      console.log('WebSocket connection closed:', event.code, event.reason);
    };

    function sendMessage() {
      const messageInputDom = document.getElementById('chat-message-input');
      const message = messageInputDom.value;
      if (message.trim() !== '') {
        chatSocket.send(JSON.stringify({ message: message }));
        messageInputDom.value = '';
      }
    }

    document.getElementById('chat-message-submit').addEventListener('click', sendMessage);

    document.getElementById('chat-message-input').addEventListener('keydown', function (event) {
      if (event.key === 'Enter') {
        sendMessage();
      }
    });

    // Function to handle chat selection and button logic
    function selectChat(clientId) {
      localStorage.setItem('selectedClientId', clientId);

      const chatLog = document.getElementById('chat-log');
      chatLog.innerHTML = '';

      const invoiceButton = document.getElementById('invoice-button');
      invoiceButton.style.display = 'none';

      invoiceButton.onclick = function() {
        window.location.href = "{% url 'invoices' 0 %}".replace('0', clientId);
      };

      window.location.href = `/chat/${clientId}`;
    }

    function updateInvoiceButtonVisibility() {
      const invoiceButton = document.getElementById('invoice-button');
      const clientId = localStorage.getItem('selectedClientId');
      const chatLog = document.getElementById('chat-log');

      if (chatLog.innerHTML.trim() !== '' && clientId) {
        invoiceButton.style.display = 'block';
        invoiceButton.onclick = function() {
          window.location.href = "{% url 'invoices' 0 %}".replace('0', clientId);
        };
      } else {
        invoiceButton.style.display = 'none';
      }
    }

    document.addEventListener('DOMContentLoaded', function () {
      scrollToLatestMessage();
      updateInvoiceButtonVisibility();
    });

    // Function to handle file upload
    document.getElementById('chat-file-upload').addEventListener('click', async () => {
      const input = document.getElementById('chat-file-select');
      const files = input.files;

      if (files.length === 0) {
        alert('No files selected');
        return;
      }

      const formData = new FormData();
      for (const file of files) {
        formData.append('files', file);
      }

      try {
        const response = await fetch(`/chat/${roomId}/upload`, {
          method: 'POST',
          body: formData,
          headers: {
            'X-CSRFToken': "{{ csrf_token }}" // Ensure CSRF token is included for Django
          }
        });

        const result = await response.json();

        if (result.success) {
          alert(result.success);
        } else {
          alert(result.error);
        }
      } catch (error) {
        console.error('Error:', error);
      }

      input.value = ''; // Clear file input
    });
  </script>

Docker compose setup and dependency is:

services:
  redis:
    image: redis:7
    container_name: redis_container
    ports:
      - "6379:6379"
    restart: always
    networks:
      - taxcoder_network

  web:
    build: .
    command: >
      sh -c "python manage.py migrate &&
             python manage.py collectstatic --noinput &&
             python manage.py compilemessages &&
             gunicorn --config gunicorn_config.py taxzen.wsgi:application"
    volumes:
      - .:/app
    expose:
      - "8000"
    depends_on:
      - redis
    networks:
      - taxcoder_network
    restart: always
    environment:
      - DEBUG=False
      - DJANGO_SETTINGS_MODULE=taxzen.settings
    env_file:
      - ./taxzen/.env

  nginx:
    image: nginx:latest
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - /etc/nginx/conf.d/taxzen.conf:/etc/nginx/conf.d/taxzen.conf:ro
      - /var/www/certbot:/var/www/certbot
      - .:/app
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      - web
    networks:
      - taxcoder_network
    restart: always

networks:
  taxcoder_network:
    driver: bridge

volumes:
  postgres_data:

Taxzen is setup at:

django_asgi_app = get_asgi_application()

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
        ),
    }
)

Here chat_consumer.py

class ChatConsumer(WebsocketConsumer):
    '''
    This method is called when a WebSocket connection is established.
    It adds the user to the room group and loads the chat history.
    '''
    def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        self.room_group_name = f"chat_{self.room_name}"

        # Join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name, self.channel_name
        )

        # Accept the WebSocket connection
        self.accept()

        # Load and send chat history to the client
        self.load_and_send_chat_history(self.room_name)

Two comments right off-hand.

  • Gunicorn is a sync wsgi handler, it does not handle async. You need to run something like Daphne as your websocket handler.

  • There’s no need to proxy https through to the back end. Nginx works perfectly fine as the TLS endpoint. (Note, there are some circumstances where you might want to proxy through TLS, but if you’re in one of those circumstances, you would know it.)

If you’re going to proxy both endpoints through the same process (Django and websockets), then you need to use an asgi server such as Daphne or uvicorn. (I use Daphne) If you’re going to use wsgi for Django, then you need to have a separate process for the websockets.