Sync main with upstream ai-dev

pull/14/head
alimu 2 weeks ago
parent 67f14790f4
commit 16bcf34e4a

@ -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)
{

@ -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<User> 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)
{

@ -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<PeerRelayEnvelope>(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;
}
}
}
}

@ -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()

@ -12,6 +12,11 @@ namespace OnlineMsgServer.Common
/// </summary>
public string? Name { get; set; }
/// <summary>
/// 是否为服务器节点伪装的 peer 用户
/// </summary>
public bool IsPeerNode { get; set; }
/// <summary>
/// 用户公钥 用于消息加密发送给用户

@ -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<string, PeerOutboundClient> _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<PeerOutboundClient> 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<User> 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<string> 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<string> 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<PeerOutboundClient> 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);
}
}
}
}
}
}

@ -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)

@ -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)}";
}
}
}

@ -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<string, DateTime> _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<string> expiredKeys = [];
foreach (KeyValuePair<string, DateTime> 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));
}
}
}

@ -41,11 +41,14 @@ namespace OnlineMsgServer.Core
/// <summary>
/// 通过publickey返回用户列表
/// </summary>
public static List<User> GetUserListByPublicKey(string publicKey)
public static List<User> 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
/// <summary>
/// 通过wsid设置用户PublicKey
/// </summary>
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<User> 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)

@ -12,6 +12,13 @@ namespace OnlineMsgServer.Core
private static readonly object _abuseLock = new();
private static readonly Dictionary<string, DateTime> _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;

@ -48,6 +48,8 @@ namespace OnlineMsgServer
//开启ws监听
wssv.AddWebSocketService<WsService>("/");
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();
}

@ -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/`AndroidKotlin + 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 <repo-root>
```
### 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-2048SPKI / 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)

Loading…
Cancel
Save