一、需求背景
公司有一批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 true
retryDuration?: number; // in milliseconds
debug?: boolean; // show logs in the console
loadingUI?: React.ReactNode; // custom component that is displayed when loading
}
interface IState {
rfb: RFB | undefined
visible: boolean
compressionLevel: number
qualityLevel: number
viewOnly: boolean
setting: string
settingHeight: number
inputVisible: boolean
clipboardtext?: 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 = v
this.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+x
if(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=2
this.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.text
this.setState({
clipboardtext: copy_text
})
}
//临时实现单行文本发送到远程主机
onPaste(e:any){
let dom = document.getElementsByTagName('textarea')
var text = dom[0].value
for(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.compressionLevel
this.state.rfb.qualityLevel = this.state.qualityLevel
this.state.rfb.viewOnly = this.state.viewOnly
if(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%',}}>
<TextArea
placeholder="不支持中文和字符转译"
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}>
<Slider
min={0}
max={9}
onChange={this.onChangeCompressionLevel}
value={typeof this.state.compressionLevel === 'number' ? this.state.compressionLevel : 0}
/>
</Col>
<Col span={12}>
<InputNumber
min={0}
max={9}
style={{ margin: '0 16px' }}
value={this.state.compressionLevel}
onChange={this.onChangeCompressionLevel}
/>
</Col>
</Row>
qualityLevel:
<Row>
<Col span={12}>
<Slider
min={0}
max={9}
onChange={this.onChangeQualityLevel}
value={typeof this.state.qualityLevel === 'number' ? this.state.qualityLevel : 0}
/>
</Col>
<Col span={12}>
<InputNumber
min={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>
<VncScreen
url={ this.props.url }
compressionLevel={9}
qualityLevel={2}
focusOnClick={true}
scaleViewport
showDotCursor={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服务