c#構建具有使用者認證與管理的socks5代理服務端

BruceNeter發表於2023-05-17

Socks 協議是一種代理 (Proxy) 協議, 例如我們所熟知的 Shdowsocks 便是 Socks 協議的一個典型應用程式, Socks 協議有多個版本, 目前最新的版本為 5, 其協議標準文件為 RFC 1928。
我們一起來使用.net 7 構建一個支援使用者管理的高效能socks5代理服務端

協議流程

1 client -> server 客戶端與服務端握手
VERSION METHODS_COUNT METHODS
1位元組 1位元組 1到255位元組,長度zMETHODS_COUNT
0x05 0x03 0x00 0x01
0x02
  1. VERSION SOCKS協議版本,目前固定0x05
  2. METHODS_COUNT 客戶端支援的認證方法數量
  3. METHODS 客戶端支援的認證方法,每個方法佔用1個位元組

METHODS列表(其他的認證方法可以自行上網瞭解)

  1. 0x00 不需要認證(常用)
  2. 0x02 賬號密碼認證(常用)
2.1 server -> client 無需認證,直接進入第3步,命令過程
VERSION METHOD
1位元組 1位元組
0x05 0x00
2.2、server -> client 密碼認證
VERSION METHOD
1位元組 1位元組
0x05 0x02
2.2.1、client -> server 客戶端傳送賬號密碼
VERSION USERNAME_LENGTH USERNAME PASSWORD_LENGTH PASSWORD
1位元組 1位元組 1到255位元組 1位元組 1到255位元組
0x01 0x01 0x0a 0x01 0x0a
  1. VERSION 認證子協商版本(與SOCKS協議版本的0x05無關係)
  2. USERNAME_LENGTH 使用者名稱長度
  3. USERNAME 使用者名稱位元組陣列,長度為USERNAME_LENGTH
  4. PASSWORD_LENGTH 密碼長度
  5. PASSWORD 密碼位元組陣列,長度為PASSWORD_LENGTH
2.2.2、server -> client 返回認證結果
VERSION STATUS
1位元組 1位元組
0x01 0x00
  1. VERSION 認證子協商版本
  2. STATUS 認證結果,0x00認證成功,大於0x00認證失敗
3.1 client -> server 傳送連線請求
VERSION COMMAND RSV ADDRESS_TYPE DST.ADDR DST.PORT
1位元組 1位元組 1位元組 1位元組 1-255位元組 2位元組
  1. VERSION SOCKS協議版本,固定0x05
  2. COMMAND 命令
    1. 0x01 CONNECT 連線上游伺服器
    2. 0x02 BIND 繫結,客戶端會接收來自代理伺服器的連結,著名的FTP被動模式
    3. 0x03 UDP ASSOCIATE UDP中繼
  3. RSV 保留欄位
  4. ADDRESS_TYPE 目標伺服器地址型別
    1. 0x01 IP V4地址
    2. 0x03 域名地址(沒有打錯,就是沒有0x02),域名地址的第1個位元組為域名長度,剩下位元組為域名名稱位元組陣列
    3. 0x04 IP V6地址
  5. DST.ADDR 目標伺服器地址(如果COMMAND是0x03,即UDP模式,此處為客戶端啟動UDP傳送訊息的主機地址)
  6. DST.PORT 目標伺服器埠(如果COMMAND是0x03,即UDP模式,此處為客戶端啟動UDP傳送訊息的埠)
3.2 server -> client 服務端響應連線結果
VERSION RESPONSE RSV ADDRESS_TYPE DST.ADDR DST.PORT
1位元組 1位元組 1位元組 1位元組 1-255位元組 2位元組
  1. VERSION SOCKS協議版本,固定0x05
  2. RESPONSE 響應命令,除0x00外,其它響應都應該直接斷開連線
    1. 0x00 代理伺服器連線目標伺服器成功
    2. 0x01 代理伺服器故障
    3. 0x02 代理伺服器規則集不允許連線
    4. 0x03 網路無法訪問
    5. 0x04 目標伺服器無法訪問(主機名無效)
    6. 0x05 連線目標伺服器被拒絕
    7. 0x06 TTL已過期
    8. 0x07 不支援的命令
    9. 0x08 不支援的目標伺服器地址型別
    10. 0x09 - 0xFF 未分配
  3. RSV 保留欄位
  4. BND.ADDR 代理伺服器連線目標伺服器成功後的代理伺服器IP
  5. BND.PORT 代理伺服器連線目標伺服器成功後的代理伺服器埠
4、資料轉發

第3步成功後,進入資料轉發階段

  1. CONNECT 則將client過來的資料原樣轉發到目標,接著再將目標回來的資料原樣返回給client
  2. BIND
  3. UDP ASSOCIATE
udp轉發的資料包
  1. 收到客戶端udp資料包後,解析出目標地址,資料,然後把資料傳送過去
  2. 收到服務端回來的udp資料後,根據相同格式,打包,然後發回客戶端
RSV FRAG ADDRESS_TYPE DST.ADDR DST.PORT DATA
2位元組 1位元組 1位元組 可變長 2位元組 可變長
  1. RSV 保留為
  2. FRAG 分片位
  3. ATYP 地址型別
    1. 0x01 IP V4地址
    2. 0x03 域名地址(沒有打錯,就是沒有0x02),域名地址的第1個位元組為域名長度,剩下位元組為域名名稱位元組陣列
    3. 0x04 IP V6地址
  4. DST.ADDR 目標地址
  5. DST.PORT 目標埠
  6. DATA 資料

狀態機控制每個連線狀態

從協議中我們可以看出,一個Socks5協議的連線需要經過握手,認證(可選),建立連線三個流程。那麼這是典型的符合狀態機模型的業務流程。

建立狀態和事件列舉

public enum ClientState
    {
        Normal,
        ToBeCertified,
        Certified,
        Connected,
        Death
    }

    public enum ClientStateEvents
    {
        OnRevAuthenticationNegotiation, //當收到客戶端認證協商
        OnRevClientProfile, //收到客戶端的認證資訊
        OnRevRequestProxy, //收到客戶端的命令請求請求代理
        OnException,
        OnDeath
    }

根據伺服器是否配置需要使用者名稱密碼登入,從而建立正確的狀態流程。

if (clientStatehandler.NeedAuth)
            {
                builder.In(ClientState.Normal)
                    .On(ClientStateEvents.OnRevAuthenticationNegotiation)
                    .Goto(ClientState.ToBeCertified)
                    .Execute<UserToken>(clientStatehandler.HandleAuthenticationNegotiationRequestAsync)
                    .On(ClientStateEvents.OnException)
                    .Goto(ClientState.Death);
            }
            else 
            {
                builder.In(ClientState.Normal)
                        .On(ClientStateEvents.OnRevAuthenticationNegotiation)
                        .Goto(ClientState.Certified)
                        .Execute<UserToken>(clientStatehandler.HandleAuthenticationNegotiationRequestAsync)
                        .On(ClientStateEvents.OnException)
                        .Goto(ClientState.Death);
            }

            builder.In(ClientState.ToBeCertified)
                .On(ClientStateEvents.OnRevClientProfile)
                .Goto(ClientState.Certified)
                .Execute<UserToken>(clientStatehandler.HandleClientProfileAsync)
                .On(ClientStateEvents.OnException)
                .Goto(ClientState.Death); ;

            builder.In(ClientState.Certified)
                .On(ClientStateEvents.OnRevRequestProxy)
                .Goto(ClientState.Connected)
                .Execute<UserToken>(clientStatehandler.HandleRequestProxyAsync)
                .On(ClientStateEvents.OnException)
                .Goto(ClientState.Death);

            builder.In(ClientState.Connected).On(ClientStateEvents.OnException).Goto(ClientState.Death);

在狀態扭轉中如果出現異常,則直接跳轉狀態到“Death”,

_machine.TransitionExceptionThrown += async (obj, e) =>
            {
                _logger.LogError(e.Exception.ToString());
                await _machine.Fire(ClientStateEvents.OnException);
            };

對應狀態扭轉建立相應的處理方法, 基本都是解析客戶端發來的資料包,判斷是否合理,最後返回一個響應。

/// <summary>
        /// 處理認證協商
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        /// <exception cref="ArgumentException"></exception>
        /// <exception cref="InvalidOperationException"></exception>
        public async Task HandleAuthenticationNegotiationRequestAsync(UserToken token)
        {
            if (token.ClientData.Length < 3)
            {
                await token.ClientSocket.SendAsync(new byte[] { 0x05, _exceptionCode });
                throw new ArgumentException("Error request format from client.");
            }
            if (token.ClientData.Span[0] != 0x05) //socks5預設頭為5
            {
                await token.ClientSocket.SendAsync(new byte[] { 0x05, _exceptionCode });
                throw new ArgumentException("Error request format from client.");
            }
            int methodCount = token.ClientData.Span[1];
            if (token.ClientData.Length < 2 + methodCount) //校驗報文
            {
                await token.ClientSocket.SendAsync(new byte[] { 0x05, _exceptionCode });
                throw new ArgumentException("Error request format from client.");
            }
            bool supprtAuth = false;
            for (int i = 0; i < methodCount; i++)
            {
                if (token.ClientData.Span[2 + i] == 0x02)
                {
                    supprtAuth = true;
                    break;
                }
            }

            if (_serverConfiguration.NeedAuth && !supprtAuth) //是否支援賬號密碼認證
            {
                await token.ClientSocket.SendAsync(new byte[] { 0x05, _exceptionCode });
                throw new InvalidOperationException("Can't support password authentication!");
            }

            await token.ClientSocket.SendAsync(new byte[] { 0x05, (byte)(_serverConfiguration.NeedAuth ? 0x02 : 0x00) });
        }

        /// <summary>
        /// 接收到客戶端認證
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        public async Task HandleClientProfileAsync(UserToken token)
        {
            var version = token.ClientData.Span[0];
            //if (version != _serverConfiguration.AuthVersion)
            //{
            //    await token.ClientSocket.SendAsync(new byte[] { 0x05, _exceptionCode });
            //    throw new ArgumentException("The certification version is inconsistent");
            //}

            var userNameLength = token.ClientData.Span[1];
            var passwordLength = token.ClientData.Span[2 + userNameLength];
            if (token.ClientData.Length < 3 + userNameLength + passwordLength)
            {
                await token.ClientSocket.SendAsync(new byte[] { 0x05, _exceptionCode });
                throw new ArgumentException("Error authentication format from client.");
            }

            var userName = Encoding.UTF8.GetString(token.ClientData.Span.Slice(2, userNameLength));
            var password = Encoding.UTF8.GetString(token.ClientData.Span.Slice(3 + userNameLength, passwordLength));
            var user = await _userService.FindSingleUserByUserNameAndPasswordAsync(userName, password);
            if (user == null || user.ExpireTime < DateTime.Now) 
            {
                await token.ClientSocket.SendAsync(new byte[] { version, _exceptionCode });
                throw new ArgumentException($"User{userName}嘗試非法登入");
            }

            token.UserName = user.UserName;
            token.Password = user.Password;
            token.ExpireTime = user.ExpireTime;
            await token.ClientSocket.SendAsync(new byte[] { version, 0x00 });
        }

        /// <summary>
        /// 客戶端請求連線
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        public async Task HandleRequestProxyAsync(UserToken token)
        {
            var data = token.ClientData.Slice(3);
            Socks5CommandType socks5CommandType = (Socks5CommandType)token.ClientData.Span[1];
            var proxyInfo = _byteUtil.GetProxyInfo(data);
            var serverPort = BitConverter.GetBytes(_serverConfiguration.Port);
            if (socks5CommandType == Socks5CommandType.Connect) //tcp
            {
                //返回連線成功
                IPEndPoint targetEP = new IPEndPoint(proxyInfo.Item2, proxyInfo.Item3);//目標伺服器的終結點
                token.ServerSocket = new Socket(targetEP.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
                token.ServerSocket.Bind(new IPEndPoint(IPAddress.Any, 0));
                var e = new SocketAsyncEventArgs
                {
                    RemoteEndPoint = new IPEndPoint(targetEP.Address, targetEP.Port)
                };
                token.ServerSocket.ConnectAsync(e);
                e.Completed += async (e, a) =>
                {
                    try
                    {
                        token.ServerBuffer = new byte[800 * 1024];//800kb
                        token.StartTcpProxy();
                        var datas = new List<byte> { 0x05, 0x0, 0, (byte)Socks5AddressType.IPV4 };
                        foreach (var add in (token.ServerSocket.LocalEndPoint as IPEndPoint).Address.GetAddressBytes())
                        {
                            datas.Add(add);
                        }
                        //代理端啟動的埠資訊回覆給客戶端
                        datas.AddRange(BitConverter.GetBytes((token.ServerSocket.LocalEndPoint as IPEndPoint).Port).Take(2).Reverse());

                        await token.ClientSocket.SendAsync(datas.ToArray());
                    }
                    catch (Exception) 
                    {
                        token.Dispose();
                    }
                };
            }
            else if (socks5CommandType == Socks5CommandType.Udp)//udp
            {
                token.ClientUdpEndPoint = new IPEndPoint(proxyInfo.Item2, proxyInfo.Item3);//客戶端發起代理的udp終結點
                token.IsSupportUdp = true;
                token.ServerSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
                token.ServerSocket.Bind(new IPEndPoint(IPAddress.Any, 0));
                token.ServerBuffer = new byte[800 * 1024];//800kb
                token.StartUdpProxy(_byteUtil);
                var addressBytes = (token.ServerSocket.LocalEndPoint as IPEndPoint).Address.GetAddressBytes();
                var portBytes = BitConverter.GetBytes((token.ServerSocket.LocalEndPoint as IPEndPoint).Port).Take(2).Reverse().ToArray();
                await token.ClientSocket.SendAsync(new byte[] { 0x05, 0x0, 0, (byte)Socks5AddressType.IPV4, addressBytes[0], addressBytes[1], addressBytes[2], addressBytes[3], portBytes[0], portBytes[1] });
            }
            else
            {
                await token.ClientSocket.SendAsync(new byte[] { 0x05, 0x1, 0, (byte)Socks5AddressType.IPV4, 0, 0, 0, 0, 0, 0 });
                throw new Exception("Unsupport proxy type.");
            }
        }

連線與使用者管理

當伺服器採用需要認證的配置時,我們會返回給客戶端0x02的認證方式,此時,客戶端需要上傳使用者名稱和密碼,如果認證成功我們就可以將使用者資訊與連線物件做繫結,方便後續管理。

在客戶端透過tcp或者udp上傳資料包,需要代理伺服器轉發時,我們記錄資料包的大小作為上傳資料包流量記錄下來,反之亦然。
示例:記錄tcp代理客戶端的下載流量

public void StartTcpProxy()
        {
            Task.Run(async () =>
            {
                while (true)
                {
                    var data = await ServerSocket.ReceiveAsync(ServerBuffer);
                    if (data == 0)
                    {
                        Dispose();
                    }

                    await ClientSocket.SendAsync(ServerBuffer.AsMemory(0, data));
                    if (!string.IsNullOrEmpty(UserName))
                        ExcuteAfterDownloadBytes?.Invoke(UserName, data);
                }
            }, CancellationTokenSource.Token);
        }

當管理介面修改某使用者的密碼或者過期時間的時候
1.修改密碼,強制目前所有使用該使用者名稱密碼的連線斷開
2.我們每個連線會有一個定時服務,判斷是否過期
從而實現使用者下線。

//更新密碼或者過期時間後
public void UpdateUserPasswordAndExpireTime(string password, DateTime dateTime)
        {
            if (password != Password)
            {
                Dispose();
            }

            if (DateTime.Now > ExpireTime)
            {
                Dispose();
            }
        }

/// <summary>
        /// 過期自動下線
        /// </summary>
        public void WhenExpireAutoOffline()
        {
            Task.Run(async () =>
            {
                while (true)
                {
                    if (DateTime.Now > ExpireTime)
                    {
                        Dispose();
                    }

                    await Task.Delay(1000);
                }
            }, CancellationTokenSource.Token);
        }

持久化

使用者資料包括,使用者名稱密碼,使用流量,過期時間等儲存在server端的sqlite資料庫中。透過EFcore來增刪改查。
如下定期更新使用者流量到資料庫

private void LoopUpdateUserFlowrate()
        {
            Task.Run(async () =>
            {
                while (true)
                {

                    var datas = _uploadBytes.Select(x =>
                    {
                        return new
                        {
                            UserName = x.Key,
                            AddUploadBytes = x.Value,
                            AddDownloadBytes = _downloadBytes.ContainsKey(x.Key) ? _downloadBytes[x.Key] : 0
                        };
                    });

                    if (datas.Count() <= 0
                        || (datas.All(x => x.AddUploadBytes == 0)
                        && datas.All(x => x.AddDownloadBytes == 0)))
                    {
                        await Task.Delay(5000);
                        continue;
                    }
                    var users = await _userService.Value.GetUsersInNamesAsync(datas.Select(x => x.UserName));

                    foreach (var item in datas)
                    {
                        users.FirstOrDefault(x => x.UserName == item.UserName).UploadBytes += item.AddUploadBytes;
                        users.FirstOrDefault(x => x.UserName == item.UserName).DownloadBytes += item.AddDownloadBytes;
                    }

                    await _userService.Value.BatchUpdateUserAsync(users);
                    _uploadBytes.Clear();
                    _downloadBytes.Clear();
                    await Task.Delay(5000);
                }
            });
        }

//批次更新使用者資訊到sqlite
        public async Task BatchUpdateUserFlowrateAsync(IEnumerable<User> users)
        {
            using (var context = _dbContextFactory.CreateDbContext())
            {
                context.Users.UpdateRange(users);
                await context.SaveChangesAsync();
            }
        }

效果示例

開啟服務
image

開啟Proxifier配置到我們的服務
image

檢視Proxifier已經流量走到我們的服務
image

服務端管理器
image

原始碼以及如何使用

https://github.com/BruceQiu1996/Socks5Server

相關文章