Guacamole实现远程桌面+实时语音(VNC)
前言
继续方案的探索与选型,原计划选择RDP协议的远程桌面是想利用它原生支持语音的。业务“远程帮办”拆解就是远程桌面 + 实时双向语音。因为发现RDP协议的远程桌面,远程现在的被远程端(win pro)系统,会有抢占问题,导致被远程端黑屏,体验不好,所以最后还是选择VNC协议,上一篇已经分享了,这继续分享双向语音方案组合。。
一、技术选型
guacamole + TigerVNC远程桌面方案,继续叠加coturn + webRTC + Node实现双向实时语音。
方案核心
1.node跑信令服务js
// signaling-server.js
const WebSocket = require('ws服务地址(guacamole内置提供的websocket)');
// 启动一个WebSocket服务器,监听8080端口
const wss = new WebSocket.Server({ port: 端口 });
console.log('Signaling server started on ws://localhost:8080');
// 使用一个Map来存储连接的客户端,键为用户ID,值为WebSocket连接对象
const clients = new Map();
wss.on('connection', (ws) => {// 为新连接生成一个唯一IDconst clientId = generateUniqueId();clients.set(clientId, ws);console.log(`Client ${clientId} connected`);// 连接成功后,将ID发送给客户端ws.send(JSON.stringify({ type: 'your-id', id: clientId }));// 监听来自客户端的消息ws.on('message', (message) => {let data;try {data = JSON.parse(message);} catch (e) {console.error('Invalid JSON received:', message);return;}console.log(`Received message from ${clientId}:`, data.type);const targetClient = clients.get(data.targetId);if (targetClient && targetClient.readyState === WebSocket.OPEN) {// 为消息添加发送者ID,然后转发data.senderId = clientId;targetClient.send(JSON.stringify(data));}});// 监听连接关闭事件ws.on('close', () => {clients.delete(clientId);console.log(`Client ${clientId} disconnected`);});ws.on('error', (error) => {console.error(`Error with client ${clientId}:`, error);clients.delete(clientId);});
});
// 生成一个简单的唯一ID函数
function generateUniqueId() {return Math.random().toString(36).substr(2, 9);
}
放在服务器上,用node.js跑。
2.guacamole增加配置(guacamole.properties)
#如果使用webRTC
enable-webrtc: true
enable-websocket: true
websocket-enabled: truewebrtc-enabled: true
webrtc-stun-server: stun:coturn部署ip:3478
webrtc-turn-server: turn:coturn部署ip:3478
webrtc-turn-username: guacamole提供账号
webrtc-turn-password: guacamole提供账号密码#音频输入配置
enable-audio: true
enable-audio-input: true
#音频编码器配置#音频质量设置
audio-bitrate: 128000audio-mime-types: audio/L16, audio/opus
3.coturn配置(turnserver.conf )
# --- 网络配置 ---
# 监听所有网络接口。注意:在生产环境中,应该只监听必要的接口
listening-ip=0.0.0.0
# 标准 TURN 端口
listening-port=3478
# TLS/DTLS 端口(取消注释以启用)
#tls-listening-port=5349
#dtls-listening-port=5349# --- 中继配置 ---
# 中继端口范围,根据您的网络环境和预期负载调整
min-port=49152
max-port=50000
# 内部中继IP地址
relay-ip=内网ip
# 外部IP地址(NAT后的公网IP,如果有)
external-ip=外网ip# --- 认证配置 ---
# 设置域名,用于长期凭证机制
realm=域名
# 启用长期凭证机制
#lt-cred-mech# --- 用户凭证 ---
# 直接在配置文件中定义用户。注意:在生产环境中应使用更安全的方法
user=guacamole提供账号:guacamole提供账号密码
#user=user2:password2# --- TLS/DTLS 配置 ---
# TLS 证书和私钥路径(取消注释以启用)
#cert=/etc/turnserver/fullchain.pem
#pkey=/etc/turnserver/privkey.pem
# 推荐的密码套件,提供强加密(取消注释以启用)
#cipher-list="ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384"# --- 安全设置 ---
# 启用指纹,防止中间人攻击
fingerprint
# 启用过期 nonce 检测,防止重放攻击(取消注释以启用)
#stale-nonce=3600
# 设置 DTLS 会话密钥的生命周期(单位:秒)(取消注释以启用)
#dtls-key-lifetime=3600# --- 性能优化 ---
# 最大允许的总带宽(字节/秒),0 表示无限制
max-bps=0
# 所有会话的总配额(字节/秒),格式:数字:数字,0 表示无限制
total-quota=0:0
# 单个用户的配额(字节/秒),0 表示无限制
user-quota=0# --- 日志设置 ---
# 启用详细日志,便于调试。在生产环境中可以降低日志级别
verbose# --- 高级配置 ---
# 允许环回地址,用于测试。生产环境中应禁用
#no-loopback-peers# 允许使用 TURN 服务的 IP 范围,增强安全性(取消注释并根据需要调整)
#allowed-peer-ip=10.0.0.0-10.255.255.255
#allowed-peer-ip=172.16.0.0-172.31.255.255
#allowed-peer-ip=192.168.0.0-192.168.255.255# 启用 CLI 访问和状态报告(取消注释并设置密码以启用)
#cli-password=<strong-admin-password>
#status-port=5986# --- 注意事项 ---
# 1. 在生产环境中,确保所有密码和密钥都是强密码,并定期更新
# 2. 根据您的具体需求和网络环境调整配置
# 3. 定期检查日志文件,监控服务器性能和可能的安全问题
# 4. 确保 TLS 证书有效且定期更新
# 5. 考虑使用防火墙进一步限制对 TURN 服务器的访问
# 6. 在生产环境中,考虑使用外部认证系统而不是直接在配置文件中存储用户凭证
# 7. 根据实际负载调整性能相关的参数
# 8. 定期更新 TURN 服务器软件以获取最新的安全补丁
集成使用
这里计划是用Electorn套浏览器,走浏览器支持的webRTC实现麦克风、语音。
guacamole本身就是走的浏览器,所以只需要参考guacamole开源的web自己写页面,实现远程桌面。
然后,页面增加走coturn的webRTC语音实现。
main.js
// public/main.js
const localVideo = document.getElementById('local-video');
const remoteVideo = document.getElementById('remote-video');
const myIdSpan = document.getElementById('my-id');
const peerIdInput = document.getElementById('peer-id-input');
const callBtn = document.getElementById('call-btn');
const hangupBtn = document.getElementById('hangup-btn');
const copyIdBtn = document.getElementById('copy-id-btn');
const callStatus = document.getElementById('call-status');
// 连接到信令服务器
const socket = new WebSocket('ws服务地址(guacamole内置提供的websocket)');let myId;
let localStream;
let peerConnection;
let targetId;
// STUN服务器配置,用于NAT穿透。这里我们使用Google的公共STUN服务器。
const configuration = {iceServers: [//{ urls: 'stun:stun.l.google.com:19302' } 谷歌提供的{ urls: 'stun:coturn部署ip:coturn的stun端口' }]
};
// 1. WebSocket 信令处理
socket.onopen = () => {console.log('Connected to signaling server');
};
socket.onmessage = (message) => {const data = JSON.parse(message.data);console.log('Received message:', data.type);switch (data.type) {case 'your-id':myId = data.id;myIdSpan.textContent = myId;copyIdBtn.disabled = false;break;case 'offer':handleOffer(data.offer, data.senderId);break;case 'answer':handleAnswer(data.answer);break;case 'candidate':handleCandidate(data.candidate);break;case 'hangup':handleHangup();break;default:break;}
};
socket.onerror = (error) => {console.error('WebSocket Error:', error);
};
// 2. 媒体流处理和UI事件
async function start() {try {// 获取本地音视频流localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });localVideo.srcObject = localStream;} catch (error) {console.error('Error accessing media devices.', error);alert('无法访问摄像头和麦克风,请检查权限。');}
}
callBtn.onclick = () => {targetId = peerIdInput.value;if (!targetId) {return alert('请输入对方的ID');}createPeerConnection();// 创建并发送OfferpeerConnection.createOffer().then(offer => peerConnection.setLocalDescription(offer)).then(() => {sendMessage({ type: 'offer', offer: peerConnection.localDescription });updateCallStatus(`正在呼叫 ${targetId}...`);}).catch(e => console.error(e));
};
hangupBtn.onclick = () => {sendMessage({ type: 'hangup' });handleHangup();
};
copyIdBtn.onclick = () => {navigator.clipboard.writeText(myId).then(() => {alert('ID已复制到剪贴板!');}).catch(err => {console.error('Could not copy text: ', err);});
};
// 3. WebRTC核心功能函数
function createPeerConnection() {peerConnection = new RTCPeerConnection(configuration);// 将本地媒体流的轨道添加到连接中localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));// 监听ICE Candidate事件,并发送给对方peerConnection.onicecandidate = (event) => {if (event.candidate) {sendMessage({ type: 'candidate', candidate: event.candidate });}};// 监听远程媒体流peerConnection.ontrack = (event) => {remoteVideo.srcObject = event.streams[0];};// 更新UIhangupBtn.disabled = false;callBtn.disabled = true;
}
function sendMessage(message) {message.targetId = targetId;socket.send(JSON.stringify(message));
}
// 被呼叫方:处理收到的Offer
function handleOffer(offer, senderId) {targetId = senderId;createPeerConnection();peerConnection.setRemoteDescription(new RTCSessionDescription(offer)).then(() => peerConnection.createAnswer()).then(answer => peerConnection.setLocalDescription(answer)).then(() => {sendMessage({ type: 'answer', answer: peerConnection.localDescription });updateCallStatus(`与 ${targetId} 通话中`);}).catch(e => console.error(e));
}
// 呼叫方:处理收到的Answer
function handleAnswer(answer) {peerConnection.setRemoteDescription(new RTCSessionDescription(answer));updateCallStatus(`与 ${targetId} 通话中`);
}
// 双方:处理收到的ICE Candidate
function handleCandidate(candidate) {peerConnection.addIceCandidate(new RTCIceCandidate(candidate)).catch(e => console.error(e));
}
// 双方:处理挂断
function handleHangup() {if (peerConnection) {peerConnection.close();peerConnection = null;}remoteVideo.srcObject = null;updateCallStatus('状态:空闲');hangupBtn.disabled = true;callBtn.disabled = false;targetId = null;
}
function updateCallStatus(status) {callStatus.textContent = `状态:${status}`;
}
// 页面加载后立即启动
start();
测试html
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>WebRTC Voice Communication</title><style>body { font-family: Arial, sans-serif; padding: 20px; }.container { display: flex; flex-direction: column; gap: 20px; }.videos { display: flex; gap: 20px; }video { border: 1px solid black; width: 320px; height: 240px; background-color: #333; }.controls { display: flex; flex-direction: column; gap: 10px; max-width: 400px; }input, button { padding: 8px; font-size: 16px; }#call-status { font-weight: bold; color: #555; }</style>
</head>
<body><h1>WebRTC 双向语音/视频通信</h1><div class="container"><div class="videos"><div><h3>本地视频</h3><video id="local-video" autoplay muted></video></div><div><h3>远程视频</h3><video id="remote-video" autoplay></video></div></div><div class="controls"><div><strong>你的ID: </strong><span id="my-id">正在连接...</span><button id="copy-id-btn" disabled>复制ID</button></div><input type="text" id="peer-id-input" placeholder="输入对方的ID"><button id="call-btn">呼叫</button><button id="hangup-btn" disabled>挂断</button><p id="call-status">状态:空闲</p></div></div><script src="main.js"></script>
</body>
</html>
效果
效果就用这个测试html先试验语音吧,Electorn组合浏览器就不细讲了,让大家自己探索吧。其实呢,我也是不擅长前端,也志不在前端。
至此,guacamole + TigerVNC + coturn + node.js + webRTC实现VNC远程桌面+双向实时语音方案就走通了,剩下就是前端集成、优化了。
总结
就写到这,希望能帮到大家,还是那个认知,思路很重要,技术的实现里,思路可以出方案。其他时候,思路变现也是有路径的,uping
