您可知道如何透過`HTTP2`實現TCP的內網穿透???

tokengo發表於2024-04-30

可能有人很疑惑應用層 轉發傳輸層?,為什麼會有這樣的需求啊???哈哈技術無所不用其極,由於一些場景下,對於一個伺服器存在某一個內部網站中,但是對於這個伺服器它沒有訪問外網的許可權,雖然也可以申請埠訪問外部指定的ip+埠,但是對於訪問服務內部的TCP的時候我們就會發現忘記申請了!這個時候我們又要提交申請,又要等審批,然後開通埠,對於這個步驟不是一般的麻煩,所以我在想是否可以直接利用現有的Http閘道器的埠進行轉發內部的TCP服務?這個時候我詢問了我們的老九大佬,由於我之前也做過透過H2實現HTTP內網穿透,可以利用H2將內部網路中的服務對映出來,但是由於底層是基於yarp的一些方法實現,所以並沒有考慮過TCP,然後於老九大佬交流深究,決定嘗試驗證可行性,然後我們的Taibai專案就誕生了,為什麼叫Taibai?您仔細看看這個拼音,翻譯過來就是太白,確實全稱應該叫太白金星,寓意上天遁地無所不能!下面我們介紹一下具體實現邏輯,確實您仔細看會發現實現是真的超級簡單的!

建立Core專案用於共用的核心類庫

建立專案名Taibai.Core

下面幾個方法都是用於操作Stream的類

DelegatingStream.cs

namespace Taibai.Core;

/// <summary>
/// 委託流
/// </summary>
public abstract class DelegatingStream : Stream
{
    /// <summary>
    /// 獲取所包裝的流物件
    /// </summary>
    protected readonly Stream Inner;

    /// <summary>
    /// 委託流
    /// </summary>
    /// <param name="inner"></param>
    public DelegatingStream(Stream inner)
    {
        this.Inner = inner;
    }

    /// <inheritdoc/>
    public override bool CanRead => Inner.CanRead;

    /// <inheritdoc/>
    public override bool CanSeek => Inner.CanSeek;

    /// <inheritdoc/>
    public override bool CanWrite => Inner.CanWrite;

    /// <inheritdoc/>
    public override long Length => Inner.Length;

    /// <inheritdoc/>
    public override bool CanTimeout => Inner.CanTimeout;

    /// <inheritdoc/>
    public override int ReadTimeout
    {
        get => Inner.ReadTimeout;
        set => Inner.ReadTimeout = value;
    }

    /// <inheritdoc/>
    public override int WriteTimeout
    {
        get => Inner.WriteTimeout;
        set => Inner.WriteTimeout = value;
    }


    /// <inheritdoc/>
    public override long Position
    {
        get => Inner.Position;
        set => Inner.Position = value;
    }

    /// <inheritdoc/>
    public override void Flush()
    {
        Inner.Flush();
    }

    /// <inheritdoc/>
    public override Task FlushAsync(CancellationToken cancellationToken)
    {
        return Inner.FlushAsync(cancellationToken);
    }

    /// <inheritdoc/>
    public override int Read(byte[] buffer, int offset, int count)
    {
        return Inner.Read(buffer, offset, count);
    }

    /// <inheritdoc/>
    public override int Read(Span<byte> destination)
    {
        return Inner.Read(destination);
    }

    /// <inheritdoc/>
    public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
    {
        return Inner.ReadAsync(buffer, offset, count, cancellationToken);
    }

    /// <inheritdoc/>
    public override ValueTask<int> ReadAsync(Memory<byte> destination, CancellationToken cancellationToken = default)
    {
        return Inner.ReadAsync(destination, cancellationToken);
    }

    /// <inheritdoc/>
    public override long Seek(long offset, SeekOrigin origin)
    {
        return Inner.Seek(offset, origin);
    }

    /// <inheritdoc/>
    public override void SetLength(long value)
    {
        Inner.SetLength(value);
    }

    /// <inheritdoc/>
    public override void Write(byte[] buffer, int offset, int count)
    {
        Inner.Write(buffer, offset, count);
    }

    /// <inheritdoc/>
    public override void Write(ReadOnlySpan<byte> source)
    {
        Inner.Write(source);
    }

    /// <inheritdoc/>
    public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
    {
        return Inner.WriteAsync(buffer, offset, count, cancellationToken);
    }

    /// <inheritdoc/>
    public override ValueTask WriteAsync(ReadOnlyMemory<byte> source, CancellationToken cancellationToken = default)
    {
        return Inner.WriteAsync(source, cancellationToken);
    }

    /// <inheritdoc/>
    public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state)
    {
        return TaskToAsyncResult.Begin(ReadAsync(buffer, offset, count), callback, state);
    }

    /// <inheritdoc/>
    public override int EndRead(IAsyncResult asyncResult)
    {
        return TaskToAsyncResult.End<int>(asyncResult);
    }

    /// <inheritdoc/>
    public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback,
        object? state)
    {
        return TaskToAsyncResult.Begin(WriteAsync(buffer, offset, count), callback, state);
    }

    /// <inheritdoc/>
    public override void EndWrite(IAsyncResult asyncResult)
    {
        TaskToAsyncResult.End(asyncResult);
    }

    /// <inheritdoc/>
    public override int ReadByte()
    {
        return Inner.ReadByte();
    }

    /// <inheritdoc/>
    public override void WriteByte(byte value)
    {
        Inner.WriteByte(value);
    }

    /// <inheritdoc/>
    public sealed override void Close()
    {
        base.Close();
    }
}

SafeWriteStream.cs

public class SafeWriteStream(Stream inner) : DelegatingStream(inner)
{
    private readonly SemaphoreSlim semaphoreSlim = new(1, 1);

    public override async ValueTask WriteAsync(ReadOnlyMemory<byte> source, CancellationToken cancellationToken = default)
    {
        try
        {
            await this.semaphoreSlim.WaitAsync(CancellationToken.None);
            await base.WriteAsync(source, cancellationToken);
            await this.FlushAsync(cancellationToken);
        }
        finally
        {
            this.semaphoreSlim.Release();
        }
    }

    public override ValueTask DisposeAsync()
    {
        this.semaphoreSlim.Dispose();
        return this.Inner.DisposeAsync();
    }

    protected override void Dispose(bool disposing)
    {
        this.semaphoreSlim.Dispose();
        this.Inner.Dispose();
    }
}

建立服務端

建立一個WebAPI的專案專案名Taibai.Server並且依賴Taibai.Core專案

建立ServerService.cs,這個類是用於管理內網的客戶端的,這個一般是部署在內網伺服器上,用於將內網的埠對映出來,但是我們的Demo只實現了簡單的管理不做埠的管理。

using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Timeouts;
using Taibai.Core;

namespace Taibai.Server;

public static class ServerService
{
    private static readonly ConcurrentDictionary<string, (CancellationToken, Stream)> ClusterConnections = new();

    public static async Task StartAsync(HttpContext context)
    {
        // 如果不是http2協議,我們不處理, 因為我們只支援http2
        if (context.Request.Protocol != HttpProtocol.Http2)
        {
            return;
        }

        // 獲取query
        var query = context.Request.Query;

        // 我們需要強制要求name引數
        var name = query["name"];

        if (string.IsNullOrEmpty(name))
        {
            context.Response.StatusCode = 400;
            Console.WriteLine("Name is required");
            return;
        }
        
        Console.WriteLine("Accepted connection from " + name);

        // 獲取http2特性
        var http2Feature = context.Features.Get<IHttpExtendedConnectFeature>();
        
        // 禁用超時
        context.Features.Get<IHttpRequestTimeoutFeature>()?.DisableTimeout();

        // 得到雙工流
        var stream = new SafeWriteStream(await http2Feature.AcceptAsync());

        // 將其新增到集合中,以便我們可以在其他地方使用
        CreateConnectionChannel(name, context.RequestAborted, stream);

        // 註冊取消連線
        context.RequestAborted.Register(() =>
        {
            // 當取消時,我們需要從集合中刪除
            ClusterConnections.TryRemove(name, out _);
        });
        
        // 由於我們需要保持連線,所以我們需要等待,直到客戶端主動斷開連線。
        await Task.Delay(-1, context.RequestAborted);
    }

    /// <summary>
    /// 透過名稱獲取連線
    /// </summary>
    /// <param name="host"></param>
    /// <returns></returns>
    public static (CancellationToken, Stream) GetConnectionChannel(string host)
    {
        return ClusterConnections[host];
    }

    /// <summary>
    /// 註冊連線
    /// </summary>
    /// <param name="host"></param>
    /// <param name="cancellationToken"></param>
    /// <param name="stream"></param>
    public static void CreateConnectionChannel(string host, CancellationToken cancellationToken, Stream stream)
    {
        ClusterConnections.GetOrAdd(host,
            _ => (cancellationToken, stream));
    }
}

然後再建立ClientMiddleware.cs,並且繼承IMiddleware,這個是我們本地使用的客戶端連結的時候進入的中介軟體,再這個中介軟體會獲取query中攜帶的name去找到指定的Stream,然後會將客戶端的Stream和獲取的server的Stream進行Copy,在這裡他們會將讀取的資料寫入到對方的流中,這樣就實現了雙工通訊

using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Timeouts;
using Taibai.Core;

namespace Taibai.Server;

public class ClientMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        
        // 如果不是http2協議,我們不處理, 因為我們只支援http2
        if (context.Request.Protocol != HttpProtocol.Http2)
        {
            return;
        }

        var name = context.Request.Query["name"];

        if (string.IsNullOrEmpty(name))
        {
            context.Response.StatusCode = 400;
            Console.WriteLine("Name is required");
            return;
        }
        
        Console.WriteLine("Accepted connection from " + name);

        var http2Feature = context.Features.Get<IHttpExtendedConnectFeature>();
        context.Features.Get<IHttpRequestTimeoutFeature>()?.DisableTimeout();

        // 得到雙工流
        var stream = new SafeWriteStream(await http2Feature.AcceptAsync());

        // 透過name找到指定的server連結,然後進行轉發。
        var (cancellationToken, reader) = ServerService.GetConnectionChannel(name);

        try
        {
            // 註冊取消連線
            cancellationToken.Register(() =>
            {
                Console.WriteLine("斷開連線");
                stream.Close();
            });

            // 得到客戶端的流,然後給我們的SafeWriteStream,然後我們就可以進行轉發了
            var socketStream = new SafeWriteStream(reader);

            // 在這裡他們會將讀取的資料寫入到對方的流中,這樣就實現了雙工通訊,這個非常簡單並且效能也不錯。
            await Task.WhenAll(
                stream.CopyToAsync(socketStream, context.RequestAborted),
                socketStream.CopyToAsync(stream, context.RequestAborted)
            );
        }
        catch (Exception e)
        {
            Console.WriteLine("斷開連線" + e.Message);
            throw;
        }
    }
}

開啟Program.cs

using Taibai.Server;

var builder = WebApplication.CreateBuilder(new WebApplicationOptions());

builder.Host.ConfigureHostOptions(host => { host.ShutdownTimeout = TimeSpan.FromSeconds(1d); });

builder.Services.AddSingleton<ClientMiddleware>();

var app = builder.Build();

app.Map("/server", app =>
{
    app.Use(Middleware);

    static async Task Middleware(HttpContext context, RequestDelegate _)
    {
        await ServerService.StartAsync(context);
    }
});

app.Map("/client", app => { app.UseMiddleware<ClientMiddleware>(); });

app.Run();

在這裡我們將server的所有路由都交過ServerService.StartAsync接管,再server會請求這個地址,

/client則給了ClientMiddleware中介軟體。

建立客戶端

上面我們實現了服務端,其實服務端可以完全放置到現有的WebApi專案當中的,而且程式碼也不是很多。

客戶端我們建立一個控制檯專案名:Taibai.Client,並且依賴Taibai.Core專案

由於我們的客戶端有些特殊,再server中部署的它不需要監聽埠,它只需要將伺服器的資料轉發到指定的一個地址即可,所以我們需要將客戶端的server部署的和本地部署的分開實現,再伺服器部署的客戶端我們命名為MonitorClient.cs

ClientOption.cs用於傳遞我們的客戶端地址配置

public class ClientOption
{
    /// <summary>
    /// 服務地址
    /// </summary>
    public string ServiceUri { get; set; }
    
}

MonitorClient.cs,作為伺服器的轉發客戶端。

using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using Taibai.Core;

namespace Taibai.Client;

public class MonitorClient(ClientOption option)
{
    private string Protocol = "taibai";
    private readonly HttpMessageInvoker httpClient = new(CreateDefaultHttpHandler(), true);
    private readonly Socket socket = new(SocketType.Stream, ProtocolType.Tcp);

    private static SocketsHttpHandler CreateDefaultHttpHandler()
    {
        return new SocketsHttpHandler
        {
            // 允許多個http2連線
            EnableMultipleHttp2Connections = true,
            // 設定連線超時時間
            ConnectTimeout = TimeSpan.FromSeconds(60),
            SslOptions = new SslClientAuthenticationOptions
            {
                // 由於我們沒有證書,所以我們需要設定為true
                RemoteCertificateValidationCallback = (_, _, _, _) => true,
            },
        };
    }

    public async Task TransportAsync(CancellationToken cancellationToken)
    {
        Console.WriteLine("連結中!");

        // 由於是測試,我們就目前先寫死遠端地址
        await socket.ConnectAsync(new IPEndPoint(IPAddress.Parse("192.168.31.250"), 3389), cancellationToken);

        Console.WriteLine("連線成功");

        // 將Socket轉換為流
        var stream = new NetworkStream(socket);
        try
        {
            // 建立伺服器的連線,然後返回一個流,這個是H2的流
            var serverStream = await this.CreateServerConnectionAsync(cancellationToken);

            Console.WriteLine("連結伺服器成功");

            // 將兩個流連線起來,這樣我們就可以進行雙工通訊了。它們會自動進行資料的傳輸。
            await Task.WhenAll(
                stream.CopyToAsync(serverStream, cancellationToken),
                serverStream.CopyToAsync(stream, cancellationToken)
            );
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
            throw;
        }
    }

    /// <summary>
    /// 建立伺服器的連線
    /// </summary> 
    /// <param name="cancellationToken"></param>
    /// <exception cref="OperationCanceledException"></exception>
    /// <returns></returns>
    public async Task<SafeWriteStream> CreateServerConnectionAsync(CancellationToken cancellationToken)
    {
        var stream = await Http20ConnectServerAsync(cancellationToken);
        return new SafeWriteStream(stream);
    }

    /// <summary>
    /// 建立http2連線
    /// </summary>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    private async Task<Stream> Http20ConnectServerAsync(CancellationToken cancellationToken)
    {
        var serverUri = new Uri(option.ServiceUri);
        // 這裡我們使用Connect方法,因為我們需要建立一個雙工流, 這樣我們就可以進行雙工通訊了。
        var request = new HttpRequestMessage(HttpMethod.Connect, serverUri);
        // 如果設定了Connect,那麼我們需要設定Protocol
        request.Headers.Protocol = Protocol;
        // 我們需要設定http2的版本
        request.Version = HttpVersion.Version20;
        
        // 我們需要確保我們的請求是http2的
        request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;

        // 設定一下超時時間,這樣我們就可以在超時的時候取消連線了。
        using var timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(60));
        using var linkedTokenSource =
            CancellationTokenSource.CreateLinkedTokenSource(timeoutTokenSource.Token, cancellationToken);

        // 傳送請求,然後等待響應
        var httpResponse = await this.httpClient.SendAsync(request, linkedTokenSource.Token);

        // 返回h2的流,用於傳輸資料
        return await httpResponse.Content.ReadAsStreamAsync(linkedTokenSource.Token);
    }
}

建立我們的本地客戶端實現類。

Client.cs這個就是在我們本地部署的服務,然後會監聽本地的60112的埠,然後會吧這個埠的資料轉發到我們的伺服器,然後伺服器會根據我們使用的name去找到指定的客戶端進行互動傳輸。

using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using Taibai.Core;
using HttpMethod = System.Net.Http.HttpMethod;

namespace Taibai.Client;


public class Client
{
    private readonly ClientOption option;

    private string Protocol = "taibai";
    private readonly HttpMessageInvoker httpClient;
    private readonly Socket socket;

    public Client(ClientOption option)
    {
        this.option = option;
        this.httpClient = new HttpMessageInvoker(CreateDefaultHttpHandler(), true);

        this.socket = new Socket(SocketType.Stream, ProtocolType.Tcp);

        // 監聽本地埠
        this.socket.Bind(new IPEndPoint(IPAddress.Loopback, 60112));
        this.socket.Listen(10);
    }

    private static SocketsHttpHandler CreateDefaultHttpHandler()
    {
        return new SocketsHttpHandler
        {
            // 允許多個http2連線
            EnableMultipleHttp2Connections = true,
            ConnectTimeout = TimeSpan.FromSeconds(60),
            ResponseDrainTimeout = TimeSpan.FromSeconds(60),  
            SslOptions = new SslClientAuthenticationOptions
            {
                // 由於我們沒有證書,所以我們需要設定為true
                RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true,
            },
        };
    }

    public async Task TransportAsync(CancellationToken cancellationToken)
    {
        Console.WriteLine("Listening on 60112");

        // 等待客戶端連線
        var client = await this.socket.AcceptAsync(cancellationToken);

        Console.WriteLine("Accepted connection from " + client.RemoteEndPoint);

        try
        {
            // 將Socket轉換為流
            var stream = new NetworkStream(client);

            // 建立伺服器的連線,然後返回一個流, 這個是H2的流
            var serverStream = await this.CreateServerConnectionAsync(cancellationToken);

            Console.WriteLine("Connected to server");

            // 將兩個流連線起來, 這樣我們就可以進行雙工通訊了. 它們會自動進行資料的傳輸.
            await Task.WhenAll(
                stream.CopyToAsync(serverStream, cancellationToken),
                serverStream.CopyToAsync(stream, cancellationToken)
            );
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
            throw;
        }
    }

    /// <summary>
    /// 建立與伺服器的連線
    /// </summary> 
    /// <param name="cancellationToken"></param>
    /// <exception cref="OperationCanceledException"></exception>
    /// <returns></returns>
    public async Task<SafeWriteStream> CreateServerConnectionAsync(CancellationToken cancellationToken)
    {
        var stream = await this.Http20ConnectServerAsync(cancellationToken);
        return new SafeWriteStream(stream);
    }

    private async Task<Stream> Http20ConnectServerAsync(CancellationToken cancellationToken)
    {
        var serverUri = new Uri(option.ServiceUri);
        // 這裡我們使用Connect方法, 因為我們需要建立一個雙工流
        var request = new HttpRequestMessage(HttpMethod.Connect, serverUri);

        // 由於我們設定了Connect方法, 所以我們需要設定協議,這樣伺服器才能識別
        request.Headers.Protocol = Protocol;
        // 設定http2版本
        request.Version = HttpVersion.Version20;
        // 強制使用http2
        request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;

        using var timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(60));
        using var linkedTokenSource =
            CancellationTokenSource.CreateLinkedTokenSource(timeoutTokenSource.Token, cancellationToken);

        // 傳送請求,等待伺服器驗證。
        var httpResponse = await this.httpClient.SendAsync(request, linkedTokenSource.Token);

        // 返回一個流
        return await httpResponse.Content.ReadAsStreamAsync(linkedTokenSource.Token);
    }
}

然後再Program.cs中,我們封裝一個簡單的控制檯版本。

using Taibai.Client;

const string commandTemplate = @"

當前是 Taibai 客戶端,輸入以下命令:

- `help` 顯示幫助
- `monitor` 使用監控模式,監聽本地埠,將流量轉發到服務端的指定地址
    - `monitor=https://localhost:7153/server?name=test`  監聽本地埠,將流量轉發到服務端指定的客戶端名稱為 test 的地址
- `client` 使用客戶端模式,連線服務端的指定地址,將流量轉發到本地埠
    - `client=https://localhost:7153/client?name=test`  連線服務端指定當前客戶端名稱為 test,將流量轉發到本地埠
- `exit` 退出

輸入命令:

";

while (true)
{
    Console.WriteLine(commandTemplate);

    var command = Console.ReadLine();


    if (command?.StartsWith("monitor=") == true)
    {
        var client = new MonitorClient(new ClientOption()
        {
            ServiceUri = command[8..]
        });

        await client.TransportAsync(new CancellationToken());
    }
    else if (command?.StartsWith("client=") == true)
    {
        var client = new Client(new ClientOption()
        {
            ServiceUri = command[7..]
        });

        await client.TransportAsync(new CancellationToken());
    }
    else if (command == "help")
    {
        Console.WriteLine(commandTemplate);
    }
    else if (command == "exit")
    {
        Console.WriteLine("Bye!");
        break;
    }
    else
    {
        Console.WriteLine("未知命令");
    }
}

我們預設提供了命令去使用指定的一個模式去連結客戶端,

然後我們釋出一下Taibai.Client,釋出完成以後我們使用ide啟動我們的Taibai.Server,請注意我們需要使用HTTPS進行啟動的,HTTP是不支援H2的!

然後再客戶端中開啟倆個控制檯皮膚,一個作為監聽的monitor,一個作為client進行連結到我們的伺服器中。

然後我們使用遠端桌面訪問我們的127.0.0.1:60112,然後我們發現連結成功!如果您跟著寫程式碼您會您發您也成功了,哦耶您獲得了一個牛逼的技能,來源於微軟MVP token的雙休大法的傳授!

技術交流分享

來自微軟MVP token

token | 最有價值專家 (microsoft.com)

技術交流群:737776595

當然如果您需要Demo的程式碼您可以聯絡我微信wk28u9123456789

相關文章