一、需求背景
公司有一批ubuntu的主机,需要研发远程上去进行代码调试,普通的远程桌面方式不易于管理,并且无法进行连接控制。
二、方案制定
基于web的远程方案有Guacamole、NoVNC两种方案,但都不利于后期工具与公司整体的SSO进行对接。
虽然Guacamole作为的guacad作为整个web工具的后端能够实现包括加密传输、用户认证、图像优化等能力,但适配的前端代码开发难度也相对较高。
由于需求比较急切,所以最后采用的是react-vnc + websockify代理的方式,将工具嵌入已有的运维平台中,实现对用户的管理和访问的控制。
三、react部分代码
import * as React from 'react'
import { VncScreen } from 'react-vnc'
import { Drawer, Button, Slider, InputNumber, Row, Col, message, Input } from 'antd';
import RFB from 'react-vnc/dist/types/noVNC/core/rfb';
import { FullscreenOutlined, SettingOutlined } from '@ant-design/icons';
import Checkbox, { CheckboxChangeEvent } from 'antd/lib/checkbox';
import copy from "copy-to-clipboard";
import TextArea from 'antd/lib/input/TextArea';interface IProps {url: string;style?: object;className?: string;viewOnly?: boolean;focusOnClick?: boolean;clipViewport?: boolean;dragViewport?: boolean;scaleViewport?: boolean;resizeSession?: boolean;showDotCursor?: boolean;background?: string;qualityLevel?: number;compressionLevel?: number;autoConnect?: number; // defaults to trueretryDuration?: number; // in millisecondsdebug?: boolean; // show logs in the consoleloadingUI?: React.ReactNode; // custom component that is displayed when loading}interface IState {rfb: RFB | undefinedvisible: booleancompressionLevel: numberqualityLevel: numberviewOnly: booleansetting: stringsettingHeight: numberinputVisible: booleanclipboardtext?: string
}export default class VNCClient extends React.Component<IProps, IState>{constructor(props: IProps){super(props)this.state = {rfb: undefined,visible: false,compressionLevel: 9,qualityLevel: 2,viewOnly: false,setting: 'none',settingHeight: 10,inputVisible: false}this.connectCallback = this.connectCallback.bind(this)this.showDrawer = this.showDrawer.bind(this)this.onDrawerClose = this.onDrawerClose.bind(this)this.onChangeCompressionLevel = this.onChangeCompressionLevel.bind(this)this.onChangeQualityLevel = this.onChangeQualityLevel.bind(this)this.onChangeViewOnly = this.onChangeViewOnly.bind(this)this.reViewScreen = this.reViewScreen.bind(this)this.showSetting = this.showSetting.bind(this)this.hideSetting = this.hideSetting.bind(this)this.onClipboard = this.onClipboard.bind(this)this.onPaste = this.onPaste.bind(this)this.openInput = this.openInput.bind(this)this.hideInput = this.hideInput.bind(this)this.handleHotkeysPress = this.handleHotkeysPress.bind(this)this.sendCtrl = this.sendCtrl.bind(this)this.sendShift = this.sendShift.bind(this)this.sendWin = this.sendWin.bind(this)this.sendAlt = this.sendAlt.bind(this)}//屏蔽prompt弹窗componentDidMount(){// window.prompt = (message?: string, _default?: string)=>{// console.log('异步拷贝失败,当前页面已禁用prompt')// if(message){// return '异步拷贝失败'// }else{// return null// }// }window.addEventListener('keydown', this.handleHotkeysPress, true)// document.getElementsByTagName('textarea')[0].addEventListener('paste', this.handlePaste)}handlePaste(event: ClipboardEvent){console.log(event)}sendCtrl(){this.state.rfb?.sendKey(0xffe3,"XK_Control_L",false)}sendShift(){this.state.rfb?.sendKey(0xffe1,"XK_Shift_L",false)}sendWin(){this.state.rfb?.sendKey(0xffeb,"XK_Super_R",false)// this.state.rfb?.sendKey(0xff52,"XK_Up",true)// this.state.rfb?.sendKey(0xff52,"XK_Up",false)//this.state.rfb?.sendKey(0xffeb,"XK_Super_R",false)}sendAlt(){this.state.rfb?.sendKey(0xffe9,"XK_Alt_L",false)}handleHotkeysPress = (event: any) => {if ((event.keyCode === 86 && event.ctrlKey) || (event.keyCode === 45 && event.shiftKey)) {console.log(event.keyCode, event.ctrlKey,event.shiftKey)try{navigator.clipboard.readText().then((v) => {this.state.rfb?.sendKey(0xffe3,"XK_Control_L",false)let pastedText = vthis.pasteSim(pastedText)}).catch((v) => {console.log("获取剪贴板失败: ", v);});}catch{alert("目前仅chrome浏览器支持剪贴板功能,但http环境剪贴板功能被禁用,请访问chrome://flags/#unsafely-treat-insecure-origin-as-secure,修改Insecure origins treated as secure为enabled,添加http://cop.cargo.intra.xiaojukeji.com,根据提示relaunch浏览器")return false}}else if ((event.keyCode === 67 && event.ctrlKey)||(event.keyCode === 88 && event.ctrlKey)||(event.keyCode === 67 && event.ctrlKey && event.shiftKey)) {//终端ctrl+shift+c 文本编辑软件ctrl+c ctrl+xif(this.state.clipboardtext){copy(this.state.clipboardtext)} }if(event.altKey && event.keyCode===38){this.state.rfb?.sendKey(0xffe3,"XK_Alt_L",false)this.state.rfb?.sendKey(0xff52,"XK_UP",false)this.state.rfb?.sendKey(0xffeb,"XK_Super_L",true)this.state.rfb?.sendKey(0xff52,"XK_UP",true)this.state.rfb?.sendKey(0xffeb,"XK_Super_L",false)this.state.rfb?.sendKey(0xff52,"XK_UP",false)}if(event.altKey && event.keyCode===40){this.state.rfb?.sendKey(0xffe9,"XK_Alt_L",false)this.state.rfb?.sendKey(0xff54,"XK_Down",false)this.state.rfb?.sendKey(0xffeb,"XK_Super_L",true)this.state.rfb?.sendKey(0xff54,"XK_Down",true)this.state.rfb?.sendKey(0xffeb,"XK_Super_L",false)this.state.rfb?.sendKey(0xff54,"XK_Down",false)}if(event.altKey && event.keyCode===37){this.state.rfb?.sendKey(0xffe9,"XK_Alt_L",false)this.state.rfb?.sendKey(0xff51,"XK_Left",false)this.state.rfb?.sendKey(0xffeb,"XK_Super_L",true)this.state.rfb?.sendKey(0xff51,"XK_Left",true)this.state.rfb?.sendKey(0xffeb,"XK_Super_L",false)this.state.rfb?.sendKey(0xff51,"XK_Left",false)}if(event.altKey && event.keyCode===39){this.state.rfb?.sendKey(0xffe9,"XK_Alt_L",false)this.state.rfb?.sendKey(0xff53,"XK_Right",false)this.state.rfb?.sendKey(0xffeb,"XK_Super_L",true)this.state.rfb?.sendKey(0xff53,"XK_Right",true)this.state.rfb?.sendKey(0xffeb,"XK_Super_L",false)this.state.rfb?.sendKey(0xff53,"XK_Right",false)}// if(event.altKey && event.ctrlKey && event.keyCode===84){// this.state.rfb?.sendKey(0xffe9,"XK_Alt_L",false)// this.state.rfb?.sendKey(0xffe3,"XK_Control_L",false)// this.state.rfb?.sendKey(0xffe3,"XK_Control_L",true)// this.state.rfb?.sendKey(0xffe9,"XK_Alt_L",true)// this.state.rfb?.sendKey(0x0054,"XK_T",true)// this.state.rfb?.sendKey(0xffe9,"XK_Alt_L",false)// this.state.rfb?.sendKey(0xffe3,"XK_Control_L",false)// this.state.rfb?.sendKey(0x0054,"XK_T",false)// }// if (event.keyCode === 91) {// this.state.rfb?.sendKey(0xffeb,"XK_Super_L",true)// this.state.rfb?.sendKey(0xffeb,"XK_Super_L",false)// }};handleMousePress = (event: any) => {console.log(event)};//使用rfb内置复制接口pasteSim(text: string){this.state.rfb?.clipboardPasteFrom(text)}//vnc远程连接回调函数,返回rfb对象connectCallback(rfb: RFB|undefined){if(rfb){//rfb.qualityLevel=2this.setState({rfb:rfb})}}showDrawer(){this.setState({visible: true});}onDrawerClose() {this.setState({visible: false});}fullscreen() {var de = document.documentElement;de.requestFullscreen();}onChangeCompressionLevel(newValue: number){this.setState({compressionLevel: newValue})}onChangeQualityLevel(newValue: number){this.setState({qualityLevel: newValue})}onChangeViewOnly(e: CheckboxChangeEvent){if(this.state.viewOnly){this.setState({viewOnly: false})}else{this.setState({viewOnly: true})}}//rfb返回剪贴板内容复制到本地剪贴板onClipboard(e:any){let copy_text = e.detail.textthis.setState({clipboardtext: copy_text})}//临时实现单行文本发送到远程主机onPaste(e:any){let dom = document.getElementsByTagName('textarea')var text = dom[0].valuefor(let char of text){if(char=='\n'){this.state.rfb?.sendKey(0xff8d,"Enter",true)this.state.rfb?.sendKey(0xff8d,"Enter",false)this.state.rfb?.sendKey(0xff50,"XK_Home",true)this.state.rfb?.sendKey(0xff50,"XK_Home",false)continue}this.state.rfb?.sendKey(char.charCodeAt(0),char,true)this.state.rfb?.sendKey(char.charCodeAt(0),char,false); }// this.state.rfb?.sendKey(0xff8d,"Enter",true)// this.state.rfb?.sendKey(0xff8d,"Enter",false)message.info("传输"+text.length+"个字符");}reViewScreen(){if(this.state.rfb){this.state.rfb.compressionLevel = this.state.compressionLevelthis.state.rfb.qualityLevel = this.state.qualityLevelthis.state.rfb.viewOnly = this.state.viewOnlyif(this.state.viewOnly){message.info('设置已修改');message.warning('进入观察者模式');}else{message.info('设置已修改');}}}showSetting(){this.setState({setting: "",settingHeight: 38})}hideSetting(){this.setState({setting: "none",settingHeight: 10})}openInput(){this.setState({inputVisible: true})}hideInput(){this.setState({inputVisible: false})}render(){return (<div style={{background:"#000000"}} ><div style={this.state.inputVisible===false?{display:'none'}:{position: 'absolute', right:'95px', textAlign:'right', bottom: '5px',width: '90%',}}><TextAreaplaceholder="不支持中文和字符转译" style={{width:'40%',//backgroundColor: 'rgba(0,0,0,0)',color: '#ffffff'}}allowClear//onPressEnter={this.onPaste}></TextArea>;{/* <Button size='small' style={{backgroundColor: 'rgba(0,0,0,0)', color:'#ffffff'}}>历史</Button> */}</div><textarea style={{display:'none'}}></textarea><div style={{position: 'absolute', right: 0, bottom: '9px'}}><Button onClick={this.onPaste} size='small' style={this.state.inputVisible===false?{display:'none'}:{backgroundColor: 'rgba(0,0,0,0)', color:'#ffffff'}}>发送</Button><Button onClick={this.openInput} size='small' style={this.state.inputVisible===true?{display:'none'}:{backgroundColor: 'rgba(0,0,0,0)', color:'#ffffff'}}>文本输入框</Button><Button onClick={this.hideInput} size='small' style={this.state.inputVisible===false?{display:'none'}:{backgroundColor: 'rgba(0,0,0,0)', color:'#ffffff'}}>隐藏</Button></div><div style={{ position: "absolute", top: "0",left: "48%",width: 100,height: this.state.settingHeight,textAlign:"center",background: "#ffffff",borderRadius: 10}}onMouseEnter={this.showSetting}onMouseLeave={this.hideSetting}><Row style={{ margin: "7px",display: this.state.setting }}><Col span={12}><Button type='default' onClick={this.fullscreen} size="small"><FullscreenOutlined /></Button></Col><Col span={12}><Button type='default' onClick={this.showDrawer} size="small">< SettingOutlined/></Button></Col></Row></div><Drawer title="设置" placement="left" width="300" onClose={this.onDrawerClose} visible={this.state.visible}>compressionLevel:<Row><Col span={12}><Slidermin={0}max={9}onChange={this.onChangeCompressionLevel}value={typeof this.state.compressionLevel === 'number' ? this.state.compressionLevel : 0}/></Col><Col span={12}><InputNumbermin={0}max={9}style={{ margin: '0 16px' }}value={this.state.compressionLevel}onChange={this.onChangeCompressionLevel}/></Col> </Row>qualityLevel:<Row><Col span={12}><Slidermin={0}max={9}onChange={this.onChangeQualityLevel}value={typeof this.state.qualityLevel === 'number' ? this.state.qualityLevel : 0}/></Col><Col span={12}><InputNumbermin={0}max={9}style={{ margin: '0 16px' }}value={this.state.qualityLevel}onChange={this.onChangeQualityLevel}/></Col> </Row>viewOnly: <Checkbox onChange={this.onChangeViewOnly} checked={this.state.viewOnly}></Checkbox><Row style={{ margin: 80}}><Button onClick={this.reViewScreen}>修改</Button></Row></Drawer><VncScreenurl={ this.props.url }compressionLevel={9}qualityLevel={2}focusOnClick={true}scaleViewportshowDotCursor={true}viewOnly={this.state.viewOnly}background="#000000"style={{width: '100vw',height: '100vh',cursor: 'pointer'}}onClipboard={this.onClipboard}onConnect={this.connectCallback}/>{/* <div style={{ position:"absolute", top:0, width:'45px', background:'#ffffff' }}><Button onClick={this.sendCtrl} size='small' >ctrl</Button><Button onClick={this.sendShift} size='small' >shift</Button><Button onClick={this.sendWin} size='small' >win</Button><Button onClick={this.sendAlt} size='small' >alt</Button></div> */}</div>)}
}
四、VNC代理
由于前端代码需要后端提供websocket的连接,所以使用websockify这个代理软件,将远程主机的VNC端口代理为websocket端口。
在启动websockify的过程中,需要指定一个文件,对自动创建的连接进行管理,通过这种方式实现的对远程主机的访问控制。
websockify --target-config=./vnc_token 8080 --log-file=/tmp/websocket.log -D
五、nginx配置
由于前端服务和websocket服务是相互独立的,这就需要使用nginx进行统一的代理,以便用户使用统一的地址进行访问。
六、后端控制逻辑
后端采用的是tornado进行的开发,其中实现了两部分内容
1、 前端主机信息展示数据接口
2、vnc页面请求访问websocket的预连接请求接口
数据展示部分代码不做过多说明。
vnc页面请求访问websocket的预连接请求接口
1、用户向后端请求要访问的主机A
2、后端接收到请求,获取主机A的vnc服务连接信息
3、后端将主机A的vnc服务连接信息写入到websockify所需配置文件中
4、后端根据主机A的vnc服务连接信息生成token返回给前端vnc页面
5、前端使用token进行websocket的访问,实现远程连接的功能
所有提供VNC服务的主机都可被远程访问,如下图,windows电脑使用UltraVNC Server实现VNC服务