mirror of https://github.com/openspug/spug
				
				
				
			U 优化websocket连接
							parent
							
								
									a5a5970001
								
							
						
					
					
						commit
						3e2357ae50
					
				|  | @ -1,43 +1,18 @@ | |||
| # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug | ||||
| # Copyright: (c) <spug.dev@gmail.com> | ||||
| # Released under the AGPL-3.0 License. | ||||
| from channels.generic.websocket import WebsocketConsumer | ||||
| from django.conf import settings | ||||
| from django_redis import get_redis_connection | ||||
| from asgiref.sync import async_to_sync | ||||
| from apps.host.models import Host | ||||
| from consumer.utils import BaseConsumer | ||||
| from apps.account.utils import has_host_perm | ||||
| from threading import Thread | ||||
| import time | ||||
| import json | ||||
| 
 | ||||
| 
 | ||||
| class ExecConsumer(WebsocketConsumer): | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.token = self.scope['url_route']['kwargs']['token'] | ||||
|         self.rds = get_redis_connection() | ||||
| 
 | ||||
|     def connect(self): | ||||
|         self.accept() | ||||
| 
 | ||||
|     def disconnect(self, code): | ||||
|         self.rds.close() | ||||
| 
 | ||||
|     def get_response(self): | ||||
|         response = self.rds.brpop(self.token, timeout=5) | ||||
|         return response[1] if response else None | ||||
| 
 | ||||
|     def receive(self, **kwargs): | ||||
|         response = self.get_response() | ||||
|         while response: | ||||
|             data = response.decode() | ||||
|             self.send(text_data=data) | ||||
|             response = self.get_response() | ||||
|         self.send(text_data='pong') | ||||
| 
 | ||||
| 
 | ||||
| class ComConsumer(WebsocketConsumer): | ||||
| class ComConsumer(BaseConsumer): | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         token = self.scope['url_route']['kwargs']['token'] | ||||
|  | @ -52,9 +27,6 @@ class ComConsumer(WebsocketConsumer): | |||
|             raise TypeError(f'unknown module for {module}') | ||||
|         self.rds = get_redis_connection() | ||||
| 
 | ||||
|     def connect(self): | ||||
|         self.accept() | ||||
| 
 | ||||
|     def disconnect(self, code): | ||||
|         self.rds.close() | ||||
| 
 | ||||
|  | @ -78,18 +50,17 @@ class ComConsumer(WebsocketConsumer): | |||
|         self.send(text_data='pong') | ||||
| 
 | ||||
| 
 | ||||
| class SSHConsumer(WebsocketConsumer): | ||||
| class SSHConsumer(BaseConsumer): | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.user = self.scope['user'] | ||||
|         self.id = self.scope['url_route']['kwargs']['id'] | ||||
|         self.chan = None | ||||
|         self.ssh = None | ||||
| 
 | ||||
|     def loop_read(self): | ||||
|         is_ready = False | ||||
|         while True: | ||||
|             data = self.chan.recv(32 * 1024) | ||||
|             # print('read: {!r}'.format(data)) | ||||
|             if not data: | ||||
|                 self.close(3333) | ||||
|                 break | ||||
|  | @ -97,6 +68,9 @@ class SSHConsumer(WebsocketConsumer): | |||
|                 text = data.decode() | ||||
|             except UnicodeDecodeError: | ||||
|                 text = data.decode(encoding='GBK', errors='ignore') | ||||
|             if not is_ready: | ||||
|                 self.send(text_data='\033[2J\033[3J\033[1;1H') | ||||
|                 is_ready = True | ||||
|             self.send(text_data=text) | ||||
| 
 | ||||
|     def receive(self, text_data=None, bytes_data=None): | ||||
|  | @ -116,34 +90,28 @@ class SSHConsumer(WebsocketConsumer): | |||
|         if self.ssh: | ||||
|             self.ssh.close() | ||||
| 
 | ||||
|     def connect(self): | ||||
|     def init(self): | ||||
|         if has_host_perm(self.user, self.id): | ||||
|             self.accept() | ||||
|             self._init() | ||||
|             self.send(text_data='\r\n正在连接至主机 ...') | ||||
|             host = Host.objects.filter(pk=self.id).first() | ||||
|             if not host: | ||||
|                 return self.close_with_message('未找到指定主机,请刷新页面重试。') | ||||
| 
 | ||||
|             try: | ||||
|                 self.ssh = host.get_ssh().get_client() | ||||
|             except Exception as e: | ||||
|                 return self.close_with_message(f'连接主机失败: {e}') | ||||
| 
 | ||||
|             self.chan = self.ssh.invoke_shell(term='xterm') | ||||
|             self.chan.transport.set_keepalive(30) | ||||
|             Thread(target=self.loop_read).start() | ||||
|         else: | ||||
|             self.close() | ||||
| 
 | ||||
|     def _init(self): | ||||
|         self.send(text_data='\r\33[KConnecting ...\r') | ||||
|         host = Host.objects.filter(pk=self.id).first() | ||||
|         if not host: | ||||
|             self.send(text_data='Unknown host\r\n') | ||||
|             self.close() | ||||
|         try: | ||||
|             self.ssh = host.get_ssh().get_client() | ||||
|         except Exception as e: | ||||
|             self.send(text_data=f'Exception: {e}\r\n'.encode()) | ||||
|             self.close() | ||||
|             return | ||||
|         self.chan = self.ssh.invoke_shell(term='xterm') | ||||
|         self.chan.transport.set_keepalive(30) | ||||
|         Thread(target=self.loop_read).start() | ||||
|             self.close_with_message('你当前无权限操作该主机,请联系管理员授权。') | ||||
| 
 | ||||
| 
 | ||||
| class NotifyConsumer(WebsocketConsumer): | ||||
|     def connect(self): | ||||
| class NotifyConsumer(BaseConsumer): | ||||
|     def init(self): | ||||
|         async_to_sync(self.channel_layer.group_add)('notify', self.channel_name) | ||||
|         self.accept() | ||||
| 
 | ||||
|     def disconnect(self, code): | ||||
|         async_to_sync(self.channel_layer.group_discard)('notify', self.channel_name) | ||||
|  | @ -155,7 +123,7 @@ class NotifyConsumer(WebsocketConsumer): | |||
|         self.send(text_data=json.dumps(event)) | ||||
| 
 | ||||
| 
 | ||||
| class PubSubConsumer(WebsocketConsumer): | ||||
| class PubSubConsumer(BaseConsumer): | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.token = self.scope['url_route']['kwargs']['token'] | ||||
|  | @ -163,9 +131,6 @@ class PubSubConsumer(WebsocketConsumer): | |||
|         self.p = self.rds.pubsub(ignore_subscribe_messages=True) | ||||
|         self.p.subscribe(self.token) | ||||
| 
 | ||||
|     def connect(self): | ||||
|         self.accept() | ||||
| 
 | ||||
|     def disconnect(self, code): | ||||
|         self.p.close() | ||||
|         self.rds.close() | ||||
|  |  | |||
|  | @ -1,50 +0,0 @@ | |||
| # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug | ||||
| # Copyright: (c) <spug.dev@gmail.com> | ||||
| # Released under the AGPL-3.0 License. | ||||
| from django.db import close_old_connections | ||||
| from channels.security.websocket import WebsocketDenier | ||||
| from apps.account.models import User | ||||
| from apps.setting.utils import AppSetting | ||||
| from libs.utils import get_request_real_ip | ||||
| from urllib.parse import parse_qs | ||||
| import time | ||||
| 
 | ||||
| 
 | ||||
| class AuthMiddleware: | ||||
|     def __init__(self, application): | ||||
|         self.application = application | ||||
| 
 | ||||
|     def __call__(self, scope): | ||||
|         # Make sure the scope is of type websocket | ||||
|         if scope["type"] != "websocket": | ||||
|             raise ValueError( | ||||
|                 "You cannot use AuthMiddleware on a non-WebSocket connection" | ||||
|             ) | ||||
|         headers = dict(scope.get('headers', [])) | ||||
|         is_ok, message = self.verify_user(scope, headers) | ||||
|         if is_ok: | ||||
|             return self.application(scope) | ||||
|         else: | ||||
|             print(message) | ||||
|             return WebsocketDenier(scope) | ||||
| 
 | ||||
|     def get_real_ip(self, headers): | ||||
|         decode_headers = { | ||||
|             'x-forwarded-for': headers.get(b'x-forwarded-for', b'').decode(), | ||||
|             'x-real-ip': headers.get(b'x-real-ip', b'').decode() | ||||
|         } | ||||
|         return get_request_real_ip(decode_headers) | ||||
| 
 | ||||
|     def verify_user(self, scope, headers): | ||||
|         close_old_connections() | ||||
|         query_string = scope['query_string'].decode() | ||||
|         x_real_ip = self.get_real_ip(headers) | ||||
|         token = parse_qs(query_string).get('x-token', [''])[0] | ||||
|         if token and len(token) == 32: | ||||
|             user = User.objects.filter(access_token=token).first() | ||||
|             if user and user.token_expired >= time.time() and user.is_active: | ||||
|                 if x_real_ip == user.last_ip or AppSetting.get_default('bind_ip') is False: | ||||
|                     scope['user'] = user | ||||
|                     return True, None | ||||
|                 return False, f'Verify failed: {x_real_ip} <> {user.last_ip if user else None}' | ||||
|         return False, 'Token is invalid' | ||||
|  | @ -3,15 +3,11 @@ | |||
| # Released under the AGPL-3.0 License. | ||||
| from django.urls import path | ||||
| from channels.routing import URLRouter | ||||
| from consumer.middleware import AuthMiddleware | ||||
| from consumer.consumers import * | ||||
| 
 | ||||
| ws_router = AuthMiddleware( | ||||
|     URLRouter([ | ||||
|         path('ws/exec/<str:token>/', ExecConsumer), | ||||
|         path('ws/ssh/<int:id>/', SSHConsumer), | ||||
|         path('ws/subscribe/<str:token>/', PubSubConsumer), | ||||
|         path('ws/<str:module>/<str:token>/', ComConsumer), | ||||
|         path('ws/notify/', NotifyConsumer), | ||||
|     ]) | ||||
| ) | ||||
| ws_router = URLRouter([ | ||||
|     path('ws/ssh/<int:id>/', SSHConsumer), | ||||
|     path('ws/subscribe/<str:token>/', PubSubConsumer), | ||||
|     path('ws/<str:module>/<str:token>/', ComConsumer), | ||||
|     path('ws/notify/', NotifyConsumer), | ||||
| ]) | ||||
|  |  | |||
|  | @ -0,0 +1,42 @@ | |||
| # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug | ||||
| # Copyright: (c) <spug.dev@gmail.com> | ||||
| # Released under the AGPL-3.0 License. | ||||
| from django.db import close_old_connections | ||||
| from channels.generic.websocket import WebsocketConsumer | ||||
| from apps.account.models import User | ||||
| from apps.setting.utils import AppSetting | ||||
| from libs.utils import get_request_real_ip | ||||
| from urllib.parse import parse_qs | ||||
| import time | ||||
| 
 | ||||
| 
 | ||||
| def get_real_ip(headers): | ||||
|     decode_headers = {k.decode(): v.decode() for k, v in headers} | ||||
|     return get_request_real_ip(decode_headers) | ||||
| 
 | ||||
| 
 | ||||
| class BaseConsumer(WebsocketConsumer): | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super(BaseConsumer, self).__init__(*args, **kwargs) | ||||
|         self.user = None | ||||
| 
 | ||||
|     def close_with_message(self, content): | ||||
|         self.send(text_data=f'\r\n\x1b[31m{content}\x1b[0m\r\n') | ||||
|         self.close() | ||||
| 
 | ||||
|     def connect(self): | ||||
|         self.accept() | ||||
|         close_old_connections() | ||||
|         query_string = self.scope['query_string'].decode() | ||||
|         x_real_ip = get_real_ip(self.scope['headers']) | ||||
|         token = parse_qs(query_string).get('x-token', [''])[0] | ||||
|         if token and len(token) == 32: | ||||
|             user = User.objects.filter(access_token=token).first() | ||||
|             if user and user.token_expired >= time.time() and user.is_active: | ||||
|                 if x_real_ip == user.last_ip or AppSetting.get_default('bind_ip') is False: | ||||
|                     self.user = user | ||||
|                     if hasattr(self, 'init'): | ||||
|                         self.init() | ||||
|                     return None | ||||
|                 self.close_with_message('触发登录IP绑定安全策略,请在系统设置/安全设置中查看配置。') | ||||
|         self.close_with_message('用户身份验证失败,请重新登录或刷新页面。') | ||||
|  | @ -70,11 +70,15 @@ export default function () { | |||
|     ws.onmessage = e => { | ||||
|       if (e.data !== 'pong') { | ||||
|         fetch(); | ||||
|         const {title, content} = JSON.parse(e.data); | ||||
|         const key = `open${Date.now()}`; | ||||
|         const description = <div style={{whiteSpace: 'pre-wrap'}}>{content}</div>; | ||||
|         const btn = <Button type="primary" size="small" onClick={() => notification.close(key)}>知道了</Button>; | ||||
|         notification.warning({message: title, description, btn, key, top: 64, duration: null}) | ||||
|         try { | ||||
|           const {title, content} = JSON.parse(e.data); | ||||
|           const key = `open${Date.now()}`; | ||||
|           const description = <div style={{whiteSpace: 'pre-wrap'}}>{content}</div>; | ||||
|           const btn = <Button type="primary" size="small" onClick={() => notification.close(key)}>知道了</Button>; | ||||
|           notification.warning({message: title, description, btn, key, top: 64, duration: null}) | ||||
|         } catch (e) { | ||||
| 
 | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ import { | |||
| import { FitAddon } from 'xterm-addon-fit'; | ||||
| import { Terminal } from 'xterm'; | ||||
| import style from './index.module.less'; | ||||
| import { X_TOKEN } from 'libs'; | ||||
| import { http, X_TOKEN } from 'libs'; | ||||
| import store from './store'; | ||||
| import gStore from 'gStore'; | ||||
| 
 | ||||
|  | @ -55,7 +55,7 @@ function OutView(props) { | |||
| 
 | ||||
|   useEffect(() => { | ||||
|     const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | ||||
|     const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/exec/${store.token}/?x-token=${X_TOKEN}`); | ||||
|     const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/subscribe/${store.token}/?x-token=${X_TOKEN}`); | ||||
|     socket.onopen = () => { | ||||
|       const message = '\r\x1b[K\x1b[36m### Waiting for scheduling ...\x1b[0m' | ||||
|       for (let key of Object.keys(store.outputs)) { | ||||
|  | @ -64,6 +64,7 @@ function OutView(props) { | |||
|       term.write(message) | ||||
|       socket.send('ok'); | ||||
|       fitPlugin.fit() | ||||
|       http.patch('/api/exec/do/', {token: store.token}) | ||||
|     } | ||||
|     socket.onmessage = e => { | ||||
|       if (e.data === 'pong') { | ||||
|  |  | |||
|  | @ -15,6 +15,7 @@ import Output from './Output'; | |||
| import { http, cleanCommand } from 'libs'; | ||||
| import moment from 'moment'; | ||||
| import store from './store'; | ||||
| import gStore from 'gStore'; | ||||
| import style from './index.module.less'; | ||||
| 
 | ||||
| function TaskIndex() { | ||||
|  | @ -28,7 +29,7 @@ function TaskIndex() { | |||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (!loading) { | ||||
|       http.get('/api/exec/history/') | ||||
|       http.get('/api/exec/do/') | ||||
|         .then(res => setHistories(res)) | ||||
|     } | ||||
|   }, [loading]) | ||||
|  | @ -40,6 +41,7 @@ function TaskIndex() { | |||
|   }, [command]) | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     gStore.fetchUserSettings() | ||||
|     return () => { | ||||
|       store.host_ids = [] | ||||
|       if (store.showConsole) { | ||||
|  |  | |||
|  | @ -174,7 +174,7 @@ class FileManager extends React.Component { | |||
| 
 | ||||
|   _updatePercent = token => { | ||||
|     const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | ||||
|     this.socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/exec/${token}/?x-token=${X_TOKEN}`); | ||||
|     this.socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/subscribe/${token}/?x-token=${X_TOKEN}`); | ||||
|     this.socket.onopen = () => this.socket.send('ok'); | ||||
|     this.socket.onmessage = e => { | ||||
|       if (e.data === 'pong') { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 vapao
						vapao