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 |
- VERSION SOCKS協議版本,目前固定0x05
- METHODS_COUNT 客戶端支援的認證方法數量
- METHODS 客戶端支援的認證方法,每個方法佔用1個位元組
METHODS列表(其他的認證方法可以自行上網瞭解)
- 0x00 不需要認證(常用)
- 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 |
- VERSION 認證子協商版本(與SOCKS協議版本的0x05無關係)
- USERNAME_LENGTH 使用者名稱長度
- USERNAME 使用者名稱位元組陣列,長度為USERNAME_LENGTH
- PASSWORD_LENGTH 密碼長度
- PASSWORD 密碼位元組陣列,長度為PASSWORD_LENGTH
2.2.2、server -> client 返回認證結果
VERSION | STATUS |
---|---|
1位元組 | 1位元組 |
0x01 | 0x00 |
- VERSION 認證子協商版本
- STATUS 認證結果,0x00認證成功,大於0x00認證失敗
3.1 client -> server 傳送連線請求
VERSION | COMMAND | RSV | ADDRESS_TYPE | DST.ADDR | DST.PORT |
---|---|---|---|---|---|
1位元組 | 1位元組 | 1位元組 | 1位元組 | 1-255位元組 | 2位元組 |
- VERSION SOCKS協議版本,固定0x05
- COMMAND 命令
- 0x01 CONNECT 連線上游伺服器
- 0x02 BIND 繫結,客戶端會接收來自代理伺服器的連結,著名的FTP被動模式
- 0x03 UDP ASSOCIATE UDP中繼
- RSV 保留欄位
- ADDRESS_TYPE 目標伺服器地址型別
- 0x01 IP V4地址
- 0x03 域名地址(沒有打錯,就是沒有0x02),域名地址的第1個位元組為域名長度,剩下位元組為域名名稱位元組陣列
- 0x04 IP V6地址
- DST.ADDR 目標伺服器地址(如果COMMAND是0x03,即UDP模式,此處為客戶端啟動UDP傳送訊息的主機地址)
- 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位元組 |
- VERSION SOCKS協議版本,固定0x05
- RESPONSE 響應命令,除0x00外,其它響應都應該直接斷開連線
- 0x00 代理伺服器連線目標伺服器成功
- 0x01 代理伺服器故障
- 0x02 代理伺服器規則集不允許連線
- 0x03 網路無法訪問
- 0x04 目標伺服器無法訪問(主機名無效)
- 0x05 連線目標伺服器被拒絕
- 0x06 TTL已過期
- 0x07 不支援的命令
- 0x08 不支援的目標伺服器地址型別
- 0x09 - 0xFF 未分配
- RSV 保留欄位
- BND.ADDR 代理伺服器連線目標伺服器成功後的代理伺服器IP
- BND.PORT 代理伺服器連線目標伺服器成功後的代理伺服器埠
4、資料轉發
第3步成功後,進入資料轉發階段
- CONNECT 則將client過來的資料原樣轉發到目標,接著再將目標回來的資料原樣返回給client
- BIND
- UDP ASSOCIATE
udp轉發的資料包
- 收到客戶端udp資料包後,解析出目標地址,資料,然後把資料傳送過去
- 收到服務端回來的udp資料後,根據相同格式,打包,然後發回客戶端
RSV | FRAG | ADDRESS_TYPE | DST.ADDR | DST.PORT | DATA |
---|---|---|---|---|---|
2位元組 | 1位元組 | 1位元組 | 可變長 | 2位元組 | 可變長 |
- RSV 保留為
- FRAG 分片位
- ATYP 地址型別
- 0x01 IP V4地址
- 0x03 域名地址(沒有打錯,就是沒有0x02),域名地址的第1個位元組為域名長度,剩下位元組為域名名稱位元組陣列
- 0x04 IP V6地址
- DST.ADDR 目標地址
- DST.PORT 目標埠
- 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();
}
}
效果示例
開啟服務
開啟Proxifier配置到我們的服務
檢視Proxifier已經流量走到我們的服務
服務端管理器