多ubuntu主机远程桌面连接方案

一、需求背景

公司有一批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服务
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

资深初级运维工程师

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值