.NE8实现HTTP隧道(Tunnel)代理转发TCP流量

在严格的端口管控下,非HTTP协议的端口可能无法对外提供服务,使用TCP over HTTP方式进行协议代理,服务端通过HTTP协议的类似 WebSocketUpgrade协议升级机制或HTTP CONNECT动词与目标服务器建立TCP通信隧道,客户端连接本地的正向代理服务器(如sshProxyCommandcurl--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 POSTHTTP请求。

// 将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请求,将获取到的TcpStreamTcpListenerNetworkStream连接。

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

测试Upgrade

测试CONNECT动词

HTTP CONNECT 客户端请求方式
CONNECT 127.0.0.1:1000 HTTP/1.1
Host: 127.0.0.1:1000
Proxy-Authorization: basic aGVsbG86d29ybGQ=

如下是正向代理服务器和目标服务器之间数据传输示意图。
网络调试工具测试CONNECT动词

关于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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值