△点击上方“Python猫”关注 ,回复“1”领取电子书


剧照:《眷思量》

https://www.cnblogs.com/lgjbky/p/15186188.html


前言

最近工作中需要开发前端操作远程虚拟机的功能,简称 WebShell。基于当前的技术栈为 react+django,调研了一会发现大部分的后端实现都是 django+channels 来实现 websocket 服务。

大致看了下觉得这不够有趣,翻了翻 django 的官方文档发现 django 原生是不支持 websocket 的,但 django3 之后支持了 asgi 协议可以自己实现 websocket 服务。

于是选定 gunicorn+uvicorn+asgi+websocket+django3.2+paramiko 来实现 WebShell。

实现 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(https://jaydenwindle.com/writing/django-websockets-zero-dependencies/) 就是一个很好的实例,但过于简单……

# asgi.pyimport os
from django.core.asgi import get_asgi_applicationfrom 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.pyasync def websocket_application(scope, receive, send):pass
# websocket.pyasync 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 (https://aliashkevich.com/websockets-in-django-3-1/) 基本可以复用了。

其中最核心的实现部分我放下面:

class WebSocket:def __init__(self, scope, receive, send):self._scope = scopeself._receive = receiveself._send = sendself._client_state = State.CONNECTINGself._app_state = State.CONNECTING
@propertydef headers(self):return Headers(self._scope)
@propertydef scheme(self):return self._scope["scheme"]
@propertydef path(self):return self._scope["path"]
@propertydef query_params(self):return QueryParams(self._scope["query_string"].decode())
@propertydef query_string(self) -> str:return self._scope["query_string"]
@propertydef 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.DISCONNECTEDelse: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 asyncioimport tracebackimport paramikofrom webshell.ssh import Base, RemoteSSHfrom 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_sessionself.ssh_session = ssh_sessionself.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_sessionself.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:returnawait 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:returnawait 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')returnif not self.ws_session.status:returnawait 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):continueawait 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("" + cols + "," + rows)});
window.addEventListener('resize', this.onResize);}
componentWillUnmount() {window.removeEventListener('resize', this.onResize);}
onResize = () => {this.fitAddon.fit();}
render() {return
this.terminal = ref as HTMLDivElement}>div>;
好了,废话不多少了,代码我放这里了webshell (https://github.com/aleimu/webshell) 欢迎 star/fork!
还不过瘾?试试它们
如果你觉得本文有帮助
请慷慨分享和点赞,感谢啦!