You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
622 lines
20 KiB
622 lines
20 KiB
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Net.Sockets;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading;
|
|
|
|
namespace AIK.Service.Service
|
|
{
|
|
/// <summary>WebSocket 连接状态(与 websocket-sharp 兼容的取值)。</summary>
|
|
public enum WebSocketState
|
|
{
|
|
New,
|
|
Connecting,
|
|
Open,
|
|
Closing,
|
|
Closed
|
|
}
|
|
|
|
/// <summary>WebSocket 关闭状态码(RFC 6455 常用值,与 websocket-sharp 兼容)。</summary>
|
|
public enum CloseStatusCode : ushort
|
|
{
|
|
Normal = 1000,
|
|
GoingAway = 1001,
|
|
ProtocolError = 1002,
|
|
UnsupportedData = 1003,
|
|
NoStatus = 1005,
|
|
Abnormal = 1006,
|
|
InvalidData = 1007,
|
|
PolicyViolation = 1008,
|
|
TooBig = 1009,
|
|
MandatoryExtension = 1010,
|
|
ServerError = 1011,
|
|
TlsHandshakeFailure = 1015
|
|
}
|
|
|
|
/// <summary>收到 WebSocket 消息的事件参数(与 websocket-sharp 兼容)。</summary>
|
|
public class MessageEventArgs : EventArgs
|
|
{
|
|
public bool IsText { get; }
|
|
public bool IsBinary { get; }
|
|
public string Data { get; }
|
|
public byte[] RawData { get; }
|
|
|
|
internal MessageEventArgs(bool isText, byte[] rawData)
|
|
{
|
|
IsText = isText;
|
|
IsBinary = !isText;
|
|
RawData = rawData ?? new byte[0];
|
|
Data = isText ? Encoding.UTF8.GetString(RawData) : string.Empty;
|
|
}
|
|
}
|
|
|
|
/// <summary>WebSocket 错误事件参数。</summary>
|
|
public class ErrorEventArgs : EventArgs
|
|
{
|
|
public string Message { get; }
|
|
public Exception Exception { get; }
|
|
|
|
internal ErrorEventArgs(string message, Exception exception = null)
|
|
{
|
|
Message = message;
|
|
Exception = exception;
|
|
}
|
|
}
|
|
|
|
/// <summary>WebSocket 关闭事件参数。</summary>
|
|
public class CloseEventArgs : EventArgs
|
|
{
|
|
public CloseStatusCode Code { get; }
|
|
public string Reason { get; }
|
|
public bool WasClean { get; }
|
|
|
|
internal CloseEventArgs(CloseStatusCode code, string reason, bool wasClean = false)
|
|
{
|
|
Code = code;
|
|
Reason = reason ?? string.Empty;
|
|
WasClean = wasClean;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 基于 BouncyCastle 纯托管 TLS 的最小 RFC 6455 WebSocket 客户端,用于 Win7 扫码登录。
|
|
/// 背景:websocket-sharp 内部硬编码 new SslStream(...)(走系统 SChannel),Win7 SChannel 未启用 TLS 1.2
|
|
/// 时握手必然失败(关闭码 1015)。本客户端用 BouncyCastleTlsHelper 建立 TLS 1.2 连接后自行实现
|
|
/// WebSocket 握手与帧编解码,完全绕过 SChannel。
|
|
/// 事件模型与 websocket-sharp 兼容:OnMessage / OnError / OnClose,Send(string|byte[]),Close(code, reason)。
|
|
/// </summary>
|
|
public class BouncyCastleWebSocketClient : IDisposable
|
|
{
|
|
private const string WsGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
|
|
private readonly Uri _uri;
|
|
private TcpClient _tcp;
|
|
private Stream _stream;
|
|
private WebSocketState _state = WebSocketState.New;
|
|
private Thread _receiveThread;
|
|
private readonly object _sendLock = new object();
|
|
private volatile bool _disposed;
|
|
|
|
public event EventHandler<MessageEventArgs> OnMessage;
|
|
public event EventHandler<ErrorEventArgs> OnError;
|
|
public event EventHandler<CloseEventArgs> OnClose;
|
|
|
|
/// <summary>连接/关闭超时(默认 15 秒,与原先 websocket-sharp WaitTime 一致)。</summary>
|
|
public TimeSpan WaitTime { get; set; } = TimeSpan.FromSeconds(15);
|
|
|
|
public WebSocketState ReadyState => _state;
|
|
|
|
public BouncyCastleWebSocketClient(string url)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
{
|
|
throw new ArgumentException("WebSocket URL 不能为空", nameof(url));
|
|
}
|
|
_uri = new Uri(url);
|
|
if (!string.Equals(_uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(_uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new ArgumentException($"不支持的 WebSocket 协议: {_uri.Scheme}", nameof(url));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 建立连接:TCP → BouncyCastle TLS 1.2 握手 → WebSocket 升级握手,成功后启动接收线程。
|
|
/// 同步阻塞(与 websocket-sharp Connect() 一致,调用方可 Task.Run 包装)。
|
|
/// </summary>
|
|
public void Connect()
|
|
{
|
|
EnsureNotDisposed();
|
|
if (_state != WebSocketState.New)
|
|
{
|
|
throw new InvalidOperationException($"当前状态不允许连接: {_state}");
|
|
}
|
|
|
|
_state = WebSocketState.Connecting;
|
|
var port = _uri.IsDefaultPort ? (_uri.Scheme == "wss" ? 443 : 80) : _uri.Port;
|
|
var host = _uri.Host;
|
|
|
|
try
|
|
{
|
|
// ---- 1. TCP 连接(带超时) ----
|
|
_tcp = new TcpClient();
|
|
var connectTask = _tcp.ConnectAsync(host, port);
|
|
if (!connectTask.Wait(WaitTime))
|
|
{
|
|
throw new TimeoutException($"TCP 连接超时: {host}:{port}");
|
|
}
|
|
|
|
// ---- 2. BouncyCastle TLS 1.2 握手(含 SNI + 证书链校验,绕过 SChannel) ----
|
|
_stream = BouncyCastleTlsHelper.ConnectTlsStream(_tcp.GetStream(), host);
|
|
|
|
// ---- 3. WebSocket 升级握手 ----
|
|
PerformHandshake(host, port);
|
|
|
|
// ---- 4. 启动接收线程 ----
|
|
_state = WebSocketState.Open;
|
|
_receiveThread = new Thread(ReceiveLoop)
|
|
{
|
|
IsBackground = true,
|
|
Name = "BouncyCastleWebSocket-Receive"
|
|
};
|
|
_receiveThread.Start();
|
|
}
|
|
catch
|
|
{
|
|
_state = WebSocketState.Closed;
|
|
CloseResources();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>发送文本消息(客户端帧必须加掩码,RFC 6455)。</summary>
|
|
public void Send(string data)
|
|
{
|
|
EnsureOpen();
|
|
var bytes = Encoding.UTF8.GetBytes(data ?? string.Empty);
|
|
SendFrame(0x1, bytes);
|
|
}
|
|
|
|
/// <summary>发送二进制消息(STOMP 心跳 0x0A 等)。</summary>
|
|
public void Send(byte[] data)
|
|
{
|
|
EnsureOpen();
|
|
SendFrame(0x2, data ?? new byte[0]);
|
|
}
|
|
|
|
/// <summary>主动关闭连接(发送 Close 帧并等待接收线程退出)。</summary>
|
|
public void Close(CloseStatusCode code, string reason)
|
|
{
|
|
if (_state == WebSocketState.Closed || _disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_state = WebSocketState.Closing;
|
|
try
|
|
{
|
|
var reasonBytes = Encoding.UTF8.GetBytes(reason ?? string.Empty);
|
|
var payload = new byte[2 + reasonBytes.Length];
|
|
payload[0] = (byte)((ushort)code >> 8);
|
|
payload[1] = (byte)((ushort)code & 0xFF);
|
|
Buffer.BlockCopy(reasonBytes, 0, payload, 2, reasonBytes.Length);
|
|
SendFrame(0x8, payload);
|
|
}
|
|
catch
|
|
{
|
|
// 发送 Close 帧失败也继续关闭资源
|
|
}
|
|
|
|
// 等待接收线程处理完对端 Close 回帧后自行退出
|
|
var thread = _receiveThread;
|
|
if (thread != null && thread.IsAlive)
|
|
{
|
|
thread.Join(WaitTime);
|
|
}
|
|
CloseResources();
|
|
_state = WebSocketState.Closed;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
_disposed = true;
|
|
try
|
|
{
|
|
if (_state == WebSocketState.Open || _state == WebSocketState.Closing)
|
|
{
|
|
Close(CloseStatusCode.GoingAway, "client disposed");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// 忽略清理异常
|
|
}
|
|
finally
|
|
{
|
|
CloseResources();
|
|
_state = WebSocketState.Closed;
|
|
}
|
|
}
|
|
|
|
#region 内部实现
|
|
|
|
private void PerformHandshake(string host, int port)
|
|
{
|
|
var keyBytes = new byte[16];
|
|
using (var rng = RandomNumberGenerator.Create())
|
|
{
|
|
rng.GetBytes(keyBytes);
|
|
}
|
|
var key = Convert.ToBase64String(keyBytes);
|
|
|
|
var path = _uri.PathAndQuery;
|
|
if (string.IsNullOrEmpty(path))
|
|
{
|
|
path = "/";
|
|
}
|
|
|
|
var sb = new StringBuilder();
|
|
sb.Append("GET ").Append(path).Append(" HTTP/1.1\r\n");
|
|
sb.Append("Host: ").Append(port == 443 ? host : $"{host}:{port}").Append("\r\n");
|
|
sb.Append("Upgrade: websocket\r\n");
|
|
sb.Append("Connection: Upgrade\r\n");
|
|
sb.Append("Sec-WebSocket-Key: ").Append(key).Append("\r\n");
|
|
sb.Append("Sec-WebSocket-Version: 13\r\n");
|
|
sb.Append("\r\n");
|
|
|
|
var head = Encoding.ASCII.GetBytes(sb.ToString());
|
|
_stream.Write(head, 0, head.Length);
|
|
_stream.Flush();
|
|
|
|
// ---- 读取响应状态行与响应头 ----
|
|
var statusLine = ReadHandshakeLine();
|
|
if (string.IsNullOrEmpty(statusLine))
|
|
{
|
|
throw new IOException("WebSocket 升级握手无响应(连接被关闭)");
|
|
}
|
|
if (!statusLine.Contains("101"))
|
|
{
|
|
throw new IOException($"WebSocket 升级握手失败: {statusLine}");
|
|
}
|
|
|
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
string line;
|
|
while (!string.IsNullOrEmpty(line = ReadHandshakeLine()))
|
|
{
|
|
int colon = line.IndexOf(':');
|
|
if (colon <= 0)
|
|
{
|
|
continue;
|
|
}
|
|
var name = line.Substring(0, colon).Trim();
|
|
var value = line.Substring(colon + 1).Trim();
|
|
headers[name] = value;
|
|
}
|
|
|
|
// 校验 Sec-WebSocket-Accept
|
|
var expectedAccept = Convert.ToBase64String(
|
|
SHA1.Create().ComputeHash(Encoding.ASCII.GetBytes(key + WsGuid)));
|
|
if (!headers.TryGetValue("Sec-WebSocket-Accept", out var accept) || accept != expectedAccept)
|
|
{
|
|
throw new IOException("WebSocket 升级握手校验失败(Sec-WebSocket-Accept 不匹配)");
|
|
}
|
|
}
|
|
|
|
private string ReadHandshakeLine()
|
|
{
|
|
var sb = new StringBuilder();
|
|
var buf = new byte[1];
|
|
int prev = -1;
|
|
while (true)
|
|
{
|
|
int read = _stream.Read(buf, 0, 1);
|
|
if (read == 0)
|
|
{
|
|
if (sb.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
break;
|
|
}
|
|
int b = buf[0];
|
|
if (prev == '\r' && b == '\n')
|
|
{
|
|
sb.Remove(sb.Length - 1, 1); // 去掉 \r
|
|
break;
|
|
}
|
|
prev = b;
|
|
sb.Append((char)b);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>接收循环:解析帧,分派事件;处理 ping/pong/close/分片。</summary>
|
|
private void ReceiveLoop()
|
|
{
|
|
var fragmentBuffer = new List<byte>();
|
|
byte fragmentOpcode = 0;
|
|
try
|
|
{
|
|
while (_state == WebSocketState.Open || _state == WebSocketState.Closing)
|
|
{
|
|
int b0 = _stream.ReadByte();
|
|
if (b0 < 0)
|
|
{
|
|
break; // 对端关闭连接
|
|
}
|
|
int b1 = _stream.ReadByte();
|
|
if (b1 < 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
bool fin = (b0 & 0x80) != 0;
|
|
int opcode = b0 & 0x0F;
|
|
bool masked = (b1 & 0x80) != 0;
|
|
long payloadLen = b1 & 0x7F;
|
|
if (payloadLen == 126)
|
|
{
|
|
payloadLen = ReadUInt16BigEndian();
|
|
}
|
|
else if (payloadLen == 127)
|
|
{
|
|
payloadLen = (long)ReadUInt64BigEndian();
|
|
if (payloadLen > int.MaxValue)
|
|
{
|
|
throw new IOException("WebSocket 帧过大");
|
|
}
|
|
}
|
|
|
|
byte[] maskKey = null;
|
|
if (masked)
|
|
{
|
|
maskKey = ReadExactly(4);
|
|
}
|
|
|
|
var payload = payloadLen > 0 ? ReadExactly((int)payloadLen) : new byte[0];
|
|
if (masked && payloadLen > 0)
|
|
{
|
|
for (int i = 0; i < payload.Length; i++)
|
|
{
|
|
payload[i] ^= maskKey[i % 4];
|
|
}
|
|
}
|
|
|
|
switch (opcode)
|
|
{
|
|
case 0x0: // continuation
|
|
fragmentBuffer.AddRange(payload);
|
|
if (fin)
|
|
{
|
|
RaiseMessage(fragmentOpcode == 0x1, fragmentBuffer.ToArray());
|
|
fragmentBuffer.Clear();
|
|
}
|
|
break;
|
|
|
|
case 0x1: // text
|
|
case 0x2: // binary
|
|
if (!fin)
|
|
{
|
|
fragmentOpcode = (byte)opcode;
|
|
fragmentBuffer.Clear();
|
|
fragmentBuffer.AddRange(payload);
|
|
}
|
|
else
|
|
{
|
|
RaiseMessage(opcode == 0x1, payload);
|
|
}
|
|
break;
|
|
|
|
case 0x8: // close
|
|
HandleCloseFrame(payload);
|
|
return;
|
|
|
|
case 0x9: // ping → 回 pong
|
|
SendFrame(0xA, payload);
|
|
break;
|
|
|
|
case 0xA: // pong
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (_state != WebSocketState.Closed)
|
|
{
|
|
try
|
|
{
|
|
OnError?.Invoke(this, new ErrorEventArgs(ex.Message, ex));
|
|
}
|
|
catch
|
|
{
|
|
// 事件处理器异常不影响连接清理
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (_state != WebSocketState.Closed)
|
|
{
|
|
_state = WebSocketState.Closed;
|
|
CloseResources();
|
|
try
|
|
{
|
|
OnClose?.Invoke(this, new CloseEventArgs(CloseStatusCode.Abnormal, "connection closed"));
|
|
}
|
|
catch
|
|
{
|
|
// 忽略
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void HandleCloseFrame(byte[] payload)
|
|
{
|
|
CloseStatusCode code = CloseStatusCode.NoStatus;
|
|
string reason = string.Empty;
|
|
if (payload.Length >= 2)
|
|
{
|
|
code = (CloseStatusCode)(ushort)((payload[0] << 8) | payload[1]);
|
|
if (payload.Length > 2)
|
|
{
|
|
reason = Encoding.UTF8.GetString(payload, 2, payload.Length - 2);
|
|
}
|
|
}
|
|
|
|
// 对端发起关闭,回一个 Close 帧
|
|
try
|
|
{
|
|
SendFrame(0x8, payload.Length >= 2 ? payload : new byte[] { 0x03, 0xE8 });
|
|
}
|
|
catch
|
|
{
|
|
// 忽略
|
|
}
|
|
|
|
_state = WebSocketState.Closed;
|
|
CloseResources();
|
|
OnClose?.Invoke(this, new CloseEventArgs(code, reason, wasClean: true));
|
|
}
|
|
|
|
private void RaiseMessage(bool isText, byte[] rawData)
|
|
{
|
|
if (rawData == null || rawData.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
OnMessage?.Invoke(this, new MessageEventArgs(isText, rawData));
|
|
}
|
|
|
|
/// <summary>发送一个 WebSocket 帧(客户端帧强制加掩码)。</summary>
|
|
private void SendFrame(byte opcode, byte[] payload)
|
|
{
|
|
lock (_sendLock)
|
|
{
|
|
EnsureNotDisposed();
|
|
var len = payload.Length;
|
|
var header = new List<byte>(14);
|
|
header.Add((byte)(0x80 | opcode)); // FIN=1
|
|
|
|
var mask = new byte[4];
|
|
using (var rng = RandomNumberGenerator.Create())
|
|
{
|
|
rng.GetBytes(mask);
|
|
}
|
|
|
|
if (len < 126)
|
|
{
|
|
header.Add((byte)(0x80 | len));
|
|
}
|
|
else if (len <= 0xFFFF)
|
|
{
|
|
header.Add((byte)(0x80 | 126));
|
|
header.Add((byte)(len >> 8));
|
|
header.Add((byte)(len & 0xFF));
|
|
}
|
|
else
|
|
{
|
|
header.Add((byte)(0x80 | 127));
|
|
ulong l = (ulong)len;
|
|
for (int i = 7; i >= 0; i--)
|
|
{
|
|
header.Add((byte)(l >> (i * 8)));
|
|
}
|
|
}
|
|
header.AddRange(mask);
|
|
|
|
var maskedPayload = new byte[len];
|
|
for (int i = 0; i < len; i++)
|
|
{
|
|
maskedPayload[i] = (byte)(payload[i] ^ mask[i % 4]);
|
|
}
|
|
|
|
_stream.Write(header.ToArray(), 0, header.Count);
|
|
if (len > 0)
|
|
{
|
|
_stream.Write(maskedPayload, 0, len);
|
|
}
|
|
_stream.Flush();
|
|
}
|
|
}
|
|
|
|
private ushort ReadUInt16BigEndian()
|
|
{
|
|
var b = ReadExactly(2);
|
|
return (ushort)((b[0] << 8) | b[1]);
|
|
}
|
|
|
|
private ulong ReadUInt64BigEndian()
|
|
{
|
|
var b = ReadExactly(8);
|
|
ulong v = 0;
|
|
for (int i = 0; i < 8; i++)
|
|
{
|
|
v = (v << 8) | b[i];
|
|
}
|
|
return v;
|
|
}
|
|
|
|
private byte[] ReadExactly(int count)
|
|
{
|
|
var buffer = new byte[count];
|
|
int offset = 0;
|
|
while (offset < count)
|
|
{
|
|
int read = _stream.Read(buffer, offset, count - offset);
|
|
if (read == 0)
|
|
{
|
|
throw new IOException("连接被对端关闭(读取中断)");
|
|
}
|
|
offset += read;
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
private void EnsureOpen()
|
|
{
|
|
EnsureNotDisposed();
|
|
if (_state != WebSocketState.Open)
|
|
{
|
|
throw new InvalidOperationException($"WebSocket 未处于 Open 状态(当前 {_state})");
|
|
}
|
|
}
|
|
|
|
private void EnsureNotDisposed()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
throw new ObjectDisposedException(nameof(BouncyCastleWebSocketClient));
|
|
}
|
|
}
|
|
|
|
private void CloseResources()
|
|
{
|
|
try
|
|
{
|
|
_stream?.Dispose();
|
|
}
|
|
catch
|
|
{
|
|
// 忽略
|
|
}
|
|
try
|
|
{
|
|
_tcp?.Close();
|
|
}
|
|
catch
|
|
{
|
|
// 忽略
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|
|
|