Files
weiyu/deploy/coturn/test_coturn.html
jack ning 626297b612 update
2025-10-09 11:59:46 +08:00

435 lines
14 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coturn STUN/TURN 服务器测试</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 15px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 600;
font-size: 14px;
}
input, select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
.btn-group {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
button {
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.result-section {
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.result-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
}
#result {
background: white;
padding: 15px;
border-radius: 6px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: #333;
}
.candidate {
padding: 8px;
margin: 5px 0;
border-radius: 4px;
background: #e8f5e9;
border-left: 3px solid #4caf50;
}
.candidate.relay {
background: #e3f2fd;
border-left-color: #2196f3;
}
.candidate.host {
background: #fff3e0;
border-left-color: #ff9800;
}
.error {
color: #d32f2f;
background: #ffebee;
padding: 8px;
border-radius: 4px;
border-left: 3px solid #d32f2f;
}
.success {
color: #388e3c;
background: #e8f5e9;
padding: 8px;
border-radius: 4px;
border-left: 3px solid #388e3c;
}
.info {
background: #e3f2fd;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #2196f3;
}
.info h3 {
color: #1976d2;
margin-bottom: 10px;
font-size: 16px;
}
.info ul {
margin-left: 20px;
color: #555;
font-size: 14px;
}
.info li {
margin: 5px 0;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.password-wrapper {
position: relative;
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: #666;
font-size: 18px;
padding: 4px 8px;
transition: color 0.3s;
user-select: none;
}
.password-toggle:hover {
color: #667eea;
}
.password-wrapper input {
padding-right: 45px;
}
</style>
</head>
<body>
<div class="container">
<h1>🔌 Coturn STUN/TURN 服务器测试</h1>
<p class="subtitle">测试您的 STUN/TURN 服务器配置和连通性</p>
<div class="info">
<h3>📋 测试说明</h3>
<ul>
<li><strong>STUN</strong>: 用于 NAT 穿透,获取公网 IP 和端口</li>
<li><strong>TURN</strong>: 用于中继传输,当 P2P 连接失败时使用</li>
<li><strong>成功标志</strong>: 看到 <code>srflx</code>STUN<code>relay</code>TURN类型的候选地址</li>
</ul>
</div>
<div class="form-group">
<label for="protocol">协议类型</label>
<select id="protocol">
<option value="stun">STUN (端口 3478)</option>
<option value="turn">TURN (端口 3478)</option>
<option value="turns">TURNS/TLS (端口 5349)</option>
</select>
</div>
<div class="form-group">
<label for="serverUrl">服务器地址</label>
<input type="text" id="serverUrl" placeholder="例如: 14.103.165.199 或 coturn.weiyuai.cn" value="14.103.165.199">
</div>
<div class="form-group">
<label for="port">端口(可选,默认 3478 或 5349</label>
<input type="text" id="port" placeholder="留空使用默认端口">
</div>
<div class="form-group">
<label for="username">用户名(仅 TURN 需要)</label>
<input type="text" id="username" placeholder="例如: username1" value="username1">
</div>
<div class="form-group">
<label for="password">密码(仅 TURN 需要)</label>
<div class="password-wrapper">
<input type="password" id="password" placeholder="例如: password1" value="password1">
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()" title="显示/隐藏密码">
<span id="toggleIcon">👁️</span>
</button>
</div>
</div>
<div class="btn-group">
<button class="btn-primary" onclick="startTest()">🚀 开始测试</button>
<button class="btn-secondary" onclick="clearResult()">🗑️ 清空结果</button>
</div>
<div class="result-section">
<div class="result-title">📊 测试结果</div>
<div id="result">点击 "开始测试" 按钮开始...</div>
</div>
</div>
<script>
let pc = null;
function togglePasswordVisibility() {
const passwordInput = document.getElementById('password');
const toggleIcon = document.getElementById('toggleIcon');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.textContent = '🙈';
} else {
passwordInput.type = 'password';
toggleIcon.textContent = '👁️';
}
}
function log(message, type = 'info') {
const resultDiv = document.getElementById('result');
const timestamp = new Date().toLocaleTimeString();
let className = '';
let icon = '';
if (type === 'error') {
className = 'error';
icon = '❌';
} else if (type === 'success') {
className = 'success';
icon = '✅';
} else if (type === 'candidate') {
className = 'candidate';
icon = '🎯';
}
const messageDiv = document.createElement('div');
messageDiv.className = className;
messageDiv.innerHTML = `[${timestamp}] ${icon} ${message}`;
resultDiv.appendChild(messageDiv);
resultDiv.scrollTop = resultDiv.scrollHeight;
}
function clearResult() {
document.getElementById('result').innerHTML = '';
if (pc) {
pc.close();
pc = null;
}
}
function getIceServer() {
const protocol = document.getElementById('protocol').value;
const serverUrl = document.getElementById('serverUrl').value.trim();
const port = document.getElementById('port').value.trim();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
if (!serverUrl) {
throw new Error('请输入服务器地址');
}
let url;
if (protocol === 'stun') {
url = `stun:${serverUrl}${port ? ':' + port : ':3478'}`;
return { urls: url };
} else if (protocol === 'turn') {
url = `turn:${serverUrl}${port ? ':' + port : ':3478'}`;
if (!username || !password) {
throw new Error('TURN 协议需要提供用户名和密码');
}
return {
urls: url,
username: username,
credential: password
};
} else if (protocol === 'turns') {
url = `turns:${serverUrl}${port ? ':' + port : ':5349'}`;
if (!username || !password) {
throw new Error('TURNS 协议需要提供用户名和密码');
}
return {
urls: url,
username: username,
credential: password
};
}
}
async function startTest() {
clearResult();
try {
const iceServer = getIceServer();
log(`🔧 配置信息: ${JSON.stringify(iceServer, null, 2)}`);
log('🌐 开始创建 PeerConnection...');
const config = {
iceServers: [iceServer],
iceCandidatePoolSize: 10
};
pc = new RTCPeerConnection(config);
let candidateCount = 0;
let relayCount = 0;
let srflxCount = 0;
pc.onicecandidate = (event) => {
if (event.candidate) {
candidateCount++;
const candidate = event.candidate;
const type = candidate.type;
let emoji = '📍';
if (type === 'relay') {
emoji = '🔄';
relayCount++;
} else if (type === 'srflx') {
emoji = '🌐';
srflxCount++;
}
log(`${emoji} [${type.toUpperCase()}] ${candidate.candidate}`, 'candidate');
} else {
log('✅ ICE 候选收集完成', 'success');
log(`📊 统计: 总共 ${candidateCount} 个候选, ${srflxCount} 个 STUN (srflx), ${relayCount} 个 TURN (relay)`, 'success');
if (candidateCount === 0) {
log('⚠️ 未获取到任何 ICE 候选,请检查服务器配置和网络连接', 'error');
} else if (relayCount > 0) {
log('🎉 TURN 服务器测试成功!已获取到 relay 候选', 'success');
} else if (srflxCount > 0) {
log('🎉 STUN 服务器测试成功!已获取到 srflx 候选', 'success');
}
}
};
pc.onicecandidateerror = (event) => {
log(`❌ ICE 错误: ${event.errorText || 'Unknown error'} (code: ${event.errorCode})`, 'error');
};
pc.oniceconnectionstatechange = () => {
log(`🔗 ICE 连接状态: ${pc.iceConnectionState}`);
};
pc.onicegatheringstatechange = () => {
log(`📡 ICE 收集状态: ${pc.iceGatheringState}`);
};
// 创建一个数据通道来触发 ICE 收集
pc.createDataChannel('test');
// 创建 offer
log('📝 创建 SDP Offer...');
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
log('✅ SDP Offer 创建成功,开始收集 ICE 候选...');
} catch (error) {
log(`❌ 测试失败: ${error.message}`, 'error');
console.error('Test error:', error);
}
}
// 页面加载时的提示
window.onload = function() {
log('👋 欢迎使用 Coturn 测试工具');
log('📖 请填写服务器信息后点击 "开始测试" 按钮');
};
</script>
</body>
</html>