Building a Django WebShell with WebSockets, ASGI, and Paramiko

This guide walks through creating a WebShell that lets a React front‑end control a remote virtual machine via Django‑based WebSocket services, covering ASGI setup, custom WebSocket handling, integration with Paramiko for SSH, and a lightweight xterm.js terminal client.

MaGe Linux Operations
MaGe Linux Operations
MaGe Linux Operations
Building a Django WebShell with WebSockets, ASGI, and Paramiko

Preface

In a recent project we needed a front‑end feature to operate a remote virtual machine, called WebShell. The stack is React + Django, and most backend implementations use django‑channels for WebSocket support.

Django itself does not support WebSocket natively, but since Django 3 it supports the ASGI protocol, allowing us to implement our own WebSocket service.

We chose the combination gunicorn + uvicorn + ASGI + WebSocket + Django 3.2 + Paramiko to build the WebShell.

Implementing the WebSocket service

Django’s project scaffold generates asgi.py and wsgi.py. For this case we use asgi.py to handle WebSocket connections, implementing the typical connect, send, receive, and disconnect callbacks.

Idea

# asgi.py
import os
from django.core.asgi import get_asgi_application
from websocket_app.websocket import websocket_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'websocket_app.settings')
django_application = get_asgi_application()

async def application(scope, receive, send):
    if scope['type'] == 'http':
        await django_application(scope, receive, send)
    elif scope['type'] == 'websocket':
        await websocket_application(scope, receive, send)
    else:
        raise NotImplementedError(f"Unknown scope type {scope['type']}")
# websocket.py
async def websocket_application(scope, receive, send):
    while True:
        event = await receive()
        if event['type'] == 'websocket.connect':
            await send({'type': 'websocket.accept'})
        if event['type'] == 'websocket.disconnect':
            break
        if event['type'] == 'websocket.receive':
            if event['text'] == 'ping':
                await send({'type': 'websocket.send', 'text': 'pong!'})

Implementation

The core WebSocket class is shown below; it wraps the ASGI scope and provides methods such as accept, close, send, and receive.

class WebSocket:
    def __init__(self, scope, receive, send):
        self._scope = scope
        self._receive = receive
        self._send = send
        self._client_state = State.CONNECTING
        self._app_state = State.CONNECTING

    @property
    def headers(self):
        return Headers(self._scope)

    @property
    def scheme(self):
        return self._scope["scheme"]

    @property
    def path(self):
        return self._scope["path"]

    @property
    def query_params(self):
        return QueryParams(self._scope["query_string"].decode())

    @property
    def query_string(self) -> str:
        return self._scope["query_string"]

    @property
    def scope(self):
        return self._scope

    async def accept(self, subprotocol: str = None):
        """Accept connection."""
        if self._client_state == State.CONNECTING:
            await self.receive()
        await self.send({"type": SendEvent.ACCEPT, "subprotocol": subprotocol})

    async def close(self, code: int = 1000):
        await self.send({"type": SendEvent.CLOSE, "code": code})

    async def send(self, message: t.Mapping):
        if self._app_state == State.DISCONNECTED:
            raise RuntimeError("WebSocket is disconnected.")
        if self._app_state == State.CONNECTING:
            assert message["type"] in {SendEvent.ACCEPT, SendEvent.CLOSE}, (
                f'Could not write event "{message["type"]}" into socket in connecting state.'
            )
            if message["type"] == SendEvent.CLOSE:
                self._app_state = State.DISCONNECTED
            else:
                self._app_state = State.CONNECTED
        elif self._app_state == State.CONNECTED:
            assert message["type"] in {SendEvent.SEND, SendEvent.CLOSE}, (
                f'Connected socket can send "{SendEvent.SEND}" and "{SendEvent.CLOSE}" events, not "{message["type"]}"'
            )
            if message["type"] == SendEvent.CLOSE:
                self._app_state = State.DISCONNECTED
        await self._send(message)

    async def receive(self):
        if self._client_state == State.DISCONNECTED:
            raise RuntimeError("WebSocket is disconnected.")
        message = await self._receive()
        if self._client_state == State.CONNECTING:
            assert message["type"] == ReceiveEvent.CONNECT, (
                f'WebSocket is in connecting state but received "{message["type"]}" event'
            )
            self._client_state = State.CONNECTED
        elif self._client_state == State.CONNECTED:
            assert message["type"] in {ReceiveEvent.RECEIVE, ReceiveEvent.DISCONNECT}, (
                f'WebSocket is connected but received invalid event "{message["type"]}".'
            )
            if message["type"] == ReceiveEvent.DISCONNECT:
                self._client_state = State.DISCONNECTED
        return message

Combining WebSocket with Paramiko

To bridge the front‑end and the remote host we create a WebShell class that couples the WebSocket instance with a Paramiko SSH channel.

import asyncio
import traceback
import paramiko
from webshell.ssh import Base, RemoteSSH
from webshell.connection import WebSocket

class WebShell:
    """Tie WebSocket and paramiko.Channel together for bidirectional data flow."""
    def __init__(self, ws_session: WebSocket,
                 ssh_session: paramiko.SSHClient = None,
                 chanel_session: paramiko.Channel = None):
        self.ws_session = ws_session
        self.ssh_session = ssh_session
        self.chanel_session = chanel_session

    def init_ssh(self, host=None, port=22, user="admin", passwd="admin@123"):
        self.ssh_session, self.chanel_session = RemoteSSH(host, port, user, passwd).session()

    def set_ssh(self, ssh_session, chanel_session):
        self.ssh_session = ssh_session
        self.chanel_session = chanel_session

    async def ready(self):
        await self.ws_session.accept()

    async def welcome(self):
        for i in range(2):
            if self.chanel_session.send_ready():
                message = self.chanel_session.recv(2048).decode('utf-8')
                if not message:
                    return
                await self.ws_session.send_text(message)

    async def web_to_ssh(self):
        while True:
            if not self.chanel_session.active or not self.ws_session.status:
                return
            await asyncio.sleep(0.01)
            shell = await self.ws_session.receive_text()
            if self.chanel_session.active and self.chanel_session.send_ready():
                self.chanel_session.send(bytes(shell, 'utf-8'))

    async def ssh_to_web(self):
        while True:
            if not self.chanel_session.active:
                await self.ws_session.send_text('ssh closed')
                return
            if not self.ws_session.status:
                return
            await asyncio.sleep(0.01)
            if self.chanel_session.recv_ready():
                message = self.chanel_session.recv(2048).decode('utf-8')
                if not len(message):
                    continue
                await self.ws_session.send_text(message)

    async def run(self):
        if not self.ssh_session:
            raise Exception("ssh not init!")
        await self.ready()
        await asyncio.gather(self.web_to_ssh(), self.ssh_to_web())

    def clear(self):
        try:
            self.ws_session.close()
        except Exception:
            traceback.print_stack()
        try:
            self.ssh_session.close()
        except Exception:
            traceback.print_stack()

Front‑end

On the client side we use xterm.js to render the terminal and connect to the Django WebSocket endpoint.

export class Term extends React.Component {
    private terminal!: HTMLDivElement;
    private fitAddon = new FitAddon();

    componentDidMount() {
        const xterm = new Terminal();
        xterm.loadAddon(this.fitAddon);
        xterm.loadAddon(new WebLinksAddon());

        const socket = new WebSocket("ws://localhost:8000/webshell/");
        socket.onopen = (event) => {
            xterm.loadAddon(new AttachAddon(socket));
            this.fitAddon.fit();
            xterm.focus();
        };

        xterm.open(this.terminal);
        xterm.onResize(({ cols, rows }) => {
            socket.send("<RESIZE>" + cols + "," + rows);
        });

        window.addEventListener('resize', this.onResize);
    }

    componentWillUnmount() {
        window.removeEventListener('resize', this.onResize);
    }

    onResize = () => {
        this.fitAddon.fit();
    }

    render() {
        return <div className="Terminal" ref={ref => this.terminal = ref as HTMLDivElement}></div>;
    }
}
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

ReactWebSocketDjangoParamikoWebshellxterm.jsASGI
MaGe Linux Operations
Written by

MaGe Linux Operations

Founded in 2009, MaGe Education is a top Chinese high‑end IT training brand. Its graduates earn 12K+ RMB salaries, and the school has trained tens of thousands of students. It offers high‑pay courses in Linux cloud operations, Python full‑stack, automation, data analysis, AI, and Go high‑concurrency architecture. Thanks to quality courses and a solid reputation, it has talent partnerships with numerous internet firms.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.