diff --git a/Common/BroadcastMessage.cs b/Common/BroadcastMessage.cs index e72a97c..e42c614 100644 --- a/Common/BroadcastMessage.cs +++ b/Common/BroadcastMessage.cs @@ -35,26 +35,20 @@ namespace OnlineMsgServer.Common return; } - Message response = new() + string senderPublicKey = UserService.GetUserPublicKeyByID(wsid)!; + if (!PeerNetworkService.TryMarkSeen(senderPublicKey, Type, key, payload.Payload)) { - Type = "broadcast", - Data = payload.Payload, - Key = UserService.GetUserNameByID(wsid), - }; - - foreach (IWebSocketSession session in Sessions.Sessions) - { - if (session.ID != wsid)//不用发给自己 - { - string? publicKey = UserService.GetUserPublicKeyByID(session.ID); - if (publicKey != null) - { - string jsonString = response.ToJsonString(); - string encryptString = RsaService.EncryptForClient(publicKey, jsonString); - session.Context.WebSocket.Send(encryptString); - } - } + return; } + + string senderName = UserService.GetUserNameByID(wsid) ?? "anonymous"; + PeerNetworkService.DeliverBroadcastToLocalClients(senderName, payload.Payload, wsid); + + string? excludePeerPublicKey = UserService.IsPeerNodeSession(wsid) + ? UserService.GetPeerPublicKeyBySessionId(wsid) + : null; + + PeerNetworkService.RelayBroadcast(payload.Payload, excludePeerPublicKey); } catch (Exception ex) { diff --git a/Common/ForwardMessage.cs b/Common/ForwardMessage.cs index 73dca1f..1d246d5 100644 --- a/Common/ForwardMessage.cs +++ b/Common/ForwardMessage.cs @@ -41,33 +41,29 @@ namespace OnlineMsgServer.Common return; } - string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!; - - Message response = new() + if (PeerNetworkService.TryHandlePeerRelayForward(wsid, forwardPublickKey, payload)) { - Type = "forward", - Data = payload.Payload, - Key = fromPublicKey, - }; - - string jsonString = response.ToJsonString(); - string encryptString = RsaService.EncryptForClient(forwardPublickKey, jsonString); + return; + } - List userList = UserService.GetUserListByPublicKey(forwardPublickKey); - if (userList.Count == 0) + string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!; + if (!PeerNetworkService.TryMarkSeen(fromPublicKey, Type, forwardPublickKey, payload.Payload)) { - Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}"); return; } - foreach (IWebSocketSession session in Sessions.Sessions) + bool delivered = PeerNetworkService.DeliverForwardToLocalClient(fromPublicKey, forwardPublickKey, payload.Payload); + if (delivered) { - if (userList.Exists(u => u.ID == session.ID)) - { - session.Context.WebSocket.Send(encryptString); - break; - } + return; } + + string? excludePeerPublicKey = UserService.IsPeerNodeSession(wsid) + ? UserService.GetPeerPublicKeyBySessionId(wsid) + : null; + + PeerNetworkService.RelayForwardMiss(forwardPublickKey, payload.Payload, excludePeerPublicKey); + Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}"); } catch (Exception ex) { diff --git a/Common/PeerRelayEnvelope.cs b/Common/PeerRelayEnvelope.cs new file mode 100644 index 0000000..60139a6 --- /dev/null +++ b/Common/PeerRelayEnvelope.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OnlineMsgServer.Common +{ + internal sealed class PeerRelayEnvelope + { + public const string OverlayName = "oms-peer/1"; + + public string Overlay { get; init; } = OverlayName; + public string Kind { get; init; } = ""; + public string TargetKey { get; init; } = ""; + public string Payload { get; init; } = ""; + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public string ToJsonString() + { + return JsonSerializer.Serialize(this, Options); + } + + public static bool TryParse(string? jsonString, out PeerRelayEnvelope envelope) + { + envelope = new PeerRelayEnvelope(); + if (string.IsNullOrWhiteSpace(jsonString)) + { + return false; + } + + try + { + PeerRelayEnvelope? parsed = JsonSerializer.Deserialize(jsonString, Options); + if (parsed == null || !string.Equals(parsed.Overlay, OverlayName, StringComparison.Ordinal)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(parsed.Kind)) + { + return false; + } + + envelope = parsed; + return true; + } + catch + { + return false; + } + } + } +} diff --git a/Common/PublicKeyMessage.cs b/Common/PublicKeyMessage.cs index e4b2cb1..be83e35 100644 --- a/Common/PublicKeyMessage.cs +++ b/Common/PublicKeyMessage.cs @@ -81,7 +81,8 @@ namespace OnlineMsgServer.Common return; } - UserService.UserLogin(wsid, payload.PublicKey, userName); + bool isPeerNode = PeerNetworkService.IsPeerUserName(userName); + UserService.UserLogin(wsid, payload.PublicKey, userName, isPeerNode); Log.Security("auth_success", $"wsid={wsid} user={userName}"); Message ack = new() diff --git a/Common/User.cs b/Common/User.cs index a30325f..d91a5e6 100644 --- a/Common/User.cs +++ b/Common/User.cs @@ -12,6 +12,11 @@ namespace OnlineMsgServer.Common /// public string? Name { get; set; } + /// + /// 是否为服务器节点伪装的 peer 用户 + /// + public bool IsPeerNode { get; set; } + /// /// 用户公钥 用于消息加密发送给用户 diff --git a/Core/PeerNetworkService.cs b/Core/PeerNetworkService.cs new file mode 100644 index 0000000..fd40991 --- /dev/null +++ b/Core/PeerNetworkService.cs @@ -0,0 +1,689 @@ +using System.IO; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OnlineMsgServer.Common; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Core +{ + internal static class PeerNetworkService + { + private static readonly object _lock = new(); + private static readonly Dictionary _outboundPeers = []; + + private static SecurityConfig _config = SecurityRuntime.Config; + private static SeenMessageCache _seenCache = new(120); + private static WebSocketSessionManager? _sessions; + private static CancellationTokenSource? _cts; + + public static void Initialize(SecurityConfig config, WebSocketSessionManager sessions) + { + lock (_lock) + { + _config = config; + _sessions = sessions; + _seenCache = new SeenMessageCache(config.SeenCacheSeconds); + } + } + + public static void Start() + { + lock (_lock) + { + if (_cts != null) + { + return; + } + + _cts = new CancellationTokenSource(); + foreach (string peerUrl in _config.PeerUrls) + { + if (_outboundPeers.ContainsKey(peerUrl)) + { + continue; + } + + PeerOutboundClient peerClient = new(peerUrl, BuildPeerDisplayName(peerUrl)); + _outboundPeers[peerUrl] = peerClient; + peerClient.Start(_cts.Token); + } + } + } + + public static void Stop() + { + CancellationTokenSource? cts; + List peers; + + lock (_lock) + { + cts = _cts; + _cts = null; + peers = [.. _outboundPeers.Values]; + _outboundPeers.Clear(); + } + + cts?.Cancel(); + foreach (PeerOutboundClient peer in peers) + { + peer.Dispose(); + } + } + + public static bool IsPeerUserName(string? userName) + { + return !string.IsNullOrWhiteSpace(userName) && + userName.StartsWith(_config.PeerUserPrefix, StringComparison.Ordinal); + } + + public static string GetPeerUserName() + { + string userName = $"{_config.PeerUserPrefix}{_config.PeerNodeName}".Trim(); + return userName.Length <= 64 ? userName : userName[..64]; + } + + public static string GetVisibleUserName(string? userName) + { + if (string.IsNullOrWhiteSpace(userName)) + { + return ""; + } + + string trimmed = userName.Trim(); + if (!IsPeerUserName(trimmed)) + { + return trimmed; + } + + string visibleName = trimmed[_config.PeerUserPrefix.Length..].Trim(); + return string.IsNullOrWhiteSpace(visibleName) ? trimmed : visibleName; + } + + public static bool TryMarkSeen(string senderIdentity, string type, string key, string payload) + { + return _seenCache.TryMark(senderIdentity, type, key, payload); + } + + public static bool TryHandlePeerRelayForward(string wsid, string targetKey, SignedMessagePayload payload) + { + if (!UserService.IsPeerNodeSession(wsid)) + { + return false; + } + + if (!string.Equals(targetKey, RsaService.GetRsaPublickKey(), StringComparison.Ordinal)) + { + return false; + } + + if (!PeerRelayEnvelope.TryParse(payload.Payload, out PeerRelayEnvelope envelope)) + { + return false; + } + + string sourcePublicKey = UserService.GetPeerPublicKeyBySessionId(wsid) ?? ""; + string sourceDisplayName = GetVisibleUserName(UserService.GetUserNameByID(wsid)); + ProcessPeerEnvelope(sourcePublicKey, sourceDisplayName, envelope); + return true; + } + + public static void RelayForwardMiss(string targetKey, string payload, string? excludePeerPublicKey = null) + { + PeerRelayEnvelope envelope = new() + { + Kind = "forward", + TargetKey = targetKey, + Payload = payload + }; + + RelayPeerEnvelope(envelope, excludePeerPublicKey); + } + + public static void RelayBroadcast(string payload, string? excludePeerPublicKey = null) + { + PeerRelayEnvelope envelope = new() + { + Kind = "broadcast", + TargetKey = "", + Payload = payload + }; + + RelayPeerEnvelope(envelope, excludePeerPublicKey); + } + + public static void DeliverBroadcastToLocalClients(string senderName, string payload, string? excludeSessionId = null) + { + WebSocketSessionManager sessions = RequireSessions(); + Message response = new() + { + Type = "broadcast", + Data = payload, + Key = senderName + }; + string jsonString = response.ToJsonString(); + + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == excludeSessionId) + { + continue; + } + + if (!UserService.IsAuthenticated(session.ID) || UserService.IsPeerNodeSession(session.ID)) + { + continue; + } + + string? publicKey = UserService.GetUserPublicKeyByID(session.ID); + if (string.IsNullOrWhiteSpace(publicKey)) + { + continue; + } + + string encryptString = RsaService.EncryptForClient(publicKey, jsonString); + session.Context.WebSocket.Send(encryptString); + } + } + + public static bool DeliverForwardToLocalClient(string senderPublicKey, string targetPublicKey, string payload) + { + WebSocketSessionManager sessions = RequireSessions(); + List userList = UserService.GetUserListByPublicKey(targetPublicKey, includePeerNodes: false); + if (userList.Count == 0) + { + return false; + } + + Message response = new() + { + Type = "forward", + Data = payload, + Key = senderPublicKey + }; + string jsonString = response.ToJsonString(); + string encryptString = RsaService.EncryptForClient(targetPublicKey, jsonString); + + foreach (IWebSocketSession session in sessions.Sessions) + { + if (userList.Exists(u => u.ID == session.ID)) + { + session.Context.WebSocket.Send(encryptString); + return true; + } + } + + return false; + } + + private static void ProcessPeerEnvelope(string sourcePublicKey, string sourceDisplayName, PeerRelayEnvelope envelope) + { + if (!TryMarkSeen(sourcePublicKey, envelope.Kind, envelope.TargetKey, envelope.Payload)) + { + return; + } + + switch (envelope.Kind) + { + case "broadcast": + DeliverBroadcastToLocalClients(sourceDisplayName, envelope.Payload); + RelayPeerEnvelope(envelope, sourcePublicKey); + break; + case "forward": + bool delivered = DeliverForwardToLocalClient(sourcePublicKey, envelope.TargetKey, envelope.Payload); + if (!delivered) + { + RelayPeerEnvelope(envelope, sourcePublicKey); + } + break; + default: + Log.Security("peer_envelope_invalid_kind", $"kind={envelope.Kind}"); + break; + } + } + + private static void RelayPeerEnvelope(PeerRelayEnvelope envelope, string? excludePeerPublicKey) + { + string payloadJson = envelope.ToJsonString(); + HashSet sentPeerKeys = []; + + foreach (PeerOutboundClient peer in SnapshotOutboundPeers()) + { + string? remotePublicKey = peer.RemotePublicKey; + if (!peer.IsAuthenticated || string.IsNullOrWhiteSpace(remotePublicKey)) + { + continue; + } + + if (string.Equals(remotePublicKey, excludePeerPublicKey, StringComparison.Ordinal) || + !sentPeerKeys.Add(remotePublicKey)) + { + continue; + } + + peer.TrySendRelayEnvelope(payloadJson); + } + + SendPeerEnvelopeToInboundPeers(payloadJson, sentPeerKeys, excludePeerPublicKey); + } + + private static void SendPeerEnvelopeToInboundPeers(string payloadJson, HashSet sentPeerKeys, string? excludePeerPublicKey) + { + WebSocketSessionManager sessions = RequireSessions(); + Message response = new() + { + Type = "forward", + Key = RsaService.GetRsaPublickKey(), + Data = payloadJson + }; + string jsonString = response.ToJsonString(); + + foreach (User user in UserService.GetAuthenticatedUsers(includePeerNodes: true)) + { + if (!user.IsPeerNode || string.IsNullOrWhiteSpace(user.PublicKey)) + { + continue; + } + + if (string.Equals(user.PublicKey, excludePeerPublicKey, StringComparison.Ordinal) || + !sentPeerKeys.Add(user.PublicKey)) + { + continue; + } + + string encryptString = RsaService.EncryptForClient(user.PublicKey, jsonString); + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == user.ID) + { + session.Context.WebSocket.Send(encryptString); + break; + } + } + } + } + + private static List SnapshotOutboundPeers() + { + lock (_lock) + { + return [.. _outboundPeers.Values]; + } + } + + private static WebSocketSessionManager RequireSessions() + { + return _sessions ?? throw new InvalidOperationException("peer network sessions not initialized"); + } + + private static string BuildPeerDisplayName(string peerUrl) + { + try + { + Uri uri = new(peerUrl); + string displayName = $"{_config.PeerUserPrefix}{BuildGuestAlias(uri.Host)}"; + return displayName.Length <= 64 ? displayName : displayName[..64]; + } + catch + { + return GetPeerUserName(); + } + } + + private static void HandlePeerSocketMessage(PeerOutboundClient peer, string text) + { + if (TryHandlePeerHello(peer, text)) + { + return; + } + + string plainText; + try + { + plainText = RsaService.Decrypt(text); + } + catch + { + return; + } + + using JsonDocument doc = JsonDocument.Parse(plainText); + JsonElement root = doc.RootElement; + if (!root.TryGetProperty("type", out JsonElement typeElement) || typeElement.ValueKind != JsonValueKind.String) + { + return; + } + + string type = typeElement.GetString() ?? ""; + switch (type) + { + case "auth_ok": + peer.MarkAuthenticated(); + Log.Debug($"peer auth ok {peer.PeerUrl}"); + return; + case "forward": + case "broadcast": + if (!root.TryGetProperty("data", out JsonElement dataElement)) + { + return; + } + + string payload = ExtractPayloadString(dataElement); + if (PeerRelayEnvelope.TryParse(payload, out PeerRelayEnvelope envelope)) + { + ProcessPeerEnvelope(peer.RemotePublicKey ?? "", GetVisibleUserName(peer.DisplayName), envelope); + } + return; + default: + return; + } + } + + private static string BuildGuestAlias(string seed) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed)); + int value = BitConverter.ToInt32(hash, 0) & int.MaxValue; + return $"guest-{(value % 900000) + 100000:D6}"; + } + + private static bool TryHandlePeerHello(PeerOutboundClient peer, string text) + { + try + { + using JsonDocument doc = JsonDocument.Parse(text); + JsonElement root = doc.RootElement; + if (!root.TryGetProperty("type", out JsonElement typeElement) || + typeElement.ValueKind != JsonValueKind.String || + !string.Equals(typeElement.GetString(), "publickey", StringComparison.Ordinal)) + { + return false; + } + + if (!root.TryGetProperty("data", out JsonElement dataElement) || dataElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!dataElement.TryGetProperty("publicKey", out JsonElement publicKeyElement) || + publicKeyElement.ValueKind != JsonValueKind.String || + !dataElement.TryGetProperty("authChallenge", out JsonElement challengeElement) || + challengeElement.ValueKind != JsonValueKind.String) + { + return false; + } + + string remotePublicKey = publicKeyElement.GetString() ?? ""; + string challenge = challengeElement.GetString() ?? ""; + if (string.IsNullOrWhiteSpace(remotePublicKey) || string.IsNullOrWhiteSpace(challenge)) + { + return false; + } + + peer.SetRemotePublicKey(remotePublicKey); + SendPeerAuth(peer, remotePublicKey, challenge); + return true; + } + catch + { + return false; + } + } + + private static void SendPeerAuth(PeerOutboundClient peer, string remotePublicKey, string challenge) + { + string localPublicKey = RsaService.GetRsaPublickKey(); + string userName = GetPeerUserName(); + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string nonce = SecurityValidator.CreateNonce(); + string signingInput = ClientRegistrationPayload.BuildSigningInput(userName, localPublicKey, challenge, timestamp, nonce); + string signature = RsaService.Sign(signingInput); + + Message request = new() + { + Type = "publickey", + Key = userName, + Data = new + { + publicKey = localPublicKey, + challenge, + timestamp, + nonce, + signature + } + }; + + string cipherText = RsaService.EncryptForClient(remotePublicKey, request.ToJsonString()); + peer.TrySendRaw(cipherText); + } + + private static string ExtractPayloadString(JsonElement dataElement) + { + return dataElement.ValueKind == JsonValueKind.String + ? dataElement.GetString() ?? "" + : dataElement.GetRawText(); + } + + private sealed class PeerOutboundClient(string peerUrl, string displayName) : IDisposable + { + private readonly object _socketLock = new(); + + private ClientWebSocket? _socket; + private Task? _runTask; + private CancellationToken _cancellationToken; + + public string PeerUrl { get; } = peerUrl; + public string DisplayName { get; } = displayName; + public string? RemotePublicKey { get; private set; } + public bool IsAuthenticated { get; private set; } + + public void Start(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + _runTask = Task.Run(RunAsync, cancellationToken); + } + + public void SetRemotePublicKey(string remotePublicKey) + { + RemotePublicKey = remotePublicKey; + } + + public void MarkAuthenticated() + { + IsAuthenticated = true; + } + + public bool TrySendRelayEnvelope(string relayPayload) + { + if (!IsAuthenticated || string.IsNullOrWhiteSpace(RemotePublicKey)) + { + return false; + } + + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string nonce = SecurityValidator.CreateNonce(); + string targetKey = RemotePublicKey; + string signature = RsaService.Sign(SignedMessagePayload.BuildSigningInput("forward", targetKey, relayPayload, timestamp, nonce)); + + Message request = new() + { + Type = "forward", + Key = targetKey, + Data = new + { + payload = relayPayload, + timestamp, + nonce, + signature + } + }; + + string cipherText = RsaService.EncryptForClient(RemotePublicKey, request.ToJsonString()); + return TrySendRaw(cipherText); + } + + public bool TrySendRaw(string text) + { + ClientWebSocket? socket; + lock (_socketLock) + { + socket = _socket; + } + + if (socket == null || socket.State != WebSocketState.Open) + { + return false; + } + + try + { + byte[] payload = Encoding.UTF8.GetBytes(text); + socket.SendAsync(payload, WebSocketMessageType.Text, true, _cancellationToken) + .GetAwaiter() + .GetResult(); + return true; + } + catch (Exception ex) + { + Log.Security("peer_send_failed", $"peer={PeerUrl} error={ex.Message}"); + return false; + } + } + + public void Dispose() + { + ClientWebSocket? socket; + lock (_socketLock) + { + socket = _socket; + _socket = null; + } + + IsAuthenticated = false; + RemotePublicKey = null; + + if (socket == null) + { + return; + } + + try + { + socket.Abort(); + } + catch + { + // ignore + } + + try + { + socket.Dispose(); + } + catch + { + // ignore + } + } + + private async Task RunAsync() + { + while (!_cancellationToken.IsCancellationRequested) + { + ClientWebSocket socket = new(); + if (PeerUrl.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)) + { + socket.Options.RemoteCertificateValidationCallback = static (_, _, _, _) => true; + } + + lock (_socketLock) + { + _socket = socket; + } + + IsAuthenticated = false; + RemotePublicKey = null; + + try + { + await socket.ConnectAsync(new Uri(PeerUrl), _cancellationToken); + Log.Debug($"peer open {PeerUrl}"); + await ReceiveLoopAsync(socket, _cancellationToken); + } + catch (OperationCanceledException) when (_cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Log.Security("peer_connect_failed", $"peer={PeerUrl} error={ex}"); + } + finally + { + string closeReason = ""; + try + { + closeReason = socket.CloseStatusDescription + ?? socket.CloseStatus?.ToString() + ?? ""; + } + catch + { + // ignore + } + + Dispose(); + Log.Debug($"peer close {PeerUrl} {closeReason}"); + } + + if (_cancellationToken.IsCancellationRequested) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(_config.PeerReconnectSeconds), _cancellationToken) + .ContinueWith(_ => { }, TaskScheduler.Default); + } + } + + private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken) + { + byte[] buffer = new byte[16 * 1024]; + using MemoryStream messageBuffer = new(); + + while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open) + { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + break; + } + + if (result.Count > 0) + { + messageBuffer.Write(buffer, 0, result.Count); + } + + if (!result.EndOfMessage) + { + continue; + } + + if (result.MessageType != WebSocketMessageType.Text) + { + messageBuffer.SetLength(0); + continue; + } + + string text = Encoding.UTF8.GetString(messageBuffer.GetBuffer(), 0, (int)messageBuffer.Length); + messageBuffer.SetLength(0); + + if (!string.IsNullOrWhiteSpace(text)) + { + HandlePeerSocketMessage(this, text); + } + } + } + } + } +} diff --git a/Core/RsaService.cs b/Core/RsaService.cs index 546b60e..2f62eb3 100644 --- a/Core/RsaService.cs +++ b/Core/RsaService.cs @@ -132,6 +132,16 @@ namespace OnlineMsgServer.Core } } + public static string Sign(string src) + { + lock (_RsaLock) + { + byte[] srcBytes = Encoding.UTF8.GetBytes(src); + byte[] signatureBytes = _Rsa.SignData(srcBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return Convert.ToBase64String(signatureBytes); + } + } + public static bool IsPublicKeyValid(string publicKeyBase64) { lock (_PublicRsaLock) diff --git a/Core/SecurityConfig.cs b/Core/SecurityConfig.cs index 518ed8b..249b1fe 100644 --- a/Core/SecurityConfig.cs +++ b/Core/SecurityConfig.cs @@ -20,9 +20,16 @@ namespace OnlineMsgServer.Core public int ChallengeTtlSeconds { get; init; } = 120; public int MaxClockSkewSeconds { get; init; } = 60; public int ReplayWindowSeconds { get; init; } = 120; + public string PeerNodeName { get; init; } = "server"; + public bool PeerNodeNameExplicitlyConfigured { get; init; } + public string PeerUserPrefix { get; init; } = "peer:"; + public string[] PeerUrls { get; init; } = []; + public int PeerReconnectSeconds { get; init; } = 5; + public int SeenCacheSeconds { get; init; } = 120; public static SecurityConfig LoadFromEnvironment() { + string? rawPeerNodeName = GetString("PEER_NODE_NAME"); return new SecurityConfig { ListenPort = GetInt("LISTEN_PORT", 13173, 1), @@ -40,6 +47,12 @@ namespace OnlineMsgServer.Core ChallengeTtlSeconds = GetInt("CHALLENGE_TTL_SECONDS", 120, 10), MaxClockSkewSeconds = GetInt("MAX_CLOCK_SKEW_SECONDS", 60, 1), ReplayWindowSeconds = GetInt("REPLAY_WINDOW_SECONDS", 120, 10), + PeerNodeName = rawPeerNodeName ?? CreateGuestName(), + PeerNodeNameExplicitlyConfigured = !string.IsNullOrWhiteSpace(rawPeerNodeName), + PeerUserPrefix = GetString("PEER_USER_PREFIX") ?? "peer:", + PeerUrls = GetCsv("PEER_URLS"), + PeerReconnectSeconds = GetInt("PEER_RECONNECT_SECONDS", 5, 1), + SeenCacheSeconds = GetInt("SEEN_CACHE_SECONDS", 120, 1), }; } @@ -89,5 +102,25 @@ namespace OnlineMsgServer.Core return Math.Max(parsed, minValue); } + + private static string[] GetCsv(string key) + { + string? value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static string CreateGuestName() + { + return $"guest-{Random.Shared.Next(100000, 1000000)}"; + } } } diff --git a/Core/SeenMessageCache.cs b/Core/SeenMessageCache.cs new file mode 100644 index 0000000..4b7550c --- /dev/null +++ b/Core/SeenMessageCache.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; + +namespace OnlineMsgServer.Core +{ + internal sealed class SeenMessageCache + { + private readonly object _lock = new(); + private readonly Dictionary _seenUntilUtc = []; + private readonly int _ttlSeconds; + + public SeenMessageCache(int ttlSeconds) + { + _ttlSeconds = Math.Max(ttlSeconds, 1); + } + + public bool TryMark(string senderIdentity, string type, string key, string payload) + { + string hash = ComputeHash(senderIdentity, type, key, payload); + DateTime nowUtc = DateTime.UtcNow; + + lock (_lock) + { + if (_seenUntilUtc.TryGetValue(hash, out DateTime untilUtc) && untilUtc > nowUtc) + { + return false; + } + + _seenUntilUtc[hash] = nowUtc.AddSeconds(_ttlSeconds); + + List expiredKeys = []; + foreach (KeyValuePair item in _seenUntilUtc) + { + if (item.Value <= nowUtc) + { + expiredKeys.Add(item.Key); + } + } + + foreach (string expiredKey in expiredKeys) + { + _seenUntilUtc.Remove(expiredKey); + } + + return true; + } + } + + private static string ComputeHash(string senderIdentity, string type, string key, string payload) + { + byte[] bytes = Encoding.UTF8.GetBytes(string.Join("\n", senderIdentity, type, key, payload)); + return Convert.ToHexString(SHA256.HashData(bytes)); + } + } +} diff --git a/Core/UserService.cs b/Core/UserService.cs index ae57b5c..f7cd5d4 100644 --- a/Core/UserService.cs +++ b/Core/UserService.cs @@ -41,11 +41,14 @@ namespace OnlineMsgServer.Core /// /// 通过publickey返回用户列表 /// - public static List GetUserListByPublicKey(string publicKey) + public static List GetUserListByPublicKey(string publicKey, bool includePeerNodes = true) { lock (_UserListLock) { - return _UserList.FindAll(u => u.PublicKey == publicKey && u.IsAuthenticated); + return _UserList.FindAll(u => + u.PublicKey == publicKey && + u.IsAuthenticated && + (includePeerNodes || !u.IsPeerNode)); } } @@ -53,7 +56,7 @@ namespace OnlineMsgServer.Core /// /// 通过wsid设置用户PublicKey /// - public static void UserLogin(string wsid, string publickey, string name) + public static void UserLogin(string wsid, string publickey, string name, bool isPeerNode = false) { lock (_UserListLock) { @@ -62,6 +65,7 @@ namespace OnlineMsgServer.Core { user.PublicKey = publickey.Trim(); user.Name = name.Trim(); + user.IsPeerNode = isPeerNode; user.IsAuthenticated = true; user.PendingChallenge = null; user.AuthenticatedAtUtc = DateTime.UtcNow; @@ -131,6 +135,50 @@ namespace OnlineMsgServer.Core } } + public static bool IsPeerNodeSession(string wsid) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + return user is { IsAuthenticated: true, IsPeerNode: true }; + } + } + + public static string? GetPeerPublicKeyBySessionId(string wsid) + { + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user is { IsAuthenticated: true, IsPeerNode: true }) + { + return user.PublicKey; + } + + return null; + } + } + + public static List GetAuthenticatedUsers(bool includePeerNodes = true) + { + lock (_UserListLock) + { + return _UserList + .Where(u => u.IsAuthenticated && (includePeerNodes || !u.IsPeerNode)) + .Select(u => new User(u.ID) + { + Name = u.Name, + PublicKey = u.PublicKey, + IsAuthenticated = u.IsAuthenticated, + IsPeerNode = u.IsPeerNode, + IpAddress = u.IpAddress, + PendingChallenge = u.PendingChallenge, + ChallengeIssuedAtUtc = u.ChallengeIssuedAtUtc, + AuthenticatedAtUtc = u.AuthenticatedAtUtc + }) + .ToList(); + } + } + public static int GetConnectionCount() { lock (_UserListLock) diff --git a/Core/WsService.cs b/Core/WsService.cs index fc4dbd0..3c405e0 100644 --- a/Core/WsService.cs +++ b/Core/WsService.cs @@ -12,6 +12,13 @@ namespace OnlineMsgServer.Core private static readonly object _abuseLock = new(); private static readonly Dictionary _ipBlockedUntil = []; + public WsService() + { + // OkHttp/Android on some paths fails to surface a compressed first message. + // Keep the handshake/hello packet uncompressed for maximum client compatibility. + IgnoreExtensions = true; + } + protected override async void OnMessage(MessageEventArgs e) { SecurityConfig config = SecurityRuntime.Config; diff --git a/Program.cs b/Program.cs index 5ed52a2..ffdb02b 100644 --- a/Program.cs +++ b/Program.cs @@ -48,6 +48,8 @@ namespace OnlineMsgServer //开启ws监听 wssv.AddWebSocketService("/"); wssv.Start(); + PeerNetworkService.Initialize(config, wssv.WebSocketServices["/"].Sessions); + PeerNetworkService.Start(); Console.WriteLine("已开启ws监听, 端口: " + config.ListenPort); bool loopFlag = true; @@ -70,6 +72,7 @@ namespace OnlineMsgServer #endif await Task.Delay(5000);// 每5秒检查一次 } + PeerNetworkService.Stop(); wssv.Stop(); } diff --git a/ReadMe.md b/ReadMe.md index 609a541..941934f 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,19 +1,48 @@ # OnlineMsgServer -在线消息中转服务(WebSocket + RSA),支持客户端鉴权、单播转发、广播、签名校验、防重放与限流。 +一个基于 WebSocket 的在线消息中转服务,使用 RSA 完成握手、公钥鉴权和业务包加密。 + +当前版本除了单机广播/私聊,还支持“服务器伪装成普通用户”的 peer 互联模式: + +- 客户端外层协议不变 +- 服务器之间通过普通 `publickey / forward / broadcast` 连接 +- 本地私聊未命中时,服务端可继续向 peer 盲转发 +- 广播可在 peer 节点之间扩散 +- 服务端内置短期 `seen-cache`,按 `hash(sender + type + key + payload)` 去重 + +这套 peer 能力更接近“盲转发网络”,不是强一致的用户目录或联邦路由系统。 + +## 功能概览 + +- WebSocket 服务,支持 `ws://` 和 `wss://` +- 明文首包下发服务端公钥与一次性 challenge +- 客户端使用自己的 RSA 公钥 + 签名完成鉴权 +- 业务消息支持广播和按公钥私聊 +- 签名校验、防重放、限流、IP 封禁、消息大小限制 +- 可选 peer 网络:广播扩散、私聊 miss 后继续中继 +- Android / Web 客户端可直接复用现有协议 ## 仓库结构 -- `deploy/`:一键部署与生产产物脚本 +- `Common/`:协议消息与业务处理器 +- `Core/`:安全配置、用户会话、peer 网络、RSA 服务 +- `deploy/`:本地测试 / 局域网证书 / 生产准备脚本 - `web-client/`:React Web 客户端 -- `android-client/`:Android(Kotlin + Compose)客户端 +- `android-client/`:Android 客户端 -## 运行前提 +## 运行依赖 - `.NET 8 SDK` - `Docker` - `openssl` -- 部署脚本 `deploy/deploy_test_ws.sh` 与 `deploy/redeploy_with_lan_cert.sh` 依赖 `ipconfig`、`route`(当前按 macOS 环境编写) + +本仓库附带的 `deploy/*.sh` 脚本按 macOS 环境编写,依赖: + +- `ipconfig` +- `route` +- `awk` +- `base64` +- `tr` ## 快速开始 @@ -23,23 +52,34 @@ cd ``` -### 1) 测试模式(WS) +### 1. 本地测试:WS ```bash bash deploy/deploy_test_ws.sh ``` -脚本会自动生成/复用协议私钥、构建镜像并以 `REQUIRE_WSS=false` 启动容器。 +脚本会: -### 2) 安全模式(WSS + 局域网证书) +- 生成或复用协议私钥 `deploy/keys/server_rsa_pkcs8.b64` +- 构建 Docker 镜像 +- 以 `REQUIRE_WSS=false` 启动单节点服务 + +### 2. 局域网测试:WSS ```bash bash deploy/redeploy_with_lan_cert.sh ``` -脚本会重签包含当前局域网 IP 的证书、构建镜像并以 `REQUIRE_WSS=true` 启动容器。 +脚本会: + +- 自动探测当前局域网 IP +- 生成包含 LAN IP 的自签名证书 +- 生成运行时使用的 `server.pfx` +- 构建镜像并以 `REQUIRE_WSS=true` 启动容器 -### 3) 生产准备(证书 + 镜像 + 部署产物) +适合 Android 真机、同网段设备和浏览器本地联调。 + +### 3. 生产准备 ```bash DOMAIN=chat.example.com \ @@ -50,9 +90,14 @@ CERT_PASSWORD='change-me' \ bash deploy/prepare_prod_release.sh ``` -输出目录默认在 `deploy/output/prod`,包含 `prod.env`、镜像 tar(可选)和运行示例脚本。 +输出默认在 `deploy/output/prod/`,包括: + +- `prod.env` +- Docker 镜像 tar(可选) +- 运行示例脚本 +- 运行时证书与协议私钥 -无 CA 证书时可临时使用自签名(仅测试): +如果只是临时测试,也可以生成自签名证书: ```bash DOMAIN=chat.example.com \ @@ -62,9 +107,9 @@ CERT_PASSWORD='change-me' \ bash deploy/prepare_prod_release.sh ``` -## 手动 Docker 启动示例 +## 手动 Docker 启动 -### WS(测试) +### 单节点:WS ```bash docker run -d --name onlinemsgserver --restart unless-stopped \ @@ -75,7 +120,7 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ onlinemsgserver:latest ``` -### WSS(生产/预生产) +### 单节点:WSS ```bash docker run -d --name onlinemsgserver --restart unless-stopped \ @@ -89,16 +134,44 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ onlinemsgserver:latest ``` +### 第二节点:通过 peer 连到第一节点 + +下面这个例子会启动第二个节点,对外提供 `13174`,并主动连到第一节点: + +```bash +docker run -d --name onlinemsgserver-peer2 --restart unless-stopped \ + -p 13174:13174 \ + -v "$(pwd)/deploy/certs:/app/certs:ro" \ + -e REQUIRE_WSS=true \ + -e LISTEN_PORT=13174 \ + -e TLS_CERT_PATH=/app/certs/server.pfx \ + -e TLS_CERT_PASSWORD=changeit \ + -e ALLOW_EPHEMERAL_SERVER_KEY=true \ + -e PEER_NODE_NAME=peer-node-b \ + -e PEER_URLS=wss://host.docker.internal:13173/ \ + onlinemsgserver:latest +``` + +这里有一个很重要的约束: + +- 如果客户端访问的是 `wss://host:13174/` +- 那容器内 `LISTEN_PORT` 也应当是 `13174` + +`WebSocketSharp` 会校验握手请求里的 `Host: host:port`,容器内监听端口和客户端看到的端口不一致时,可能直接返回 `400 Bad Request`。 + ## 协议说明 ### 加密方式 -- RSA-2048-OAEP-SHA256 -- 明文按 190 字节分块加密 -- 密文按 256 字节分块解密 -- 业务消息传输为 base64 字符串 +- 服务端握手公钥:RSA-2048(SPKI / PKCS8) +- 传输加密:`RSA/ECB/OAEPWithSHA-256AndMGF1Padding` +- 明文按 `190` 字节分块加密 +- 密文按 `256` 字节分块解密 +- WebSocket 上传输的是 base64 字符串 + +### 通用包结构 -### 通用包结构(客户端 -> 服务端) +客户端发给服务端的明文结构如下,随后再整体用服务端公钥加密: ```json { @@ -108,7 +181,9 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ } ``` -### 连接首包(服务端 -> 客户端,明文) +### 首包:服务端 -> 客户端(明文) + +客户端建立连接后,服务端立即发送: ```json { @@ -122,83 +197,196 @@ docker run -d --name onlinemsgserver --restart unless-stopped \ } ``` -### 鉴权登记 `type=publickey`(客户端 -> 服务端) +### 鉴权:`type=publickey` + +- `key`:用户名 +- `data.publicKey`:客户端公钥 +- `data.challenge`:首包中的 `authChallenge` +- `data.timestamp`:Unix 秒级时间戳 +- `data.nonce`:随机串 +- `data.signature`:客户端私钥签名 -- `key`:用户名(为空时服务端会生成匿名名) -- `data`: +示例: ```json { - "publicKey": "客户端公钥(base64 SPKI)", - "challenge": "上一步 authChallenge", - "timestamp": 1739600000, - "nonce": "随机字符串", - "signature": "签名(base64)" + "type": "publickey", + "key": "guest-123456", + "data": { + "publicKey": "base64-spki", + "challenge": "challenge-from-server", + "timestamp": 1739600000, + "nonce": "random-string", + "signature": "base64-signature" + } } ``` -签名串: +签名原文: ```text -publickey\n{userName}\n{publicKey}\n{challenge}\n{timestamp}\n{nonce} +publickey +{userName} +{publicKey} +{challenge} +{timestamp} +{nonce} ``` -### 单播 `type=forward` +### 私聊:`type=forward` -- `key`:目标客户端公钥 -- `data`: +- `key`:目标用户公钥 +- `data.payload`:消息内容 +- `data.timestamp` / `data.nonce` / `data.signature`:发送者签名信息 ```json { - "payload": "消息内容", - "timestamp": 1739600000, - "nonce": "随机字符串", - "signature": "签名(base64)" + "type": "forward", + "key": "target-user-public-key", + "data": { + "payload": "hello", + "timestamp": 1739600000, + "nonce": "random-string", + "signature": "base64-signature" + } } ``` -签名串: +签名原文: ```text -forward\n{targetPublicKey}\n{payload}\n{timestamp}\n{nonce} +forward +{targetPublicKey} +{payload} +{timestamp} +{nonce} ``` -### 广播 `type=broadcast` +### 广播:`type=broadcast` -- `key`:可为空字符串 -- `data`:同 `forward` +- `key`:通常为空字符串 +- `data`:结构与 `forward` 相同 -签名串: +签名原文: ```text -broadcast\n{key}\n{payload}\n{timestamp}\n{nonce} +broadcast +{key} +{payload} +{timestamp} +{nonce} ``` ### 连接流程 -1. 客户端建立 WebSocket 连接后接收明文 `publickey` 首包。 -2. 客户端发送签名鉴权包(`type=publickey`)。 -3. 鉴权成功后,客户端发送 `forward` / `broadcast` 业务消息(加密 + 签名)。 +1. 客户端建立 WebSocket 连接。 +2. 服务端发送明文 `publickey` 首包。 +3. 客户端用自己的私钥签名后发送 `type=publickey` 鉴权包。 +4. 服务端返回加密的 `auth_ok`。 +5. 客户端开始发送 `forward` / `broadcast`。 + +## Peer 网络说明 + +Peer 网络不引入新的客户端外层协议。节点之间也是普通登录用户,只是服务端会把这类会话当成 peer 处理。 + +当前行为: + +- 本地广播:先发给本地普通客户端,再扩散到 peer +- 从 peer 收到广播:投递给本地普通客户端,再继续扩散 +- 本地私聊命中:直接投递 +- 本地私聊 miss:包装为内部 relay 后继续发给 peer +- peer 收到私聊 relay:本地命中就投递,命不中就继续向其他 peer 转发 + +当前实现特点: + +- 不做用户发现 +- 不维护“谁在哪台服务器”的路由表 +- 只保证尽力转发 +- 依赖短期 `seen-cache` 防止消息在环路里重复扩散 + +### Peer 命名 + +为了让客户端界面更像普通聊天用户: + +- 服务端内部仍用 `peer:` 前缀区分 peer 会话 +- 发给客户端前会去掉这个内部前缀 +- 如果显式设置了 `PEER_NODE_NAME=peer-node-b`,客户端看到的是 `peer-node-b` +- 如果没有显式设置 `PEER_NODE_NAME`,默认自动生成 `guest-xxxxxx` ## 环境变量 +### 基础运行 + - `LISTEN_PORT`:监听端口,默认 `13173` - `REQUIRE_WSS`:是否启用 WSS,默认 `false` -- `TLS_CERT_PATH`:证书路径(启用 WSS 时必填) -- `TLS_CERT_PASSWORD`:证书密码(可空) -- `SERVER_PRIVATE_KEY_B64`:服务端私钥(PKCS8 base64) -- `SERVER_PRIVATE_KEY_PATH`:服务端私钥文件路径(与上面二选一) -- `ALLOW_EPHEMERAL_SERVER_KEY`:允许使用临时内存私钥,默认 `false` +- `TLS_CERT_PATH`:PFX 证书路径,启用 WSS 时必填 +- `TLS_CERT_PASSWORD`:PFX 证书密码,可空 + +### 协议私钥 + +- `SERVER_PRIVATE_KEY_B64`:协议私钥(PKCS8 base64) +- `SERVER_PRIVATE_KEY_PATH`:协议私钥文件路径 +- `ALLOW_EPHEMERAL_SERVER_KEY`:若未提供私钥,是否允许启动临时内存私钥,默认 `false` + +### 安全限制 + - `MAX_CONNECTIONS`:最大连接数,默认 `1000` - `MAX_MESSAGE_BYTES`:单消息最大字节数,默认 `65536` - `RATE_LIMIT_COUNT`:限流窗口允许消息数,默认 `30` - `RATE_LIMIT_WINDOW_SECONDS`:限流窗口秒数,默认 `10` - `IP_BLOCK_SECONDS`:触发滥用后的封禁秒数,默认 `120` -- `CHALLENGE_TTL_SECONDS`:挑战值有效期秒数,默认 `120` +- `CHALLENGE_TTL_SECONDS`:challenge 有效期秒数,默认 `120` - `MAX_CLOCK_SKEW_SECONDS`:允许时钟偏差秒数,默认 `60` - `REPLAY_WINDOW_SECONDS`:防重放窗口秒数,默认 `120` +- `SEEN_CACHE_SECONDS`:短期去重缓存秒数,默认 `120` + +### Peer + +- `PEER_NODE_NAME`:peer 登录名;未显式配置时自动生成 `guest-xxxxxx` +- `PEER_USER_PREFIX`:内部保留前缀,默认 `peer:` +- `PEER_URLS`:要主动连接的 peer 地址,逗号分隔 +- `PEER_RECONNECT_SECONDS`:peer 断线后的重连间隔,默认 `5` + +## 本地调试建议 + +### Android 连 `ws://` + +Android 9 之后默认禁止明文流量。若用 `ws://` 调试,需要客户端显式允许 cleartext。 + +### Android 连 `wss://` + +若服务端使用自签名证书,需要满足其一: + +- 设备/模拟器信任这张 CA +- Android debug 包内置该 CA 的信任配置 + +### 多实例本地测试 + +同一台机器上起多个节点时,建议: + +- 为每个节点分配不同 `LISTEN_PORT` +- 对外映射端口和 `LISTEN_PORT` 保持一致 +- 第一个节点使用固定协议私钥 +- 第二个测试节点可使用 `ALLOW_EPHEMERAL_SERVER_KEY=true` + +## 排错 + +### `expected HTTP 101 but was 400` + +常见原因: + +- 容器内 `LISTEN_PORT` 与客户端访问端口不一致 +- 客户端实际访问了错误的 `Host: port` + +### Android 显示“未收到服务器首包” + +当前服务端已禁用 WebSocket 压缩扩展协商,以避免某些 Android/OkHttp 路径拿不到压缩后的首个 `publickey` Hello。 + +### Peer 连不上 WSS + +当前 peer 出站连接使用 .NET `ClientWebSocket`,可以直连 `wss://` peer。若是自签名测试环境,请确认目标地址可达,并尽量使用稳定的局域网地址或 `host.docker.internal`。 -## 客户端文档 +## 相关文档 -- Web 客户端说明:`web-client/README.md` -- Android 客户端说明:`android-client/README.md` +- Web 客户端:[web-client/README.md](/Users/solux/Codes/OnlineMsgServer/web-client/README.md) +- Android 客户端:[android-client/README.md](/Users/solux/Codes/OnlineMsgServer/android-client/README.md)