講到網頁即時通訊,解決方案很多,從 setInterval()、Long Polling、Server-Sent EventsWebSockets,只需幾行程式就能做個雛形。

但回歸到實務應用,若需求複雜一點,想深一點,一般得考慮程式效率與即時性、連線斷線自動重連機制、群組廣播需求、WebSockets 不通時自動換用 SSE/Long Polling,若全部自己刻,測到穩定無錯命都去掉半條了。

因此,要用 .NET 搞定複雜即時通訊,微軟官方的網頁即時通訊開源框架 - SignalR,便是首選中的首選。離我上次玩 SignalR超過十年,對象是網站整合應用。如今,.NET 網站主流已換成 ASP.NET Core,.NET 版本來到 10.0,是時侯重新會一會老朋友,順便試試不同玩法 - 與 CLI 程式整合。

起了個 Side Project,練習用最新版 Microsoft.AspNetCore.SignalR.Core 1.2.0 (2025/1)、Microsoft.AspNetCore.SignalR.Client 10.0.0 (2025/11) 讓兩個不相通網段的 CLI 程式建立安全 P2P 通訊。專案構想如下:

  • 範例的兩位主角為 Console Application,跟過往網頁應用以網站為中心不同,更偏向用 SignalR 實現 P2P 即時通訊。實際寫完,發現 Microsoft.AspNetCore.SignalR.Client 程式庫把底層運作包得很好,上手容易,寫起來毫不費力。
  • 兩支 Console 程式位於被防火牆保護,無法直接存取對方 IP 的兩個網段,二者透過主動連上 SignalR Hug,建立一條可雙向即時通訊的管道。
  • 「安全性」是本次練習的一大重點! 一方面要防止資料被竊聽,也要防止有人假冒傳輸對象。最正統解法走 X.509 憑證身分驗證 / TLS / SSH... 先不要搞到太複雜,就先採「雙方事先約定金鑰,所有訊息 AES256 E2E 加密」意思一下,除非偷到金鑰,基本上第三者無法竊聽及冒充身分。
  • SignalR Hub 部分,則實作簡單 IP 白名單管控及 Access Key Header 檢查,進一步強化安全性。

透過這個小練習,為日後實際用 SignalR 戰鬥預做準備。

這個 PoC 專案分為三部分,完整原始碼已放上 Github,這裡只簡要說明:

SignalR-P2P-Hub

ASP.NET Core Web Razor 專案,安裝 Microsoft.AspNetCore.SignalR.Core 套件,依官方教學 Program.cs 加上 builder.Services.AddSignalR(); 及 app.MapHub("/chatHub"); 再實作 ChatHub,最基本的 SignalR Hub 便架好了。

為了增加安全性,一方面也為實戰預做準備,順手加上兩層安全管控:IP 白名單及 Access Key 檢查。

IP 白名單部分是在 appsettings.json 列舉可連線的 IP 清單:(若不要限制來源 IP,可加入 *)

  "AllowedClientIPs": [
    "127.0.0.1",
    "::1",
    "192.168.1.100"
  ]  

Program.cs 則要插入檢查 IP 的中介層:

var app = builder.Build();

// 以 appsettings.json AllowedClientIPs 限定客戶端 IP
app.UseMiddleware<IpWhitelistMiddleware>();

// ... 略 ...

IpWhitelistMiddleware 寫法如下:

public class IpWhitelistMiddleware
{
    private readonly RequestDelegate _next;
    private readonly string[] _allowedIPs;

    public IpWhitelistMiddleware(RequestDelegate next, IConfiguration configuration)
    {
        _next = next;
        _allowedIPs = configuration.GetSection("AllowedClientIPs").Get<string[]>() ?? Array.Empty<string>();
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (!_allowedIPs.Contains("*")) // 若 AllowedClientIPs 有 *,則不限來源 IP
        {
            var remoteIp = context.Connection.RemoteIpAddress;
    
            if (remoteIp != null && !_allowedIPs.Contains(remoteIp.ToString()))
            {
                context.Response.StatusCode = StatusCodes.Status403Forbidden;
                await context.Response.WriteAsync("Forbidden: IP not allowed");
                return;
            }
        }
        await _next(context);
    }
}

至於 Access Key,我採取的做法是要求連線時加入 X-Access-Key Header。ChatHub 則在 OnConnectedAsync() 時檢查,若未附上 Header 或與 appsettings.json 設定不同則拒絕連線。來源 IP 的檢查也可以寫在這裡,甚至可以指派不同 Access Key 給不同 IP 進一步提高安全性。上面選擇用 Middleware 實作的一項重要理由是我想多練習一種寫法。:D

using Microsoft.AspNetCore.SignalR;

namespace SignalR_P2P
{
    public class ChatHub : Hub
    {
        private readonly IConfiguration _configuration;
        public ChatHub(IConfiguration configuration)
        {
            _configuration = configuration;
        }
        // 連線時驗證 X-Access-Key 標頭需與 appsettings.json AccessKey 一致
        // TODO: 可設計管理機制,配發不同 AccessKey 給不同 IP 進一步提高安全性
        public override async Task OnConnectedAsync()
        {
            var accessKey = Context.GetHttpContext()?.Request.Headers["X-Access-Key"].ToString();
            var validAccessKey = _configuration["AccessKey"];

            if (string.IsNullOrEmpty(accessKey) || accessKey != validAccessKey)
            {
                // 比對失敗,中斷連線
                Context.Abort();
                return;
            }

            await base.OnConnectedAsync();
        }

        public async Task JoinGroup(string groupName)
        {
            // 將 ConnectionId 加入指定群組,而 SignalR 會在斷線時將連線退出群組
            await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        }
        
        public async Task SendGroupMessage(string groupName, string message)
        {
            await Clients.OthersInGroup(groupName).SendAsync("ReceiveMessage", message);
        }
    }
}

若排除 Access Key 檢查,ChatHub 本體超級簡單,只有 JoinGroup 與 SendGroupMessage 兩個方法,在 SendGroupMessage() 中,則是利用 Clients.OthersInGroup(groupName).SendAsync(..) 傳送訊息給自己以外的群組成員。

SignalR-P2P-Control

這個展示範例是讓 SignalR-P2P-Control 連上 SignalR Hub 加入指定名稱的群組,與也連上 SignalR Hub 加入同一群組的 SignalR-P2P-Agent 建立即時通訊管道。Control 端從 Console.ReadLine() 讀取指令,共有 /helo、/os、/proc 三種指令,分別用來測試 Agent 回應、取得 Agent OS 版本、查詢 Agent 上吃 RAM 前五名的程序資訊,沒啥營養,但用來驗證機制可行性很夠了。

前面提到要實作 E2E 加密,加解密部分用 System.Security.Cryptography.Aes 實作即可,由於不是本次重點,我把它包成獨立 Class Library。Control 端的 Program.cs 長這樣:

using Microsoft.AspNetCore.SignalR.Client;

class Program
{
    static readonly HttpClient httpClient = new();

    static async Task Main(string[] args)
    {
        var hubUrl = args.Length > 0 ? args[0] : "http://localhost:5566/chathub";
        var accessKey = Environment.GetEnvironmentVariable("SGRP2P_ACCESS_KEY") ?? 
            throw new Exception("SGRP2P_ACCESS_KEY not set.");
        var connection = new HubConnectionBuilder()
            .WithUrl(hubUrl, options =>
            {
                options.Headers.Add("X-Access-Key", accessKey); # 加上 X-Access-Key Header
            })
            .WithAutomaticReconnect()
            .Build();

        var sendMessage = async (string message) =>
            await connection.InvokeAsync("SendGroupMessage", CryptoLib.Cipher.GroupId, CryptoLib.Cipher.Encrypt(message));

        connection.On<string>("ReceiveMessage", async (message) =>
        {
            if (string.IsNullOrWhiteSpace(message)) return;
            try
            {
                message = CryptoLib.Cipher.Decrypt(message); // 收到訊息時先解密
                Console.ForegroundColor = ConsoleColor.Yellow;
                Console.WriteLine(message);
            }
            catch
            {
                Console.WriteLine("Failed to decrypt message.");
                return;
            }
            Console.ResetColor();
        });

        try
        {
            await connection.StartAsync();
            await connection.InvokeAsync("JoinGroup", CryptoLib.Cipher.GroupId);
            await sendMessage("/helo");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error connecting to SignalR hub: {ex.Message}");
        }

        while (true)
        {
            var message = Console.ReadLine();
            if (string.IsNullOrEmpty(message)) continue;
            if (message == "/exit") break;
            await sendMessage(message);
        }
        await connection.StopAsync();
    }
}

SignalR-P2P-Agent

Agent 端需實作收到 /helo、/os、/proc 指令時的處理,程式碼稍多,但倒也沒什麼深奧之處。

using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using System.Diagnostics;

class Program
{
    static readonly HttpClient httpClient = new();
    static async Task Main(string[] args)
    {
        var hubUrl = args.Length > 0 ? args[0] : "http://localhost:5566/chathub";
        var accessKey = Environment.GetEnvironmentVariable("SGRP2P_ACCESS_KEY") ?? 
            throw new Exception("SGRP2P_ACCESS_KEY not set.");
        var connection = new HubConnectionBuilder()
            .WithUrl(hubUrl, options =>
            {
                options.Headers.Add("X-Access-Key", accessKey);
            })
            .WithAutomaticReconnect()
            .Build();

        var sendMessage = async (string message) =>
            await connection.InvokeAsync("SendGroupMessage", CryptoLib.Cipher.GroupId, CryptoLib.Cipher.Encrypt(message));

        // Register a handler for messages from the hub
        connection.On<string>("ReceiveMessage", async (message) =>
        {
            if (string.IsNullOrWhiteSpace(message)) return;
            try
            {
                message = CryptoLib.Cipher.Decrypt(message);
                Console.WriteLine("Received: " + message);
            }
            catch
            {
                Console.WriteLine("Failed to decrypt message.");
                return;
            }
            var m = Regex.Match(message, @"^/(?<cmd>[a-z]+)(?<args>.*)$");
            if (m.Success)
            {
                var cmd = m.Groups["cmd"].Value;
                switch (cmd)
                {
                    case "helo":
                        await sendMessage("Agent is online");
                        break;
                    case "os":
                        var osVer = System.Runtime.InteropServices.RuntimeInformation.OSDescription;
                        await sendMessage($"OS Version: {osVer}");
                        break;
                    case "proc":
                        var procList = ListProcesses(5);
                        await sendMessage($"Top Processes:\n{procList}");
                        break;
                    default:
                        await sendMessage("Unknown command.");
                        break;
                }
            }
            else
            {
                await sendMessage("Not a command.");
            }

        });

        try
        {
            await connection.StartAsync();
            await connection.InvokeAsync("JoinGroup", CryptoLib.Cipher.GroupId);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error connecting to SignalR hub: {ex.Message}");
        }

        while (true)
        {
            var message = Console.ReadLine();
            if (string.IsNullOrEmpty(message)) continue;
            if (message == "/exit") break;
        }
        await connection.StopAsync();
    }

    static string ListProcesses(int topN = 10)
    {
        // ... 使用 Process.GetProcesses() 查詢吃 RAM 大戶 ...
    }    

}

最後,分別把三者執行起來,從 Control 端下指令,順利收到 Agent 端回應,測試成功!

完整程式碼在 Github,需要的同學可自取。

Demonstrates using SignalR in .NET for secure, end-to-end encrypted P2P communication between CLI apps across networks, with IP and Access Key controls.


Comments

Be the first to post a comment

Post a comment