當前位置: 妍妍網 > 碼農

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

2024-04-30碼農

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

建立Core計畫用於共用的核心類別庫

建立計畫名 Taibai.Core

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

DelegatingStream.cs

namespaceTaibai.Core;
///<summary>
/// 委托流
///</summary>
publicabstract classDelegatingStream : Stream
{
///<summary>
/// 獲取所包裝的流物件
///</summary>
protectedreadonly Stream Inner;
///<summary>
/// 委托流
///</summary>
///<param name="inner"></param>
publicDelegatingStream(Stream inner)
{
this.Inner = inner;
}
///<inheritdoc/>
publicoverridebool CanRead => Inner.CanRead;
///<inheritdoc/>
publicoverridebool CanSeek => Inner.CanSeek;
///<inheritdoc/>
publicoverridebool CanWrite => Inner.CanWrite;
///<inheritdoc/>
publicoverridelong Length => Inner.Length;
///<inheritdoc/>
publicoverridebool CanTimeout => Inner.CanTimeout;
///<inheritdoc/>
publicoverrideint ReadTimeout
{
get => Inner.ReadTimeout;
set => Inner.ReadTimeout = value;
}
///<inheritdoc/>
publicoverrideint WriteTimeout
{
get => Inner.WriteTimeout;
set => Inner.WriteTimeout = value;
}

///<inheritdoc/>
publicoverridelong Position
{
get => Inner.Position;
set => Inner.Position = value;
}
///<inheritdoc/>
publicoverridevoidFlush()
{
Inner.Flush();
}
///<inheritdoc/>
publicoverride Task FlushAsync(CancellationToken cancellationToken)
{
return Inner.FlushAsync(cancellationToken);
}
///<inheritdoc/>
publicoverrideintRead(byte[] buffer, int offset, int count)
{
return Inner.Read(buffer, offset, count);
}
///<inheritdoc/>
publicoverrideintRead(Span<byte> destination)
{
return Inner.Read(destination);
}
///<inheritdoc/>
publicoverride Task<intReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
return Inner.ReadAsync(buffer, offset, count, cancellationToken);
}
///<inheritdoc/>
publicoverride ValueTask<intReadAsync(Memory<byte> destination, CancellationToken cancellationToken = default)
{
return Inner.ReadAsync(destination, cancellationToken);
}
///<inheritdoc/>
publicoverridelongSeek(long offset, SeekOrigin origin)
{
return Inner.Seek(offset, origin);
}
///<inheritdoc/>
publicoverridevoidSetLength(longvalue)
{
Inner.SetLength(value);
}
///<inheritdoc/>
publicoverridevoidWrite(byte[] buffer, int offset, int count)
{
Inner.Write(buffer, offset, count);
}
///<inheritdoc/>
publicoverridevoidWrite(ReadOnlySpan<byte> source)
{
Inner.Write(source);
}
///<inheritdoc/>
publicoverride Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
return Inner.WriteAsync(buffer, offset, count, cancellationToken);
}
///<inheritdoc/>
publicoverride ValueTask WriteAsync(ReadOnlyMemory<byte> source, CancellationToken cancellationToken = default)
{
return Inner.WriteAsync(source, cancellationToken);
}
///<inheritdoc/>
publicoverride IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state)
{
return TaskToAsyncResult.Begin(ReadAsync(buffer, offset, count), callback, state);
}
///<inheritdoc/>
publicoverrideintEndRead(IAsyncResult asyncResult)
{
return TaskToAsyncResult.End<int>(asyncResult);
}
///<inheritdoc/>
publicoverride IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback,
object? state
)

{
return TaskToAsyncResult.Begin(WriteAsync(buffer, offset, count), callback, state);
}
///<inheritdoc/>
publicoverridevoidEndWrite(IAsyncResult asyncResult)
{
TaskToAsyncResult.End(asyncResult);
}
///<inheritdoc/>
publicoverrideintReadByte()
{
return Inner.ReadByte();
}
///<inheritdoc/>
publicoverridevoidWriteByte(bytevalue)
{
Inner.WriteByte(value);
}
///<inheritdoc/>
publicsealedoverridevoidClose()
{
base.Close();
}
}


























SafeWriteStream.cs

public class SafeWriteStream(Stream inner) : DelegatingStream(inner)
{
privatereadonly SemaphoreSlim semaphoreSlim = new(11);
publicoverrideasync ValueTask WriteAsync(ReadOnlyMemory<byte> source, CancellationToken cancellationToken = default)
{
try
{
awaitthis.semaphoreSlim.WaitAsync(CancellationToken.None);
awaitbase.WriteAsync(source, cancellationToken);
awaitthis.FlushAsync(cancellationToken);
}
finally
{
this.semaphoreSlim.Release();
}
}
publicoverride ValueTask DisposeAsync()
{
this.semaphoreSlim.Dispose();
returnthis.Inner.DisposeAsync();
}
protectedoverridevoidDispose(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;
namespaceTaibai.Server;
publicstatic classServerService
{
privatestaticreadonly ConcurrentDictionary<string, (CancellationToken, Stream)> ClusterConnections = new();
publicstaticasync 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>
publicstatic (CancellationToken, StreamGetConnectionChannel(string host)
{
return ClusterConnections[host];
}
///<summary>
/// 註冊連線
///</summary>
///<param name="host"></param>
///<param name="cancellationToken"></param>
///<param name="stream"></param>
publicstaticvoidCreateConnectionChannel(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;
namespaceTaibai.Server;
public classClientMiddleware : IMiddleware
{
publicasync 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);
staticasync 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 classClientOption
{
///<summary>
/// 服務地址
///</summary>
publicstring ServiceUri { getset; }
}

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

using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using Taibai.Core;
namespaceTaibai.Client;
public class MonitorClient(ClientOption option)
{
privatestring Protocol = "taibai";
privatereadonly HttpMessageInvoker httpClient = new(CreateDefaultHttpHandler(), true);
privatereadonly Socket socket = new(SocketType.Stream, ProtocolType.Tcp);
privatestatic SocketsHttpHandler CreateDefaultHttpHandler()
{
returnnew SocketsHttpHandler
{
// 允許多個http2連線
EnableMultipleHttp2Connections = true,
// 設定連線超時時間
ConnectTimeout = TimeSpan.FromSeconds(60),
SslOptions = new SslClientAuthenticationOptions
{
// 由於我們沒有證書,所以我們需要設定為true
RemoteCertificateValidationCallback = (_, _, _, _) => true,
},
};
}
publicasync 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 = awaitthis.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>
publicasync Task<SafeWriteStream> CreateServerConnectionAsync(CancellationToken cancellationToken)
{
var stream = await Http20ConnectServerAsync(cancellationToken);
returnnew SafeWriteStream(stream);
}
///<summary>
/// 建立http2連線
///</summary>
///<param name="cancellationToken"></param>
///<returns></returns>
privateasync 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;
// 設定一下超時時間,這樣我們就可以在超時的時候取消連線了。
usingvar timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(60));
usingvar linkedTokenSource =
CancellationTokenSource.CreateLinkedTokenSource(timeoutTokenSource.Token, cancellationToken);
// 發送請求,然後等待響應
var httpResponse = awaitthis.httpClient.SendAsync(request, linkedTokenSource.Token);
// 返回h2的流,用於傳輸數據
returnawait 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;
namespaceTaibai.Client;

public classClient
{
privatereadonly ClientOption option;
privatestring Protocol = "taibai";
privatereadonly HttpMessageInvoker httpClient;
privatereadonly Socket socket;
publicClient(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);
}
privatestatic SocketsHttpHandler CreateDefaultHttpHandler()
{
returnnew SocketsHttpHandler
{
// 允許多個http2連線
EnableMultipleHttp2Connections = true,
ConnectTimeout = TimeSpan.FromSeconds(60),
ResponseDrainTimeout = TimeSpan.FromSeconds(60),
SslOptions = new SslClientAuthenticationOptions
{
// 由於我們沒有證書,所以我們需要設定為true
RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true,
},
};
}
publicasync Task TransportAsync(CancellationToken cancellationToken)
{
Console.WriteLine("Listening on 60112");
// 等待客戶端連線
var client = awaitthis.socket.AcceptAsync(cancellationToken);
Console.WriteLine("Accepted connection from " + client.RemoteEndPoint);
try
{
// 將Socket轉換為流
var stream = new NetworkStream(client);
// 建立伺服器的連線,然後返回一個流, 這個是H2的流
var serverStream = awaitthis.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>
publicasync Task<SafeWriteStream> CreateServerConnectionAsync(CancellationToken cancellationToken)
{
var stream = awaitthis.Http20ConnectServerAsync(cancellationToken);
returnnew SafeWriteStream(stream);
}
privateasync 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;
usingvar timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(60));
usingvar linkedTokenSource =
CancellationTokenSource.CreateLinkedTokenSource(timeoutTokenSource.Token, cancellationToken);
// 發送請求,等待伺服器驗證。
var httpResponse = awaitthis.httpClient.SendAsync(request, linkedTokenSource.Token);
// 返回一個流
returnawait httpResponse.Content.ReadAsStreamAsync(linkedTokenSource.Token);
}
}


















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

using Taibai.Client;
conststring 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());
}
elseif (command?.StartsWith("client=") == true)
{
var client = new Client(new ClientOption()
{
ServiceUri = command[7..]
});
await client.TransportAsync(new CancellationToken());
}
elseif (command == "help")
{
Console.WriteLine(commandTemplate);
}
elseif (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