Skip to content

Commit 4aeff4e

Browse files
committed
Proxy
1 parent 75f7ad4 commit 4aeff4e

24 files changed

Lines changed: 3319 additions & 306 deletions

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10"/>
1515
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.10"/>
1616
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10"/>
17+
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10"/>
1718
<PackageVersion Include="Microsoft.Extensions.Configuration.CommandLine" Version="10.0.10"/>
1819
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10"/>
1920
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10"/>

Shiny.Net.HttpServer.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
<Project Path="src/Shiny.Net.HttpServer.Mediator/Shiny.Net.HttpServer.Mediator.csproj" />
3434
<Project Path="src/Shiny.Net.HttpServer.Mediator.SourceGenerators/Shiny.Net.HttpServer.Mediator.SourceGenerators.csproj" />
3535
<Project Path="src/Shiny.Net.HttpServer.Mobile/Shiny.Net.HttpServer.Mobile.csproj" />
36+
<Project Path="src/Shiny.Net.HttpServer.Proxy/Shiny.Net.HttpServer.Proxy.csproj" />
3637
<Project Path="src/Shiny.Net.HttpServer.SourceGenerators/Shiny.Net.HttpServer.SourceGenerators.csproj" />
3738
<Project Path="src/Shiny.Net.HttpServer.Ssh/Shiny.Net.HttpServer.Ssh.csproj" />
3839
<Project Path="src/Shiny.Net.HttpServer.Testing/Shiny.Net.HttpServer.Testing.csproj" />

build.slnf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"src\\Shiny.Net.HttpServer.Mobile\\Shiny.Net.HttpServer.Mobile.csproj",
77
"src\\Shiny.Net.HttpServer.SourceGenerators\\Shiny.Net.HttpServer.SourceGenerators.csproj",
88
"src\\Shiny.Net.HttpServer.Jwt\\Shiny.Net.HttpServer.Jwt.csproj",
9+
"src\\Shiny.Net.HttpServer.Proxy\\Shiny.Net.HttpServer.Proxy.csproj",
910
"src\\Shiny.Net.HttpServer.Grpc\\Shiny.Net.HttpServer.Grpc.csproj",
1011
"src\\Shiny.Net.HttpServer.AzureRelay\\Shiny.Net.HttpServer.AzureRelay.csproj",
1112
"src\\Shiny.Net.HttpServer.Mcp\\Shiny.Net.HttpServer.Mcp.csproj",

readme.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ JWT, OpenAPI, HPACK, QPACK — is built on what is in the box.
1919
| [Shiny.Net.HttpServer.Jwt](https://www.nuget.org/packages/Shiny.Net.HttpServer.Jwt) | JWT authentication on in-box crypto — no `Microsoft.IdentityModel` dependency |
2020
| [Shiny.Net.HttpServer.AzureRelay](https://www.nuget.org/packages/Shiny.Net.HttpServer.AzureRelay) | Azure Relay tunnel provider |
2121
| [Shiny.Net.HttpServer.Ssh](https://www.nuget.org/packages/Shiny.Net.HttpServer.Ssh) | SSH remote-forwarding tunnel provider, including zero-account quick tunnels |
22+
| [Shiny.Net.HttpServer.Proxy](https://www.nuget.org/packages/Shiny.Net.HttpServer.Proxy) | A reverse proxy — destination clusters, load balancing, active and passive health checks, session affinity, a transform pipeline, WebSocket and HTTP upgrade forwarding, and routes read from `IConfiguration` with hot reload |
2223
| [Shiny.Net.HttpServer.Mcp](https://www.nuget.org/packages/Shiny.Net.HttpServer.Mcp) | Model Context Protocol (Streamable HTTP) transport — host an MCP server without ASP.NET Core, including inside a MAUI app |
2324
| [Shiny.Net.HttpServer.Mediator](https://www.nuget.org/packages/Shiny.Net.HttpServer.Mediator) | Publishes Shiny.Mediator requests, commands and streams as endpoints generated at compile time. Generator included |
2425
| [Shiny.Net.HttpServer.DocumentDb](https://www.nuget.org/packages/Shiny.Net.HttpServer.DocumentDb) | Publishes a Shiny.DocumentDb type as a REST resource — list, by-id, count, CRUD, merge-patch and a live SSE tail |
@@ -130,7 +131,7 @@ is built on the one below and they compose in the same app.
130131

131132
| | |
132133
| --- | --- |
133-
| **Core** | Routing with constraints and runtime-mutable routes, ASP.NET-shaped middleware that can read and rewrite both bodies of an exchange, a real `IServiceScope` per request, results in both `Results.*` and `IActionResult` spellings, RFC 9457 problem details and an exception-handler chain, per-endpoint request timeouts, and a reverse proxy route |
134+
| **Core** | Routing with constraints and runtime-mutable routes, ASP.NET-shaped middleware that can read and rewrite both bodies of an exchange, a real `IServiceScope` per request, results in both `Results.*` and `IActionResult` spellings, RFC 9457 problem details and an exception-handler chain, per-endpoint request timeouts |
134135
| **Caching** | Conditional requests for handlers that are not serving a file — `If-Match`, `If-None-Match`, `If-Modified-Since`, 304 and 412 — plus output caching with a bounded in-memory store, where the saving is battery rather than bandwidth |
135136
| **Diagnostics** | Health checks with liveness/readiness tags, telemetry on the in-box primitives — one `Activity` per request continuing the caller's `traceparent`, and the OpenTelemetry HTTP metrics an ASP.NET dashboard already reads — and W3C access logs, rolled and pruned, written off the request path |
136137
| **Formats** | Content negotiation in both directions — responses chosen from `Accept`, request bodies from `Content-Type`. JSON out of the box; XML, MessagePack and protobuf are one line each, and a format of your own is an `IOutputFormatter`/`IInputFormatter` pair. XML and MessagePack need no dependency and no attributes on your DTOs: they read the same `JsonTypeInfo` the JSON path reads, which is what keeps them AOT-clean where `XmlSerializer` cannot be |
@@ -140,6 +141,7 @@ is built on the one below and they compose in the same app.
140141
| **TLS** | Several endpoints with per-endpoint TLS, self-signed certificates generated in managed code (iOS and Android included), client certificates, and SPKI pinning for the app's own `HttpClient` |
141142
| **OpenAPI** | An OpenAPI 3.0.3 document built entirely from compile-time metadata and your `JsonSerializerContext` — no reflection, no document object model |
142143
| **Tunnelling** | A pluggable `ITunnelProvider`, the reference relay (both ends), SSH remote forwarding, zero-account quick tunnels, and Azure Relay |
144+
| **Proxy** | A reverse proxy that goes further than a forwarded route: destination clusters with five load-balancing policies, active probes and passive failure tracking that take a bad instance out of rotation, cookie or header session affinity, a composable transform pipeline for paths, queries and headers in both directions, WebSocket and HTTP upgrade forwarding, and routes read from `IConfiguration` and reloaded onto the running server without dropping the routes that did not change |
143145
| **Mediator** | Shiny.Mediator handlers published as endpoints — requests as JSON, commands as a status code, stream requests as Server-Sent Events, all bound at compile time |
144146
| **DocumentDb** | A document type as a complete HTTP resource, with filtering, cursor paging, sparse fieldsets, ETag/If-Match, RFC 7396 merge-patch, a live SSE tail, and server-side scopes enforced on both sides of a write |
145147
| **gRPC** | Unary, client-streaming, server-streaming and bidirectional methods, deadlines, per-message compression and status in trailers — plus gRPC-Web for browsers and anything on HTTP/1.1. Marshalling is yours, so nothing reflects over your messages |

skills/shiny-httpserver/SKILL.md

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: shiny-httpserver
3-
description: Generate code using Shiny.Net.HttpServer — a dependency-light, AOT/trim-clean HTTP/1.1, HTTP/2 & HTTP/3 server that runs anywhere .NET runs, including .NET MAUI and native tvOS, where ASP.NET Core cannot. Covers routing, middleware, source-generated typed endpoints, results and JSON, content negotiation with XML/MessagePack/protobuf formatters in both directions, static files and Blazor WASM, uploads/downloads, WebSockets, SSE, sessions, OpenAPI, authentication (Basic/API key/cookie/JWT), authorization, CORS, rate limiting, IP filtering, TLS and self-signed certificates, tunnelling (relay, SSH, quick tunnels, Azure Relay, and supervised cloudflared/ngrok/tailscale agents), serving a directory over WebDAV, serving gRPC and gRPC-Web, hosting an MCP server with RFC 9728 OAuth discovery, health checks, OpenTelemetry-shaped metrics and tracing, W3C access logs, request timeouts, output caching and conditional requests, request decompression, antiforgery and browser security headers, reverse-proxy routes, mDNS/Bonjour advertising and discovery, MAUI lifecycle (background/foreground, Android foreground service, network rebinding), and an in-memory test harness.
3+
description: Generate code using Shiny.Net.HttpServer — a dependency-light, AOT/trim-clean HTTP/1.1, HTTP/2 & HTTP/3 server that runs anywhere .NET runs, including .NET MAUI and native tvOS, where ASP.NET Core cannot. Covers routing, middleware, source-generated typed endpoints, results and JSON, content negotiation with XML/MessagePack/protobuf formatters in both directions, static files and Blazor WASM, uploads/downloads, WebSockets, SSE, sessions, OpenAPI, authentication (Basic/API key/cookie/JWT), authorization, CORS, rate limiting, IP filtering, TLS and self-signed certificates, tunnelling (relay, SSH, quick tunnels, Azure Relay, and supervised cloudflared/ngrok/tailscale agents), serving a directory over WebDAV, serving gRPC and gRPC-Web, hosting an MCP server with RFC 9728 OAuth discovery, health checks, OpenTelemetry-shaped metrics and tracing, W3C access logs, request timeouts, output caching and conditional requests, request decompression, antiforgery and browser security headers, a reverse proxy with destination clusters, load balancing, health checks, session affinity, transforms, WebSocket forwarding and IConfiguration-driven routes, mDNS/Bonjour advertising and discovery, MAUI lifecycle (background/foreground, Android foreground service, network rebinding), and an in-memory test harness.
44
auto_invoke: true
55
triggers:
66
- Shiny.Net.HttpServer
@@ -106,6 +106,23 @@ triggers:
106106
- ValidateAntiforgery
107107
- MapProxy
108108
- ProxyOptions
109+
- MapReverseProxy
110+
- AddReverseProxy
111+
- reverse proxy
112+
- YARP
113+
- ProxyCluster
114+
- ProxyDestination
115+
- LoadBalancingPolicy
116+
- ILoadBalancingPolicy
117+
- SessionAffinityMode
118+
- TransformBuilder
119+
- HttpForwarder
120+
- ReverseProxyRuntime
121+
- ReverseProxyConfiguration
122+
- ForwardUpgrades
123+
- load balancing
124+
- health checks for destinations
125+
- Shiny.Net.HttpServer.Proxy
109126
- AddHttpServerAdvertisement
110127
- IHttpServerAdvertiser
111128
- AddHttpServerLocator
@@ -296,6 +313,7 @@ must hold that line, or it fails on a trimmed device build.
296313
```bash
297314
dotnet add package Shiny.Net.HttpServer # the server + the typed-endpoint generator
298315
dotnet add package Shiny.Net.HttpServer.Jwt # JWT auth
316+
dotnet add package Shiny.Net.HttpServer.Proxy # reverse proxy: clusters, LB, health, transforms
299317
dotnet add package Shiny.Net.HttpServer.Ssh # SSH + quick tunnels
300318
dotnet add package Shiny.Net.HttpServer.AzureRelay # Azure Relay tunnel (NOT AOT-clean)
301319
dotnet add package Shiny.Net.HttpServer.Mcp # Model Context Protocol transport
@@ -1020,14 +1038,74 @@ var tokens = ctx.GetRequiredService<IAntiforgery>().GetTokens(ctx);
10201038

10211039
## Proxying to another server
10221040

1041+
Needs `Shiny.Net.HttpServer.Proxy` and `using Shiny.Net.HttpServer.Proxy;` — it is **not** in the core
1042+
package. This is tier 2 (routes): a proxy route is an ordinary route with a generated handler, so
1043+
middleware, authentication and rate limiting apply to it exactly as they do to anything else.
1044+
1045+
**One destination** — reach for this first, and only escalate when the ask names more than one:
1046+
10231047
```csharp
10241048
app.MapProxy("/api/{*path}", "https://api.example.com");
10251049
app.MapProxy("/printer/{*path}", "http://192.168.1.50", o => o.RewriteHost = false);
10261050
```
10271051

10281052
Bodies stream both ways, `X-Forwarded-*` describe the original caller, an unreachable upstream is a
1029-
**502** and one that will not answer is a **504**. A protocol upgrade is not forwarded — a WebSocket
1030-
through this route will not work.
1053+
**502**, one that will not answer is a **504**, and a WebSocket (or any HTTP/1.1 upgrade) is forwarded
1054+
end to end — `o.ForwardUpgrades = false` turns that off.
1055+
1056+
**A cluster** when there is more than one instance behind the route:
1057+
1058+
```csharp
1059+
app.MapProxy("/api/{*path}", cluster =>
1060+
{
1061+
cluster.AddDestination("a", "https://a.internal");
1062+
cluster.AddDestination("b", "https://b.internal");
1063+
1064+
cluster.LoadBalancing = LoadBalancingPolicy.PowerOfTwoChoices; // default; also RoundRobin,
1065+
// LeastRequests, Random, First
1066+
cluster.HealthCheck.Active.Enabled = true; // off by default — probes cost battery
1067+
cluster.HealthCheck.Active.Path = "/health";
1068+
cluster.SessionAffinity.Mode = SessionAffinityMode.Cookie; // or Header
1069+
});
1070+
```
1071+
1072+
Passive health (on for a cluster, off for the single-destination call) takes a destination out after
1073+
`FailureThreshold` consecutive transport failures for `ReactivationPeriod`. When every destination is
1074+
out, the route answers **503**.
1075+
1076+
**Transforms** instead of `BeforeSend`/`AfterReceive` whenever the change is a path, query or header:
1077+
1078+
```csharp
1079+
cluster.Transforms
1080+
.RemovePathPrefix("/api")
1081+
.SetQueryValue("tenant", "acme")
1082+
.SetRequestHeader("X-Api-Key", key)
1083+
.RemoveResponseHeader("Server");
1084+
```
1085+
1086+
**From configuration** when the ask is routes that change without a rebuild. The shape matches YARP's,
1087+
including `{**rest}` catch-alls, and it is parsed by hand rather than reflection-bound, so it stays
1088+
AOT-clean:
1089+
1090+
```csharp
1091+
builder.AddReverseProxy(configuration.GetSection("ReverseProxy"));
1092+
// no container: var proxy = app.MapReverseProxy(configuration.GetSection("ReverseProxy"));
1093+
```
1094+
1095+
```json
1096+
{ "ReverseProxy": {
1097+
"Routes": { "api": { "ClusterId": "backend", "Match": { "Path": "/api/{**rest}" },
1098+
"Transforms": [ { "PathRemovePrefix": "/api" } ] } },
1099+
"Clusters": { "backend": { "LoadBalancingPolicy": "LeastRequests",
1100+
"Destinations": { "d1": { "Address": "https://a.internal" } } } } } }
1101+
```
1102+
1103+
Reload is applied to the running server: routes swap atomically, clusters keep their health state, and
1104+
routes mapped in code are untouched.
1105+
1106+
- **Do not put a proxy on a tunnel without authentication and rate limiting in front of it**, and keep
1107+
the destination fixed rather than reading it from the request.
1108+
- `HttpForwarder.ForwardAsync(ctx, "http://…")` forwards from inside a hand-written handler.
10311109

10321110
## Tunnelling
10331111

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
namespace Shiny.Net.HttpServer.Proxy;
2+
3+
/// <summary>Both halves of a cluster's health story.</summary>
4+
public sealed class ClusterHealthCheckOptions
5+
{
6+
/// <summary>Probing destinations on a timer, whether or not any traffic is flowing.</summary>
7+
public ActiveHealthCheckOptions Active { get; } = new();
8+
9+
/// <summary>Watching what real requests do.</summary>
10+
public PassiveHealthCheckOptions Passive { get; } = new();
11+
}
12+
13+
/// <summary>Polling destinations to find out whether they are alive.</summary>
14+
public sealed class ActiveHealthCheckOptions
15+
{
16+
/// <summary>Off by default: a probe every few seconds against every destination is not free,
17+
/// and on a battery-powered device it is the kind of not-free that shows up in a graph.</summary>
18+
public bool Enabled { get; set; }
19+
20+
/// <summary>How often each destination is probed.</summary>
21+
public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(10);
22+
23+
/// <summary>How long a probe may take before it counts as a failure.</summary>
24+
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(5);
25+
26+
/// <summary>Path probed on each destination, relative to its address (or its <see cref="ProxyDestination.Health"/>).</summary>
27+
public string Path { get; set; } = "/health";
28+
29+
/// <summary>
30+
/// Decides whether a probe response means healthy. The default accepts any 2xx.
31+
/// <para>
32+
/// Worth replacing when the upstream reports its own readiness in a body or a header — an
33+
/// upstream that returns 200 while refusing work is exactly the case an active check exists for.
34+
/// </para>
35+
/// </summary>
36+
public Func<HttpResponseMessage, bool>? Policy { get; set; }
37+
}
38+
39+
/// <summary>Taking a destination out of rotation because real requests to it are failing.</summary>
40+
public sealed class PassiveHealthCheckOptions
41+
{
42+
/// <summary>
43+
/// On by default for a cluster, off for the single-destination <c>MapProxy</c> shorthand.
44+
/// <para>
45+
/// The distinction matters: with one destination there is nowhere else to send the request, so
46+
/// quarantining it would only turn a 502 that names a real upstream failure into a 503 that
47+
/// names nothing.
48+
/// </para>
49+
/// </summary>
50+
public bool Enabled { get; set; } = true;
51+
52+
/// <summary>Consecutive failures before the destination is taken out.</summary>
53+
public int FailureThreshold { get; set; } = 3;
54+
55+
/// <summary>How long it stays out before traffic is tried again.</summary>
56+
public TimeSpan ReactivationPeriod { get; set; } = TimeSpan.FromSeconds(30);
57+
}

0 commit comments

Comments
 (0)