Django3 uses WebSocket to implement WebShell

Preface

Recently, we need to develop the front-end function of operating remote virtual machines, referred to as WebShell. Based on the current technology stack of react+django, we found that most of the back-end implementations are django+channels to achieve websocket services.

After a general look at this is not interesting enough, looked through the official documentation of django and found that django does not natively support websocket, but after django3 supports the asgi protocol to achieve their own websocket services. So selected

gunicorn+uvicorn+asgi+websocket+django3.2+paramiko to implement WebShell.

Implementing websocket service

The project generated by django's own scaffolding will automatically generate two files asgi.py and wsgi.py, most common applications use wsgi.py with nginx to deploy online services. This time we mainly use asgi.py

The idea of implementing websocket service can be found roughly by searching the Internet, mainly to achieve connect/send/receive/disconnect the handling of several actions.

Here How to Add Websockets to a Django App without Extra Dependencies is a good example

, but too simple ........ :

Ideas

 
 # 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):
   pass
 
 # 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 above code provides the idea, for a more complete implementation you can refer here websockets-in-django-3-1 which is basically reusable

I'll put the core of the implementation below:

 
 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.
        :param subprotocol: The subprotocol the server wishes to accept.
        :type subprotocol: str, optional
        """ 
       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}, (
                   'Could not write event "%s" into socket in connecting state.'
 
                    % message [ "type" ]
            )
           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}, (
                   'Connected socket can send "%s" and "%s" events, not "%s"'
 
                    % (SendEvent.SEND, SendEvent.CLOSE, 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, (
                   'WebSocket is in connecting state but received "%s" event'
 
                    % message [ "type" ]
            )
            self._client_state = State.CONNECTED

       elif self._client_state == State.CONNECTED:
           assert message [" type" ] in {ReceiveEvent.RECEIVE, ReceiveEvent.DISCONNECT}, (
                   'WebSocket is connected but received invalid event "%s". '
 
                    % message ["type" ]
            )
           if message [" type" ] == ReceiveEvent.DISCONNECT:
                self._client_state = State.DISCONNECTED

       return message

The stitching monster

As a qualified code mover, in order to improve the efficiency of handling or to build some wheels to fill some holes, how to combine the above WebSocket class with paramiko to achieve from the front-end to accept characters passed to the remote host and accept the return at the same time?

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


 class WebShell:
   """Organize WebSocket and paramiko.Channel to interoperate data between them.""" 

   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 ):
       # Show Linux welcome related content 
       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):
       # print('--------web_to_ssh------->')
 
       while True:
           # print('--------------->')
 
           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()
           # print('-------shell-------->', shell)
 
           if self.chanel_session.active and self.chanel_session.send_ready():
                self.chanel_session.send(bytes(shell, 'utf-8') )
           # print('--------------->', "end")
 

   async def ssh_to_web(self):
       # print('<--------ssh_to_web-----------')
 
       while True:
           # print('<-------------------')
 
           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') 
               # print('<---------message----------', message)
 
               if not len(message):
                   continue 
               await self.ws_session.send_text(message)
           # print('<-------------------', "end")
 

   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()

The front end

xterm.js is perfectly fine, just search for something that looks simple.

 
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());

       // using wss for https
       // const socket = new WebSocket ("ws://" + window.location.host + "/api/v1/ws" );
        const socket = new WebSocket("ws ://localhost:8000/webshell/" );
       // socket.onclose = (event) => {
       // this.props.onClose();
       // }
        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>;
    }
}

Ok, enough of the nonsense, I'll put the code here webshell welcome star/fork!

Reference