在严格的端口管控下,非HTTP
协议的端口可能无法对外提供服务,使用TCP over HTTP
方式进行协议代理,服务端通过HTTP
协议的类似 WebSocket
的Upgrade
协议升级机制或HTTP CONNECT
动词与目标服务器建立TCP
通信隧道,客户端连接本地的正向代理服务器(如ssh
的ProxyCommand
,curl
的--proxy
)提供的TCP
端口通过反向代理服务器连接到目标服务器TCP
端口。
原理图如下:
这里在NET8.0中实现反向代理服务器部分,新建MiniApi项目
编辑Program.cs
文件,删除无用代码
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();
// TODO 协议升级机制和CONNECT动词
app.Run();
添加HTTP
协议升级机制,因为采取了类似WebSocket
的机制,大部分浏览器、反向代理、安全策略支持
// 将HTTP请求通过协议升级机制转为远程TCP请求(WebSocket分支,Nginx支持,主流浏览器都支持)
app.Map("/http2tcp", async (context) =>
{
var connetion = context.Connection;
Console.WriteLine($"{DateTime.Now:HH:mm:ss} 通信请求,HTTP/{connetion.RemoteIpAddress}:{connetion.RemotePort}/{connetion.LocalIpAddress}:{connetion.LocalPort}");
var upgradeFeature = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpUpgradeFeature>();
if (upgradeFeature != null && upgradeFeature.IsUpgradableRequest)
{
context.Features.Get<Microsoft.AspNetCore.Http.Timeouts.IHttpRequestTimeoutFeature>()?.DisableTimeout();
using var tcpClient = new System.Net.Sockets.TcpClient();
await tcpClient.ConnectAsync(System.Net.IPEndPoint.Parse("127.0.0.1:4600"));
Console.WriteLine($"{DateTime.Now:HH:mm:ss} 通信建立,TCP/{tcpClient.Client.LocalEndPoint}/{tcpClient.Client.RemoteEndPoint}");
using var tcpSream = tcpClient.GetStream();
context.Response.Headers.Connection = Microsoft.Net.Http.Headers.HeaderNames.Upgrade;
context.Response.Headers.Upgrade = "http2tcp/1.0";
Stream httpStream = await upgradeFeature.UpgradeAsync();
var taskX = tcpSream.CopyToAsync(httpStream);
var tsakY = httpStream.CopyToAsync(tcpSream);
Console.WriteLine($"{DateTime.Now:HH:mm:ss} 通信成功,http://{connetion.LocalIpAddress}:{connetion.LocalPort}{context.Request.Path} - tcp/{tcpClient.Client.RemoteEndPoint}");
Task.WaitAny(taskX, tsakY);
Console.WriteLine($"{DateTime.Now:HH:mm:ss} 通信结束,{(tsakY.Status == TaskStatus.RanToCompletion ? "客户端关闭" : "服务端关闭")}");
}
});
添加CONNECT
动词,因为获取了请求头中的HOST
参数,有任意访问网络资源的风险,生产环境注意权限控制,并且部分反向代理服务器不支持CONNECT
动词,或者部分安全策略不开放非GET
POST
的HTTP
请求。
// 将HTTP请求通过CONNECT方法转为TCP请求,主流浏览器都支持
app.MapMethods("", new[] { HttpMethods.Connect }, async (context) =>
{
var auth = context.Request.Headers["Proxy-Authorization"];
await context.Response.Body.FlushAsync();
var socket = context.Features.Get<Microsoft.AspNetCore.Connections.Features.IConnectionSocketFeature>()!.Socket;
var stream = new System.Net.Sockets.NetworkStream(socket);
using var tcpClient = new System.Net.Sockets.TcpClient();
await tcpClient.ConnectAsync(System.Net.IPEndPoint.Parse(context.Request.Host.Value));
using var network = tcpClient.GetStream();
var taskX = network.CopyToAsync(stream);
var tsakY = stream.CopyToAsync(network);
Task.WaitAny(taskX, tsakY);
await socket.DisconnectAsync(true);
socket.Close();
});
正向代理服务器协议升级机制具体实现如下,根据TcpListener
监听的端口发起HTTP
请求,将获取到的TcpStream
和TcpListener
的NetworkStream
连接。
var localPort = 7000;
var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Any, localPort);
listener.Start();
var proxyUri = new Uri("http://localhost:5000/http2tcp");
Console.WriteLine($"{DateTime.Now:HH:mm:ss.FFFF} 监听TCP/{localPort},代理{proxyUri}");
while (true)
{
var tcpClient = listener.AcceptTcpClient();
Console.WriteLine($"{DateTime.Now:HH:mm:ss.FFFF} 通信新增,{tcpClient.Client.RemoteEndPoint}/{tcpClient.Client.LocalEndPoint}");
HandleClientAsync(tcpClient, proxyUri);
}
static async Task HandleClientAsync(System.Net.Sockets.TcpClient tcpClient, Uri proxyUri)
{
using var httpClient = new System.Net.Sockets.TcpClient(proxyUri.Host, proxyUri.Port);
try
{
var tcpStream = tcpClient.GetStream();
var httpStream = httpClient.GetStream();
if (proxyUri.Scheme == "https")
{
var sslStream = new System.Net.Security.SslStream(httpStream, false, (sender, certificate, chain, sslPolicyErrors) => true);
sslStream.AuthenticateAsClient(proxyUri.Host);
await HandleWorkAsync(sslStream, tcpStream, proxyUri);
CloseTcpClient(httpClient, tcpClient);
return;
}
await HandleWorkAsync(httpStream, tcpStream, proxyUri);
CloseTcpClient(httpClient, tcpClient);
}
catch (Exception ex)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.FFFF} 通信失败,{ex}");
CloseTcpClient(httpClient, tcpClient);
}
}
static async Task HandleWorkAsync(Stream httpStream, Stream tcpStream, Uri proxyUri)
{
await SendRequestAsync(httpStream, proxyUri);
if (!(await CheckReponseAsync(httpStream, proxyUri))) return;
var tsakA = httpStream.CopyToAsync(tcpStream);
var taskB = tcpStream.CopyToAsync(httpStream);
Task.WaitAny(tsakA, taskB);
Console.WriteLine($"{DateTime.Now:HH:mm:ss.FFFF} 通信结束,{(taskB.Status == TaskStatus.RanToCompletion ? "本地关闭" : "远程关闭")}");
}
static async Task SendRequestAsync(Stream stream, Uri proxyUri)
{
var requestBuilder = new System.Text.StringBuilder();
requestBuilder.AppendLine($"GET {proxyUri.PathAndQuery}{proxyUri.Fragment} HTTP/1.1");
requestBuilder.AppendLine($"Host: {proxyUri.Host}");
requestBuilder.AppendLine($"Connection: Upgrade");
requestBuilder.AppendLine($"Upgrade: http2tcp/1.0");
requestBuilder.AppendLine();
byte[] requestBytes = System.Text.Encoding.UTF8.GetBytes(requestBuilder.ToString());
await stream.WriteAsync(requestBytes, 0, requestBytes.Length);
}
static async Task<bool> CheckReponseAsync(Stream stream, Uri proxyUri)
{
byte[] responseBytes = new byte[4096];
int bytesRead = 0;
var responseBuilder = new System.Text.StringBuilder();
CancellationTokenSource timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
if ((bytesRead = await stream.ReadAsync(responseBytes, 0, responseBytes.Length, timeoutCts.Token)) > 0)
{
responseBuilder.Append(System.Text.Encoding.UTF8.GetString(responseBytes, 0, bytesRead));
}
string response = responseBuilder.ToString();
if (!response.Contains("HTTP/1.1 101 Switching Protocols"))
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.FFFF} 通信失败,通过{proxyUri}升级失败,错误的响应码");
return false;
}
bool upgradeSuccess = response.Contains("Upgrade: http2tcp/1.0") && response.Contains("Connection: Upgrade");
if (!upgradeSuccess)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.FFFF} 通信失败,通过{proxyUri}协议升级失败,错误的响应头");
return false;
}
Console.WriteLine($"{DateTime.Now:HH:mm:ss.FFFF} 通信开始,通过{proxyUri}协议升级成功");
return true;
}
static void CloseTcpClient(System.Net.Sockets.TcpClient httpClient, System.Net.Sockets.TcpClient tcpClient)
{
var tcpRemote = tcpClient.Client.RemoteEndPoint;
var tcpLocal = tcpClient.Client.LocalEndPoint;
var httpRemote = httpClient.Client.RemoteEndPoint;
var httpLocal = httpClient.Client.LocalEndPoint;
if (httpClient.Connected)
{
httpClient.Client.Shutdown(System.Net.Sockets.SocketShutdown.Both);
httpClient.Close();
}
if (tcpClient.Connected)
{
tcpClient.Client.Shutdown(System.Net.Sockets.SocketShutdown.Both);
tcpClient.Close();
}
Console.WriteLine($"{DateTime.Now:HH:mm:ss.FFFF} 通信结束,TCP/{tcpRemote}/{tcpLocal}和HTTP/{httpLocal}/{httpRemote}连接关闭");
}
下面使用网络调试工具模拟客户端和正向代理服务器的请求,先用HTTP
请求建立TCP
连接,然后发送测试数据
测试协议升级机制
// HTTP Upgrade 客户端请求方式
GET http://127.0.0.1:5199/http2tcp HTTP/1.1
Connection: Upgrade
Upgrade: example/1, foo/2
User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/7.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E)
Accept-Encoding: gzip, deflate
Host: 127.0.0.1:5199
测试CONNECT
动词
HTTP CONNECT 客户端请求方式
CONNECT 127.0.0.1:1000 HTTP/1.1
Host: 127.0.0.1:1000
Proxy-Authorization: basic aGVsbG86d29ybGQ=
如下是正向代理服务器和目标服务器之间数据传输示意图。
关于CONNECT
动词也可以直接使用curl
命令测试
curl -p --proxy my_username:my_password@localhost:5199 http://www.baidu.com
使用FRP
实现http2tcp
的效果
配置frps
[common]
bind_port = 7000
./frps -c frps.ini
配置frpc
[common]
server_addr = 1.2.3.4
server_port = 7000
token = your_token
[ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 22
remote_port = 6000
plugin = http_proxy
plugin_http_user = username
plugin_http_passwd = password
./frpc -c frpc.ini
客户端使用
ssh -o 'ProxyCommand nc -x 1.2.3.4:6000 %h %p' username@internal-ip
参考:
http2tcp: 一个通过 HTTP 转发 TCP 流量的小工具
[Go] 不到 100 行代码实现一个支持 CONNECT 动词的 HTTP 服务器
Protocol upgrade mechanism
HTTP tunneling
HTTP CONNECT