mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Root cause: server's db.Role and db.VoiceState structs had no JSON tags, causing Go to serialize field names as PascalCase while the C# client expected snake_case. Every role deserialized with Id=0, crashing ToDictionary with "duplicate key: 0". - Add json tags to Role and VoiceState in Server/db/models.go - Change Disconnected event to carry reason string for diagnostics - Wire ErrorReceived in MainViewModel to show server-side WS errors - Fix MainWindow to surface WebSocket errors on MainPage (not ConnectPage) - Use _reconnectCts.Token for receive loop instead of caller's token - Make ToDictionary calls safe with TryAdd to prevent future crashes
141 lines
4.6 KiB
C#
141 lines
4.6 KiB
C#
using System.IO;
|
|
using System.Net.WebSockets;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace OwnCord.Client.Services;
|
|
|
|
public sealed class WebSocketService : IWebSocketService, IDisposable
|
|
{
|
|
private readonly ICertificateTrustService _trustService;
|
|
private ClientWebSocket? _ws;
|
|
|
|
public WebSocketService(ICertificateTrustService trustService)
|
|
{
|
|
_trustService = trustService;
|
|
}
|
|
|
|
public bool IsConnected => _ws?.State == WebSocketState.Open;
|
|
public WebSocketState State => _ws?.State ?? WebSocketState.None;
|
|
|
|
public event Action<string>? MessageReceived;
|
|
public event Action<string>? Disconnected;
|
|
|
|
public async Task ConnectAsync(string uri, string token, CancellationToken ct = default)
|
|
{
|
|
_ws?.Dispose();
|
|
_ws = new ClientWebSocket();
|
|
|
|
var host = ExtractHost(uri);
|
|
|
|
// Trust-On-First-Use (TOFU) certificate pinning.
|
|
// On first connection to a host, the certificate SHA-256 fingerprint is stored.
|
|
// On subsequent connections, the fingerprint must match the stored value.
|
|
_ws.Options.RemoteCertificateValidationCallback = (_, cert, _, _) =>
|
|
{
|
|
if (cert == null) return false;
|
|
var fingerprint = cert.GetCertHashString(HashAlgorithmName.SHA256);
|
|
return _trustService.IsTrusted(host, fingerprint);
|
|
};
|
|
|
|
await _ws.ConnectAsync(new Uri(uri), ct);
|
|
var auth = JsonSerializer.Serialize(new { type = "auth", payload = new { token } });
|
|
await SendRawAsync(auth, ct);
|
|
}
|
|
|
|
public async Task SendAsync(object message, CancellationToken ct = default)
|
|
{
|
|
var json = JsonSerializer.Serialize(message);
|
|
await SendRawAsync(json, ct);
|
|
}
|
|
|
|
public async Task RunReceiveLoopAsync(CancellationToken ct)
|
|
{
|
|
if (_ws is null) return;
|
|
var buf = new byte[8192];
|
|
|
|
try
|
|
{
|
|
while (_ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
WebSocketReceiveResult result;
|
|
do
|
|
{
|
|
result = await _ws.ReceiveAsync(buf, ct);
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
var desc = _ws.CloseStatusDescription ?? _ws.CloseStatus?.ToString() ?? "server closed connection";
|
|
Disconnected?.Invoke(desc);
|
|
return;
|
|
}
|
|
ms.Write(buf, 0, result.Count);
|
|
} while (!result.EndOfMessage);
|
|
|
|
var text = Encoding.UTF8.GetString(ms.ToArray());
|
|
MessageReceived?.Invoke(text);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Normal shutdown via cancellation.
|
|
}
|
|
catch (WebSocketException ex)
|
|
{
|
|
Disconnected?.Invoke($"WebSocket error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public async IAsyncEnumerable<string> ReceiveAsync([EnumeratorCancellation] CancellationToken ct)
|
|
{
|
|
if (_ws is null) yield break;
|
|
var buf = new byte[8192];
|
|
while (_ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
WebSocketReceiveResult result;
|
|
do
|
|
{
|
|
result = await _ws.ReceiveAsync(buf, ct);
|
|
if (result.MessageType == WebSocketMessageType.Close) yield break;
|
|
ms.Write(buf, 0, result.Count);
|
|
} while (!result.EndOfMessage);
|
|
yield return Encoding.UTF8.GetString(ms.ToArray());
|
|
}
|
|
}
|
|
|
|
public async Task DisconnectAsync()
|
|
{
|
|
if (_ws?.State == WebSocketState.Open)
|
|
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnect", default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts "host:port" from a WebSocket URI for use as the trust store key.
|
|
/// e.g. "wss://server.local:8443/ws" → "server.local:8443"
|
|
/// </summary>
|
|
private static string ExtractHost(string uri)
|
|
{
|
|
try
|
|
{
|
|
var u = new Uri(uri);
|
|
return u.IsDefaultPort ? u.Host : $"{u.Host}:{u.Port}";
|
|
}
|
|
catch
|
|
{
|
|
return uri;
|
|
}
|
|
}
|
|
|
|
private async Task SendRawAsync(string text, CancellationToken ct)
|
|
{
|
|
if (_ws is null) return;
|
|
var bytes = Encoding.UTF8.GetBytes(text);
|
|
await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
|
|
}
|
|
|
|
public void Dispose() => _ws?.Dispose();
|
|
}
|