Django3基于ASGI与WebSocket实现WebShell的详细过程
本文详细介绍如何在Django3环境中利用原生ASGI协议实现WebSocket服务,进而构建WebShell。通过结合Gunicorn、Uvicorn、Paramiko以及前端xterm.js,实现了从React前端到远程虚拟机的字符交互与命令回显,提供了完整的实现思路与核心代码参考。
前言
近期工作需求涉及开发前端操作远程虚拟机的功能,即WebShell。基于现有的React与Django技术栈进行调研,发现多数后端方案采用Django配合Channels来提供WebSocket服务。考虑到这种方案缺乏探索性,查阅Django官方文档后得知,Django原生并不直接支持WebSocket,但从Django3版本开始支持ASGI协议,允许开发者自行实现WebSocket服务。因此,最终确定的技术组合为:Gunicorn + Uvicorn + ASGI + WebSocket + Django3.2 + Paramiko。
实现websocket服务
使用Django脚手架生成的项目中会自动包含asgi.py和wsgi.py两个文件。常规应用通常使用wsgi.py配合Nginx进行线上部署,而本次主要使用asgi.py。实现WebSocket服务的思路在网络上较为常见,核心在于处理connect、send、receive和disconnect这几个动作。参考“How to Add Websockets to a Django App without Extra Dependencies”一文是一个不错的实例,但其实现过于简单,不足以应对复杂场景。
思路
# 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!'
})
实现
上述代码提供了基本思路,较为完整的参考可参见“websockets-in-django-3-1”,基本可以直接复用。其中最核心的实现部分如下所示:
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
缝合怪
作为高效的代码整合者,为了提高效率并填平坑洼,需要将上述WebSocket类与Paramiko库结合,从而实现从前段接收字符并传递给远程主机,同时接收返回数据的功能。
import asyncio
import traceback
import paramiko
from webshell.ssh import Base, RemoteSSH
from webshell.connection import WebSocket
class WebShell:
"""整理 WebSocket 和 paramiko.Channel,实现两者的数据互通"""
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):
# 展示Linux欢迎相关内容
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()
前端
前端终端模拟可使用xterm.js,搜索并选择一个界面简洁的库即可满足需求。
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>;
}
}
不再赘述,完整代码已托管于“webshell”仓库,欢迎Star或Fork!
参考资料
webshell
django文档
graphene-django文档
django 异步视图
websockets-in-django-3-1
How to Add Websockets to a Django App without Extra Dependencies
到此这篇关于Django3使用WebSocket实现WebShell的文章就介绍到这了,更多相关Django3实现WebShell内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













