Skip to content

Commit

Permalink
Merge pull request #26 from twitchax/ws-support
Browse files Browse the repository at this point in the history
WebSocket Support
  • Loading branch information
twitchax authored Oct 9, 2019
2 parents 40a7763 + ef261c6 commit aa1da30
Show file tree
Hide file tree
Showing 17 changed files with 902 additions and 187 deletions.
59 changes: 54 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,29 @@ public void ConfigureServices(IServiceCollection services)
}
```

#### Run a Proxy

You can run a proxy over all endpoints.

```csharp
app.RunProxy("https://google.com");
```

In addition, you can route this proxy depending on the context.

```csharp
app.RunProxy(context =>
{
if(context.WebSockets.IsWebSocketRequest)
return "wss://mysite.com/ws";

return "https://mysite.com";
});
```

#### Existing Controller

You can use the proxy functionality on an existing `Controller` by leveraging the `Proxy` extension method.
You can define a proxy over a specific endpoint on an existing `Controller` by leveraging the `Proxy` extension method.

```csharp
public class MyController : Controller
Expand All @@ -57,6 +77,19 @@ public class MyController : Controller
}
```

In addition, you can proxy to WebSocket endpoints.

```csharp
public class MyController : Controller
{
[Route("ws")]
public Task OpenWs()
{
return this.ProxyAsync($"wss://myendpoint.com/ws");
}
}
```

You can also pass special options that apply when the proxy operation occurs.

```csharp
Expand All @@ -67,32 +100,48 @@ public class MyController : Controller
{
var options = ProxyOptions.Instance
.WithShouldAddForwardedHeaders(false)
.WithHttpClientName("MyCustomClient")
.WithIntercept(async context =>
{
if(c.Connection.RemotePort == 7777)
{
c.Response.StatusCode = 300;
await c.Response.WriteAsync("I don't like this port, so I am not proxying this request!");
return true;
}

return false;
})
.WithBeforeSend((c, hrm) =>
{
// Set something that is needed for the downstream endpoint.
hrm.Headers.Authorization = new AuthenticationHeaderValue("Basic");

return Task.CompletedTask;
})
.WithAfterReceive((c, hrm) =>
{
// Alter the content in some way before sending back to client.
var newContent = new StringContent("It's all greek...er, Latin...to me!");
hrm.Content = newContent;

return Task.CompletedTask;
})
.WithHandleFailure((c, e) =>
.WithHandleFailure(async (c, e) =>
{
// Return a custom error response.
c.Response.StatusCode = 403;
c.Response.WriteAsync("Things borked.");
await c.Response.WriteAsync("Things borked.");
});

return this.ProxyAsync($"https://jsonplaceholder.typicode.com/posts/{postId}");
return this.ProxyAsync($"https://jsonplaceholder.typicode.com/posts/{postId}", options);
}
}
```

#### Application Builder

You can define a proxy in `Configure(IApplicationBuilder app, IHostingEnvironment env)`. The arguments are passed to the underlying lambda as a `Dictionary`.
You can define a proxy over a specific endpoint in `Configure(IApplicationBuilder app, IHostingEnvironment env)`. The arguments are passed to the underlying lambda as a `Dictionary`.

```csharp
app.UseProxy("api/{arg1}/{arg2}", async (args) => {
Expand Down
55 changes: 55 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
TODO:
* Should UseProxy require the user to set all of the proxies at once? YES, in 4.0.0...`UseProxies` with builders.
* Remove the [ProxyRoute] attribute? Maybe, in 4.0.0. If we keep it, change it to `UseStaticProxies`, and somehow return options?
* Round robin helper, and protocol helper for `RunProxy`? Maybe in 4.0.0.
* Add options for WebSocket calls.
* Make options handlers called `Async`?
* Allow the user to set options via a lambda for builder purposes?
* Add a `RunProxy` that takes a `getProxiedAddress` as a `Task<string>`.

Some ideas of how `UseProxies` should work in 4.0.0.

```csharp

// Custom top-level extension method.
app.UseProxies(proxies =>
{
proxies.Map("/route/thingy")
.ToHttp("http://mysite.com/") // OR To(http, ws)
.WithOption1();

// OR
proxies.Map("/route/thingy", proxy =>
{
// Make sure the proxy builder has HttpContext on it.
proxy.ToHttp("http://mysite.com")
.WithOption1(...);

proxy.ToWs(...);
});
});

// OR?
// Piggy-back on the ASP.NET Core 3 endpoints pattern.
app.UseEndpoints(endpoints =>
{
endpoints.Map("/my/path", context =>
{
return context.ProxyAsync("http://mysite.com", options =>
{
options.WithOption1();
});

// OR?
return context.HttpProxyTo("http://mysite.com", options =>
{
options.WithOption1();
});

// OR, maybe there is an `HttpProxyTo` and `WsProxyTo`, and a `ProxyTo` that does its best to decide.
});
})
```
1 change: 0 additions & 1 deletion src/Core/AspNetCore.Proxy.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="3.0.0" />
</ItemGroup>

Expand Down
45 changes: 45 additions & 0 deletions src/Core/Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Http;
using System;
using System.Threading.Tasks;

namespace AspNetCore.Proxy
{
internal static class Extensions
{
internal static async Task ExecuteProxyOperationAsync(this HttpContext context, string uri, ProxyOptions options = null)
{
try
{
if (context.WebSockets.IsWebSocketRequest)
{
if(!uri.StartsWith("ws", System.StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("A WebSocket request must forward to a WebSocket (ws[s]) endpoint.");

await context.ExecuteWsProxyOperationAsync(uri, options).ConfigureAwait(false);
return;
}

// Assume HTTP if not WebSocket.
if(!uri.StartsWith("http", System.StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("An HTTP request must forward to an HTTP (http[s]) endpoint.");

await context.ExecuteHttpProxyOperationAsync(uri, options).ConfigureAwait(false);
}
catch (Exception e)
{
if(!context.Response.HasStarted)
{
if (options?.HandleFailure == null)
{
// If the failures are not caught, then write a generic response.
context.Response.StatusCode = 502;
await context.Response.WriteAsync($"Request could not be proxied.\n\n{e.Message}\n\n{e.StackTrace}").ConfigureAwait(false);
return;
}

await options.HandleFailure(context, e).ConfigureAwait(false);
}
}
}
}
}
151 changes: 4 additions & 147 deletions src/Core/Helpers.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyModel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyModel;

namespace AspNetCore.Proxy
{
internal static class Helpers
{
internal static readonly string ProxyClientName = "AspNetCore.Proxy.ProxyClient";
internal static readonly string HttpProxyClientName = "AspNetCore.Proxy.HttpProxyClient";
internal static readonly string[] WebSocketNotForwardedHeaders = new[] { "Connection", "Host", "Upgrade", "Sec-WebSocket-Accept", "Sec-WebSocket-Protocol", "Sec-WebSocket-Key", "Sec-WebSocket-Version", "Sec-WebSocket-Extensions" };

internal static IEnumerable<Assembly> GetReferencingAssemblies()
{
Expand All @@ -31,142 +25,5 @@ internal static IEnumerable<Assembly> GetReferencingAssemblies()
}
return assemblies;
}

internal static async Task ExecuteProxyOperation(HttpContext context, string uri, ProxyOptions options = null)
{
try
{
var proxiedRequest = context.CreateProxiedHttpRequest(uri, options?.ShouldAddForwardedHeaders ?? true);

if(options?.BeforeSend != null)
await options.BeforeSend(context, proxiedRequest).ConfigureAwait(false);
var proxiedResponse = await context
.SendProxiedHttpRequest(proxiedRequest, options?.HttpClientName ?? Helpers.ProxyClientName)
.ConfigureAwait(false);

if(options?.AfterReceive != null)
await options.AfterReceive(context, proxiedResponse).ConfigureAwait(false);
await context.WriteProxiedHttpResponse(proxiedResponse).ConfigureAwait(false);
}
catch (Exception e)
{
if (options?.HandleFailure == null)
{
// If the failures are not caught, then write a generic response.
context.Response.StatusCode = 502;
await context.Response.WriteAsync($"Request could not be proxied.\n\n{e.Message}\n\n{e.StackTrace}.").ConfigureAwait(false);
return;
}

await options.HandleFailure(context, e).ConfigureAwait(false);
}
}
}

internal static class Extensions
{
internal static HttpRequestMessage CreateProxiedHttpRequest(this HttpContext context, string uriString, bool shouldAddForwardedHeaders)
{
var uri = new Uri(uriString);
var request = context.Request;

var requestMessage = new HttpRequestMessage();
var requestMethod = request.Method;

// Write to request content, when necessary.
if (!HttpMethods.IsGet(requestMethod) &&
!HttpMethods.IsHead(requestMethod) &&
!HttpMethods.IsDelete(requestMethod) &&
!HttpMethods.IsTrace(requestMethod))
{
var streamContent = new StreamContent(request.Body);
requestMessage.Content = streamContent;
}

// Copy the request headers.
foreach (var header in context.Request.Headers)
if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()))
requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());

// Add forwarded headers.
if(shouldAddForwardedHeaders)
AddForwardedHeadersToRequest(context, requestMessage);

// Set destination and method.
requestMessage.Headers.Host = uri.Authority;
requestMessage.RequestUri = uri;
requestMessage.Method = new HttpMethod(request.Method);

return requestMessage;
}

internal static Task<HttpResponseMessage> SendProxiedHttpRequest(this HttpContext context, HttpRequestMessage message, string httpClientName)
{
return context.RequestServices
.GetService<IHttpClientFactory>()
.CreateClient(httpClientName)
.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);
}

internal static Task WriteProxiedHttpResponse(this HttpContext context, HttpResponseMessage responseMessage)
{
var response = context.Response;

response.StatusCode = (int)responseMessage.StatusCode;
foreach (var header in responseMessage.Headers)
{
response.Headers[header.Key] = header.Value.ToArray();
}

foreach (var header in responseMessage.Content.Headers)
{
response.Headers[header.Key] = header.Value.ToArray();
}

response.Headers.Remove("transfer-encoding");

return responseMessage.Content.CopyToAsync(response.Body);
}

private static void AddForwardedHeadersToRequest(HttpContext context, HttpRequestMessage requestMessage)
{
var request = context.Request;
var connection = context.Connection;

var host = request.Host.ToString();
var protocol = request.Scheme;

var localIp = connection.LocalIpAddress?.ToString();
var isLocalIpV6 = connection.LocalIpAddress?.AddressFamily == AddressFamily.InterNetworkV6;

var remoteIp = context.Connection.RemoteIpAddress?.ToString();
var isRemoteIpV6 = connection.RemoteIpAddress?.AddressFamily == AddressFamily.InterNetworkV6;

if(remoteIp != null)
requestMessage.Headers.TryAddWithoutValidation("X-Forwarded-For", remoteIp);
requestMessage.Headers.TryAddWithoutValidation("X-Forwarded-Proto", protocol);
requestMessage.Headers.TryAddWithoutValidation("X-Forwarded-Host", host);

// Fix IPv6 IPs for the `Forwarded` header.
var forwardedHeader = new StringBuilder($"proto={protocol};host={host};");

if(localIp != null)
{
if(isLocalIpV6)
localIp = $"\"[{localIp}]\"";

forwardedHeader.Append($"by={localIp};");
}

if(remoteIp != null)
{
if(isRemoteIpV6)
remoteIp = $"\"[{remoteIp}]\"";

forwardedHeader.Append($"for={remoteIp};");
}

requestMessage.Headers.TryAddWithoutValidation("Forwarded", forwardedHeader.ToString());
}
}
}
}
Loading

0 comments on commit aa1da30

Please sign in to comment.