diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index c8026213..9cf54b42 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -6,7 +6,7 @@ Dev Proxy is a cross-platform .NET API simulator and proxy for testing how appli
## Critical (Non-Obvious)
-- **Proxy engine:** Uses [svrooij/unobtanium-web-proxy](https://github.com/svrooij/unobtanium-web-proxy) (fork of Titanium.Web.Proxy)
+- **Proxy engine:** Built on [ASP.NET Core Kestrel](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel) (a custom forward-proxy + MITM engine; see `DevProxy.Proxy.Kestrel`)
- **Config tokens:** Paths in config files can use `~appFolder` for resolution
- **MCP Server:** Use [@devproxy/mcp](https://www.npmjs.com/package/@devproxy/mcp) to programmatically retrieve up-to-date docs and JSON schemas
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d8e9f732..18ce286a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,4 +28,4 @@ Typos are embarrassing! Most PRs that fix typos will be accepted immediately. To
## Our foundation
-The Dev Proxy is built with .NET 9 and uses the [Unobtanium Web Proxy](https://github.com/svrooij/unobtanium-web-proxy).
+The Dev Proxy is built with .NET and uses [ASP.NET Core Kestrel](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel) for its forward-proxy engine.
diff --git a/DevProxy.Abstractions.Tests/DevProxy.Abstractions.Tests.csproj b/DevProxy.Abstractions.Tests/DevProxy.Abstractions.Tests.csproj
new file mode 100644
index 00000000..988daa36
--- /dev/null
+++ b/DevProxy.Abstractions.Tests/DevProxy.Abstractions.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+ $(NoWarn);CA1707;CA1861
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DevProxy.Abstractions.Tests/Proxy/Http/ForwardingInvariantsTests.cs b/DevProxy.Abstractions.Tests/Proxy/Http/ForwardingInvariantsTests.cs
new file mode 100644
index 00000000..17de3761
--- /dev/null
+++ b/DevProxy.Abstractions.Tests/Proxy/Http/ForwardingInvariantsTests.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Proxy.Http;
+using Xunit;
+
+namespace DevProxy.Abstractions.Tests.Proxy.Http;
+
+public class ForwardingInvariantsTests
+{
+ [Theory]
+ [InlineData("Connection")]
+ [InlineData("proxy-connection")]
+ [InlineData("KEEP-ALIVE")]
+ [InlineData("Transfer-Encoding")]
+ [InlineData("te")]
+ [InlineData("Trailer")]
+ [InlineData("Upgrade")]
+ [InlineData("Proxy-Authenticate")]
+ [InlineData("Proxy-Authorization")]
+ public void HopByHopHeaders_ContainsExpected_CaseInsensitive(string name) =>
+ Assert.Contains(name, (IReadOnlySet)ForwardingInvariants.HopByHopHeaders);
+
+ [Theory]
+ [InlineData("Content-Type")]
+ [InlineData("Host")]
+ [InlineData("Set-Cookie")]
+ [InlineData("Authorization")]
+ public void HopByHopHeaders_ExcludesEndToEndHeaders(string name) =>
+ Assert.DoesNotContain(name, (IReadOnlySet)ForwardingInvariants.HopByHopHeaders);
+}
diff --git a/DevProxy.Abstractions.Tests/Proxy/Http/HeaderCollectionTests.cs b/DevProxy.Abstractions.Tests/Proxy/Http/HeaderCollectionTests.cs
new file mode 100644
index 00000000..5dac10af
--- /dev/null
+++ b/DevProxy.Abstractions.Tests/Proxy/Http/HeaderCollectionTests.cs
@@ -0,0 +1,105 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Proxy.Http;
+using Xunit;
+
+namespace DevProxy.Abstractions.Tests.Proxy.Http;
+
+public class HeaderCollectionTests
+{
+ [Fact]
+ public void Add_PreservesWireOrderAndDuplicates()
+ {
+ var headers = new HeaderCollection();
+ headers.Add("Set-Cookie", "a=1");
+ headers.Add("Set-Cookie", "b=2");
+
+ var all = headers.GetAll("set-cookie").ToList();
+
+ Assert.Equal(2, all.Count);
+ Assert.Equal("a=1", all[0].Value);
+ Assert.Equal("b=2", all[1].Value);
+ }
+
+ [Fact]
+ public void Contains_And_GetFirst_AreCaseInsensitive()
+ {
+ var headers = new HeaderCollection();
+ headers.Add("Content-Type", "application/json");
+
+ Assert.True(headers.Contains("content-type"));
+ Assert.Equal("application/json", headers.GetFirst("CONTENT-TYPE")!.Value);
+ }
+
+ [Fact]
+ public void GetFirst_ReturnsNull_WhenAbsent()
+ {
+ var headers = new HeaderCollection();
+ Assert.Null(headers.GetFirst("X-Missing"));
+ Assert.Empty(headers.GetAll("X-Missing"));
+ }
+
+ [Fact]
+ public void Replace_CollapsesDuplicatesToSingleValue()
+ {
+ var headers = new HeaderCollection();
+ headers.Add("X-Test", "old1");
+ headers.Add("X-Test", "old2");
+
+ headers.Replace("x-test", "new");
+
+ var all = headers.GetAll("X-Test").ToList();
+ Assert.Single(all);
+ Assert.Equal("new", all[0].Value);
+ }
+
+ [Fact]
+ public void Replace_AddsWhenAbsent()
+ {
+ var headers = new HeaderCollection();
+ headers.Replace("X-New", "value");
+ Assert.Equal("value", headers.GetFirst("X-New")!.Value);
+ }
+
+ [Fact]
+ public void Remove_RemovesAllOccurrences_AndReportsResult()
+ {
+ var headers = new HeaderCollection();
+ headers.Add("X-Dup", "1");
+ headers.Add("X-Dup", "2");
+
+ Assert.True(headers.Remove("x-dup"));
+ Assert.Equal(0, headers.Count);
+ Assert.False(headers.Remove("x-dup"));
+ }
+
+ [Fact]
+ public void AddRange_And_Linq_Work()
+ {
+ var headers = new HeaderCollection(new[]
+ {
+ new HttpHeader("A", "1"),
+ new HttpHeader("B", "2"),
+ });
+ headers.AddRange(new[] { new HttpHeader("C", "3") });
+
+ Assert.Equal(3, headers.Count);
+ Assert.Contains(headers, h => h.Name == "C");
+ Assert.Equal("1", headers.First(h => h.Name == "A").Value);
+ }
+
+ [Fact]
+ public void SeedingConstructor_PreservesOrder()
+ {
+ var headers = new HeaderCollection(new[]
+ {
+ new HttpHeader("First", "1"),
+ new HttpHeader("Second", "2"),
+ });
+
+ var names = headers.Select(h => h.Name).ToList();
+ Assert.Equal(new[] { "First", "Second" }, names);
+ }
+}
diff --git a/DevProxy.Abstractions.Tests/Proxy/RootTrustPolicyTests.cs b/DevProxy.Abstractions.Tests/Proxy/RootTrustPolicyTests.cs
new file mode 100644
index 00000000..74a1939e
--- /dev/null
+++ b/DevProxy.Abstractions.Tests/Proxy/RootTrustPolicyTests.cs
@@ -0,0 +1,99 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Proxy;
+using Xunit;
+
+namespace DevProxy.Abstractions.Tests.Proxy;
+
+public class RootTrustPolicyTests
+{
+ // ── installCert gate ────────────────────────────────────────────────
+ [Theory]
+ [InlineData(true, false)] // mac
+ [InlineData(false, true)] // windows
+ [InlineData(false, false)] // linux
+ public void Decide_InstallCertFalse_AlwaysSkips(bool isMac, bool isWindows)
+ {
+ var action = RootTrustPolicy.Decide(
+ isMac, isWindows, installCert: false, noFirstRun: false, isFirstRun: true, firstRunAnswer: "y");
+
+ Assert.Equal(RootTrustAction.Skip, action);
+ }
+
+ // ── Windows ─────────────────────────────────────────────────────────
+ [Theory]
+ [InlineData(true, false)] // first run, ignored on Windows
+ [InlineData(false, true)] // noFirstRun, ignored on Windows
+ public void Decide_Windows_InstallCert_AlwaysTrustsStore(bool isFirstRun, bool noFirstRun)
+ {
+ var action = RootTrustPolicy.Decide(
+ isMac: false, isWindows: true, installCert: true, noFirstRun: noFirstRun, isFirstRun: isFirstRun, firstRunAnswer: null);
+
+ Assert.Equal(RootTrustAction.TrustWindowsStore, action);
+ }
+
+ // ── macOS first-run flow ────────────────────────────────────────────
+ [Fact]
+ public void Decide_Mac_FirstRun_EmptyAnswer_Trusts()
+ {
+ var action = RootTrustPolicy.Decide(
+ isMac: true, isWindows: false, installCert: true, noFirstRun: false, isFirstRun: true, firstRunAnswer: "");
+
+ Assert.Equal(RootTrustAction.TrustMacKeychain, action);
+ }
+
+ [Theory]
+ [InlineData("y")]
+ [InlineData("Y")]
+ [InlineData("yes")]
+ [InlineData("anything")]
+ public void Decide_Mac_FirstRun_NonNoAnswer_Trusts(string answer)
+ {
+ var action = RootTrustPolicy.Decide(
+ isMac: true, isWindows: false, installCert: true, noFirstRun: false, isFirstRun: true, firstRunAnswer: answer);
+
+ Assert.Equal(RootTrustAction.TrustMacKeychain, action);
+ }
+
+ [Theory]
+ [InlineData("n")]
+ [InlineData("N")]
+ [InlineData(" n ")]
+ public void Decide_Mac_FirstRun_AnswerNo_Skips(string answer)
+ {
+ var action = RootTrustPolicy.Decide(
+ isMac: true, isWindows: false, installCert: true, noFirstRun: false, isFirstRun: true, firstRunAnswer: answer);
+
+ Assert.Equal(RootTrustAction.Skip, action);
+ }
+
+ [Fact]
+ public void Decide_Mac_NotFirstRun_Skips()
+ {
+ var action = RootTrustPolicy.Decide(
+ isMac: true, isWindows: false, installCert: true, noFirstRun: false, isFirstRun: false, firstRunAnswer: "y");
+
+ Assert.Equal(RootTrustAction.Skip, action);
+ }
+
+ [Fact]
+ public void Decide_Mac_NoFirstRun_Skips()
+ {
+ var action = RootTrustPolicy.Decide(
+ isMac: true, isWindows: false, installCert: true, noFirstRun: true, isFirstRun: true, firstRunAnswer: "y");
+
+ Assert.Equal(RootTrustAction.Skip, action);
+ }
+
+ // ── Linux ───────────────────────────────────────────────────────────
+ [Fact]
+ public void Decide_Linux_InstallCert_IsManual()
+ {
+ var action = RootTrustPolicy.Decide(
+ isMac: false, isWindows: false, installCert: true, noFirstRun: false, isFirstRun: true, firstRunAnswer: "y");
+
+ Assert.Equal(RootTrustAction.ManualLinux, action);
+ }
+}
diff --git a/DevProxy.Abstractions.Tests/Proxy/SystemProxyAddressTests.cs b/DevProxy.Abstractions.Tests/Proxy/SystemProxyAddressTests.cs
new file mode 100644
index 00000000..0bde0039
--- /dev/null
+++ b/DevProxy.Abstractions.Tests/Proxy/SystemProxyAddressTests.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Proxy;
+using Xunit;
+
+namespace DevProxy.Abstractions.Tests.Proxy;
+
+public class SystemProxyAddressTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("0.0.0.0")]
+ [InlineData("::")]
+ public void ResolveHost_WildcardOrEmpty_CollapsesToLoopback(string? ipAddress) =>
+ Assert.Equal("127.0.0.1", SystemProxyAddress.ResolveHost(ipAddress));
+
+ [Theory]
+ [InlineData("127.0.0.1")]
+ [InlineData("10.0.0.5")]
+ [InlineData("192.168.1.10")]
+ public void ResolveHost_ExplicitAddress_PassesThrough(string ipAddress) =>
+ Assert.Equal(ipAddress, SystemProxyAddress.ResolveHost(ipAddress));
+
+ [Fact]
+ public void ToHostPort_ComposesNormalizedHostAndPort() =>
+ Assert.Equal("127.0.0.1:8000", SystemProxyAddress.ToHostPort("0.0.0.0", 8000));
+
+ [Fact]
+ public void ToHostPort_ExplicitAddress() =>
+ Assert.Equal("10.0.0.5:9090", SystemProxyAddress.ToHostPort("10.0.0.5", 9090));
+
+ [Fact]
+ public void ToHostPort_NullAddress_UsesLoopback() =>
+ Assert.Equal("127.0.0.1:8080", SystemProxyAddress.ToHostPort(null, 8080));
+}
diff --git a/DevProxy.Abstractions.Tests/Proxy/WatchedHostExtractorTests.cs b/DevProxy.Abstractions.Tests/Proxy/WatchedHostExtractorTests.cs
new file mode 100644
index 00000000..4d4ac5f7
--- /dev/null
+++ b/DevProxy.Abstractions.Tests/Proxy/WatchedHostExtractorTests.cs
@@ -0,0 +1,61 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text.RegularExpressions;
+using DevProxy.Abstractions.Proxy;
+using Xunit;
+
+namespace DevProxy.Abstractions.Tests.Proxy;
+
+public class WatchedHostExtractorTests
+{
+ // Mirrors PluginServiceExtensions.ConvertToRegex so tests feed the same
+ // UrlToWatch regex shape both engines receive at runtime.
+ private static Regex ToUrlRegex(string pattern) =>
+ new(
+ $"^{Regex.Escape(pattern).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase)}$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ private static string HostPattern(string urlPattern) =>
+ WatchedHostExtractor.ToHostRegex(ToUrlRegex(urlPattern)).ToString();
+
+ [Fact]
+ public void ToHostRegex_StripsSchemeAndPath() =>
+ Assert.Equal("^api\\.contoso\\.com$", HostPattern("https://api.contoso.com/v1/users"));
+
+ [Fact]
+ public void ToHostRegex_HostOnlyPattern_NoScheme() =>
+ Assert.Equal("^api\\.contoso\\.com$", HostPattern("api.contoso.com"));
+
+ [Fact]
+ public void ToHostRegex_StripsPort() =>
+ Assert.Equal("^localhost$", HostPattern("https://localhost:3000/*"));
+
+ [Fact]
+ public void ToHostRegex_PreservesWildcardSubdomain() =>
+ Assert.Equal("^.*\\.contoso\\.com$", HostPattern("https://*.contoso.com/*"));
+
+ [Fact]
+ public void ToHostRegex_GlobalWildcard() =>
+ Assert.Equal("^.*$", HostPattern("https://*/*"));
+
+ [Fact]
+ public void ToHostRegex_NoPath_NoTrailingSlash() =>
+ Assert.Equal("^api\\.contoso\\.com$", HostPattern("https://api.contoso.com"));
+
+ [Theory]
+ [InlineData("https://jsonplaceholder.typicode.com/*", "jsonplaceholder.typicode.com", true)]
+ [InlineData("https://jsonplaceholder.typicode.com/*", "example.com", false)]
+ [InlineData("https://*.contoso.com/*", "api.contoso.com", true)]
+ [InlineData("https://*.contoso.com/*", "contoso.net", false)]
+ public void ToHostRegex_MatchesExpectedHosts(string urlPattern, string host, bool expected)
+ {
+ var hostRegex = WatchedHostExtractor.ToHostRegex(ToUrlRegex(urlPattern));
+ Assert.Equal(expected, hostRegex.IsMatch(host));
+ }
+
+ [Fact]
+ public void ToHostRegex_Throws_OnNull() =>
+ Assert.Throws(() => WatchedHostExtractor.ToHostRegex(null!));
+}
diff --git a/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs b/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs
new file mode 100644
index 00000000..45ff6060
--- /dev/null
+++ b/DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Utils;
+using Xunit;
+
+namespace DevProxy.Abstractions.Tests.Utils;
+
+public class ProxyUtilsTests
+{
+ [Theory]
+ [InlineData("wss://ws.example.test/socket", "https://ws.example.test/socket")]
+ [InlineData("ws://ws.example.test/socket", "http://ws.example.test/socket")]
+ [InlineData("WSS://ws.example.test/*", "https://ws.example.test/*")]
+ [InlineData("Ws://ws.example.test/*", "http://ws.example.test/*")]
+ public void NormalizeWebSocketScheme_RewritesWebSocketSchemes(string input, string expected) =>
+ Assert.Equal(expected, ProxyUtils.NormalizeWebSocketScheme(input));
+
+ [Theory]
+ [InlineData("https://api.contoso.com/v1")]
+ [InlineData("http://api.contoso.com/v1")]
+ [InlineData("api.contoso.com/*")]
+ [InlineData("")]
+ public void NormalizeWebSocketScheme_LeavesOtherSchemesUntouched(string input) =>
+ Assert.Equal(input, ProxyUtils.NormalizeWebSocketScheme(input));
+
+ [Fact]
+ public void NormalizeWebSocketScheme_DoesNotRewriteSchemeInPath() =>
+ // only a leading ws(s):// is a scheme; an embedded one must survive
+ Assert.Equal(
+ "https://host/redirect?to=ws://other",
+ ProxyUtils.NormalizeWebSocketScheme("https://host/redirect?to=ws://other"));
+
+ [Fact]
+ public void NormalizeWebSocketScheme_NullThrows() =>
+ Assert.Throws(() => ProxyUtils.NormalizeWebSocketScheme(null!));
+}
diff --git a/DevProxy.Abstractions/DevProxy.Abstractions.csproj b/DevProxy.Abstractions/DevProxy.Abstractions.csproj
index 434b45d3..c9376923 100644
--- a/DevProxy.Abstractions/DevProxy.Abstractions.csproj
+++ b/DevProxy.Abstractions/DevProxy.Abstractions.csproj
@@ -5,7 +5,7 @@
DevProxy.Abstractions
enable
enable
- 3.1.0
+ 4.0.0
false
true
true
@@ -44,7 +44,6 @@
-
diff --git a/DevProxy.Abstractions/Extensions/FuncExtensions.cs b/DevProxy.Abstractions/Extensions/FuncExtensions.cs
deleted file mode 100644
index 4f0565db..00000000
--- a/DevProxy.Abstractions/Extensions/FuncExtensions.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-// See the LICENSE file in the project root for more information.
-
-// from: https://github.com/justcoding121/titanium-web-proxy/blob/902504a324425e4e49fc5ba604c2b7fa172e68ce/src/Titanium.Web.Proxy/Extensions/FuncExtensions.cs
-
-#pragma warning disable IDE0130
-namespace Titanium.Web.Proxy.EventArguments;
-#pragma warning restore IDE0130
-
-public static class FuncExtensions
-{
- internal static async Task InvokeAsync(this AsyncEventHandler callback, object sender, T args, ExceptionHandler? exceptionFunc)
- {
- var invocationList = callback.GetInvocationList();
-
- foreach (var @delegate in invocationList)
- {
- await InternalInvokeAsync((AsyncEventHandler)@delegate, sender, args, exceptionFunc);
- }
- }
-
- private static async Task InternalInvokeAsync(AsyncEventHandler callback, object sender, T e, ExceptionHandler? exceptionFunc)
- {
- try
- {
- await callback(sender, e);
- }
- catch (Exception ex)
- {
- exceptionFunc?.Invoke(new InvalidOperationException("Exception thrown in user event", ex));
- }
- }
-}
\ No newline at end of file
diff --git a/DevProxy.Abstractions/Plugins/PluginEvents.cs b/DevProxy.Abstractions/Plugins/PluginEvents.cs
index de9298f0..cad3c058 100644
--- a/DevProxy.Abstractions/Plugins/PluginEvents.cs
+++ b/DevProxy.Abstractions/Plugins/PluginEvents.cs
@@ -2,11 +2,11 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
-using Titanium.Web.Proxy.Http;
+using DevProxy.Abstractions.Proxy.Http;
namespace DevProxy.Abstractions.Plugins;
-public class ThrottlerInfo(string throttlingKey, Func shouldThrottle, DateTime resetTime)
+public class ThrottlerInfo(string throttlingKey, Func shouldThrottle, DateTime resetTime)
{
///
/// Time when the throttling window will be reset
@@ -20,7 +20,7 @@ public class ThrottlerInfo(string throttlingKey, Func
- public Func ShouldThrottle { get; private set; } = shouldThrottle ?? throw new ArgumentNullException(nameof(shouldThrottle));
+ public Func ShouldThrottle { get; private set; } = shouldThrottle ?? throw new ArgumentNullException(nameof(shouldThrottle));
///
/// Throttling key used to identify which requests should be throttled.
/// Can be set to a hostname, full URL or a custom string value, that
diff --git a/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs b/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs
new file mode 100644
index 00000000..de11004c
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs
@@ -0,0 +1,71 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.Frozen;
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// The forwarding contract the proxy engine must honor so that plugins see a
+/// consistent model. These are the
+/// rules that, if violated, silently corrupt traffic — documented here as the
+/// single source of truth and partially enforced via the shared
+/// set.
+///
+///
+/// client ──request──► [strip hop-by-hop] ─► [plugins] ─► [re-add framing] ──► origin
+/// client ◄─response── [re-add framing] ◄─ [plugins] ◄─ [strip hop-by-hop] ◄── origin
+///
+///
+///
+/// -
+/// Hop-by-hop headers () are connection-scoped
+/// and MUST be stripped before forwarding, never copied end-to-end. Any header
+/// named in a request's Connection header is also hop-by-hop for that message.
+///
+/// -
+/// Content-Length / Transfer-Encoding. After a plugin mutates a body the
+/// engine MUST recompute Content-Length (or switch to chunked) and ensure
+/// exactly one framing mechanism is present — never both, to avoid request smuggling.
+///
+/// -
+/// Host. The outgoing Host header MUST match the forwarded
+/// RequestUri authority after any redirect/rewrite.
+///
+/// -
+/// Set-Cookie. Multiple Set-Cookie headers MUST be preserved as
+/// separate headers and never folded into one comma-joined value.
+///
+/// -
+/// Decompressed bodies. Plugins always observe decompressed payloads. The
+/// engine decodes Content-Encoding on read; on write-back it MUST re-encode
+/// (or drop the encoding and fix Content-Encoding/Content-Length)
+/// so the client still receives a valid message. See .
+///
+/// -
+/// Upstream HTTP version. Requests are forwarded as HTTP/1.1. h2 clients are
+/// negotiated down to HTTP/1.1 via ALPN; h2-only clients are blind-tunnelled and
+/// never reach this forwarding path.
+///
+///
+///
+public static class ForwardingInvariants
+{
+ ///
+ /// Connection-scoped headers that must be stripped before forwarding a message
+ /// (RFC 9110 §7.6.1). Case-insensitive.
+ ///
+ public static FrozenSet HopByHopHeaders { get; } = new[]
+ {
+ "Connection",
+ "Proxy-Connection",
+ "Keep-Alive",
+ "Transfer-Encoding",
+ "TE",
+ "Trailer",
+ "Upgrade",
+ "Proxy-Authenticate",
+ "Proxy-Authorization",
+ }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
+}
diff --git a/DevProxy.Abstractions/Proxy/Http/HeaderCollection.cs b/DevProxy.Abstractions/Proxy/Http/HeaderCollection.cs
new file mode 100644
index 00000000..a0d7660d
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/HeaderCollection.cs
@@ -0,0 +1,97 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections;
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// Default in-memory . Preserves insertion (wire)
+/// order and matches names case-insensitively. Used by the proxy engine, mocked
+/// responses, and tests.
+///
+public sealed class HeaderCollection : IHeaderCollection
+{
+ private readonly List _headers;
+
+ /// Creates an empty collection.
+ public HeaderCollection() => _headers = [];
+
+ /// Creates a collection seeded with the given headers (wire order preserved).
+ public HeaderCollection(IEnumerable headers)
+ {
+ ArgumentNullException.ThrowIfNull(headers);
+ _headers = [.. headers];
+ }
+
+ ///
+ public int Count => _headers.Count;
+
+ ///
+ public bool Contains(string name)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ return _headers.Exists(h => NameEquals(h.Name, name));
+ }
+
+ ///
+ public IHttpHeader? GetFirst(string name)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ return _headers.Find(h => NameEquals(h.Name, name));
+ }
+
+ ///
+ public IEnumerable GetAll(string name)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ return _headers.Where(h => NameEquals(h.Name, name));
+ }
+
+ ///
+ public void Add(string name, string value)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentNullException.ThrowIfNull(value);
+ _headers.Add(new HttpHeader(name, value));
+ }
+
+ ///
+ public void Add(IHttpHeader header)
+ {
+ ArgumentNullException.ThrowIfNull(header);
+ _headers.Add(header);
+ }
+
+ ///
+ public void AddRange(IEnumerable headers)
+ {
+ ArgumentNullException.ThrowIfNull(headers);
+ _headers.AddRange(headers);
+ }
+
+ ///
+ public void Replace(string name, string value)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ ArgumentNullException.ThrowIfNull(value);
+ _ = Remove(name);
+ _headers.Add(new HttpHeader(name, value));
+ }
+
+ ///
+ public bool Remove(string name)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+ return _headers.RemoveAll(h => NameEquals(h.Name, name)) > 0;
+ }
+
+ ///
+ public IEnumerator GetEnumerator() => _headers.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ private static bool NameEquals(string a, string b) =>
+ string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/DevProxy.Abstractions/Proxy/Http/HttpHeader.cs b/DevProxy.Abstractions/Proxy/Http/HttpHeader.cs
new file mode 100644
index 00000000..6585315c
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/HttpHeader.cs
@@ -0,0 +1,12 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// Default immutable implementation. Plugins use this
+/// to construct headers for mocked responses (the canonical replacement for the
+/// engine-specific header type).
+///
+public sealed record HttpHeader(string Name, string Value) : IHttpHeader;
diff --git a/DevProxy.Abstractions/Proxy/Http/IHeaderCollection.cs b/DevProxy.Abstractions/Proxy/Http/IHeaderCollection.cs
new file mode 100644
index 00000000..3f4d5dce
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/IHeaderCollection.cs
@@ -0,0 +1,49 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// An ordered, case-insensitive collection of HTTP headers. Implements
+/// so LINQ (FirstOrDefault, Any,
+/// Select, Where) works directly against it.
+///
+///
+/// Header names are matched case-insensitively (RFC 9110). A name may appear
+/// more than once (e.g. Set-Cookie); returns every
+/// occurrence in wire order while returns the first.
+///
+///
+public interface IHeaderCollection : IEnumerable
+{
+ /// Number of header entries (counts repeated names separately).
+ int Count { get; }
+
+ /// True if at least one header with the given name exists.
+ bool Contains(string name);
+
+ /// First header with the given name, or null.
+ IHttpHeader? GetFirst(string name);
+
+ /// All headers with the given name, in wire order (never null).
+ IEnumerable GetAll(string name);
+
+ /// Appends a header. Does not remove existing headers with the same name.
+ void Add(string name, string value);
+
+ /// Appends a header. Does not remove existing headers with the same name.
+ void Add(IHttpHeader header);
+
+ /// Appends a range of headers.
+ void AddRange(IEnumerable headers);
+
+ ///
+ /// Replaces all headers with the given name with a single header of that
+ /// name and value, adding it if none existed.
+ ///
+ void Replace(string name, string value);
+
+ /// Removes all headers with the given name. Returns true if any were removed.
+ bool Remove(string name);
+}
diff --git a/DevProxy.Abstractions/Proxy/Http/IHttpHeader.cs b/DevProxy.Abstractions/Proxy/Http/IHttpHeader.cs
new file mode 100644
index 00000000..c40aaaf7
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/IHttpHeader.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// A single HTTP header. Canonical Dev Proxy model — independent of any
+/// underlying proxy engine. Immutable: to change a header, replace it in the
+/// owning .
+///
+public interface IHttpHeader
+{
+ /// Header name (case-insensitive per RFC 9110).
+ string Name { get; }
+
+ /// Header value.
+ string Value { get; }
+}
diff --git a/DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs b/DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs
new file mode 100644
index 00000000..ba9f822e
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs
@@ -0,0 +1,72 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// Common surface shared by and
+/// .
+///
+///
+/// Body visibility contract: and
+/// always expose the decompressed payload
+/// (Content-Encoding removed) regardless of how it travelled on the wire. The
+/// engine is responsible for transparently decoding on read and re-encoding /
+/// fixing Content-Length and Content-Encoding on write-back. See
+/// .
+///
+///
+///
+/// The body is only materialized when the engine buffers it for the exchange. For
+/// ordinary (non-streamed) responses the full body is buffered and is readable and
+/// mutable. For streamed responses (text/event-stream) the body is forwarded
+/// live and only a capped copy is retained for read-only inspection; a streaming
+/// pass-through exchange retains nothing, so accessing then yields
+/// an empty buffer and may be true while the bytes are not
+/// available to inspect.
+///
+///
+public interface IHttpMessage
+{
+ /// The message headers.
+ IHeaderCollection Headers { get; }
+
+ ///
+ /// Value of the Content-Type header, or null when absent.
+ ///
+ string? ContentType { get; }
+
+ ///
+ /// True when the message carries (or is declared to carry) a body. This can
+ /// be true even when the body is not buffered for inspection.
+ ///
+ bool HasBody { get; }
+
+ ///
+ /// The decompressed body bytes when buffered for this exchange; otherwise an
+ /// empty buffer. See the body-visibility contract on .
+ ///
+ ReadOnlyMemory Body { get; }
+
+ ///
+ /// The decompressed body decoded as text using the charset from
+ /// (UTF-8 when unspecified). Empty when the body is
+ /// not buffered.
+ ///
+ string BodyString { get; }
+
+ ///
+ /// Replaces the body with the given decompressed bytes and updates
+ /// Content-Length. When is supplied the
+ /// Content-Type header is set accordingly.
+ ///
+ void SetBody(ReadOnlyMemory body, string? contentType = null);
+
+ ///
+ /// Replaces the body with the UTF-8 encoding of and
+ /// updates Content-Length. When is
+ /// supplied the Content-Type header is set accordingly.
+ ///
+ void SetBodyString(string body, string? contentType = null);
+}
diff --git a/DevProxy.Abstractions/Proxy/Http/IHttpRequest.cs b/DevProxy.Abstractions/Proxy/Http/IHttpRequest.cs
new file mode 100644
index 00000000..79389339
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/IHttpRequest.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// An intercepted HTTP request in the canonical Dev Proxy model.
+///
+public interface IHttpRequest : IHttpMessage
+{
+ /// Absolute request URI. The canonical identity of the request target.
+ Uri RequestUri { get; }
+
+ ///
+ /// Convenience accessor equal to .AbsoluteUri.
+ ///
+ string Url { get; set; }
+
+ /// HTTP method (e.g. GET, POST), upper-cased.
+ string Method { get; }
+
+ /// Negotiated HTTP version for this request.
+ Version HttpVersion { get; }
+
+ ///
+ /// True when this request is a WebSocket upgrade handshake
+ /// (Upgrade: websocket).
+ ///
+ bool IsWebSocketRequest { get; }
+}
diff --git a/DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs b/DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs
new file mode 100644
index 00000000..171a3b48
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// An intercepted (or mocked) HTTP response in the canonical Dev Proxy model.
+///
+public interface IHttpResponse : IHttpMessage
+{
+ /// HTTP status code.
+ HttpStatusCode StatusCode { get; set; }
+
+ ///
+ /// Reason phrase. When null the engine emits the default phrase for
+ /// .
+ ///
+ string? StatusDescription { get; set; }
+
+ /// Negotiated HTTP version for this response.
+ Version HttpVersion { get; }
+}
diff --git a/DevProxy.Abstractions/Proxy/Http/IProxySession.cs b/DevProxy.Abstractions/Proxy/Http/IProxySession.cs
new file mode 100644
index 00000000..550da2fd
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/IProxySession.cs
@@ -0,0 +1,74 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// A single logical request/response exchange — the canonical replacement for
+/// the engine-specific session type that plugins previously consumed via
+/// Session.HttpClient.Request / .Response.
+///
+///
+/// Lifetime & identity. One TCP connection can carry many exchanges
+/// (HTTP keep-alive), a CONNECT tunnel, or a WebSocket session. Each exchange
+/// gets a stable logical . Plugins MUST key per-exchange
+/// state on and never on object identity / hash codes —
+/// reusing a connection must not leak state between exchanges.
+///
+///
+public interface IProxySession
+{
+ ///
+ /// Stable, logical identifier for this exchange. Unique per request/response
+ /// pair even when the underlying connection is reused.
+ ///
+ string SessionId { get; }
+
+ /// The intercepted request.
+ IHttpRequest Request { get; }
+
+ ///
+ /// The response, once available (origin response received, or a plugin
+ /// produced one via ).
+ /// null during the request phase before any response exists.
+ ///
+ IHttpResponse? Response { get; }
+
+ ///
+ /// PID of the local process that originated the request, when the engine
+ /// could resolve it; otherwise null.
+ ///
+ int? ProcessId { get; }
+
+ ///
+ /// True once a response has been produced for this exchange (by the origin
+ /// or a plugin). Equivalent to being non-null.
+ ///
+ bool HasResponse { get; }
+
+ ///
+ /// Produces a mocked text response and short-circuits the exchange: no
+ /// request is sent upstream and becomes non-null. This
+ /// is the canonical mocking primitive (the replacement for
+ /// GenericResponse).
+ ///
+ void Respond(string body, HttpStatusCode statusCode, IEnumerable headers);
+
+ ///
+ /// Produces a mocked binary response and short-circuits the exchange.
+ ///
+ void Respond(ReadOnlyMemory body, HttpStatusCode statusCode, IEnumerable headers);
+
+ ///
+ /// Mocks a WebSocket exchange: when this request is a WebSocket upgrade
+ /// (), the engine completes the
+ /// handshake itself (no origin is contacted) and then runs
+ /// over the live connection so the plugin can script the conversation. This is the
+ /// WebSocket analogue of :
+ /// the plugin declares intent here during BeforeRequest and the engine executes it.
+ ///
+ void HandleWebSocket(Func handler);
+}
diff --git a/DevProxy.Abstractions/Proxy/Http/IWebSocketConnection.cs b/DevProxy.Abstractions/Proxy/Http/IWebSocketConnection.cs
new file mode 100644
index 00000000..5f1fc0d0
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/Http/IWebSocketConnection.cs
@@ -0,0 +1,59 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net.WebSockets;
+using System.Text;
+
+namespace DevProxy.Abstractions.Proxy.Http;
+
+///
+/// A single WebSocket message handed to / produced by a plugin that mocks a
+/// WebSocket exchange. The reuses the framework
+/// (Text/Binary/Close) so the
+/// abstraction doesn't invent a parallel enum.
+///
+/// Whether the payload is text, binary, or a close signal.
+///
+/// The (already reassembled) message payload. Empty for .
+///
+public sealed record WebSocketMessage(WebSocketMessageType Type, ReadOnlyMemory Data)
+{
+ /// The payload decoded as UTF-8 text (useful for Text messages).
+ public string Text => Encoding.UTF8.GetString(Data.Span);
+}
+
+///
+/// A duplex WebSocket channel the engine hands to a plugin after it has completed the
+/// upgrade handshake on the client's behalf. The engine owns the transport (framing via
+/// the framework ); the plugin owns the behavior (what to send,
+/// how to react to received messages). This mirrors the request/response mocking split:
+/// the plugin declares intent, the engine executes it.
+///
+///
+/// plugin handler engine (IWebSocketConnection)
+/// ────────────── ─────────────────────────────
+/// SendTextAsync("welcome") ───────────▶ WebSocket.SendAsync(Text)
+/// var m = ReceiveAsync() ◀─────────── WebSocket.ReceiveAsync (reassembled)
+/// if m.Text == "ping" → SendTextAsync("pong")
+/// CloseAsync() ───────────▶ WebSocket.CloseOutputAsync
+///
+///
+public interface IWebSocketConnection
+{
+ /// Sends a UTF-8 text message to the client.
+ Task SendTextAsync(string message, CancellationToken cancellationToken);
+
+ /// Sends a binary message to the client.
+ Task SendBinaryAsync(ReadOnlyMemory message, CancellationToken cancellationToken);
+
+ ///
+ /// Receives the next complete message from the client (fragments are reassembled).
+ /// Returns a message when the client closes,
+ /// and null when the underlying connection ends without a close frame.
+ ///
+ Task ReceiveAsync(CancellationToken cancellationToken);
+
+ /// Sends a normal-closure close frame to the client (idempotent).
+ Task CloseAsync(CancellationToken cancellationToken);
+}
diff --git a/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs
new file mode 100644
index 00000000..90c3dfd3
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs
@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Security.Cryptography.X509Certificates;
+
+namespace DevProxy.Abstractions.Proxy;
+
+///
+/// Installs the proxy's root certificate into the operating-system trust store so
+/// intercepted HTTPS traffic validates without per-request warnings.
+///
+///
+/// This is the engine-host boundary for certificate trust: a proxy engine (e.g. the
+/// Kestrel engine) owns minting/persisting its root but cannot reference the host's
+/// platform trust helpers, so it calls this abstraction. The host supplies the
+/// implementation (mac keychain, Windows root store, first-run prompt). Implementations
+/// are expected to be idempotent and to honor the user's trust/first-run configuration.
+///
+///
+public interface IRootCertificateTrust
+{
+ ///
+ /// Ensures is trusted by the OS, subject to the
+ /// host's install/first-run policy. Best-effort: failures are logged, not thrown.
+ ///
+ void EnsureTrusted(X509Certificate2 rootCertificate);
+
+ ///
+ /// Unconditionally installs into the OS trust
+ /// store, bypassing the first-run/install policy. Used by the explicit
+ /// cert ensure command. Best-effort: failures are logged, not thrown.
+ ///
+ void Trust(X509Certificate2 rootCertificate);
+
+ ///
+ /// Removes from the OS trust store (and clears the
+ /// first-run flag). Used by the explicit cert remove command. Best-effort:
+ /// failures are logged, not thrown.
+ ///
+ void Untrust(X509Certificate2 rootCertificate);
+}
diff --git a/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs b/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs
new file mode 100644
index 00000000..d29269a3
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace DevProxy.Abstractions.Proxy;
+
+///
+/// Turns the operating-system HTTP/HTTPS proxy on and off. Engine-agnostic: the Kestrel
+/// engine and the stop command's crash-cleanup path drive the same implementation,
+/// so there is one place that owns the OS proxy state.
+///
+///
+/// The implementation lives in the host (it needs platform I/O — the Windows registry +
+/// WinINET refresh, the macOS toggle-proxy.sh script). The Kestrel engine project
+/// cannot reference the host, so it receives this through the abstraction, exactly like
+/// .
+///
+///
+public interface ISystemProxyManager
+{
+ ///
+ /// Registers Dev Proxy as the system HTTP/HTTPS proxy at :
+ /// . Wildcard bind addresses are normalized to loopback.
+ ///
+ void Enable(string? ipAddress, int port);
+
+ ///
+ /// Removes Dev Proxy as the system proxy, restoring direct connections. Safe to call
+ /// even if the proxy was never enabled (idempotent best-effort cleanup).
+ ///
+ void Disable();
+}
diff --git a/DevProxy.Abstractions/Proxy/IProxyLogger.cs b/DevProxy.Abstractions/Proxy/LoggingContext.cs
similarity index 79%
rename from DevProxy.Abstractions/Proxy/IProxyLogger.cs
rename to DevProxy.Abstractions/Proxy/LoggingContext.cs
index 4330268e..bff280dd 100644
--- a/DevProxy.Abstractions/Proxy/IProxyLogger.cs
+++ b/DevProxy.Abstractions/Proxy/LoggingContext.cs
@@ -2,7 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
-using Titanium.Web.Proxy.EventArguments;
+using DevProxy.Abstractions.Proxy.Http;
namespace DevProxy.Abstractions.Proxy;
@@ -23,9 +23,9 @@ public enum MessageType
Timestamp
}
-public class LoggingContext(SessionEventArgs session)
+public class LoggingContext(IProxySession session)
{
- public SessionEventArgs Session { get; } = session;
+ public IProxySession Session { get; } = session;
}
public class StdioLoggingContext(StdioSession session, StdioMessageDirection direction)
diff --git a/DevProxy.Abstractions/Proxy/ProxyEvents.cs b/DevProxy.Abstractions/Proxy/ProxyEvents.cs
index 249685cd..edc4cb24 100644
--- a/DevProxy.Abstractions/Proxy/ProxyEvents.cs
+++ b/DevProxy.Abstractions/Proxy/ProxyEvents.cs
@@ -2,10 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
+using DevProxy.Abstractions.Proxy.Http;
using DevProxy.Abstractions.Utils;
using System.CommandLine;
using System.Text.Json.Serialization;
-using Titanium.Web.Proxy.EventArguments;
namespace DevProxy.Abstractions.Proxy;
@@ -15,17 +15,17 @@ public class ProxyEventArgsBase
public Dictionary GlobalData { get; init; } = [];
}
-public class ProxyHttpEventArgsBase(SessionEventArgs session) : ProxyEventArgsBase
+public class ProxyHttpEventArgsBase(IProxySession proxySession) : ProxyEventArgsBase
{
- public SessionEventArgs Session { get; } = session ??
- throw new ArgumentNullException(nameof(session));
+ public IProxySession ProxySession { get; } = proxySession ??
+ throw new ArgumentNullException(nameof(proxySession));
public bool HasRequestUrlMatch(ISet watchedUrls) =>
- ProxyUtils.MatchesUrlToWatch(watchedUrls, Session.HttpClient.Request.RequestUri.AbsoluteUri);
+ ProxyUtils.MatchesUrlToWatch(watchedUrls, ProxySession.Request.RequestUri.AbsoluteUri);
}
-public class ProxyRequestArgs(SessionEventArgs session, ResponseState responseState) :
- ProxyHttpEventArgsBase(session)
+public class ProxyRequestArgs(IProxySession proxySession, ResponseState responseState) :
+ ProxyHttpEventArgsBase(proxySession)
{
public ResponseState ResponseState { get; } = responseState ??
throw new ArgumentNullException(nameof(responseState));
@@ -35,8 +35,8 @@ public bool ShouldExecute(ISet watchedUrls) =>
&& HasRequestUrlMatch(watchedUrls);
}
-public class ProxyResponseArgs(SessionEventArgs session, ResponseState responseState) :
- ProxyHttpEventArgsBase(session)
+public class ProxyResponseArgs(IProxySession proxySession, ResponseState responseState) :
+ ProxyHttpEventArgsBase(proxySession)
{
public ResponseState ResponseState { get; } = responseState ??
throw new ArgumentNullException(nameof(responseState));
@@ -72,7 +72,7 @@ public class RequestLog
public string? Url { get; init; }
public RequestLog(string message, MessageType messageType, LoggingContext? context) :
- this(message, messageType, context?.Session.HttpClient.Request.Method, context?.Session.HttpClient.Request.Url, context)
+ this(message, messageType, context?.Session.Request.Method, context?.Session.Request.Url, context)
{
}
diff --git a/DevProxy.Abstractions/Proxy/RootTrustPolicy.cs b/DevProxy.Abstractions/Proxy/RootTrustPolicy.cs
new file mode 100644
index 00000000..826bd524
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/RootTrustPolicy.cs
@@ -0,0 +1,77 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace DevProxy.Abstractions.Proxy;
+
+/// What a root-trust implementation should do for the current platform/config.
+public enum RootTrustAction
+{
+ /// Do nothing (trust disabled, already-trusted, or user declined).
+ Skip,
+
+ /// Trust the root in the macOS login keychain (first-run flow).
+ TrustMacKeychain,
+
+ /// Install the root into the Windows CurrentUser root store.
+ TrustWindowsStore,
+
+ /// Trust can't be automated on Linux; tell the user to trust manually.
+ ManualLinux,
+}
+
+///
+/// Pure decision table for OS-trust installation, factored out of the platform I/O so it
+/// can be exhaustively unit-tested. Mirrors today's behavior: Windows installs into the
+/// user root store whenever cert install is enabled; macOS trusts via the keychain only on
+/// first run (and only if the user doesn't decline); Linux is manual.
+///
+///
+/// installCert == false ─────────────────────► Skip
+/// │
+/// ┌─ Windows ───┴──────────────────────────────────────────► TrustWindowsStore
+/// │
+/// ├─ macOS ── noFirstRun? ──yes──────────────────────────► Skip
+/// │ │ no
+/// │ ├─ already ran once (!isFirstRun)? ──yes───► Skip
+/// │ ├─ answer == "n"? ──yes────────────────────► Skip
+/// │ └─ otherwise ──────────────────────────────► TrustMacKeychain
+/// │
+/// └─ Linux ────────────────────────────────────────────────► ManualLinux
+///
+///
+public static class RootTrustPolicy
+{
+ public static RootTrustAction Decide(
+ bool isMac,
+ bool isWindows,
+ bool installCert,
+ bool noFirstRun,
+ bool isFirstRun,
+ string? firstRunAnswer)
+ {
+ if (!installCert)
+ {
+ return RootTrustAction.Skip;
+ }
+
+ if (isWindows)
+ {
+ return RootTrustAction.TrustWindowsStore;
+ }
+
+ if (isMac)
+ {
+ if (noFirstRun || !isFirstRun)
+ {
+ return RootTrustAction.Skip;
+ }
+
+ return string.Equals(firstRunAnswer?.Trim(), "n", StringComparison.OrdinalIgnoreCase)
+ ? RootTrustAction.Skip
+ : RootTrustAction.TrustMacKeychain;
+ }
+
+ return RootTrustAction.ManualLinux;
+ }
+}
diff --git a/DevProxy.Abstractions/Proxy/SystemProxyAddress.cs b/DevProxy.Abstractions/Proxy/SystemProxyAddress.cs
new file mode 100644
index 00000000..37f40f7d
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/SystemProxyAddress.cs
@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+
+namespace DevProxy.Abstractions.Proxy;
+
+///
+/// Pure helpers for composing the address clients should use to reach this proxy when it
+/// is registered as the system proxy. Separated from the platform I/O so the
+/// (only interesting) normalization logic is unit-testable.
+///
+///
+/// bind address system-proxy host
+/// ───────────── ─────────────────
+/// (null/empty) → 127.0.0.1 (no explicit bind ⇒ loopback)
+/// 0.0.0.0 → 127.0.0.1 (wildcard IPv4 ⇒ loopback for clients)
+/// :: → 127.0.0.1 (wildcard IPv6 ⇒ loopback for clients)
+/// 10.0.0.5 → 10.0.0.5 (explicit address passed through)
+///
+///
+public static class SystemProxyAddress
+{
+ ///
+ /// Normalizes a bind address to the host clients should target. A wildcard or unset
+ /// bind address can't be dialed by a client, so it collapses to loopback.
+ ///
+ public static string ResolveHost(string? ipAddress)
+ {
+ if (string.IsNullOrWhiteSpace(ipAddress))
+ {
+ return "127.0.0.1";
+ }
+
+ return ipAddress is "0.0.0.0" or "::" ? "127.0.0.1" : ipAddress;
+ }
+
+ ///
+ /// The host:port value used for the Windows ProxyServer registry setting.
+ ///
+ public static string ToHostPort(string? ipAddress, int port) =>
+ $"{ResolveHost(ipAddress)}:{port.ToString(CultureInfo.InvariantCulture)}";
+}
diff --git a/DevProxy.Abstractions/Proxy/WatchedHostExtractor.cs b/DevProxy.Abstractions/Proxy/WatchedHostExtractor.cs
new file mode 100644
index 00000000..5adbfa23
--- /dev/null
+++ b/DevProxy.Abstractions/Proxy/WatchedHostExtractor.cs
@@ -0,0 +1,69 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text.RegularExpressions;
+
+namespace DevProxy.Abstractions.Proxy;
+
+///
+/// Derives a host-only match pattern from a urlsToWatch URL regex. Both
+/// proxy engines need to decide, at CONNECT time, whether a host is watched (→ decrypt)
+/// using only the host (the CONNECT authority carries no path), so they both reduce the
+/// richer URL patterns to host patterns the same way. This is the single shared
+/// implementation so the two engines can't drift.
+///
+///
+/// ^https://api\.contoso\.com/.*$ (urlsToWatch regex)
+/// │ unescape, trim ^ $, .* → *
+/// ▼
+/// https://api.contoso.com/*
+/// │ strip scheme (take authority up to first '/')
+/// ▼
+/// api.contoso.com[:port]
+/// │ strip :port
+/// ▼
+/// api.contoso.com
+/// │ escape, * → .*, anchor
+/// ▼
+/// ^api\.contoso\.com$ (host regex)
+///
+///
+public static class WatchedHostExtractor
+{
+ ///
+ /// Builds an anchored, case-insensitive host regex from a watched-URL regex.
+ ///
+ public static Regex ToHostRegex(Regex urlRegex)
+ {
+ ArgumentNullException.ThrowIfNull(urlRegex);
+
+ var pattern = Regex.Unescape(urlRegex.ToString())
+ .Trim('^', '$')
+ .Replace(".*", "*", StringComparison.OrdinalIgnoreCase);
+
+ string host;
+ if (pattern.Contains("://", StringComparison.OrdinalIgnoreCase))
+ {
+ // Scheme present: take the authority (everything up to the first '/').
+ var chunks = pattern.Split("://");
+ var slash = chunks[1].IndexOf('/', StringComparison.OrdinalIgnoreCase);
+ host = slash < 0 ? chunks[1] : chunks[1][..slash];
+ }
+ else
+ {
+ // No scheme: the whole pattern is treated as a host name.
+ host = pattern;
+ }
+
+ // Drop a trailing :port — matching is on host only.
+ var portPos = host.IndexOf(':', StringComparison.OrdinalIgnoreCase);
+ if (portPos > 0)
+ {
+ host = host[..portPos];
+ }
+
+ var regexString = Regex.Escape(host).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase);
+ return new Regex($"^{regexString}$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ }
+}
diff --git a/DevProxy.Abstractions/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs
index b5799481..ccd33c8c 100644
--- a/DevProxy.Abstractions/Utils/ProxyUtils.cs
+++ b/DevProxy.Abstractions/Utils/ProxyUtils.cs
@@ -4,6 +4,7 @@
using DevProxy.Abstractions.Models;
using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Proxy.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Schema;
@@ -14,7 +15,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
-using Titanium.Web.Proxy.Http;
namespace DevProxy.Abstractions.Utils;
@@ -104,7 +104,7 @@ static ProxyUtils()
JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
}
- public static bool IsGraphRequest(Request request)
+ public static bool IsGraphRequest(IHttpRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -136,16 +136,20 @@ public static Uri GetAbsoluteRequestUrlFromBatch(Uri batchRequestUri, string rel
return absoluteRequestUrl;
}
- public static bool IsSdkRequest(Request request)
+ public static bool IsSdkRequest(IHttpRequest request)
{
ArgumentNullException.ThrowIfNull(request);
- return request.Headers.HeaderExists("SdkVersion");
+ return request.Headers.Contains("SdkVersion");
}
- public static bool IsGraphBetaRequest(Request request) =>
- IsGraphRequest(request) &&
- IsGraphBetaUrl(request.RequestUri);
+ public static bool IsGraphBetaRequest(IHttpRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ return IsGraphRequest(request) &&
+ IsGraphBetaUrl(request.RequestUri);
+ }
public static bool IsGraphBetaUrl(Uri uri)
{
@@ -161,13 +165,21 @@ public static bool IsGraphBetaUrl(Uri uri)
/// string a guid representing the a unique identifier for the request
/// string representation of the date and time the request was made
/// IList with defaults consistent with Microsoft Graph. Automatically adds CORS headers when the Origin header is present
- public static IList BuildGraphResponseHeaders(Request request, string requestId, string requestDate)
+ public static IList BuildGraphResponseHeaders(IHttpRequest request, string requestId, string requestDate)
{
+ ArgumentNullException.ThrowIfNull(request);
+
if (!IsGraphRequest(request))
{
return [];
}
+ var hasOrigin = request.Headers.FirstOrDefault((h) => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)) is not null;
+ return BuildGraphResponseHeadersCore(hasOrigin, requestId, requestDate);
+ }
+
+ private static List BuildGraphResponseHeadersCore(bool hasOriginHeader, string requestId, string requestDate)
+ {
var headers = new List
{
new ("Cache-Control", "no-store"),
@@ -178,7 +190,7 @@ public static IList BuildGraphResponseHeaders(Request reques
new ("Date", requestDate),
new ("Content-Type", "application/json")
};
- if (request.Headers.FirstOrDefault((h) => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)) is not null)
+ if (hasOriginHeader)
{
headers.Add(new("Access-Control-Allow-Origin", "*"));
headers.Add(new("Access-Control-Expose-Headers", "ETag, Location, Preference-Applied, Content-Range, request-id, client-request-id, ReadWriteConsistencyToken, SdkVersion, WWW-Authenticate, x-ms-client-gcc-tenant, Retry-After"));
@@ -528,6 +540,32 @@ public static string PatternToRegex(string pattern)
return $"^{Regex.Escape(pattern).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase)}$";
}
+ ///
+ /// Normalizes a WebSocket URL scheme to its HTTP equivalent (wss:// →
+ /// https://, ws:// → http://), leaving any other scheme untouched.
+ ///
+ ///
+ /// The proxy engine reports an intercepted WebSocket upgrade with an http(s)
+ /// scheme (a ws(s) connection is an HTTP Upgrade on the wire). Authors,
+ /// however, naturally write wss://host/* in urlsToWatch and mock URLs.
+ /// Normalizing both sides to http(s) before matching lets either form work.
+ ///
+ ///
+ public static string NormalizeWebSocketScheme(string url)
+ {
+ ArgumentNullException.ThrowIfNull(url);
+
+ if (url.StartsWith("wss://", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Concat("https://", url.AsSpan("wss://".Length));
+ }
+ if (url.StartsWith("ws://", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Concat("http://", url.AsSpan("ws://".Length));
+ }
+ return url;
+ }
+
public static string RegexToPattern(Regex regex)
{
ArgumentNullException.ThrowIfNull(regex);
diff --git a/DevProxy.Abstractions/packages.lock.json b/DevProxy.Abstractions/packages.lock.json
index b0e79ee6..bc15d968 100644
--- a/DevProxy.Abstractions/packages.lock.json
+++ b/DevProxy.Abstractions/packages.lock.json
@@ -107,27 +107,12 @@
"resolved": "2.0.9",
"contentHash": "SW0WhEk4NFVZ4lOnsLrHQOV/7s0eTidezNybHQWXfqhuXWB17X3RXbrifeWBbUx1iu+NcYchVSufmW7svjUEnA=="
},
- "Unobtanium.Web.Proxy": {
- "type": "Direct",
- "requested": "[0.1.5, )",
- "resolved": "0.1.5",
- "contentHash": "HiICGm0e44+i4aVHpLn+aphmSC2eQnDvlTttw1rE0hntOZKoLGRy37sydqqbRP1ZokMf3Mt0GEgSWxDwnucKGg==",
- "dependencies": {
- "BouncyCastle.Cryptography": "2.4.0",
- "Microsoft.Extensions.Logging.Abstractions": "8.0.1"
- }
- },
"YamlDotNet": {
"type": "Direct",
"requested": "[18.0.0, )",
"resolved": "18.0.0",
"contentHash": "ptHVgcYmLejGuWXV7RMFoEqFKYMXnieOlWLPzEslfDtzZ9ngMhjYwykfqjBN2+fMEAEyobozkj07lKEpR4dssA=="
},
- "BouncyCastle.Cryptography": {
- "type": "Transitive",
- "resolved": "2.4.0",
- "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
- },
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.9",
diff --git a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs
new file mode 100644
index 00000000..be477243
--- /dev/null
+++ b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs
@@ -0,0 +1,168 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics;
+using System.Globalization;
+using System.Net;
+using DevProxy.Abstractions.Plugins;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Plugins.Behavior;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Per-plugin integration coverage for the Behavior/ plugins, proving each still
+/// works end-to-end through the Kestrel engine (real request/response data reaching the
+/// plugin's hooks). Behaviour is asserted purely from the HTTP response the client sees.
+///
+///
+/// client ─▶ Kestrel engine ─▶ [behavior plugin] ─▶ FakeOrigin
+/// │
+/// overrides status / adds latency / throttles
+///
+///
+public sealed class BehaviorPluginsIntegrationTests
+{
+ private static readonly HttpClient SharedHttpClient = new();
+ private static readonly TestProxyConfiguration ProxyConfig = new();
+
+ [Fact]
+ public async Task GenericRandomError_Rate100_OverridesOriginWithConfiguredError()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ // rate:100 ⇒ always fail; a single configured 503 error keyed by a wildcard URL.
+ var config = PluginConfig.FromJson($$"""
+ {
+ "rate": 100,
+ "errors": [
+ {
+ "request": { "url": "http://{{origin.Host}}/*" },
+ "responses": [ { "statusCode": 503 } ]
+ }
+ ]
+ }
+ """);
+ var plugin = new GenericRandomErrorPlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ // /get would return 200 "hello get" if the plugin did NOT override it.
+ using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Latency_AddsConfiguredDelayBeforeForwarding()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ const int minMs = 400;
+ var config = PluginConfig.FromJson($$"""
+ { "minMs": {{minMs}}, "maxMs": {{minMs + 50}} }
+ """);
+ var plugin = new LatencyPlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ var stopwatch = Stopwatch.StartNew();
+ using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+ stopwatch.Stop();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ // Allow scheduling slack below the configured floor, but it must clearly exceed
+ // an un-delayed localhost round-trip (single-digit ms).
+ Assert.True(
+ stopwatch.ElapsedMilliseconds >= minMs - 100,
+ $"Expected >= {minMs - 100}ms of injected latency, saw {stopwatch.ElapsedMilliseconds}ms.");
+ }
+
+ [Fact]
+ public async Task RateLimiting_ThrottlesOnceResourcesAreExhausted()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ // rateLimit:2 / costPerRequest:2 ⇒ request #1 drains to 0 (passes through),
+ // request #2 goes negative and is throttled with 429.
+ var config = PluginConfig.FromJson("""
+ { "rateLimit": 2, "costPerRequest": 2, "resetTimeWindowSeconds": 300 }
+ """);
+ var plugin = new RateLimitingPlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ using var first = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+ using var second = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+
+ Assert.Equal(HttpStatusCode.OK, first.StatusCode);
+ Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode);
+ }
+
+ [Fact]
+ public async Task RateLimitingPlusRetryAfter_ThrottledResponseCarriesRetryAfter()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ var rateLimiting = new RateLimitingPlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ PluginConfig.FromJson("""
+ { "rateLimit": 2, "costPerRequest": 2, "resetTimeWindowSeconds": 300 }
+ """));
+ var retryAfter = new RetryAfterPlugin(
+ NullLogger.Instance,
+ urls);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [rateLimiting, retryAfter]);
+ using var client = proxy.CreateHttpClient();
+
+ // #1 passes, #2 is throttled (429 + Retry-After) once resources are exhausted.
+ using var first = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+ using var throttled = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+
+ Assert.Equal(HttpStatusCode.OK, first.StatusCode);
+ Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode);
+ Assert.True(
+ throttled.Headers.TryGetValues("Retry-After", out var values),
+ "Throttled response should carry a Retry-After header.");
+ Assert.True(
+ int.TryParse(
+ string.Join(string.Empty, values!),
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out _),
+ "Retry-After header should be an integer seconds value.");
+ }
+}
diff --git a/DevProxy.Integration.Tests/CapturingLoggerFactory.cs b/DevProxy.Integration.Tests/CapturingLoggerFactory.cs
new file mode 100644
index 00000000..43f60446
--- /dev/null
+++ b/DevProxy.Integration.Tests/CapturingLoggerFactory.cs
@@ -0,0 +1,82 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.Concurrent;
+using DevProxy.Abstractions.Proxy;
+using Microsoft.Extensions.Logging;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// An whose loggers capture every
+/// emitted via ILogger.LogRequest(...) — mirroring what the host's production
+/// RequestLogger does (it enqueues state objects).
+///
+/// This is the seam that lets the in-process engine harness assert plugin
+/// behaviour that surfaces as log/guidance entries rather than HTTP responses:
+///
+///
+/// traffic ─▶ KestrelProxyEngine (PluginPipeline) ──┐
+/// ├─▶ LogRequest(RequestLog)
+/// guidance/behaviour plugin (its ILogger) ─────────┘ │
+/// ▼
+/// CapturingLogger.Logs (thread-safe)
+/// │
+/// reporter.AfterRecordingStopAsync(RecordingArgs(Logs))
+///
+///
+/// Both the engine and any plugin constructed with a logger from this factory write
+/// into the same sink, so a single collection reflects the full run.
+///
+internal sealed class CapturingLoggerFactory : ILoggerFactory
+{
+ private readonly ConcurrentQueue _logs = new();
+ private readonly CapturingLogger _logger;
+
+ public CapturingLoggerFactory() => _logger = new CapturingLogger(_logs);
+
+ /// Every captured so far, in emission order.
+ public IReadOnlyList Logs => [.. _logs];
+
+ ///
+ /// Captured logs filtered to a single — convenience for
+ /// asserting a plugin emitted (e.g.) a tip.
+ ///
+ public IReadOnlyList LogsOfType(MessageType type) =>
+ [.. _logs.Where(l => l.MessageType == type)];
+
+ public ILogger CreateLogger(string categoryName) => _logger;
+
+ public void AddProvider(ILoggerProvider provider)
+ {
+ // No-op: this factory only ever hands out the single capturing logger.
+ }
+
+ public void Dispose()
+ {
+ // Nothing to dispose; the backing queue is released with the instance.
+ }
+
+ private sealed class CapturingLogger(ConcurrentQueue sink) : ILogger
+ {
+ private readonly ConcurrentQueue _sink = sink;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ if (state is RequestLog requestLog)
+ {
+ _sink.Enqueue(requestLog);
+ }
+ }
+ }
+}
diff --git a/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj b/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj
new file mode 100644
index 00000000..baf7ffd7
--- /dev/null
+++ b/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj
@@ -0,0 +1,45 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+ $(NoWarn);CA1707;CA1861
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DevProxy.Integration.Tests/DisabledLanguageModelClient.cs b/DevProxy.Integration.Tests/DisabledLanguageModelClient.cs
new file mode 100644
index 00000000..e70b5262
--- /dev/null
+++ b/DevProxy.Integration.Tests/DisabledLanguageModelClient.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.LanguageModel;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// A language model client that is always disabled and never returns a completion. The
+/// spec generators (OpenAPI, TypeSpec) only consult the LM for cosmetic enrichment
+/// (operation ids / descriptions) and fall back to deterministic generation when it
+/// yields nothing — so this keeps those generators fully hermetic.
+///
+internal sealed class DisabledLanguageModelClient : ILanguageModelClient
+{
+ public Task GenerateChatCompletionAsync(
+ string promptFileName, Dictionary parameters, CancellationToken cancellationToken) =>
+ Task.FromResult(null);
+
+ public Task GenerateChatCompletionAsync(
+ IEnumerable messages, CompletionOptions? options, CancellationToken cancellationToken) =>
+ Task.FromResult(null);
+
+ public Task GenerateCompletionAsync(
+ string prompt, CompletionOptions? options, CancellationToken cancellationToken) =>
+ Task.FromResult(null);
+
+ public Task IsEnabledAsync(CancellationToken cancellationToken) => Task.FromResult(false);
+}
diff --git a/DevProxy.Integration.Tests/EnginePortPublishingTests.cs b/DevProxy.Integration.Tests/EnginePortPublishingTests.cs
new file mode 100644
index 00000000..e8ee4e2b
--- /dev/null
+++ b/DevProxy.Integration.Tests/EnginePortPublishingTests.cs
@@ -0,0 +1,78 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text.RegularExpressions;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Proxy.Kestrel;
+using DevProxy.Proxy.Kestrel.Internal;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Regression coverage for the daemon-readiness contract: when started with
+/// --port 0 the engine must publish the OS-assigned port back to the shared
+/// . The host persists that port in the daemon state
+/// file, and the parent process's readiness poll, devproxy stop, and
+/// devproxy status all require Port > 0. Without the write-back,
+/// detached mode (--detach) silently breaks: the daemon orphans, the parent
+/// times out, and the system proxy is left on.
+///
+public sealed class EnginePortPublishingTests
+{
+ [Fact]
+ public async Task ExecuteAsync_WithPortZero_PublishesBoundPortToConfiguration()
+ {
+ var configuration = new TestProxyConfiguration
+ {
+ Port = 0,
+ IPAddress = "127.0.0.1",
+ AsSystemProxy = false,
+ };
+ var urlsToWatch = new HashSet
+ {
+ new(new Regex("^https?://example.com/.*$", RegexOptions.Compiled | RegexOptions.IgnoreCase)),
+ };
+
+ var engine = new KestrelProxyEngine(
+ CertificateAuthority.CreateDefault(),
+ [],
+ urlsToWatch,
+ configuration,
+ [],
+ NullLoggerFactory.Instance);
+
+ using var cts = new CancellationTokenSource();
+ await engine.StartAsync(cts.Token);
+ try
+ {
+ // The engine binds asynchronously inside ExecuteAsync, then publishes the
+ // resolved port; give it a brief window to do so.
+ var deadline = Environment.TickCount64 + 5_000;
+ while (configuration.Port == 0 && Environment.TickCount64 < deadline)
+ {
+ await Task.Delay(25);
+ }
+
+ Assert.True(
+ configuration.Port > 0,
+ "Engine must publish the OS-assigned port back to IProxyConfiguration.Port when started with --port 0.");
+ }
+ finally
+ {
+ await cts.CancelAsync();
+ try
+ {
+ await engine.StopAsync(CancellationToken.None);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+
+ engine.Dispose();
+ }
+ }
+}
diff --git a/DevProxy.Integration.Tests/FakeOrigin.cs b/DevProxy.Integration.Tests/FakeOrigin.cs
new file mode 100644
index 00000000..d9bd16de
--- /dev/null
+++ b/DevProxy.Integration.Tests/FakeOrigin.cs
@@ -0,0 +1,127 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Text;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// A deterministic upstream origin server used as the integration target. Each scenario
+/// asserts the proxy faithfully relays this origin's responses.
+///
+///
+/// GET /get → 200 "hello get" (plain finite body)
+/// POST /echo → 200 echoes request body (X-Echo-Length header)
+/// GET /status/{code} → {code} "status {code}"
+/// GET /headers → 200, reflects X-Probe request header in body
+/// GET /sse → 200 text/event-stream, 5 events flushed ~50ms apart
+/// GET /big/{n} → 200, n bytes of 'A' (large finite body)
+/// GET /json → 200 application/json, a 2-element array
+///
+///
+internal sealed class FakeOrigin : IAsyncDisposable
+{
+ private readonly WebApplication _app;
+ private readonly System.Collections.Concurrent.ConcurrentQueue _received;
+
+ public int Port { get; }
+
+ public string Host => $"127.0.0.1:{Port.ToString(CultureInfo.InvariantCulture)}";
+
+ /// Every request this origin actually received, in arrival order.
+ public IReadOnlyCollection ReceivedRequests => _received.ToArray();
+
+ private FakeOrigin(WebApplication app, int port, System.Collections.Concurrent.ConcurrentQueue received)
+ {
+ _app = app;
+ Port = port;
+ _received = received;
+ }
+
+ public static async Task StartAsync()
+ {
+ var port = NetUtil.GetFreePort();
+ var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions());
+ _ = builder.WebHost.UseKestrelCore();
+ _ = builder.Logging.ClearProviders();
+ _ = builder.Services.AddRouting();
+ builder.WebHost.ConfigureKestrel(options =>
+ options.Listen(System.Net.IPAddress.Loopback, port, listen =>
+ listen.Protocols = HttpProtocols.Http1));
+
+ var app = builder.Build();
+
+ var received = new System.Collections.Concurrent.ConcurrentQueue();
+ app.Use(async (ctx, next) =>
+ {
+ received.Enqueue(new ReceivedRequest(ctx.Request.Method, ctx.Request.Path + ctx.Request.QueryString));
+ await next().ConfigureAwait(false);
+ });
+
+ app.MapGet("/get", () => Results.Text("hello get"));
+
+ app.MapPost("/echo", async (HttpContext ctx) =>
+ {
+ using var reader = new StreamReader(ctx.Request.Body);
+ var body = await reader.ReadToEndAsync().ConfigureAwait(false);
+ ctx.Response.Headers["X-Echo-Length"] =
+ body.Length.ToString(CultureInfo.InvariantCulture);
+ await ctx.Response.WriteAsync(body).ConfigureAwait(false);
+ });
+
+ app.MapGet("/status/{code:int}", (int code) =>
+ Results.Text(
+ $"status {code.ToString(CultureInfo.InvariantCulture)}",
+ statusCode: code));
+
+ app.MapGet("/nocontent", () => Results.StatusCode(204));
+
+ app.MapGet("/headers", (HttpContext ctx) =>
+ {
+ var probe = ctx.Request.Headers["X-Probe"].ToString();
+ return Results.Text($"probe={probe}");
+ });
+
+ app.MapGet("/sse", async (HttpContext ctx) =>
+ {
+ ctx.Response.Headers.ContentType = "text/event-stream";
+ for (var i = 0; i < 5; i++)
+ {
+ var payload = Encoding.UTF8.GetBytes(
+ $"data: event-{i.ToString(CultureInfo.InvariantCulture)}\n\n");
+ await ctx.Response.Body.WriteAsync(payload).ConfigureAwait(false);
+ await ctx.Response.Body.FlushAsync().ConfigureAwait(false);
+ await Task.Delay(50).ConfigureAwait(false);
+ }
+ });
+
+ app.MapGet("/big/{n:int}", (int n) =>
+ Results.Text(new string('A', n)));
+
+ app.MapGet("/json", () => Results.Json(new[]
+ {
+ new { id = 1, name = "alpha" },
+ new { id = 2, name = "beta" },
+ }));
+
+ await app.StartAsync().ConfigureAwait(false);
+ return new FakeOrigin(app, port, received);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _app.StopAsync().ConfigureAwait(false);
+ await _app.DisposeAsync().ConfigureAwait(false);
+ }
+}
+
+internal sealed record ReceivedRequest(string Method, string PathAndQuery);
diff --git a/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs
new file mode 100644
index 00000000..b323ea8f
--- /dev/null
+++ b/DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs
@@ -0,0 +1,185 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using DevProxy.Abstractions.Models;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Utils;
+using DevProxy.Plugins.Generation;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Coverage for generation plugins, which turn the recorded InterceptedResponse log
+/// stream into an artifact (HAR, .http, OpenAPI/TypeSpec spec, mock file) on
+/// AfterRecordingStopAsync. These plugins write their output to the process current
+/// working directory, so each test redirects the CWD to a temp folder (assembly test
+/// parallelization is disabled — see TestParallelization.cs) and asserts both the stored
+/// report and the generated file.
+///
+public sealed class GenerationPluginsIntegrationTests
+{
+ private static ISet Watch => KestrelProxyHarness.BuildUrlsToWatch("api.contoso.com");
+
+ private static RecordingArgs Recording(IEnumerable logs) =>
+ new(logs)
+ {
+ GlobalData = new() { [ProxyUtils.ReportsKey] = new Dictionary() },
+ };
+
+ private static RequestLog SampleExchange() =>
+ TestExchange
+ .Request("GET", "https://api.contoso.com/users", headers: [("Accept", "application/json")])
+ .WithResponse(
+ HttpStatusCode.OK,
+ headers: [("Content-Type", "application/json")],
+ body: """[ { "id": 1, "name": "alpha" } ]""")
+ .AsRequestLog(MessageType.InterceptedResponse);
+
+ ///
+ /// Runs with the process CWD redirected to a fresh temp
+ /// directory, returning the directory so the caller can inspect generated files.
+ ///
+ private static async Task InTempCwdAsync(Func action)
+ {
+ var dir = Directory.CreateTempSubdirectory("devproxy-gen-");
+ var originalCwd = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(dir.FullName);
+ try
+ {
+ await action();
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalCwd);
+ }
+
+ return dir;
+ }
+
+ [Fact]
+ public async Task HarGenerator_WritesHarFile()
+ {
+ var plugin = new HarGeneratorPlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ Watch,
+ new TestProxyConfiguration(),
+ PluginConfig.Empty());
+
+ var args = Recording([SampleExchange()]);
+ var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(args, CancellationToken.None));
+ try
+ {
+ var har = dir.GetFiles("devproxy-*.har").Single();
+ var content = await File.ReadAllTextAsync(har.FullName);
+ Assert.Contains("api.contoso.com/users", content, StringComparison.Ordinal);
+ }
+ finally
+ {
+ dir.Delete(recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task MockGenerator_WritesMockFile()
+ {
+ var plugin = new MockGeneratorPlugin(NullLogger.Instance, Watch);
+
+ var args = Recording([SampleExchange()]);
+ var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(args, CancellationToken.None));
+ try
+ {
+ var mock = dir.GetFiles("mocks-*.json").Single();
+ var content = await File.ReadAllTextAsync(mock.FullName);
+ Assert.Contains("api.contoso.com/users", content, StringComparison.Ordinal);
+ }
+ finally
+ {
+ dir.Delete(recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task HttpFileGenerator_WritesHttpFile()
+ {
+ var plugin = new HttpFileGeneratorPlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ Watch,
+ new TestProxyConfiguration(),
+ PluginConfig.Empty());
+
+ var args = Recording([SampleExchange()]);
+ var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(args, CancellationToken.None));
+ try
+ {
+ var report = Assert.IsType(
+ ((Dictionary)args.GlobalData[ProxyUtils.ReportsKey])[plugin.Name]);
+ Assert.NotEmpty(report);
+
+ var httpFile = dir.GetFiles("requests_*.http").Single();
+ var content = await File.ReadAllTextAsync(httpFile.FullName);
+ Assert.Contains("api.contoso.com/users", content, StringComparison.Ordinal);
+ }
+ finally
+ {
+ dir.Delete(recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task OpenApiSpecGenerator_WritesSpec()
+ {
+ var plugin = new OpenApiSpecGeneratorPlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ Watch,
+ new DisabledLanguageModelClient(),
+ new TestProxyConfiguration(),
+ PluginConfig.Empty());
+
+ var args = Recording([SampleExchange()]);
+ var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(args, CancellationToken.None));
+ try
+ {
+ var report = Assert.IsType(
+ ((Dictionary)args.GlobalData[ProxyUtils.ReportsKey])[plugin.Name]);
+ Assert.NotEmpty(report);
+ Assert.NotEmpty(dir.GetFiles("*.json"));
+ }
+ finally
+ {
+ dir.Delete(recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task TypeSpecGenerator_WritesSpec()
+ {
+ var plugin = new TypeSpecGeneratorPlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ Watch,
+ new DisabledLanguageModelClient(),
+ new TestProxyConfiguration(),
+ PluginConfig.Empty());
+
+ var args = Recording([SampleExchange()]);
+ var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(args, CancellationToken.None));
+ try
+ {
+ var report = Assert.IsType(
+ ((Dictionary)args.GlobalData[ProxyUtils.ReportsKey])[plugin.Name]);
+ Assert.NotEmpty(report);
+ Assert.NotEmpty(dir.GetFiles("*.tsp"));
+ }
+ finally
+ {
+ dir.Delete(recursive: true);
+ }
+ }
+}
diff --git a/DevProxy.Integration.Tests/GuidancePluginsIntegrationTests.cs b/DevProxy.Integration.Tests/GuidancePluginsIntegrationTests.cs
new file mode 100644
index 00000000..a4e1c2fd
--- /dev/null
+++ b/DevProxy.Integration.Tests/GuidancePluginsIntegrationTests.cs
@@ -0,0 +1,127 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using DevProxy.Abstractions.Models;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Plugins.Guidance;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Coverage for guidance plugins, which emit advisory log entries (Warning/Tip/Failed)
+/// rather than altering the HTTP exchange. Each plugin is constructed with a logger backed
+/// by , driven with a shaped
+/// to satisfy its trigger, and asserted on the captured RequestLog.
+///
+/// Most of these gate on the upstream being graph.microsoft.com, which the loopback
+/// origin cannot impersonate through real routing — so they are driven at the plugin hook
+/// with the engine's real canonical session (see ).
+///
+/// NOT hermetic (deferred): GraphSelectGuidancePlugin requires a populated Microsoft
+/// Graph metadata SQLite database (MSGraphDb) built from Graph OpenAPI definitions.
+///
+public sealed class GuidancePluginsIntegrationTests
+{
+ private static ISet GraphWatch => KestrelProxyHarness.BuildUrlsToWatch("graph.microsoft.com");
+
+ private static Logger Logger(CapturingLoggerFactory factory) => new(factory);
+
+ [Fact]
+ public async Task GraphBetaSupport_WarnsOnBetaRequest()
+ {
+ var factory = new CapturingLoggerFactory();
+ var plugin = new GraphBetaSupportGuidancePlugin(Logger(factory), GraphWatch);
+
+ var exchange = TestExchange
+ .Request("GET", "https://graph.microsoft.com/beta/me")
+ .WithResponse(HttpStatusCode.OK);
+ await plugin.AfterResponseAsync(exchange.ResponseArgs, CancellationToken.None);
+
+ Assert.Contains(factory.LogsOfType(MessageType.Warning), l => l.Message.Contains("beta", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task GraphClientRequestId_WarnsWhenHeaderMissing()
+ {
+ var factory = new CapturingLoggerFactory();
+ var plugin = new GraphClientRequestIdGuidancePlugin(Logger(factory), GraphWatch);
+
+ var exchange = TestExchange.Request("GET", "https://graph.microsoft.com/v1.0/me");
+ await plugin.BeforeRequestAsync(exchange.RequestArgs, CancellationToken.None);
+
+ Assert.Contains(factory.LogsOfType(MessageType.Warning), l => l.Message.Contains("client-request-id", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task GraphSdk_TipsOnErrorWithoutSdk()
+ {
+ var factory = new CapturingLoggerFactory();
+ var plugin = new GraphSdkGuidancePlugin(Logger(factory), GraphWatch);
+
+ var exchange = TestExchange
+ .Request("GET", "https://graph.microsoft.com/v1.0/me")
+ .WithResponse(HttpStatusCode.NotFound);
+ await plugin.AfterResponseAsync(exchange.ResponseArgs, CancellationToken.None);
+
+ Assert.NotEmpty(factory.LogsOfType(MessageType.Tip));
+ }
+
+ [Fact]
+ public async Task ODataPaging_WarnsOnManualSkipToken()
+ {
+ var factory = new CapturingLoggerFactory();
+ var plugin = new ODataPagingGuidancePlugin(Logger(factory), GraphWatch);
+
+ var exchange = TestExchange.Request("GET", "https://graph.microsoft.com/v1.0/users?$skiptoken=abc123");
+ await plugin.BeforeRequestAsync(exchange.RequestArgs, CancellationToken.None);
+
+ Assert.Contains(factory.LogsOfType(MessageType.Warning), l => l.Message.Contains("paging", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task ODSPSearch_WarnsOnDeprecatedDriveSearch()
+ {
+ var factory = new CapturingLoggerFactory();
+ var plugin = new ODSPSearchGuidancePlugin(Logger(factory), GraphWatch);
+
+ var exchange = TestExchange.Request("GET", "https://graph.microsoft.com/v1.0/me/drive/root/search(q='report')");
+ await plugin.BeforeRequestAsync(exchange.RequestArgs, CancellationToken.None);
+
+ Assert.Contains(factory.LogsOfType(MessageType.Warning), l => l.Message.Contains("search", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task GraphConnector_FailsOnPatchWithoutSchema()
+ {
+ var factory = new CapturingLoggerFactory();
+ var plugin = new GraphConnectorGuidancePlugin(Logger(factory), GraphWatch);
+
+ var exchange = TestExchange.Request("PATCH", "https://graph.microsoft.com/v1.0/external/connections/test/schema", body: "");
+ await plugin.BeforeRequestAsync(exchange.RequestArgs, CancellationToken.None);
+
+ Assert.NotEmpty(factory.LogsOfType(MessageType.Failed));
+ }
+
+ [Fact]
+ public async Task CachingGuidance_WarnsOnRepeatRequestWithinWindow()
+ {
+ var factory = new CapturingLoggerFactory();
+ var config = PluginConfig.FromJson("""{ "cacheThresholdSeconds": 30 }""");
+ var plugin = new CachingGuidancePlugin(
+ TestDefaults.HttpClient,
+ Logger(factory),
+ GraphWatch,
+ new TestProxyConfiguration(),
+ config);
+
+ var url = "https://graph.microsoft.com/v1.0/me";
+ await plugin.BeforeRequestAsync(TestExchange.Request("GET", url).RequestArgs, CancellationToken.None);
+ await plugin.BeforeRequestAsync(TestExchange.Request("GET", url).RequestArgs, CancellationToken.None);
+
+ Assert.Contains(factory.LogsOfType(MessageType.Warning), l => l.Message.Contains("cache", StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/DevProxy.Integration.Tests/InitializingMockingPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/InitializingMockingPluginsIntegrationTests.cs
new file mode 100644
index 00000000..39d55583
--- /dev/null
+++ b/DevProxy.Integration.Tests/InitializingMockingPluginsIntegrationTests.cs
@@ -0,0 +1,108 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Net;
+using DevProxy.Plugins.Mocking;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Integration coverage for the two mocking plugins that need InitializeAsync /
+/// out-of-band behavior and therefore don't fit the request-intercept template:
+///
+/// • — event-driven; on MockRequestAsync it
+/// fires an outbound request via its injected . Verified by
+/// pointing it at and asserting the origin was hit.
+/// • — serves an in-memory REST API from a data file,
+/// short-circuiting matched requests (origin never contacted). Verified end-to-end
+/// through the Kestrel engine against a temp api.json + data.json.
+///
+public sealed class InitializingMockingPluginsIntegrationTests
+{
+ [Fact]
+ public async Task MockRequestPlugin_FiresConfiguredOutboundRequest()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+
+ var config = PluginConfig.FromJson($$"""
+ {
+ "request": {
+ "url": "http://{{origin.Host}}/get",
+ "method": "GET"
+ }
+ }
+ """);
+
+ var plugin = new MockRequestPlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ KestrelProxyHarness.BuildUrlsToWatch(origin.Host),
+ new TestProxyConfiguration(),
+ config);
+
+ await plugin.MockRequestAsync(EventArgs.Empty, CancellationToken.None);
+
+ Assert.Contains(origin.ReceivedRequests, r => r.Method == "GET" && r.PathAndQuery == "/get");
+ }
+
+ [Fact]
+ public async Task CrudApiPlugin_ServesInMemoryDataThroughEngine()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+
+ var dir = Directory.CreateTempSubdirectory("devproxy-crud-");
+ try
+ {
+ var baseUrl = $"http://{origin.Host}/api/items";
+ await File.WriteAllTextAsync(
+ Path.Combine(dir.FullName, "data.json"),
+ """[ { "id": 1, "name": "alpha" }, { "id": 2, "name": "beta" } ]""");
+ await File.WriteAllTextAsync(
+ Path.Combine(dir.FullName, "api.json"),
+ $$"""
+ {
+ "baseUrl": "{{baseUrl}}",
+ "dataFile": "data.json",
+ "actions": [ { "action": "getAll", "url": "" } ]
+ }
+ """);
+
+ var apiFile = Path.Combine(dir.FullName, "api.json");
+ var proxyConfig = new TestProxyConfiguration
+ {
+ ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"),
+ };
+ var config = PluginConfig.FromJson($$"""{ "apiFile": "{{apiFile.Replace("\\", "\\\\", StringComparison.Ordinal)}}" }""");
+
+ var plugin = new CrudApiPlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ KestrelProxyHarness.BuildUrlsToWatch(origin.Host),
+ proxyConfig,
+ config);
+
+ using var host = new PluginTestHost(proxyConfig);
+ await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(new Uri(baseUrl));
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Contains("alpha", body, StringComparison.Ordinal);
+ Assert.Contains("beta", body, StringComparison.Ordinal);
+ // CrudApi short-circuits: the origin must never have received the request.
+ Assert.DoesNotContain(origin.ReceivedRequests, r => r.PathAndQuery.StartsWith("/api/items", StringComparison.Ordinal));
+ }
+ finally
+ {
+ dir.Delete(recursive: true);
+ }
+ }
+}
diff --git a/DevProxy.Integration.Tests/KestrelProxyHarness.cs b/DevProxy.Integration.Tests/KestrelProxyHarness.cs
new file mode 100644
index 00000000..73e313ab
--- /dev/null
+++ b/DevProxy.Integration.Tests/KestrelProxyHarness.cs
@@ -0,0 +1,150 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using System.Text.RegularExpressions;
+using DevProxy.Abstractions.Plugins;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Proxy.Kestrel;
+using DevProxy.Proxy.Kestrel.Internal;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Boots a on a free localhost port, watching the
+/// supplied host, and hands back an wired to route through it.
+///
+///
+/// test HttpClient ──(absolute-form GET http://origin/..)──▶ Kestrel engine ──▶ FakeOrigin
+///
+///
+/// No OS trust / no system-proxy registration (both injected as null), so the harness
+/// never touches the machine. HTTPS MITM rows additionally trust the engine root via a
+/// per-client server-certificate callback (see ).
+///
+internal sealed class KestrelProxyHarness : IAsyncDisposable
+{
+ private readonly KestrelProxyEngine _engine;
+ private readonly CancellationTokenSource _cts = new();
+
+ public int Port { get; }
+
+ private KestrelProxyHarness(KestrelProxyEngine engine, int port)
+ {
+ _engine = engine;
+ Port = port;
+ }
+
+ ///
+ /// Builds the urlsToWatch set the engine uses for a host — exposed so a test
+ /// can construct a plugin against the same set the engine matches on.
+ ///
+ public static HashSet BuildUrlsToWatch(string watchedHost) =>
+ [
+ new(new Regex(
+ $"^https?://{Regex.Escape(watchedHost)}/.*$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase)),
+ ];
+
+ ///
+ /// When supplied, the engine logs through it — pass a
+ /// to assert the
+ /// entries the pipeline emits (guidance tips, intercepted-response records that
+ /// reporters later consume). Defaults to a no-op factory.
+ ///
+ public static async Task StartAsync(
+ string watchedHost,
+ IEnumerable? plugins = null,
+ ILoggerFactory? loggerFactory = null)
+ {
+ var port = NetUtil.GetFreePort();
+ var configuration = new TestProxyConfiguration
+ {
+ Port = port,
+ IPAddress = "127.0.0.1",
+ AsSystemProxy = false,
+ };
+
+ // Watch http(s):///* so the engine MITMs/inspects the origin.
+ var urlsToWatch = BuildUrlsToWatch(watchedHost);
+
+ var engine = new KestrelProxyEngine(
+ CertificateAuthority.CreateDefault(),
+ plugins ?? [],
+ urlsToWatch,
+ configuration,
+ [],
+ loggerFactory ?? NullLoggerFactory.Instance);
+
+ var harness = new KestrelProxyHarness(engine, port);
+ await engine.StartAsync(harness._cts.Token).ConfigureAwait(false);
+ await harness.WaitUntilListeningAsync().ConfigureAwait(false);
+ return harness;
+ }
+
+ ///
+ /// Creates an that routes through the proxy. When
+ /// is set (HTTPS rows) the client accepts
+ /// the engine's MITM leaf without an OS trust store.
+ ///
+ public HttpClient CreateHttpClient(bool trustAllServerCerts = false)
+ {
+ var handler = new HttpClientHandler
+ {
+ Proxy = new WebProxy($"http://127.0.0.1:{Port.ToString(CultureInfo.InvariantCulture)}"),
+ UseProxy = true,
+ AllowAutoRedirect = false,
+ };
+ if (trustAllServerCerts)
+ {
+ handler.ServerCertificateCustomValidationCallback =
+ (_, _, _, _) => true;
+ }
+
+ return new HttpClient(handler)
+ {
+ Timeout = TimeSpan.FromSeconds(30),
+ };
+ }
+
+ private async Task WaitUntilListeningAsync()
+ {
+ for (var attempt = 0; attempt < 100; attempt++)
+ {
+ try
+ {
+ using var probe = new TcpClient();
+ await probe.ConnectAsync(IPAddress.Loopback, Port).ConfigureAwait(false);
+ return;
+ }
+ catch (SocketException)
+ {
+ await Task.Delay(25).ConfigureAwait(false);
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Kestrel proxy did not start listening on port {Port.ToString(CultureInfo.InvariantCulture)}.");
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync().ConfigureAwait(false);
+ try
+ {
+ await _engine.StopAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+
+ _engine.Dispose();
+ _cts.Dispose();
+ }
+}
diff --git a/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs
new file mode 100644
index 00000000..b0915d60
--- /dev/null
+++ b/DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs
@@ -0,0 +1,182 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using DevProxy.Plugins.Behavior;
+using DevProxy.Plugins.Manipulation;
+using DevProxy.Plugins.Mocking;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Per-plugin integration coverage for the request-manipulation and mocking plugins,
+/// proving each reshapes the request/response correctly through the Kestrel engine.
+///
+public sealed class ManipulationAndMockingIntegrationTests
+{
+ private static readonly HttpClient SharedHttpClient = new();
+ private static readonly TestProxyConfiguration ProxyConfig = new();
+
+ [Fact]
+ public async Task Rewrite_RewritesRequestUrl_OriginServesRewrittenPath()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ // Rewrite /get → /status/503 so the origin serves the rewritten path.
+ var config = PluginConfig.FromJson("""
+ {
+ "rewrites": [
+ { "in": { "url": "/get$" }, "out": { "url": "/status/503" } }
+ ]
+ }
+ """);
+ var plugin = new RewritePlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+
+ // 503 proves the request was rewritten to /status/503 before forwarding.
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task MockResponse_ShortCircuitsWithConfiguredMock()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ var config = PluginConfig.FromJson($$"""
+ {
+ "mocks": [
+ {
+ "request": { "url": "http://{{origin.Host}}/get", "method": "GET" },
+ "response": { "statusCode": 201, "body": "mocked-by-test" }
+ }
+ ]
+ }
+ """);
+ var plugin = new MockResponsePlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+ var body = await response.Content.ReadAsStringAsync();
+
+ // /get normally returns 200 "hello get"; the mock overrides it.
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ Assert.Contains("mocked-by-test", body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Auth_ApiKey_RejectsRequestWithoutKey()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ var config = PluginConfig.FromJson("""
+ {
+ "type": "apiKey",
+ "apiKey": {
+ "allowedKeys": [ "secret-key" ],
+ "parameters": [ { "in": "header", "name": "x-api-key" } ]
+ }
+ }
+ """);
+ var plugin = new AuthPlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ // No x-api-key header ⇒ 401 before the request ever reaches the origin.
+ using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Auth_ApiKey_AllowsRequestWithValidKey()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ var config = PluginConfig.FromJson("""
+ {
+ "type": "apiKey",
+ "apiKey": {
+ "allowedKeys": [ "secret-key" ],
+ "parameters": [ { "in": "header", "name": "x-api-key" } ]
+ }
+ }
+ """);
+ var plugin = new AuthPlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ using var request = new HttpRequestMessage(
+ HttpMethod.Get, new Uri($"http://{origin.Host}/get"));
+ request.Headers.Add("x-api-key", "secret-key");
+ using var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("hello get", body);
+ }
+
+ [Fact]
+ public async Task GraphRandomError_Rate100_FailsEveryMatchedRequest()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host);
+
+ var config = PluginConfig.FromJson("""{ "rate": 100 }""");
+ var plugin = new GraphRandomErrorPlugin(
+ SharedHttpClient,
+ NullLogger.Instance,
+ urls,
+ ProxyConfig,
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(
+ origin.Host, [plugin]);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+
+ // rate:100 ⇒ always an injected error status; never the origin's 200.
+ Assert.True(
+ (int)response.StatusCode >= 400,
+ $"Expected an injected 4xx/5xx error, saw {(int)response.StatusCode}.");
+ }
+}
diff --git a/DevProxy.Integration.Tests/MockShortCircuitPlugin.cs b/DevProxy.Integration.Tests/MockShortCircuitPlugin.cs
new file mode 100644
index 00000000..c20d0c81
--- /dev/null
+++ b/DevProxy.Integration.Tests/MockShortCircuitPlugin.cs
@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using DevProxy.Abstractions.Plugins;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Proxy.Http;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Minimal plugin that short-circuits every matched request with a canned response —
+/// the mocking primitive (GenericResponse in Titanium, Respond on the
+/// canonical session). Used to prove the Kestrel engine honours a plugin-set response
+/// without ever contacting the origin.
+///
+internal sealed class MockShortCircuitPlugin(ISet urlsToWatch)
+ : BasePlugin(NullLogger.Instance, urlsToWatch)
+{
+ public const string MockBody = "mocked-by-plugin";
+
+ public const int MockStatus = 418;
+
+ public override string Name => nameof(MockShortCircuitPlugin);
+
+ public override Task BeforeRequestAsync(
+ ProxyRequestArgs e, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+ if (!e.ShouldExecute(UrlsToWatch))
+ {
+ return Task.CompletedTask;
+ }
+
+ e.ProxySession.Respond(
+ MockBody,
+ (HttpStatusCode)MockStatus,
+ [new HttpHeader("X-Mocked", "true")]);
+ e.ResponseState.HasBeenSet = true;
+ return Task.CompletedTask;
+ }
+}
diff --git a/DevProxy.Integration.Tests/MockingAndSmugglingIntegrationTests.cs b/DevProxy.Integration.Tests/MockingAndSmugglingIntegrationTests.cs
new file mode 100644
index 00000000..1c11bf9a
--- /dev/null
+++ b/DevProxy.Integration.Tests/MockingAndSmugglingIntegrationTests.cs
@@ -0,0 +1,83 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using DevProxy.Abstractions.Plugins;
+using DevProxy.Abstractions.Proxy;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Integration scenarios for the mocking short-circuit and the request-smuggling guard.
+///
+public sealed class MockingAndSmugglingIntegrationTests
+{
+ [Fact]
+ public async Task PluginRespond_ShortCircuits_OriginNeverContacted()
+ {
+ // Watch a host the origin does NOT serve — if the mock did not short-circuit,
+ // the forward would fail, proving the response came purely from the plugin.
+ const string watchedHost = "127.0.0.1:59999";
+ var urlsToWatch = new HashSet
+ {
+ new(new System.Text.RegularExpressions.Regex(
+ "^https?://127\\.0\\.0\\.1:59999/.*$",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase)),
+ };
+ var plugins = new IPlugin[] { new MockShortCircuitPlugin(urlsToWatch) };
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(watchedHost, plugins);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(new Uri($"http://{watchedHost}/anything"));
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(MockShortCircuitPlugin.MockStatus, (int)response.StatusCode);
+ Assert.Equal(MockShortCircuitPlugin.MockBody, body);
+ Assert.Contains(
+ response.Headers.TryGetValues("X-Mocked", out var v) ? v : [],
+ h => string.Equals(h, "true", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task ContentLengthPlusChunked_IsRejectedWith400()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+
+ // Raw absolute-form proxy request carrying BOTH Content-Length and
+ // Transfer-Encoding: chunked — a classic request-smuggling vector
+ // (RFC 9112 §6.3.3) the engine must refuse.
+ var raw =
+ $"GET http://{origin.Host}/get HTTP/1.1\r\n" +
+ $"Host: {origin.Host}\r\n" +
+ "Content-Length: 5\r\n" +
+ "Transfer-Encoding: chunked\r\n" +
+ "\r\n" +
+ "0\r\n\r\n";
+
+ using var tcp = new TcpClient();
+ await tcp.ConnectAsync(IPAddress.Loopback, proxy.Port);
+ var stream = tcp.GetStream();
+ var bytes = Encoding.ASCII.GetBytes(raw);
+ await stream.WriteAsync(bytes);
+ await stream.FlushAsync();
+
+ var response = await ReadStatusLineAsync(stream);
+
+ Assert.Contains("400", response, StringComparison.Ordinal);
+ }
+
+ private static async Task ReadStatusLineAsync(NetworkStream stream)
+ {
+ var buffer = new byte[1024];
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var read = await stream.ReadAsync(buffer, cts.Token);
+ return Encoding.ASCII.GetString(buffer, 0, read);
+ }
+}
diff --git a/DevProxy.Integration.Tests/NetUtil.cs b/DevProxy.Integration.Tests/NetUtil.cs
new file mode 100644
index 00000000..f9eb7faa
--- /dev/null
+++ b/DevProxy.Integration.Tests/NetUtil.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net.Sockets;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Test networking helpers.
+///
+internal static class NetUtil
+{
+ ///
+ /// Reserves a free localhost TCP port by binding to port 0, reading the
+ /// OS-assigned port, then releasing it. There is an inherent (tiny) race
+ /// between release and re-bind, but it is acceptable for hermetic tests.
+ ///
+ public static int GetFreePort()
+ {
+ var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ try
+ {
+ return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ }
+ finally
+ {
+ listener.Stop();
+ }
+ }
+}
diff --git a/DevProxy.Integration.Tests/PlainHttpIntegrationTests.cs b/DevProxy.Integration.Tests/PlainHttpIntegrationTests.cs
new file mode 100644
index 00000000..fa8d7ffd
--- /dev/null
+++ b/DevProxy.Integration.Tests/PlainHttpIntegrationTests.cs
@@ -0,0 +1,133 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Text;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Integration scenarios that exercise the Kestrel engine over plain HTTP (absolute-form
+/// proxy requests — no CONNECT, no TLS). These assert the engine relays the origin's
+/// response faithfully: status, body, and request-body round-tripping.
+///
+public sealed class PlainHttpIntegrationTests
+{
+ [Fact]
+ public async Task Get_NoBody_RelaysStatusAndBody()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(
+ new Uri($"http://{origin.Host}/get"));
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("hello get", body);
+ }
+
+ [Fact]
+ public async Task Post_SmallBody_EchoesBodyAndHeader()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var content = new StringContent("ping", Encoding.UTF8);
+ using var response = await client.PostAsync(
+ new Uri($"http://{origin.Host}/echo"), content);
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("ping", body);
+ Assert.Equal("4", response.Headers.TryGetValues("X-Echo-Length", out var v)
+ ? string.Join(string.Empty, v)
+ : response.Content.Headers.TryGetValues("X-Echo-Length", out var cv)
+ ? string.Join(string.Empty, cv)
+ : null);
+ }
+
+ [Fact]
+ public async Task Post_LargeBody_RoundTripsIntact()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ var payload = new string('x', 100_000);
+ using var content = new StringContent(payload, Encoding.UTF8);
+ using var response = await client.PostAsync(
+ new Uri($"http://{origin.Host}/echo"), content);
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(payload, body);
+ }
+
+ [Theory]
+ [InlineData(404)]
+ [InlineData(500)]
+ [InlineData(418)]
+ public async Task Get_StatusCode_RelaysStatus(int code)
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(
+ new Uri($"http://{origin.Host}/status/{code}"));
+
+ Assert.Equal(code, (int)response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Get_NoContent_RelaysEmptyBody()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(
+ new Uri($"http://{origin.Host}/nocontent"));
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(string.Empty, body);
+ }
+
+ [Fact]
+ public async Task Get_LargeBody_RelaysIntact()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(
+ new Uri($"http://{origin.Host}/big/200000"));
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(200_000, body.Length);
+ Assert.Equal(new string('A', 200_000), body);
+ }
+
+ [Fact]
+ public async Task Get_RequestHeader_ReachesOrigin()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var request = new HttpRequestMessage(
+ HttpMethod.Get, new Uri($"http://{origin.Host}/headers"));
+ request.Headers.Add("X-Probe", "abc123");
+ using var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal("probe=abc123", body);
+ }
+}
diff --git a/DevProxy.Integration.Tests/PluginConfig.cs b/DevProxy.Integration.Tests/PluginConfig.cs
new file mode 100644
index 00000000..7736efcf
--- /dev/null
+++ b/DevProxy.Integration.Tests/PluginConfig.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+using Microsoft.Extensions.Configuration;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Builds the that configured plugins
+/// (BasePlugin<TConfiguration>) bind their Configuration from —
+/// from an inline JSON object, so tests never touch disk.
+///
+/// The plugin's config object is nested under a stable key and the matching
+/// section is returned, exactly how the host resolves a plugin's
+/// configSection from devproxyrc.json.
+///
+internal static class PluginConfig
+{
+ private const string SectionName = "plugin";
+
+ ///
+ /// Returns a populated section from a JSON object literal, e.g.
+ /// FromJson("{ \"rate\": 100 }").
+ ///
+ public static IConfigurationSection FromJson(string configObjectJson)
+ {
+ var document = $"{{ \"{SectionName}\": {configObjectJson} }}";
+ var configuration = new ConfigurationBuilder()
+ .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(document)))
+ .Build();
+ return configuration.GetSection(SectionName);
+ }
+
+ /// An absent section, so the plugin falls back to config defaults.
+ public static IConfigurationSection Empty() =>
+ new ConfigurationBuilder().Build().GetSection(SectionName);
+}
diff --git a/DevProxy.Integration.Tests/PluginTestHost.cs b/DevProxy.Integration.Tests/PluginTestHost.cs
new file mode 100644
index 00000000..4d38f7da
--- /dev/null
+++ b/DevProxy.Integration.Tests/PluginTestHost.cs
@@ -0,0 +1,45 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Proxy;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Minimal dependency-injection host for plugins whose InitializeAsync
+/// constructs file loaders via ActivatorUtilities.CreateInstance<…Loader>.
+/// Those loaders (subclasses of BaseLoader) resolve ,
+/// , and from the service
+/// provider, so the harness must register exactly those.
+///
+/// InitArgs.ServiceProvider ──► HttpClient
+/// ├──► ILoggerFactory / ILogger<T>
+/// └──► IProxyConfiguration
+///
+internal sealed class PluginTestHost : IDisposable
+{
+ private readonly ServiceProvider _provider;
+
+ public PluginTestHost(IProxyConfiguration proxyConfiguration, ILoggerFactory? loggerFactory = null)
+ {
+ ArgumentNullException.ThrowIfNull(proxyConfiguration);
+
+ var services = new ServiceCollection();
+ _ = services.AddSingleton(loggerFactory ?? NullLoggerFactory.Instance);
+ _ = services.AddSingleton(typeof(ILogger<>), typeof(Logger<>));
+ _ = services.AddSingleton(proxyConfiguration);
+ _ = services.AddSingleton(TestDefaults.HttpClient);
+
+ _provider = services.BuildServiceProvider();
+ }
+
+ public IServiceProvider Services => _provider;
+
+ public InitArgs CreateInitArgs() => new() { ServiceProvider = _provider };
+
+ public void Dispose() => _provider.Dispose();
+}
diff --git a/DevProxy.Integration.Tests/ProcessSmokeTests.cs b/DevProxy.Integration.Tests/ProcessSmokeTests.cs
new file mode 100644
index 00000000..02e7f1aa
--- /dev/null
+++ b/DevProxy.Integration.Tests/ProcessSmokeTests.cs
@@ -0,0 +1,231 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics;
+using System.Net;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// End-to-end process-level smoke test. Unlike the in-process plugin suites (which boot the
+/// engine/plugins directly), this spawns the REAL DevProxy host executable with a
+/// config file and asserts that:
+///
+/// - config-driven plugin loading works (the host reads devproxyrc.json, loads
+/// MockResponsePlugin from disk via pluginPath, and binds its config section);
+/// - the Kestrel engine starts, registers as an explicit HTTP proxy, and a real request
+/// routed through it is short-circuited by the mock — proving the whole wire is intact after
+/// the Titanium → Kestrel cut-over.
+///
+/// Uses a plain-HTTP watched URL so no TLS interception / CA trust is needed; the mock
+/// short-circuits before any forward, so the (non-existent) origin is never contacted.
+///
+[Collection("process-smoke")]
+public sealed class ProcessSmokeTests
+{
+ private static readonly TimeSpan s_startupTimeout = TimeSpan.FromSeconds(90);
+
+ [Fact]
+ public async Task Host_LoadsMockPluginFromConfig_AndServesMock()
+ {
+ var hostDll = LocateHostDll();
+ Assert.True(File.Exists(hostDll), $"DevProxy host not built at {hostDll}. Run `dotnet build DevProxy` first.");
+
+ var workDir = Directory.CreateTempSubdirectory("devproxy-smoke-");
+ try
+ {
+ await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "mocks.json"), MocksFile);
+ await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "devproxyrc.json"), ConfigFile);
+
+ using var process = StartHost(hostDll, workDir.FullName);
+ try
+ {
+ var port = await WaitForListeningPortAsync(process);
+
+ using var handler = new HttpClientHandler
+ {
+ Proxy = new WebProxy($"http://127.0.0.1:{port}"),
+ UseProxy = true,
+ };
+ using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) };
+
+ using var response = await client.GetAsync(new Uri("http://api.contoso.local/hello"));
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Contains("hello from the mock", body, StringComparison.Ordinal);
+ }
+ finally
+ {
+ StopHost(process);
+ }
+ }
+ finally
+ {
+ workDir.Delete(recursive: true);
+ // The host writes a timestamped log to the working dir's parent in some modes;
+ // the temp dir delete above covers logs written under workDir. Best-effort.
+ }
+ }
+
+ private static Process StartHost(string hostDll, string workDir)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ WorkingDirectory = workDir,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+ psi.ArgumentList.Add(hostDll);
+ psi.ArgumentList.Add("--config-file");
+ psi.ArgumentList.Add(Path.Combine(workDir, "devproxyrc.json"));
+ psi.ArgumentList.Add("--port");
+ psi.ArgumentList.Add("0");
+ psi.ArgumentList.Add("--api-port");
+ psi.ArgumentList.Add("0");
+ psi.ArgumentList.Add("--as-system-proxy");
+ psi.ArgumentList.Add("false");
+ psi.ArgumentList.Add("--no-first-run");
+
+ var process = new Process { StartInfo = psi };
+ Assert.True(process.Start(), "Failed to start DevProxy host process.");
+ return process;
+ }
+
+ ///
+ /// Reads the host's stdout until it logs the bound proxy port
+ /// (listening on 127.0.0.1:<port>) or the startup timeout elapses.
+ ///
+ private static async Task WaitForListeningPortAsync(Process process)
+ {
+ using var cts = new CancellationTokenSource(s_startupTimeout);
+ var captured = new System.Text.StringBuilder();
+
+ while (!cts.IsCancellationRequested)
+ {
+ var line = await ReadLineAsync(process.StandardOutput, cts.Token);
+ if (line is null)
+ {
+ // stdout closed — the process exited before listening.
+ var err = await process.StandardError.ReadToEndAsync(CancellationToken.None);
+ throw new InvalidOperationException(
+ $"DevProxy host exited before listening.\nSTDOUT:\n{captured}\nSTDERR:\n{err}");
+ }
+
+ _ = captured.AppendLine(line);
+ var marker = "listening on 127.0.0.1:";
+ var idx = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (idx >= 0)
+ {
+ var portText = new string(line[(idx + marker.Length)..]
+ .TakeWhile(char.IsDigit)
+ .ToArray());
+ if (int.TryParse(portText, out var port) && port > 0)
+ {
+ return port;
+ }
+ }
+ }
+
+ throw new TimeoutException(
+ $"DevProxy host did not report a listening port within {s_startupTimeout.TotalSeconds}s.\nSTDOUT:\n{captured}");
+ }
+
+ private static async Task ReadLineAsync(StreamReader reader, CancellationToken ct)
+ {
+ try
+ {
+ return await reader.ReadLineAsync(ct);
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ }
+
+ private static void StopHost(Process process)
+ {
+ try
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ _ = process.WaitForExit(10_000);
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // Process already gone.
+ }
+ }
+
+ ///
+ /// Walks up from the test assembly output to the repo root, then resolves the host's
+ /// build output for the same configuration (Debug/Release).
+ ///
+ private static string LocateHostDll()
+ {
+ var config =
+#if DEBUG
+ "Debug";
+#else
+ "Release";
+#endif
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null)
+ {
+ var candidate = Path.Combine(dir.FullName, "DevProxy", "bin", config, "net10.0", "DevProxy.dll");
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+
+ dir = dir.Parent;
+ }
+
+ // Fall back to a path relative to the repo root guess so the assert message is useful.
+ return Path.Combine(AppContext.BaseDirectory, "DevProxy.dll");
+ }
+
+ private const string ConfigFile = """
+ {
+ "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/rc.schema.json",
+ "plugins": [
+ {
+ "name": "MockResponsePlugin",
+ "enabled": true,
+ "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll",
+ "configSection": "mocksPlugin"
+ }
+ ],
+ "urlsToWatch": [
+ "http://api.contoso.local/*"
+ ],
+ "mocksPlugin": {
+ "mocksFile": "mocks.json"
+ },
+ "logLevel": "information"
+ }
+ """;
+
+ private const string MocksFile = """
+ {
+ "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/mockresponseplugin.mocksfile.schema.json",
+ "mocks": [
+ {
+ "request": {
+ "url": "http://api.contoso.local/hello"
+ },
+ "response": {
+ "statusCode": 200,
+ "body": "hello from the mock"
+ }
+ }
+ ]
+ }
+ """;
+}
diff --git a/DevProxy.Integration.Tests/ReportingPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/ReportingPluginsIntegrationTests.cs
new file mode 100644
index 00000000..4d886c24
--- /dev/null
+++ b/DevProxy.Integration.Tests/ReportingPluginsIntegrationTests.cs
@@ -0,0 +1,153 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using DevProxy.Abstractions.Models;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Utils;
+using DevProxy.Plugins.Reporting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Coverage for reporting plugins, which observe the recorded RequestLog stream and
+/// emit a structured report into GlobalData[ProxyUtils.ReportsKey][PluginName] on
+/// AfterRecordingStopAsync — exactly as the host's ProxyStateController drives them at
+/// stop-recording. Each test feeds engine-shaped logs (via ),
+/// invokes the stop hook, and asserts the stored report.
+///
+/// NOT hermetic (deferred): GraphMinimalPermissions(+Guidance) call Microsoft Graph permission
+/// endpoints; MinimalCsomPermissions needs the shipped CSOM types definition + CSOM bodies;
+/// ApiCenter* require Azure API Center. Those are integration-with-live-backend (Bucket 3).
+///
+public sealed class ReportingPluginsIntegrationTests
+{
+ private static RecordingArgs Recording(IEnumerable logs) =>
+ new(logs)
+ {
+ GlobalData = new() { [ProxyUtils.ReportsKey] = new Dictionary() },
+ };
+
+ private static T GetReport(RecordingArgs args, string pluginName) where T : class =>
+ Assert.IsAssignableFrom(((Dictionary)args.GlobalData[ProxyUtils.ReportsKey])[pluginName]);
+
+ [Fact]
+ public async Task UrlDiscovery_CollectsDistinctInterceptedUrls()
+ {
+ var watch = KestrelProxyHarness.BuildUrlsToWatch("api.contoso.com");
+ var plugin = new UrlDiscoveryPlugin(NullLogger.Instance, watch);
+
+ var logs = new[]
+ {
+ TestExchange.Request("GET", "https://api.contoso.com/users").AsRequestLog(),
+ TestExchange.Request("GET", "https://api.contoso.com/orders").AsRequestLog(),
+ TestExchange.Request("GET", "https://api.contoso.com/users").AsRequestLog(),
+ };
+ var args = Recording(logs);
+
+ await plugin.AfterRecordingStopAsync(args, CancellationToken.None);
+
+ var report = GetReport(args, plugin.Name);
+ Assert.Equal(
+ ["https://api.contoso.com/orders", "https://api.contoso.com/users"],
+ report.Data);
+ }
+
+ [Fact]
+ public async Task ExecutionSummary_GroupsInterceptedRequests()
+ {
+ var watch = KestrelProxyHarness.BuildUrlsToWatch("api.contoso.com");
+ var config = PluginConfig.FromJson("""{ "groupBy": "url" }""");
+ var plugin = new ExecutionSummaryPlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ watch,
+ new TestProxyConfiguration(),
+ config);
+
+ var logs = new[]
+ {
+ TestExchange.Request("GET", "https://api.contoso.com/users").AsRequestLog(),
+ TestExchange.Request("POST", "https://api.contoso.com/orders").AsRequestLog(),
+ };
+ var args = Recording(logs);
+
+ await plugin.AfterRecordingStopAsync(args, CancellationToken.None);
+
+ var report = GetReport(args, plugin.Name);
+ Assert.NotEmpty(report.Data);
+ }
+
+ [Fact]
+ public async Task MinimalPermissions_ReportsPermissionsFromApiSpec()
+ {
+ var dir = Directory.CreateTempSubdirectory("devproxy-minperms-");
+ try
+ {
+ // OpenAPI spec with an OAuth2 scope on GET /users — the minimal permission the
+ // recorded request requires.
+ await File.WriteAllTextAsync(Path.Combine(dir.FullName, "contoso.json"), """
+ {
+ "openapi": "3.0.0",
+ "info": { "title": "Contoso", "version": "1.0" },
+ "servers": [ { "url": "https://api.contoso.com" } ],
+ "components": {
+ "securitySchemes": {
+ "oauth2": {
+ "type": "oauth2",
+ "flows": {
+ "authorizationCode": {
+ "authorizationUrl": "https://login.contoso.com/authorize",
+ "tokenUrl": "https://login.contoso.com/token",
+ "scopes": { "User.Read": "Read users", "User.Write": "Write users" }
+ }
+ }
+ }
+ }
+ },
+ "paths": {
+ "/users": {
+ "get": {
+ "security": [ { "oauth2": [ "User.Read" ] } ],
+ "responses": { "200": { "description": "ok" } }
+ }
+ }
+ }
+ }
+ """);
+
+ var watch = KestrelProxyHarness.BuildUrlsToWatch("api.contoso.com");
+ var proxyConfig = new TestProxyConfiguration
+ {
+ ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"),
+ };
+ var config = PluginConfig.FromJson($$"""{ "apiSpecsFolderPath": "{{dir.FullName.Replace("\\", "\\\\", StringComparison.Ordinal)}}" }""");
+
+ var plugin = new MinimalPermissionsPlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ watch,
+ proxyConfig,
+ config);
+
+ using var host = new PluginTestHost(proxyConfig);
+ await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);
+
+ var args = Recording([
+ TestExchange.Request("GET", "https://api.contoso.com/users").AsRequestLog(),
+ ]);
+
+ await plugin.AfterRecordingStopAsync(args, CancellationToken.None);
+
+ var report = GetReport(args, plugin.Name);
+ Assert.NotNull(report);
+ }
+ finally
+ {
+ dir.Delete(recursive: true);
+ }
+ }
+}
diff --git a/DevProxy.Integration.Tests/StreamingAndConnectionIntegrationTests.cs b/DevProxy.Integration.Tests/StreamingAndConnectionIntegrationTests.cs
new file mode 100644
index 00000000..13c50814
--- /dev/null
+++ b/DevProxy.Integration.Tests/StreamingAndConnectionIntegrationTests.cs
@@ -0,0 +1,99 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Integration scenarios for streaming bodies, chunked request framing, and connection reuse —
+/// all over plain HTTP so they need no TLS trust.
+///
+public sealed class StreamingAndConnectionIntegrationTests
+{
+ [Fact]
+ public async Task Sse_IsRelayedChunked_WithAllEvents()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var response = await client.GetAsync(
+ new Uri($"http://{origin.Host}/sse"),
+ HttpCompletionOption.ResponseHeadersRead);
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ // The engine re-frames the streamed origin body as HTTP/1.1 chunked.
+ Assert.True(response.Headers.TransferEncodingChunked ?? false);
+ Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
+ for (var i = 0; i < 5; i++)
+ {
+ Assert.Contains($"data: event-{i}", body, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public async Task ChunkedRequestBody_IsReframedAndReachesOrigin()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ // No Content-Length + chunked transfer-encoding → HttpClient streams chunked.
+ using var content = new StreamContent(
+ new MemoryStream(Encoding.UTF8.GetBytes("chunked-payload")));
+ content.Headers.ContentLength = null;
+ using var request = new HttpRequestMessage(
+ HttpMethod.Post, new Uri($"http://{origin.Host}/echo"))
+ {
+ Content = content,
+ };
+ request.Headers.TransferEncodingChunked = true;
+
+ using var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("chunked-payload", body);
+ }
+
+ [Fact]
+ public async Task KeepAlive_TwoRequestsSucceed_ResponseAdvertisesKeepAlive()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var first = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+ using var second = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
+
+ Assert.Equal(HttpStatusCode.OK, first.StatusCode);
+ Assert.Equal(HttpStatusCode.OK, second.StatusCode);
+ Assert.Equal("hello get", await second.Content.ReadAsStringAsync());
+ Assert.Contains(
+ first.Headers.Connection,
+ v => string.Equals(v, "keep-alive", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task ConnectionClose_IsHonoured()
+ {
+ await using var origin = await FakeOrigin.StartAsync();
+ await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host);
+ using var client = proxy.CreateHttpClient();
+
+ using var request = new HttpRequestMessage(
+ HttpMethod.Get, new Uri($"http://{origin.Host}/get"));
+ request.Headers.ConnectionClose = true;
+
+ using var response = await client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("hello get", await response.Content.ReadAsStringAsync());
+ }
+}
diff --git a/DevProxy.Integration.Tests/TestDefaults.cs b/DevProxy.Integration.Tests/TestDefaults.cs
new file mode 100644
index 00000000..348cda0d
--- /dev/null
+++ b/DevProxy.Integration.Tests/TestDefaults.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Shared, process-wide test defaults. A single is reused
+/// across plugin constructions and the DI host to avoid socket exhaustion.
+///
+internal static class TestDefaults
+{
+ public static readonly HttpClient HttpClient = new();
+}
diff --git a/DevProxy.Integration.Tests/TestExchange.cs b/DevProxy.Integration.Tests/TestExchange.cs
new file mode 100644
index 00000000..34090c8b
--- /dev/null
+++ b/DevProxy.Integration.Tests/TestExchange.cs
@@ -0,0 +1,100 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Text;
+using DevProxy.Abstractions.Models;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Proxy.Http;
+using DevProxy.Proxy.Kestrel.Http;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Builds the engine's real canonical exchange types (,
+/// , ) so plugin hooks can
+/// be driven directly. This is the high-fidelity path for plugins gated on a fixed upstream
+/// host (e.g. graph.microsoft.com) that the loopback cannot
+/// impersonate through real engine routing — the session object is byte-identical to what the
+/// engine constructs, so the test exercises the migrated plugin against the production model.
+///
+///
+/// MutableHttpRequest ─┐
+/// ├─► CanonicalProxySession ─► ProxyRequestArgs (BeforeRequest)
+/// (+ MutableHttpResponse via SetResponse) ───────► ProxyResponseArgs (Before/AfterResponse)
+///
+///
+internal sealed class TestExchange
+{
+ public CanonicalProxySession Session { get; }
+ public ResponseState State { get; } = new();
+
+ private TestExchange(CanonicalProxySession session) => Session = session;
+
+ public ProxyRequestArgs RequestArgs => new(Session, State);
+ public ProxyResponseArgs ResponseArgs => new(Session, State);
+
+ public static TestExchange Request(
+ string method,
+ string url,
+ IEnumerable<(string Name, string Value)>? headers = null,
+ string? body = null)
+ {
+ var collection = new HeaderCollection();
+ if (headers is not null)
+ {
+ foreach (var (name, value) in headers)
+ {
+ collection.Add(new HttpHeader(name, value));
+ }
+ }
+
+ ReadOnlyMemory bodyBytes = body is null
+ ? ReadOnlyMemory.Empty
+ : Encoding.UTF8.GetBytes(body);
+
+ var request = new MutableHttpRequest(
+ method,
+ new Uri(url, UriKind.Absolute),
+ HttpVersion.Version11,
+ collection,
+ bodyBytes);
+
+ return new TestExchange(new CanonicalProxySession(Guid.NewGuid().ToString(), request, processId: null));
+ }
+
+ public TestExchange WithResponse(
+ HttpStatusCode statusCode,
+ IEnumerable<(string Name, string Value)>? headers = null,
+ string? body = null)
+ {
+ var collection = new HeaderCollection();
+ if (headers is not null)
+ {
+ foreach (var (name, value) in headers)
+ {
+ collection.Add(new HttpHeader(name, value));
+ }
+ }
+
+ ReadOnlyMemory bodyBytes = body is null
+ ? ReadOnlyMemory.Empty
+ : Encoding.UTF8.GetBytes(body);
+
+ Session.SetOriginResponse(new MutableHttpResponse(
+ statusCode,
+ HttpVersion.Version11,
+ collection,
+ bodyBytes));
+ return this;
+ }
+
+ ///
+ /// Projects this exchange into a exactly as the engine pipeline
+ /// emits one (method/url derived from the session request), for feeding reporter and
+ /// generator plugins' AfterRecordingStopAsync.
+ ///
+ public RequestLog AsRequestLog(MessageType messageType = MessageType.InterceptedRequest) =>
+ new($"{Session.Request.Method} {Session.Request.Url}", messageType, new LoggingContext(Session));
+}
diff --git a/DevProxy.Integration.Tests/TestParallelization.cs b/DevProxy.Integration.Tests/TestParallelization.cs
new file mode 100644
index 00000000..698f4bfd
--- /dev/null
+++ b/DevProxy.Integration.Tests/TestParallelization.cs
@@ -0,0 +1,9 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+// Several generator plugins write timestamped output files to the *current working
+// directory*. The generator tests temporarily redirect the process CWD to a temp folder,
+// which is process-global state — so the whole assembly must run serially to prevent a
+// concurrent test from observing or polluting the redirected directory.
+[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)]
diff --git a/DevProxy.Integration.Tests/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs
new file mode 100644
index 00000000..86126d18
--- /dev/null
+++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs
@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Models;
+using DevProxy.Abstractions.Proxy;
+using Microsoft.Extensions.Logging;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Minimal for the integration harness. Only the
+/// members the Kestrel engine reads at boot (Port, IPAddress, AsSystemProxy,
+/// WatchPids/WatchProcessNames) actually matter; the rest carry inert defaults.
+///
+internal sealed class TestProxyConfiguration : IProxyConfiguration
+{
+ public int ApiPort { get; set; }
+ public bool AsSystemProxy { get; set; }
+ public string ConfigFile { get; set; } = "devproxyrc.json";
+ public Dictionary Env { get; set; } = [];
+ public IEnumerable? FilterByHeaders { get; }
+ public bool InstallCert { get; set; }
+ public string? IPAddress { get; set; } = "127.0.0.1";
+ public OutputFormat Output { get; set; } = OutputFormat.Text;
+ public LogLevel LogLevel => LogLevel.Information;
+ public ReleaseType NewVersionNotification => ReleaseType.None;
+ public bool NoFirstRun { get; set; } = true;
+ public bool NoWatch { get; set; }
+ public int Port { get; set; }
+ public bool Record { get; set; }
+ public bool ShowTimestamps => false;
+ public long? TimeoutSeconds { get; set; }
+ public bool ValidateSchemas => false;
+ public IEnumerable WatchPids { get; set; } = [];
+ public IEnumerable WatchProcessNames { get; set; } = [];
+}
diff --git a/DevProxy.Integration.Tests/WebSocketMessageMatcherTests.cs b/DevProxy.Integration.Tests/WebSocketMessageMatcherTests.cs
new file mode 100644
index 00000000..c57e16a4
--- /dev/null
+++ b/DevProxy.Integration.Tests/WebSocketMessageMatcherTests.cs
@@ -0,0 +1,77 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text.Json.Nodes;
+using DevProxy.Plugins.Mocking;
+using Xunit;
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// Pure unit coverage for — the bodyFragment /
+/// bodyRegex / bodyJson operators, their precedence, plus the catch-all and
+/// malformed-input behavior — without a live socket.
+///
+public sealed class WebSocketMessageMatcherTests
+{
+ [Theory]
+ [InlineData("ell", "hello", true)] // substring
+ [InlineData("ELL", "hello", true)] // case-insensitive
+ [InlineData("xyz", "hello", false)]
+ public void BodyFragment_IsCaseInsensitiveSubstring(string fragment, string message, bool expected) =>
+ Assert.Equal(expected, WebSocketMessageMatcher.Matches(
+ new WebSocketMessageMatch { BodyFragment = fragment }, message));
+
+ [Theory]
+ [InlineData("^h.*o$", "hello", true)]
+ [InlineData("^\\d+$", "12345", true)]
+ [InlineData("^\\d+$", "12a45", false)]
+ public void BodyRegex_MatchesPattern(string pattern, string message, bool expected) =>
+ Assert.Equal(expected, WebSocketMessageMatcher.Matches(
+ new WebSocketMessageMatch { BodyRegex = pattern }, message));
+
+ [Fact]
+ public void BodyRegex_MalformedPattern_DoesNotThrow_AndDoesNotMatch() =>
+ Assert.False(WebSocketMessageMatcher.Matches(
+ new WebSocketMessageMatch { BodyRegex = "(unclosed" }, "anything"));
+
+ [Theory]
+ [InlineData("""{ "a": 1, "b": 2 }""", """{"b":2,"a":1}""", true)] // order-insensitive
+ [InlineData("""{ "a": 1 }""", """{ "a": 2 }""", false)] // value differs
+ [InlineData("""[1, 2, 3]""", """[1,2,3]""", true)] // arrays, whitespace
+ public void BodyJson_ComparesStructurally(string expectedJson, string message, bool expected) =>
+ Assert.Equal(expected, WebSocketMessageMatcher.Matches(
+ new WebSocketMessageMatch { BodyJson = JsonNode.Parse(expectedJson) }, message));
+
+ [Fact]
+ public void BodyJson_InvalidInboundJson_DoesNotThrow_AndDoesNotMatch() =>
+ Assert.False(WebSocketMessageMatcher.Matches(
+ new WebSocketMessageMatch { BodyJson = JsonNode.Parse("""{ "a": 1 }""") }, "not json"));
+
+ [Fact]
+ public void Precedence_BodyJson_WinsOverRegexAndFragment() =>
+ // bodyJson is set and matches; bodyRegex/bodyFragment would NOT match the raw text,
+ // proving only the highest-precedence criterion is evaluated.
+ Assert.True(WebSocketMessageMatcher.Matches(
+ new WebSocketMessageMatch
+ {
+ BodyJson = JsonNode.Parse("""{ "a": 1 }"""),
+ BodyRegex = "^nope$",
+ BodyFragment = "zzz",
+ },
+ """{"a":1}"""));
+
+ [Fact]
+ public void Precedence_BodyRegex_WinsOverFragment() =>
+ Assert.True(WebSocketMessageMatcher.Matches(
+ new WebSocketMessageMatch { BodyRegex = "^hello$", BodyFragment = "zzz" }, "hello"));
+
+ [Fact]
+ public void NullMatch_IsCatchAll() =>
+ Assert.True(WebSocketMessageMatcher.Matches(null, "literally anything"));
+
+ [Fact]
+ public void NoCriteriaSet_IsCatchAll() =>
+ Assert.True(WebSocketMessageMatcher.Matches(new WebSocketMessageMatch(), "anything"));
+}
diff --git a/DevProxy.Integration.Tests/WebSocketMockIntegrationTests.cs b/DevProxy.Integration.Tests/WebSocketMockIntegrationTests.cs
new file mode 100644
index 00000000..935d8d5b
--- /dev/null
+++ b/DevProxy.Integration.Tests/WebSocketMockIntegrationTests.cs
@@ -0,0 +1,142 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Net;
+using System.Net.WebSockets;
+using System.Text;
+using DevProxy.Plugins.Mocking;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+using FrameworkWsMessageType = System.Net.WebSockets.WebSocketMessageType;
+
+// The ClientWebSocket trusts the engine's MITM leaf (no OS trust store in tests), exactly
+// like KestrelProxyHarness.CreateHttpClient does for HTTPS rows.
+#pragma warning disable CA5359
+
+namespace DevProxy.Integration.Tests;
+
+///
+/// End-to-end coverage for through the real
+/// : a genuine
+/// dials wss:// THROUGH the proxy, which MITMs the
+/// CONNECT (the plugin answers the handshake itself — no origin is ever dialed) and runs
+/// the scripted conversation.
+///
+///
+/// ClientWebSocket ──CONNECT ws.example.test:443──▶ Kestrel engine (MITM)
+/// ──GET Upgrade: websocket───────▶ WebSocketMockResponsePlugin
+/// ◀── 101 + scripted frames ────── (proxy IS the WS server)
+///
+///
+///
+/// wss:// (not ws://) is used deliberately: it forces CONNECT + TLS + MITM,
+/// the exact path WebSocket mocking must work on. The fake host never resolves because
+/// the proxy short-circuits the mock before any origin dial.
+///
+///
+public sealed class WebSocketMockIntegrationTests
+{
+ private const string Host = "ws.example.test";
+
+ [Fact]
+ public async Task MocksWebSocket_OnConnect_Reactive_AndClose_OverMitm()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+
+ var config = PluginConfig.FromJson($$"""
+ {
+ "mocks": [
+ {
+ "url": "wss://{{Host}}/socket",
+ "onConnect": [ { "body": "welcome" } ],
+ "rules": [
+ { "match": { "bodyRegex": "^ping$" }, "responses": [ { "body": "pong" } ] },
+ {
+ "match": { "bodyFragment": "bye" },
+ "responses": [ { "body": "goodbye" } ],
+ "closeAfter": true
+ }
+ ]
+ }
+ ]
+ }
+ """);
+
+ var plugin = new WebSocketMockResponsePlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ KestrelProxyHarness.BuildUrlsToWatch(Host),
+ new TestProxyConfiguration(),
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(Host, [plugin]);
+
+ using var ws = new ClientWebSocket();
+ ws.Options.Proxy = new WebProxy(
+ $"http://127.0.0.1:{proxy.Port.ToString(CultureInfo.InvariantCulture)}");
+ ws.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true;
+
+ await ws.ConnectAsync(new Uri($"wss://{Host}/socket"), cts.Token);
+
+ // on-connect scripted message arrives immediately after the handshake.
+ Assert.Equal("welcome", await ReceiveTextAsync(ws, cts.Token));
+
+ // reactive rule: ping → pong.
+ await SendTextAsync(ws, "ping", cts.Token);
+ Assert.Equal("pong", await ReceiveTextAsync(ws, cts.Token));
+
+ // contains-match rule replies then closes.
+ await SendTextAsync(ws, "bye now", cts.Token);
+ Assert.Equal("goodbye", await ReceiveTextAsync(ws, cts.Token));
+
+ // closeAfter: the mock server sends a close frame.
+ var close = await ws.ReceiveAsync(new byte[16], cts.Token);
+ Assert.Equal(FrameworkWsMessageType.Close, close.MessageType);
+ }
+
+ [Fact]
+ public async Task DoesNotMock_WhenNoMockMatchesUrl()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+
+ // Mock is scoped to a DIFFERENT path; the request to /other must not be mocked.
+ var config = PluginConfig.FromJson($$"""
+ {
+ "mocks": [
+ { "url": "wss://{{Host}}/socket", "onConnect": [ { "body": "welcome" } ] }
+ ]
+ }
+ """);
+
+ var plugin = new WebSocketMockResponsePlugin(
+ TestDefaults.HttpClient,
+ NullLogger.Instance,
+ KestrelProxyHarness.BuildUrlsToWatch(Host),
+ new TestProxyConfiguration(),
+ config);
+
+ await using var proxy = await KestrelProxyHarness.StartAsync(Host, [plugin]);
+
+ using var ws = new ClientWebSocket();
+ ws.Options.Proxy = new WebProxy(
+ $"http://127.0.0.1:{proxy.Port.ToString(CultureInfo.InvariantCulture)}");
+ ws.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true;
+
+ // No mock matches /other and there is no origin to relay to, so the upgrade fails
+ // rather than completing the scripted handshake.
+ await Assert.ThrowsAnyAsync(
+ async () => await ws.ConnectAsync(new Uri($"wss://{Host}/other"), cts.Token));
+ }
+
+ private static Task SendTextAsync(WebSocket ws, string text, CancellationToken ct) =>
+ ws.SendAsync(Encoding.UTF8.GetBytes(text).AsMemory(), FrameworkWsMessageType.Text, endOfMessage: true, ct).AsTask();
+
+ private static async Task ReceiveTextAsync(WebSocket ws, CancellationToken ct)
+ {
+ var buffer = new byte[8 * 1024];
+ var result = await ws.ReceiveAsync(buffer, ct);
+ return Encoding.UTF8.GetString(buffer, 0, result.Count);
+ }
+}
diff --git a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs
index 8a437734..8c6f8d7c 100644
--- a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs
+++ b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs
@@ -4,6 +4,7 @@
using DevProxy.Abstractions.Plugins;
using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Proxy.Http;
using DevProxy.Abstractions.Utils;
using DevProxy.Plugins.Models;
using Microsoft.Extensions.Configuration;
@@ -15,8 +16,6 @@
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
-using Titanium.Web.Proxy.Http;
-using Titanium.Web.Proxy.Models;
namespace DevProxy.Plugins.Behavior;
@@ -116,12 +115,12 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
if (e.ResponseState.HasBeenSet)
{
- Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
@@ -129,7 +128,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
if (failMode == GenericRandomErrorFailMode.PassThru && Configuration.Rate != 100)
{
- Logger.LogRequest("Pass through", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Pass through", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
FailResponse(e);
@@ -143,7 +142,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
private void FailResponse(ProxyRequestArgs e)
{
- var matchingResponse = GetMatchingErrorResponse(e.Session.HttpClient.Request);
+ var matchingResponse = GetMatchingErrorResponse(e.ProxySession.Request);
if (matchingResponse is not null &&
matchingResponse.Responses is not null)
{
@@ -153,17 +152,17 @@ private void FailResponse(ProxyRequestArgs e)
}
else
{
- Logger.LogRequest("No matching error response found", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("No matching error response found", MessageType.Skipped, new LoggingContext(e.ProxySession));
}
}
- private static ThrottlingInfo ShouldThrottle(Request request, string throttlingKey, int retryAfterInSeconds)
+ private static ThrottlingInfo ShouldThrottle(IHttpRequest request, string throttlingKey, int retryAfterInSeconds)
{
var throttleKeyForRequest = BuildThrottleKey(request);
return new(throttleKeyForRequest == throttlingKey ? retryAfterInSeconds : 0, "Retry-After");
}
- private GenericErrorResponse? GetMatchingErrorResponse(Request request)
+ private GenericErrorResponse? GetMatchingErrorResponse(IHttpRequest request)
{
if (Configuration.Errors is null ||
!Configuration.Errors.Any())
@@ -213,8 +212,7 @@ private static ThrottlingInfo ShouldThrottle(Request request, string throttlingK
private void UpdateProxyResponse(ProxyRequestArgs e, GenericErrorResponseResponse error)
{
- var session = e.Session;
- var request = session.HttpClient.Request;
+ var request = e.ProxySession.Request;
var headers = new List();
if (error.Headers is not null)
{
@@ -279,20 +277,20 @@ private void UpdateProxyResponse(ProxyRequestArgs e, GenericErrorResponseRespons
if (!File.Exists(filePath))
{
Logger.LogError("File {FilePath} not found. Serving file path in the mock response", (string?)filePath);
- session.GenericResponse(body, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value)));
+ e.ProxySession.Respond(body, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value)));
}
else
{
var bodyBytes = File.ReadAllBytes(filePath);
- session.GenericResponse(bodyBytes, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value)));
+ e.ProxySession.Respond(bodyBytes, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value)));
}
}
else
{
- session.GenericResponse(body, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value)));
+ e.ProxySession.Respond(body, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value)));
}
e.ResponseState.HasBeenSet = true;
- Logger.LogRequest($"{error.StatusCode} {statusCode}", MessageType.Chaos, new LoggingContext(e.Session));
+ Logger.LogRequest($"{error.StatusCode} {statusCode}", MessageType.Chaos, new LoggingContext(e.ProxySession));
}
private void ValidateErrors()
@@ -346,7 +344,7 @@ private void ValidateErrors()
}
}
- private static bool HasMatchingBody(GenericErrorResponse errorResponse, Request request)
+ private static bool HasMatchingBody(GenericErrorResponse errorResponse, IHttpRequest request)
{
if (request.Method == "GET")
{
@@ -371,7 +369,7 @@ private static bool HasMatchingBody(GenericErrorResponse errorResponse, Request
}
// throttle requests per host
- private static string BuildThrottleKey(Request r) => r.RequestUri.Host;
+ private static string BuildThrottleKey(IHttpRequest r) => r.RequestUri.Host;
protected override void Dispose(bool disposing)
{
diff --git a/DevProxy.Plugins/Behavior/GraphRandomErrorPlugin.cs b/DevProxy.Plugins/Behavior/GraphRandomErrorPlugin.cs
index e3d9c598..1e69d29e 100644
--- a/DevProxy.Plugins/Behavior/GraphRandomErrorPlugin.cs
+++ b/DevProxy.Plugins/Behavior/GraphRandomErrorPlugin.cs
@@ -2,9 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
-using DevProxy.Abstractions.Proxy;
using DevProxy.Abstractions.Plugins;
using DevProxy.Abstractions.Models;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Proxy.Http;
using DevProxy.Abstractions.Utils;
using DevProxy.Plugins.Utils;
using Microsoft.Extensions.Configuration;
@@ -15,8 +16,6 @@
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
-using Titanium.Web.Proxy.Http;
-using Titanium.Web.Proxy.Models;
using DevProxy.Plugins.Models;
namespace DevProxy.Plugins.Behavior;
@@ -177,22 +176,22 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
var state = e.ResponseState;
if (state.HasBeenSet)
{
- Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
var failMode = ShouldFail();
if (failMode == GraphRandomErrorFailMode.PassThru && Configuration.Rate != 100)
{
- Logger.LogRequest("Pass through", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Pass through", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
- if (ProxyUtils.IsGraphBatchUrl(e.Session.HttpClient.Request.RequestUri))
+ if (ProxyUtils.IsGraphBatchUrl(e.ProxySession.Request.RequestUri))
{
FailBatch(e);
}
@@ -212,7 +211,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
private void FailResponse(ProxyRequestArgs e)
{
// pick a random error response for the current request method
- var methodStatusCodes = _methodStatusCode[e.Session.HttpClient.Request.Method ?? "GET"];
+ var methodStatusCodes = _methodStatusCode[e.ProxySession.Request.Method];
var errorStatus = methodStatusCodes[_random.Next(0, methodStatusCodes.Length)];
UpdateProxyResponse(e, errorStatus);
}
@@ -221,7 +220,7 @@ private void FailBatch(ProxyRequestArgs e)
{
var batchResponse = new GraphBatchResponsePayload();
- var batch = JsonSerializer.Deserialize(e.Session.HttpClient.Request.BodyString, ProxyUtils.JsonSerializerOptions);
+ var batch = JsonSerializer.Deserialize(e.ProxySession.Request.BodyString, ProxyUtils.JsonSerializerOptions);
if (batch == null)
{
UpdateProxyBatchResponse(e, batchResponse);
@@ -258,7 +257,7 @@ private void FailBatch(ProxyRequestArgs e)
if (errorStatus == HttpStatusCode.TooManyRequests)
{
var retryAfterDate = DateTime.Now.AddSeconds(Configuration.RetryAfterInSeconds);
- var requestUrl = ProxyUtils.GetAbsoluteRequestUrlFromBatch(e.Session.HttpClient.Request.RequestUri, request.Url);
+ var requestUrl = ProxyUtils.GetAbsoluteRequestUrlFromBatch(e.ProxySession.Request.RequestUri, request.Url);
if (!e.GlobalData.TryGetValue(RetryAfterPlugin.ThrottledRequestsKey, out var value))
{
@@ -279,7 +278,7 @@ private void FailBatch(ProxyRequestArgs e)
UpdateProxyBatchResponse(e, batchResponse);
}
- private ThrottlingInfo ShouldThrottle(Request request, string throttlingKey)
+ private ThrottlingInfo ShouldThrottle(IHttpRequest request, string throttlingKey)
{
var throttleKeyForRequest = GraphUtils.BuildThrottleKey(request);
return new(throttleKeyForRequest == throttlingKey ? Configuration.RetryAfterInSeconds : 0, "Retry-After");
@@ -287,12 +286,11 @@ private ThrottlingInfo ShouldThrottle(Request request, string throttlingKey)
private void UpdateProxyResponse(ProxyRequestArgs e, HttpStatusCode errorStatus)
{
- var session = e.Session;
var requestId = Guid.NewGuid().ToString();
var now = DateTime.Now;
var requestDateHeader = now.ToString("r", CultureInfo.InvariantCulture);
var requestDateInnerError = now.ToString("s", CultureInfo.InvariantCulture);
- var request = session.HttpClient.Request;
+ var request = e.ProxySession.Request;
var headers = ProxyUtils.BuildGraphResponseHeaders(request, requestId, requestDateHeader);
if (errorStatus == HttpStatusCode.TooManyRequests)
{
@@ -321,8 +319,8 @@ private void UpdateProxyResponse(ProxyRequestArgs e, HttpStatusCode errorStatus)
}),
ProxyUtils.JsonSerializerOptions
);
- Logger.LogRequest($"{(int)errorStatus} {errorStatus}", MessageType.Chaos, new LoggingContext(e.Session));
- session.GenericResponse(body ?? string.Empty, errorStatus, headers.Select(h => new HttpHeader(h.Name, h.Value)));
+ Logger.LogRequest($"{(int)errorStatus} {errorStatus}", MessageType.Chaos, new LoggingContext(e.ProxySession));
+ e.ProxySession.Respond(body ?? string.Empty, errorStatus, headers.Select(h => new HttpHeader(h.Name, h.Value)));
}
private void UpdateProxyBatchResponse(ProxyRequestArgs ev, GraphBatchResponsePayload response)
@@ -330,16 +328,15 @@ private void UpdateProxyBatchResponse(ProxyRequestArgs ev, GraphBatchResponsePay
// failed batch uses 200 OK status code
var errorStatus = HttpStatusCode.OK;
- var session = ev.Session;
var requestId = Guid.NewGuid().ToString();
var requestDate = DateTime.Now.ToString("r", CultureInfo.InvariantCulture);
- var request = session.HttpClient.Request;
+ var request = ev.ProxySession.Request;
var headers = ProxyUtils.BuildGraphResponseHeaders(request, requestId, requestDate);
var body = JsonSerializer.Serialize(response, ProxyUtils.JsonSerializerOptions);
- Logger.LogRequest($"{(int)errorStatus} {errorStatus}", MessageType.Chaos, new LoggingContext(ev.Session));
- session.GenericResponse(body, errorStatus, headers.Select(h => new HttpHeader(h.Name, h.Value)));
+ Logger.LogRequest($"{(int)errorStatus} {errorStatus}", MessageType.Chaos, new LoggingContext(ev.ProxySession));
+ ev.ProxySession.Respond(body, errorStatus, headers.Select(h => new HttpHeader(h.Name, h.Value)));
}
- private static string BuildApiErrorMessage(Request r) => $"Some error was generated by the proxy. {(ProxyUtils.IsGraphRequest(r) ? ProxyUtils.IsSdkRequest(r) ? "" : string.Join(' ', MessageUtils.BuildUseSdkForErrorsMessage()) : "")}";
+ private static string BuildApiErrorMessage(IHttpRequest r) => $"Some error was generated by the proxy. {(ProxyUtils.IsGraphRequest(r) ? ProxyUtils.IsSdkRequest(r) ? "" : string.Join(' ', MessageUtils.BuildUseSdkForErrorsMessage()) : "")}";
}
diff --git a/DevProxy.Plugins/Behavior/LanguageModelFailurePlugin.cs b/DevProxy.Plugins/Behavior/LanguageModelFailurePlugin.cs
index 2563113f..cc2a8482 100644
--- a/DevProxy.Plugins/Behavior/LanguageModelFailurePlugin.cs
+++ b/DevProxy.Plugins/Behavior/LanguageModelFailurePlugin.cs
@@ -55,31 +55,30 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return;
}
if (e.ResponseState.HasBeenSet)
{
- Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.ProxySession));
return;
}
- var request = e.Session.HttpClient.Request;
+ var request = e.ProxySession.Request;
if (request.Method is null ||
!request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
!request.HasBody)
{
- Logger.LogRequest("Request is not a POST request with a body", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Request is not a POST request with a body", MessageType.Skipped, new LoggingContext(e.ProxySession));
return;
}
- Logger.LogDebug("Request encoding: {Encoding}", request.Encoding?.EncodingName ?? "null");
- var originalBody = System.Text.Encoding.UTF8.GetString(request.Body);
+ var originalBody = request.BodyString;
Logger.LogDebug("Original request body:\n{Body}", originalBody);
if (!OpenAIRequest.TryGetCompletionLikeRequest(originalBody, Logger, out var openAiRequest))
{
- Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new LoggingContext(e.ProxySession));
return;
}
@@ -94,10 +93,10 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
{
completionRequest.Prompt += "\n\n" + faultPrompt;
Logger.LogDebug("Modified completion request prompt: {Prompt}", completionRequest.Prompt);
- Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new LoggingContext(e.Session));
+ Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new LoggingContext(e.ProxySession));
var modifiedBody = JsonSerializer.Serialize(completionRequest, ProxyUtils.JsonSerializerOptions);
Logger.LogDebug("Modified request body:\n{Body}", modifiedBody);
- e.Session.SetRequestBodyString(modifiedBody);
+ e.ProxySession.Request.SetBodyString(modifiedBody);
}
else if (openAiRequest is OpenAIChatCompletionRequest chatRequest)
{
@@ -119,10 +118,10 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
};
Logger.LogDebug("Added fault prompt to messages: {Prompt}", faultPrompt);
- Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new LoggingContext(e.Session));
+ Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new LoggingContext(e.ProxySession));
var modifiedBody = JsonSerializer.Serialize(newRequest, ProxyUtils.JsonSerializerOptions);
Logger.LogDebug("Modified request body:\n{Body}", modifiedBody);
- e.Session.SetRequestBodyString(modifiedBody);
+ e.ProxySession.Request.SetBodyString(modifiedBody);
}
else if (openAiRequest is OpenAIResponsesRequest responsesRequest)
{
@@ -151,10 +150,10 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
};
Logger.LogDebug("Added fault prompt to Responses API input: {Prompt}", faultPrompt);
- Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new LoggingContext(e.Session));
+ Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new LoggingContext(e.ProxySession));
var modifiedBody = JsonSerializer.Serialize(newRequest, ProxyUtils.JsonSerializerOptions);
Logger.LogDebug("Modified request body:\n{Body}", modifiedBody);
- e.Session.SetRequestBodyString(modifiedBody);
+ e.ProxySession.Request.SetBodyString(modifiedBody);
}
else
{
diff --git a/DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs b/DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs
index 9858f672..a5238b1c 100644
--- a/DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs
+++ b/DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs
@@ -6,6 +6,7 @@
using DevProxy.Abstractions.Models;
using DevProxy.Abstractions.Plugins;
using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Proxy.Http;
using DevProxy.Abstractions.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -13,8 +14,6 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
-using Titanium.Web.Proxy.Http;
-using Titanium.Web.Proxy.Models;
namespace DevProxy.Plugins.Behavior;
@@ -77,31 +76,29 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
ArgumentNullException.ThrowIfNull(e);
- var session = e.Session;
var state = e.ResponseState;
if (state.HasBeenSet)
{
- Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
- var request = e.Session.HttpClient.Request;
- if (request.Method is null ||
- !request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
+ var request = e.ProxySession.Request;
+ if (!request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
!request.HasBody)
{
- Logger.LogRequest("Request is not a POST request with a body", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Request is not a POST request with a body", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
if (!OpenAIRequest.TryGetCompletionLikeRequest(request.BodyString, Logger, out var openAiRequest))
{
- Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
@@ -127,7 +124,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
// check if we have tokens available
if (_promptTokensRemaining <= 0 || _completionTokensRemaining <= 0)
{
- Logger.LogRequest($"Exceeded token limit when calling {request.Url}. Request will be throttled", MessageType.Failed, new LoggingContext(e.Session));
+ Logger.LogRequest($"Exceeded token limit when calling {request.Url}. Request will be throttled", MessageType.Failed, new LoggingContext(e.ProxySession));
if (Configuration.WhenLimitExceeded == TokenLimitResponseWhenExceeded.Throttle)
{
@@ -184,13 +181,13 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
string body = Configuration.CustomResponse.Body is not null ?
JsonSerializer.Serialize(Configuration.CustomResponse.Body, ProxyUtils.JsonSerializerOptions) :
"";
- e.Session.GenericResponse(body, responseCode, headers);
+ e.ProxySession.Respond(body, responseCode, headers);
state.HasBeenSet = true;
}
else
{
- Logger.LogRequest($"Custom behavior not set. {Configuration.CustomResponseFile} not found.", MessageType.Failed, new LoggingContext(e.Session));
- e.Session.GenericResponse("Custom response file not found.", HttpStatusCode.InternalServerError, []);
+ Logger.LogRequest($"Custom behavior not set. {Configuration.CustomResponseFile} not found.", MessageType.Failed, new LoggingContext(e.ProxySession));
+ e.ProxySession.Respond("Custom response file not found.", HttpStatusCode.InternalServerError, []);
state.HasBeenSet = true;
}
}
@@ -211,13 +208,12 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
- var request = e.Session.HttpClient.Request;
- if (request.Method is null ||
- !request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
+ var request = e.ProxySession.Request;
+ if (!request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
!request.HasBody)
{
Logger.LogDebug("Skipping non-POST request");
@@ -231,7 +227,7 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
}
// Read the response body to get token usage
- var response = e.Session.HttpClient.Response;
+ var response = e.ProxySession.Response!;
if (response.HasBody)
{
var responseBody = response.BodyString;
@@ -257,7 +253,7 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
_completionTokensRemaining = 0;
}
- Logger.LogRequest($"Consumed {promptTokens} prompt tokens and {completionTokens} completion tokens. Remaining - Prompt: {_promptTokensRemaining}, Completion: {_completionTokensRemaining}", MessageType.Processed, new LoggingContext(e.Session));
+ Logger.LogRequest($"Consumed {promptTokens} prompt tokens and {completionTokens} completion tokens. Remaining - Prompt: {_promptTokensRemaining}, Completion: {_completionTokensRemaining}", MessageType.Processed, new LoggingContext(e.ProxySession));
}
}
catch (JsonException ex)
@@ -271,7 +267,7 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
return Task.CompletedTask;
}
- private ThrottlingInfo ShouldThrottle(Request request, string throttlingKey)
+ private ThrottlingInfo ShouldThrottle(IHttpRequest request, string throttlingKey)
{
var throttleKeyForRequest = BuildThrottleKey(request);
return new(throttleKeyForRequest == throttlingKey ?
@@ -283,7 +279,7 @@ private void ThrottleResponse(ProxyRequestArgs e)
{
var headers = new List();
var body = string.Empty;
- var request = e.Session.HttpClient.Request;
+ var request = e.ProxySession.Request;
// Build standard OpenAI error response for token limit exceeded
var openAiError = new
@@ -305,10 +301,10 @@ private void ThrottleResponse(ProxyRequestArgs e)
headers.Add(new("Access-Control-Expose-Headers", Configuration.HeaderRetryAfter));
}
- e.Session.GenericResponse(body, HttpStatusCode.TooManyRequests, [.. headers.Select(h => new HttpHeader(h.Name, h.Value))]);
+ e.ProxySession.Respond(body, HttpStatusCode.TooManyRequests, [.. headers.Select(h => new HttpHeader(h.Name, h.Value))]);
}
- private static string BuildThrottleKey(Request r) => r.RequestUri.Host;
+ private static string BuildThrottleKey(IHttpRequest r) => r.RequestUri.Host;
protected override void Dispose(bool disposing)
{
diff --git a/DevProxy.Plugins/Behavior/LatencyPlugin.cs b/DevProxy.Plugins/Behavior/LatencyPlugin.cs
index 28105880..a47d9464 100644
--- a/DevProxy.Plugins/Behavior/LatencyPlugin.cs
+++ b/DevProxy.Plugins/Behavior/LatencyPlugin.cs
@@ -41,11 +41,11 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return;
}
- await ApplyDelayAsync("request", new LoggingContext(e.Session), cancellationToken);
+ await ApplyDelayAsync("request", new LoggingContext(e.ProxySession), cancellationToken);
Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync));
}
diff --git a/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs b/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs
index 9af07719..46cd78ac 100644
--- a/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs
+++ b/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs
@@ -5,6 +5,7 @@
using DevProxy.Abstractions.Models;
using DevProxy.Abstractions.Plugins;
using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Proxy.Http;
using DevProxy.Abstractions.Utils;
using DevProxy.Plugins.Models;
using DevProxy.Plugins.Utils;
@@ -15,8 +16,6 @@
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
-using Titanium.Web.Proxy.Http;
-using Titanium.Web.Proxy.Models;
namespace DevProxy.Plugins.Behavior;
@@ -89,16 +88,15 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
ArgumentNullException.ThrowIfNull(e);
- var session = e.Session;
var state = e.ResponseState;
if (state.HasBeenSet)
{
- Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
@@ -124,9 +122,9 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
if (_resourcesRemaining < 0)
{
_resourcesRemaining = 0;
- var request = e.Session.HttpClient.Request;
+ var request = e.ProxySession.Request;
- Logger.LogRequest($"Exceeded resource limit when calling {request.Url}. Request will be throttled", MessageType.Failed, new LoggingContext(e.Session));
+ Logger.LogRequest($"Exceeded resource limit when calling {request.Url}. Request will be throttled", MessageType.Failed, new LoggingContext(e.ProxySession));
if (Configuration.WhenLimitExceeded == RateLimitResponseWhenLimitExceeded.Throttle)
{
if (!e.GlobalData.TryGetValue(RetryAfterPlugin.ThrottledRequestsKey, out var value))
@@ -182,18 +180,18 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
string body = Configuration.CustomResponse.Body is not null ?
JsonSerializer.Serialize(Configuration.CustomResponse.Body, ProxyUtils.JsonSerializerOptions) :
"";
- e.Session.GenericResponse(body, responseCode, headers);
+ e.ProxySession.Respond(body, responseCode, headers);
state.HasBeenSet = true;
}
else
{
- Logger.LogRequest($"Custom behavior not set. {Configuration.CustomResponseFile} not found.", MessageType.Failed, new LoggingContext(e.Session));
+ Logger.LogRequest($"Custom behavior not set. {Configuration.CustomResponseFile} not found.", MessageType.Failed, new LoggingContext(e.ProxySession));
}
}
}
else
{
- Logger.LogRequest($"Resources remaining: {_resourcesRemaining}", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest($"Resources remaining: {_resourcesRemaining}", MessageType.Skipped, new LoggingContext(e.ProxySession));
}
StoreRateLimitingHeaders(e);
@@ -208,12 +206,12 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
if (e.ResponseState.HasBeenSet)
{
- Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
@@ -223,7 +221,7 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
return Task.CompletedTask;
}
- private ThrottlingInfo ShouldThrottle(Request request, string throttlingKey)
+ private ThrottlingInfo ShouldThrottle(IHttpRequest request, string throttlingKey)
{
var throttleKeyForRequest = BuildThrottleKey(request);
return new(throttleKeyForRequest == throttlingKey ?
@@ -237,8 +235,8 @@ private void UpdateProxyResponse(ProxyHttpEventArgsBase e, HttpStatusCode errorS
{
var headers = new List();
var body = string.Empty;
- var request = e.Session.HttpClient.Request;
- var response = e.Session.HttpClient.Response;
+ var request = e.ProxySession.Request;
+ var response = e.ProxySession.Response!;
// resources exceeded
if (errorStatus == HttpStatusCode.TooManyRequests)
@@ -273,7 +271,7 @@ private void UpdateProxyResponse(ProxyHttpEventArgsBase e, HttpStatusCode errorS
headers.Add(new("Access-Control-Expose-Headers", Configuration.HeaderRetryAfter));
}
- e.Session.GenericResponse(body ?? string.Empty, errorStatus, [.. headers.Select(h => new HttpHeader(h.Name, h.Value))]);
+ e.ProxySession.Respond(body ?? string.Empty, errorStatus, [.. headers.Select(h => new HttpHeader(h.Name, h.Value))]);
return;
}
@@ -284,8 +282,8 @@ private void UpdateProxyResponse(ProxyHttpEventArgsBase e, HttpStatusCode errorS
}
// add headers to the original API response, avoiding duplicates
- headers.ForEach(h => e.Session.HttpClient.Response.Headers.RemoveHeader(h.Name));
- e.Session.HttpClient.Response.Headers.AddHeaders(headers.Select(h => new HttpHeader(h.Name, h.Value)).ToArray());
+ headers.ForEach(h => response.Headers.Remove(h.Name));
+ response.Headers.AddRange(headers.Select(h => new HttpHeader(h.Name, h.Value)));
}
private void StoreRateLimitingHeaders(ProxyRequestArgs e)
@@ -314,7 +312,7 @@ private void StoreRateLimitingHeaders(ProxyRequestArgs e)
private void ExposeRateLimitingForCors(List headers, ProxyRequestArgs e)
{
- var request = e.Session.HttpClient.Request;
+ var request = e.ProxySession.Request;
if (request.Headers.FirstOrDefault((h) => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)) is null)
{
return;
@@ -324,9 +322,9 @@ private void ExposeRateLimitingForCors(List headers, ProxyRe
headers.Add(new("Access-Control-Expose-Headers", $"{Configuration.HeaderLimit}, {Configuration.HeaderRemaining}, {Configuration.HeaderReset}, {Configuration.HeaderRetryAfter}"));
}
- private static string BuildApiErrorMessage(Request r) => $"Some error was generated by the proxy. {(ProxyUtils.IsGraphRequest(r) ? ProxyUtils.IsSdkRequest(r) ? "" : string.Join(' ', MessageUtils.BuildUseSdkForErrorsMessage()) : "")}";
+ private static string BuildApiErrorMessage(IHttpRequest r) => $"Some error was generated by the proxy. {(ProxyUtils.IsGraphRequest(r) ? ProxyUtils.IsSdkRequest(r) ? "" : string.Join(' ', MessageUtils.BuildUseSdkForErrorsMessage()) : "")}";
- private static string BuildThrottleKey(Request r)
+ private static string BuildThrottleKey(IHttpRequest r)
{
if (ProxyUtils.IsGraphRequest(r))
{
diff --git a/DevProxy.Plugins/Behavior/RetryAfterPlugin.cs b/DevProxy.Plugins/Behavior/RetryAfterPlugin.cs
index 52f2ce9c..4eb4a72a 100644
--- a/DevProxy.Plugins/Behavior/RetryAfterPlugin.cs
+++ b/DevProxy.Plugins/Behavior/RetryAfterPlugin.cs
@@ -5,6 +5,7 @@
using DevProxy.Abstractions.Models;
using DevProxy.Abstractions.Plugins;
using DevProxy.Abstractions.Proxy;
+using DevProxy.Abstractions.Proxy.Http;
using DevProxy.Abstractions.Utils;
using DevProxy.Plugins.Models;
using DevProxy.Plugins.Utils;
@@ -13,8 +14,6 @@
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
-using Titanium.Web.Proxy.Http;
-using Titanium.Web.Proxy.Models;
namespace DevProxy.Plugins.Behavior;
@@ -34,17 +33,17 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
if (!e.HasRequestUrlMatch(UrlsToWatch))
{
- Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
if (e.ResponseState.HasBeenSet)
{
- Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
- if (string.Equals(e.Session.HttpClient.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(e.ProxySession.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
- Logger.LogRequest("Skipping OPTIONS request", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Skipping OPTIONS request", MessageType.Skipped, new LoggingContext(e.ProxySession));
return Task.CompletedTask;
}
@@ -56,16 +55,16 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
private void ThrottleIfNecessary(ProxyRequestArgs e)
{
- var request = e.Session.HttpClient.Request;
+ var request = e.ProxySession.Request;
if (!e.GlobalData.TryGetValue(ThrottledRequestsKey, out var value))
{
- Logger.LogRequest("Request not throttled", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Request not throttled", MessageType.Skipped, new LoggingContext(e.ProxySession));
return;
}
if (value is not List throttledRequests)
{
- Logger.LogRequest("Request not throttled", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Request not throttled", MessageType.Skipped, new LoggingContext(e.ProxySession));
return;
}
@@ -77,7 +76,7 @@ private void ThrottleIfNecessary(ProxyRequestArgs e)
if (throttledRequests.Count == 0)
{
- Logger.LogRequest("Request not throttled", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Request not throttled", MessageType.Skipped, new LoggingContext(e.ProxySession));
return;
}
@@ -87,7 +86,7 @@ private void ThrottleIfNecessary(ProxyRequestArgs e)
if (throttleInfo.ThrottleForSeconds > 0)
{
var message = $"Calling {request.Url} before waiting for the Retry-After period. Request will be throttled. Throttling on {throttler.ThrottlingKey}.";
- Logger.LogRequest(message, MessageType.Failed, new LoggingContext(e.Session));
+ Logger.LogRequest(message, MessageType.Failed, new LoggingContext(e.ProxySession));
throttler.ResetTime = DateTime.Now.AddSeconds(throttleInfo.ThrottleForSeconds);
UpdateProxyResponse(e, throttleInfo, string.Join(' ', message));
@@ -95,14 +94,14 @@ private void ThrottleIfNecessary(ProxyRequestArgs e)
}
}
- Logger.LogRequest("Request not throttled", MessageType.Skipped, new LoggingContext(e.Session));
+ Logger.LogRequest("Request not throttled", MessageType.Skipped, new LoggingContext(e.ProxySession));
}
private static void UpdateProxyResponse(ProxyRequestArgs e, ThrottlingInfo throttlingInfo, string message)
{
var headers = new List();
var body = string.Empty;
- var request = e.Session.HttpClient.Request;
+ var request = e.ProxySession.Request;
// override the response body and headers for the error response
if (ProxyUtils.IsGraphRequest(request))
@@ -139,9 +138,9 @@ private static void UpdateProxyResponse(ProxyRequestArgs e, ThrottlingInfo throt
headers.Add(new(throttlingInfo.RetryAfterHeaderName, throttlingInfo.ThrottleForSeconds.ToString(CultureInfo.InvariantCulture)));
- e.Session.GenericResponse(body ?? string.Empty, HttpStatusCode.TooManyRequests, headers.Select(h => new HttpHeader(h.Name, h.Value)));
+ e.ProxySession.Respond(body ?? string.Empty, HttpStatusCode.TooManyRequests, headers.Select(h => new HttpHeader(h.Name, h.Value)));
e.ResponseState.HasBeenSet = true;
}
- private static string BuildApiErrorMessage(Request r, string message) => $"{message} {(ProxyUtils.IsGraphRequest(r) ? ProxyUtils.IsSdkRequest(r) ? "" : string.Join(' ', MessageUtils.BuildUseSdkForErrorsMessage()) : "")}";
+ private static string BuildApiErrorMessage(IHttpRequest r, string message) => $"{message} {(ProxyUtils.IsGraphRequest(r) ? ProxyUtils.IsSdkRequest(r) ? "" : string.Join(' ', MessageUtils.BuildUseSdkForErrorsMessage()) : "")}";
}
diff --git a/DevProxy.Plugins/DevProxy.Plugins.csproj b/DevProxy.Plugins/DevProxy.Plugins.csproj
index 5edf8179..7bfb7a96 100644
--- a/DevProxy.Plugins/DevProxy.Plugins.csproj
+++ b/DevProxy.Plugins/DevProxy.Plugins.csproj
@@ -6,7 +6,7 @@
enable
true
OnOutputUpdated
- 3.1.0
+ 4.0.0
false
true
true
@@ -65,10 +65,6 @@
false
runtime
-
- false
- runtime
-
@@ -76,6 +72,9 @@
runtime
+
+
+
+ $(NoWarn);CA1707;CA1861
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DevProxy.Proxy.Kestrel.Tests/HostWatchListTests.cs b/DevProxy.Proxy.Kestrel.Tests/HostWatchListTests.cs
new file mode 100644
index 00000000..1ab73675
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/HostWatchListTests.cs
@@ -0,0 +1,98 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text.RegularExpressions;
+using DevProxy.Abstractions.Proxy;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class HostWatchListTests
+{
+ // Mirrors DevProxy's PluginServiceExtensions.ConvertToRegex so tests exercise the
+ // same UrlToWatch shape the engine receives at runtime.
+ private static UrlToWatch ToWatch(string pattern)
+ {
+ var exclude = false;
+ if (pattern.StartsWith('!'))
+ {
+ exclude = true;
+ pattern = pattern[1..];
+ }
+
+ return new UrlToWatch(
+ new Regex($"^{Regex.Escape(pattern).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase)}$", RegexOptions.Compiled | RegexOptions.IgnoreCase),
+ exclude);
+ }
+
+ [Fact]
+ public void IsWatched_MatchesExactHost()
+ {
+ var list = HostWatchList.FromUrls([ToWatch("https://jsonplaceholder.typicode.com/*")]);
+
+ Assert.True(list.IsWatched("jsonplaceholder.typicode.com"));
+ Assert.False(list.IsWatched("example.com"));
+ }
+
+ [Fact]
+ public void IsWatched_MatchesWildcardSubdomain()
+ {
+ var list = HostWatchList.FromUrls([ToWatch("https://*.contoso.com/*")]);
+
+ Assert.True(list.IsWatched("api.contoso.com"));
+ Assert.True(list.IsWatched("www.contoso.com"));
+ Assert.False(list.IsWatched("contoso.net"));
+ }
+
+ [Fact]
+ public void IsWatched_StripsPortFromPattern()
+ {
+ var list = HostWatchList.FromUrls([ToWatch("https://localhost:3000/*")]);
+
+ Assert.True(list.IsWatched("localhost"));
+ }
+
+ [Fact]
+ public void IsWatched_RespectsExclusion()
+ {
+ // URL matching is first-match-wins, so the more specific exclusion must be
+ // ordered before the broad include (Dev Proxy's documented convention).
+ var list = HostWatchList.FromUrls(
+ [
+ ToWatch("!https://admin.contoso.com/*"),
+ ToWatch("https://*.contoso.com/*"),
+ ]);
+
+ Assert.True(list.IsWatched("api.contoso.com"));
+ Assert.False(list.IsWatched("admin.contoso.com"));
+ }
+
+ [Fact]
+ public void IsWatched_ExclusionOrderedAfterIncludeHasNoEffect()
+ {
+ // Documents the order-dependence: an exclusion placed after the broad include
+ // never wins, mirroring ProxyUtils.MatchesUrlToWatch (FirstOrDefault).
+ var list = HostWatchList.FromUrls(
+ [
+ ToWatch("https://*.contoso.com/*"),
+ ToWatch("!https://admin.contoso.com/*"),
+ ]);
+
+ Assert.True(list.IsWatched("admin.contoso.com"));
+ }
+
+ [Fact]
+ public void IsWatched_GlobalWildcard_MatchesAnyHost()
+ {
+ var list = HostWatchList.FromUrls([ToWatch("https://*/*")]);
+
+ Assert.True(list.IsWatched("anything.example"));
+ }
+
+ [Fact]
+ public void FromUrls_Throws_OnNull() =>
+ Assert.Throws(() => HostWatchList.FromUrls(null!));
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs b/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs
new file mode 100644
index 00000000..8fcbcda3
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs
@@ -0,0 +1,261 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class Http1ConnectionReaderTests
+{
+ [Fact]
+ public async Task ReadHeadAsync_ParsesRequestLineAndHeaders()
+ {
+ var reader = ReaderOver(
+ "GET /posts/1 HTTP/1.1\r\n" +
+ "Host: example.com\r\n" +
+ "\r\n");
+
+ var head = await reader.ReadHeadAsync(CancellationToken.None);
+
+ Assert.NotNull(head);
+ Assert.Equal("GET", head!.Method);
+ Assert.Equal("/posts/1", head.Target);
+ Assert.Contains(head.Headers, h => h.Name == "Host" && h.Value == "example.com");
+ }
+
+ [Fact]
+ public async Task ReadHeadAsync_ReturnsNull_OnCleanEof()
+ {
+ var reader = ReaderOver("");
+
+ Assert.Null(await reader.ReadHeadAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ReadHeadAsync_ReturnsNull_OnTruncatedHeaderBlock()
+ {
+ // Connection closed before the terminating CRLFCRLF arrived.
+ var reader = ReaderOver("GET / HTTP/1.1\r\nHost: x\r\n");
+
+ Assert.Null(await reader.ReadHeadAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ReadHeadAsync_Throws_OnMalformedRequestLine()
+ {
+ var reader = ReaderOver("GARBAGE\r\n\r\n");
+
+ _ = await Assert.ThrowsAsync(
+ async () => await reader.ReadHeadAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ReadBodyAsync_ReadsContentLengthBody()
+ {
+ var reader = ReaderOver(
+ "POST /posts HTTP/1.1\r\n" +
+ "Content-Length: 3\r\n" +
+ "\r\n" +
+ "abc");
+
+ var head = await reader.ReadHeadAsync(CancellationToken.None);
+ var body = await reader.ReadBodyAsync(
+ Http1RequestReader.GetContentLength(head!.Headers), CancellationToken.None);
+
+ Assert.Equal("abc", Encoding.ASCII.GetString(body));
+ }
+
+ [Fact]
+ public async Task ReadBodyAsync_ReturnsEmpty_WhenContentLengthZero()
+ {
+ var reader = ReaderOver("GET / HTTP/1.1\r\n\r\n");
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+ var body = await reader.ReadBodyAsync(0, CancellationToken.None);
+
+ Assert.Empty(body);
+ }
+
+ [Fact]
+ public async Task KeepAlive_ReadsTwoPipelinedRequestsFromOneBuffer()
+ {
+ // Both requests (with bodies) arrive back-to-back in a single buffer. The
+ // reader must frame each one exactly, retaining request 2's bytes while it
+ // serves request 1. This is the core keep-alive/pipelining correctness case.
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nContent-Length: 2\r\n\r\nAA" +
+ "POST /b HTTP/1.1\r\nContent-Length: 3\r\n\r\nBBB",
+ // Fragment aggressively to prove cross-read accumulation works.
+ maxBytesPerRead: 5);
+
+ var first = await reader.ReadHeadAsync(CancellationToken.None);
+ Assert.Equal("/a", first!.Target);
+ var firstBody = await reader.ReadBodyAsync(2, CancellationToken.None);
+ Assert.Equal("AA", Encoding.ASCII.GetString(firstBody));
+
+ var second = await reader.ReadHeadAsync(CancellationToken.None);
+ Assert.Equal("/b", second!.Target);
+ var secondBody = await reader.ReadBodyAsync(3, CancellationToken.None);
+ Assert.Equal("BBB", Encoding.ASCII.GetString(secondBody));
+
+ Assert.Null(await reader.ReadHeadAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ReadBodyAsync_RetainsSurplusBytesForNextRequest()
+ {
+ // The header read over-reads into request 2; ReadBodyAsync must consume exactly
+ // Content-Length and leave the surplus for the next ReadHeadAsync.
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nContent-Length: 1\r\n\r\nX" +
+ "GET /b HTTP/1.1\r\n\r\n");
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+ var body = await reader.ReadBodyAsync(1, CancellationToken.None);
+ Assert.Equal("X", Encoding.ASCII.GetString(body));
+
+ var next = await reader.ReadHeadAsync(CancellationToken.None);
+ Assert.Equal("/b", next!.Target);
+ }
+
+ [Fact]
+ public async Task ReadChunkedBodyAsync_DecodesSingleChunk()
+ {
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n" +
+ "5\r\nhello\r\n0\r\n\r\n");
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+ var body = await reader.ReadChunkedBodyAsync(CancellationToken.None);
+
+ Assert.Equal("hello", Encoding.ASCII.GetString(body));
+ }
+
+ [Fact]
+ public async Task ReadChunkedBodyAsync_DecodesMultipleChunks()
+ {
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n" +
+ "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n");
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+ var body = await reader.ReadChunkedBodyAsync(CancellationToken.None);
+
+ Assert.Equal("hello world", Encoding.ASCII.GetString(body));
+ }
+
+ [Fact]
+ public async Task ReadChunkedBodyAsync_IgnoresChunkExtensions()
+ {
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n" +
+ "5;name=value\r\nhello\r\n0\r\n\r\n");
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+ var body = await reader.ReadChunkedBodyAsync(CancellationToken.None);
+
+ Assert.Equal("hello", Encoding.ASCII.GetString(body));
+ }
+
+ [Fact]
+ public async Task ReadChunkedBodyAsync_ConsumesTrailers_AndRetainsSurplus()
+ {
+ // Trailers after the 0-length chunk are consumed (dropped), and a pipelined
+ // next request that arrives right after the terminating blank line survives.
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n" +
+ "4\r\ndata\r\n0\r\nX-Checksum: abc123\r\n\r\n" +
+ "GET /b HTTP/1.1\r\n\r\n");
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+ var body = await reader.ReadChunkedBodyAsync(CancellationToken.None);
+ Assert.Equal("data", Encoding.ASCII.GetString(body));
+
+ var next = await reader.ReadHeadAsync(CancellationToken.None);
+ Assert.Equal("/b", next!.Target);
+ }
+
+ [Fact]
+ public async Task ReadChunkedBodyAsync_DecodesUnderAggressiveFragmentation()
+ {
+ // One byte per read forces every CRLF and chunk boundary to straddle reads,
+ // exercising the cross-read line/exact accumulation.
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n" +
+ "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n",
+ maxBytesPerRead: 1);
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+ var body = await reader.ReadChunkedBodyAsync(CancellationToken.None);
+
+ Assert.Equal("hello world", Encoding.ASCII.GetString(body));
+ }
+
+ [Fact]
+ public async Task ReadChunkedBodyAsync_Throws_OnMalformedChunkSize()
+ {
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n" +
+ "zz\r\nhello\r\n0\r\n\r\n");
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+
+ _ = await Assert.ThrowsAsync(
+ async () => await reader.ReadChunkedBodyAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ReadChunkedBodyAsync_Throws_OnTruncatedBody()
+ {
+ // The stream ends before the declared 9 bytes (and terminating chunk) arrive.
+ var reader = ReaderOver(
+ "POST /a HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n" +
+ "9\r\nhello");
+
+ _ = await reader.ReadHeadAsync(CancellationToken.None);
+
+ _ = await Assert.ThrowsAsync(
+ async () => await reader.ReadChunkedBodyAsync(CancellationToken.None));
+ }
+
+ private static Http1ConnectionReader ReaderOver(string raw, int maxBytesPerRead = int.MaxValue) =>
+ new(new ChunkedStream(Encoding.ASCII.GetBytes(raw), maxBytesPerRead));
+
+ // A read-only stream that returns at most maxBytesPerRead bytes per ReadAsync,
+ // simulating TCP segmentation so the reader's cross-read accumulation is exercised.
+ private sealed class ChunkedStream(byte[] data, int maxBytesPerRead) : Stream
+ {
+ private int _position;
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => data.Length;
+ public override long Position { get => _position; set => throw new NotSupportedException(); }
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ var remaining = data.Length - _position;
+ if (remaining <= 0)
+ {
+ return ValueTask.FromResult(0);
+ }
+ var toCopy = Math.Min(Math.Min(remaining, buffer.Length), maxBytesPerRead);
+ data.AsSpan(_position, toCopy).CopyTo(buffer.Span);
+ _position += toCopy;
+ return ValueTask.FromResult(toCopy);
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
+
+ public override void Flush() { }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs b/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs
new file mode 100644
index 00000000..caaf3007
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs
@@ -0,0 +1,127 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class Http1RequestReaderTests
+{
+ [Fact]
+ public void ParseHead_ParsesRequestLineAndHeaders()
+ {
+ const string headerText =
+ "GET /posts/1 HTTP/1.1\r\n" +
+ "Host: example.com\r\n" +
+ "Accept: application/json";
+
+ var head = Http1RequestReader.ParseHead(headerText);
+
+ Assert.Equal("GET", head.Method);
+ Assert.Equal("/posts/1", head.Target);
+ Assert.Equal("HTTP/1.1", head.Version);
+ Assert.Contains(head.Headers, h => h.Name == "Host" && h.Value == "example.com");
+ Assert.Contains(head.Headers, h => h.Name == "Accept" && h.Value == "application/json");
+ }
+
+ [Fact]
+ public void ParseHead_Throws_OnMalformedRequestLine()
+ {
+ _ = Assert.Throws(() => Http1RequestReader.ParseHead("GARBAGE"));
+ }
+
+ [Fact]
+ public void GetContentLength_ReadsHeaderCaseInsensitively()
+ {
+ var headers = new List<(string Name, string Value)>
+ {
+ ("Host", "example.com"),
+ ("content-length", "42"),
+ };
+
+ Assert.Equal(42, Http1RequestReader.GetContentLength(headers));
+ }
+
+ [Fact]
+ public void GetContentLength_DefaultsToZero_WhenAbsent()
+ {
+ var headers = new List<(string Name, string Value)> { ("Host", "example.com") };
+
+ Assert.Equal(0, Http1RequestReader.GetContentLength(headers));
+ }
+
+ [Fact]
+ public void IndexOfDoubleCrlf_FindsTerminator()
+ {
+ var data = Encoding.ASCII.GetBytes("ab\r\n\r\ncd");
+
+ Assert.Equal(2, Http1RequestReader.IndexOfDoubleCrlf(data));
+ }
+
+ [Fact]
+ public void IndexOfDoubleCrlf_ReturnsMinusOne_WhenAbsent()
+ {
+ var data = Encoding.ASCII.GetBytes("no terminator here");
+
+ Assert.Equal(-1, Http1RequestReader.IndexOfDoubleCrlf(data));
+ }
+
+ [Fact]
+ public void DetectBodyFraming_None_WhenNoFramingHeaders()
+ {
+ var headers = new List<(string Name, string Value)> { ("Host", "example.com") };
+
+ Assert.Equal(RequestBodyFraming.None, Http1RequestReader.DetectBodyFraming(headers));
+ }
+
+ [Fact]
+ public void DetectBodyFraming_ContentLength_WhenOnlyContentLength()
+ {
+ var headers = new List<(string Name, string Value)> { ("Content-Length", "12") };
+
+ Assert.Equal(RequestBodyFraming.ContentLength, Http1RequestReader.DetectBodyFraming(headers));
+ }
+
+ [Theory]
+ [InlineData("chunked")]
+ [InlineData("Chunked")]
+ [InlineData("gzip, chunked")]
+ public void DetectBodyFraming_Chunked_WhenTransferEncodingChunked(string transferEncoding)
+ {
+ var headers = new List<(string Name, string Value)> { ("Transfer-Encoding", transferEncoding) };
+
+ Assert.Equal(RequestBodyFraming.Chunked, Http1RequestReader.DetectBodyFraming(headers));
+ }
+
+ [Fact]
+ public void DetectBodyFraming_Conflicting_WhenBothPresent()
+ {
+ // Smuggling vector: the two framings disagree on the body boundary.
+ var headers = new List<(string Name, string Value)>
+ {
+ ("Content-Length", "5"),
+ ("Transfer-Encoding", "chunked"),
+ };
+
+ Assert.Equal(RequestBodyFraming.Conflicting, Http1RequestReader.DetectBodyFraming(headers));
+ }
+
+ [Theory]
+ [InlineData("100-continue", true)]
+ [InlineData("100-Continue", true)]
+ [InlineData("", false)]
+ public void HasExpectContinue_DetectsHeader(string expect, bool present)
+ {
+ var headers = new List<(string Name, string Value)> { ("Host", "x") };
+ if (expect.Length > 0)
+ {
+ headers.Add(("Expect", expect));
+ }
+
+ Assert.Equal(present, Http1RequestReader.HasExpectContinue(headers));
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/MutableHttpModelTests.cs b/DevProxy.Proxy.Kestrel.Tests/MutableHttpModelTests.cs
new file mode 100644
index 00000000..159d41ed
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/MutableHttpModelTests.cs
@@ -0,0 +1,104 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Text;
+using DevProxy.Abstractions.Proxy.Http;
+using DevProxy.Proxy.Kestrel.Http;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class MutableHttpModelTests
+{
+ private static MutableHttpRequest Request(string method = "GET", string url = "https://example.com/", HeaderCollection? headers = null) =>
+ new(method, new Uri(url), HttpVersion.Version11, headers ?? new HeaderCollection(), ReadOnlyMemory.Empty);
+
+ [Fact]
+ public void Request_UpperCasesMethod() =>
+ Assert.Equal("POST", Request(method: "post").Method);
+
+ [Fact]
+ public void Request_Url_RoundTrips()
+ {
+ var request = Request();
+ request.Url = "https://contoso.com/api";
+
+ Assert.Equal("https://contoso.com/api", request.Url);
+ Assert.Equal("contoso.com", request.RequestUri.Host);
+ }
+
+ [Fact]
+ public void Request_IsWebSocketRequest_DetectsUpgradeHeader()
+ {
+ var headers = new HeaderCollection();
+ headers.Add("Upgrade", "websocket");
+
+ Assert.True(Request(headers: headers).IsWebSocketRequest);
+ Assert.False(Request().IsWebSocketRequest);
+ }
+
+ [Fact]
+ public void SetBody_UpdatesContentLengthHeader()
+ {
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty);
+
+ response.SetBody(Encoding.ASCII.GetBytes("abcd"));
+
+ Assert.Equal("4", response.Headers.GetFirst("Content-Length")?.Value);
+ Assert.True(response.HasBody);
+ }
+
+ [Fact]
+ public void SetBodyString_SetsContentTypeWhenProvided()
+ {
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty);
+
+ response.SetBodyString("{}", "application/json");
+
+ Assert.Equal("application/json", response.ContentType);
+ Assert.Equal("{}", response.BodyString);
+ }
+
+ [Fact]
+ public void ContentType_ReadsFromHeaders()
+ {
+ var headers = new HeaderCollection();
+ headers.Add("Content-Type", "text/html; charset=utf-8");
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, headers, Encoding.UTF8.GetBytes(""));
+
+ Assert.Equal("text/html; charset=utf-8", response.ContentType);
+ Assert.Equal("", response.BodyString);
+ }
+
+ [Fact]
+ public void Respond_SetsPluginMockedResponse()
+ {
+ var session = new CanonicalProxySession("abc", Request(), processId: null);
+
+ session.Respond("nope", HttpStatusCode.TooManyRequests, []);
+
+ Assert.True(session.RespondedByPlugin);
+ Assert.True(session.HasResponse);
+ Assert.Equal(HttpStatusCode.TooManyRequests, session.MutableResponse!.StatusCode);
+ Assert.Equal("nope", session.MutableResponse.BodyString);
+ }
+
+ [Fact]
+ public void SetOriginResponse_DoesNotFlagPluginMocked()
+ {
+ var session = new CanonicalProxySession("abc", Request(), processId: null);
+ var origin = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty);
+
+ session.SetOriginResponse(origin);
+
+ Assert.True(session.HasResponse);
+ Assert.False(session.RespondedByPlugin);
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs
new file mode 100644
index 00000000..c4c54387
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs
@@ -0,0 +1,189 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class ProcessFilterTests
+{
+ private static ProcessFilter Filter(
+ IEnumerable? pids = null,
+ IEnumerable? names = null,
+ Func? resolvePid = null,
+ Func? resolveName = null) =>
+ new(pids ?? [], names ?? [], resolvePid, resolveName);
+
+ [Fact]
+ public void IsEmpty_True_WhenNoFilterConfigured()
+ {
+ Assert.True(Filter().IsEmpty);
+ }
+
+ [Fact]
+ public void IsEmpty_False_WhenPidsConfigured()
+ {
+ Assert.False(Filter(pids: [123]).IsEmpty);
+ }
+
+ [Fact]
+ public void IsEmpty_False_WhenNamesConfigured()
+ {
+ Assert.False(Filter(names: ["node"]).IsEmpty);
+ }
+
+ [Fact]
+ public void IsWatchedProcess_True_WhenNoFilterConfigured()
+ {
+ // No filter ⇒ every process watched; resolver must never even be consulted.
+ var filter = Filter(resolvePid: _ => throw new InvalidOperationException("should not resolve"));
+ Assert.True(filter.IsWatchedProcess(54321));
+ }
+
+ [Fact]
+ public void IsWatchedProcess_True_WhenPidMatches()
+ {
+ var filter = Filter(pids: [4242], resolvePid: _ => 4242);
+ Assert.True(filter.IsWatchedProcess(54321));
+ }
+
+ [Fact]
+ public void IsWatchedProcess_False_WhenPidDoesNotMatch()
+ {
+ var filter = Filter(pids: [4242], resolvePid: _ => 9999);
+ Assert.False(filter.IsWatchedProcess(54321));
+ }
+
+ [Fact]
+ public void IsWatchedProcess_False_WhenPidUnresolved()
+ {
+ var filter = Filter(pids: [4242], resolvePid: _ => null);
+ Assert.False(filter.IsWatchedProcess(54321));
+ }
+
+ [Fact]
+ public void IsWatchedProcess_False_WhenResolverReturnsMinusOne()
+ {
+ // -1 is the "not found" sentinel from the listing tools; treat as unresolved.
+ var filter = Filter(pids: [4242], resolvePid: _ => -1);
+ Assert.False(filter.IsWatchedProcess(54321));
+ }
+
+ [Fact]
+ public void IsWatchedProcess_True_WhenProcessNameMatches()
+ {
+ var filter = Filter(names: ["node"], resolvePid: _ => 4242, resolveName: _ => "node");
+ Assert.True(filter.IsWatchedProcess(54321));
+ }
+
+ [Fact]
+ public void IsWatchedProcess_False_WhenProcessNameDiffers()
+ {
+ var filter = Filter(names: ["node"], resolvePid: _ => 4242, resolveName: _ => "chrome");
+ Assert.False(filter.IsWatchedProcess(54321));
+ }
+
+ [Fact]
+ public void IsWatchedProcess_NameMatchIsCaseSensitive_MatchingTitanium()
+ {
+ var filter = Filter(names: ["node"], resolvePid: _ => 4242, resolveName: _ => "Node");
+ Assert.False(filter.IsWatchedProcess(54321));
+ }
+
+ [Fact]
+ public void IsWatchedProcess_PidWins_WithoutResolvingName()
+ {
+ var filter = Filter(
+ pids: [4242],
+ names: ["node"],
+ resolvePid: _ => 4242,
+ resolveName: _ => throw new InvalidOperationException("name lookup not needed"));
+ Assert.True(filter.IsWatchedProcess(54321));
+ }
+}
+
+public class LsofParserTests
+{
+ // Realistic `lsof -i :54321` output. The client process (node) owns the
+ // …:54321->… socket; the proxy (dotnet) owns the reverse …->…:54321 socket.
+ private const string Sample =
+ "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" +
+ "node 4242 waldek 23u IPv4 0x1234 0t0 TCP 127.0.0.1:54321->127.0.0.1:8897 (ESTABLISHED)\n" +
+ "dotnet 9001 waldek 30u IPv4 0x5678 0t0 TCP 127.0.0.1:8897->127.0.0.1:54321 (ESTABLISHED)\n";
+
+ [Fact]
+ public void ParsePid_ReturnsClientProcessPid()
+ {
+ Assert.Equal(4242, LsofParser.ParsePid(Sample, 54321));
+ }
+
+ [Fact]
+ public void ParsePid_SelectsSourcePortLine_NotDestination()
+ {
+ // 54321 appears as the client's SOURCE port (":54321->", matches) and as the
+ // proxy's DESTINATION ("->...:54321", must NOT match). The marker selects the
+ // client process (4242), not the proxy (9001).
+ Assert.Equal(4242, LsofParser.ParsePid(Sample, 54321));
+ }
+
+ [Fact]
+ public void ParsePid_ReturnsNull_WhenPortAbsent()
+ {
+ Assert.Null(LsofParser.ParsePid(Sample, 11111));
+ }
+
+ [Fact]
+ public void ParsePid_ReturnsNull_OnEmptyOutput()
+ {
+ Assert.Null(LsofParser.ParsePid("", 54321));
+ }
+}
+
+public class NetstatParserTests
+{
+ // Realistic `netstat -ano -p tcp`. The client socket's LOCAL address is …:54321.
+ private const string Sample =
+ "\nActive Connections\n\n" +
+ " Proto Local Address Foreign Address State PID\n" +
+ " TCP 127.0.0.1:54321 127.0.0.1:8897 ESTABLISHED 4242\n" +
+ " TCP 127.0.0.1:8897 127.0.0.1:54321 ESTABLISHED 9001\n";
+
+ [Fact]
+ public void ParsePid_ReturnsClientProcessPid_ByLocalPort()
+ {
+ Assert.Equal(4242, NetstatParser.ParsePid(Sample, 54321));
+ }
+
+ [Fact]
+ public void ParsePid_MatchesByLocalPort_NotForeignPort()
+ {
+ // Matching is by LOCAL address port. Querying the proxy's own listen port (8897)
+ // therefore returns the proxy row's PID — in practice we always query the client's
+ // source port (54321), which uniquely identifies the client socket.
+ Assert.Equal(9001, NetstatParser.ParsePid(Sample, 8897));
+ }
+
+ [Fact]
+ public void ParsePid_ReturnsNull_WhenPortAbsent()
+ {
+ Assert.Null(NetstatParser.ParsePid(Sample, 11111));
+ }
+
+ [Fact]
+ public void ParsePid_HandlesIPv6LocalAddress()
+ {
+ const string ipv6 =
+ " Proto Local Address Foreign Address State PID\n" +
+ " TCP [::1]:54321 [::1]:8897 ESTABLISHED 7777\n";
+ Assert.Equal(7777, NetstatParser.ParsePid(ipv6, 54321));
+ }
+
+ [Fact]
+ public void ParsePid_ReturnsNull_OnEmptyOutput()
+ {
+ Assert.Null(NetstatParser.ParsePid("", 54321));
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs
new file mode 100644
index 00000000..b7064a07
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs
@@ -0,0 +1,200 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Text;
+using DevProxy.Abstractions.Proxy.Http;
+using DevProxy.Proxy.Kestrel.Http;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class ResponseWriterTests
+{
+ private static async Task WriteAsync(MutableHttpResponse response, bool keepAlive = false, string method = "GET")
+ {
+ using var stream = new MemoryStream();
+ await ResponseWriter.WriteAsync(stream, response, keepAlive, method, CancellationToken.None);
+ return Encoding.ASCII.GetString(stream.ToArray());
+ }
+
+ [Fact]
+ public async Task WriteAsync_WritesStatusLineAndBody()
+ {
+ var headers = new HeaderCollection();
+ headers.Add("Content-Type", "text/plain");
+ var body = Encoding.ASCII.GetBytes("hello");
+ var response = new MutableHttpResponse(HttpStatusCode.OK, HttpVersion.Version11, headers, body);
+
+ var output = await WriteAsync(response);
+
+ Assert.StartsWith("HTTP/1.1 200 OK\r\n", output, StringComparison.Ordinal);
+ Assert.Contains("Content-Type: text/plain\r\n", output, StringComparison.Ordinal);
+ Assert.EndsWith("hello", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteContinueAsync_WritesInterim100()
+ {
+ using var stream = new MemoryStream();
+
+ await ResponseWriter.WriteContinueAsync(stream, CancellationToken.None);
+
+ Assert.Equal("HTTP/1.1 100 Continue\r\n\r\n", Encoding.ASCII.GetString(stream.ToArray()));
+ }
+
+ [Fact]
+ public async Task WriteAsync_RecomputesContentLengthFromBody()
+ {
+ var headers = new HeaderCollection();
+ // A stale Content-Length must be replaced with the actual body length.
+ headers.Add("Content-Length", "999");
+ var body = Encoding.ASCII.GetBytes("12345");
+ var response = new MutableHttpResponse(HttpStatusCode.OK, HttpVersion.Version11, headers, body);
+
+ var output = await WriteAsync(response);
+
+ Assert.Contains("Content-Length: 5\r\n", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("Content-Length: 999", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_StripsHopByHopAndContentEncodingHeaders()
+ {
+ var headers = new HeaderCollection();
+ headers.Add("Content-Encoding", "gzip");
+ headers.Add("Transfer-Encoding", "chunked");
+ headers.Add("Connection", "keep-alive");
+ headers.Add("X-Custom", "keep-me");
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, headers, Encoding.ASCII.GetBytes("body"));
+
+ var output = await WriteAsync(response);
+
+ Assert.DoesNotContain("Content-Encoding", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("Transfer-Encoding", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("keep-alive", output, StringComparison.Ordinal);
+ Assert.Contains("X-Custom: keep-me\r\n", output, StringComparison.Ordinal);
+ // The incoming hop-by-hop Connection header is stripped; the writer emits its
+ // own based on the keepAlive flag (false here).
+ Assert.Contains("Connection: close\r\n", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_WritesConnectionClose_WhenNotKeepAlive()
+ {
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, new HeaderCollection(), Encoding.ASCII.GetBytes("x"));
+
+ var output = await WriteAsync(response, keepAlive: false);
+
+ Assert.Contains("Connection: close\r\n", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("Connection: keep-alive", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_WritesConnectionKeepAlive_WhenKeepAlive()
+ {
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, new HeaderCollection(), Encoding.ASCII.GetBytes("x"));
+
+ var output = await WriteAsync(response, keepAlive: true);
+
+ Assert.Contains("Connection: keep-alive\r\n", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("Connection: close", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_UsesReasonPhraseForStatusCode()
+ {
+ var response = new MutableHttpResponse(
+ HttpStatusCode.NotFound, HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty);
+
+ var output = await WriteAsync(response);
+
+ Assert.StartsWith("HTTP/1.1 404 Not Found\r\n", output, StringComparison.Ordinal);
+ Assert.Contains("Content-Length: 0\r\n", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_PrefersExplicitStatusDescription()
+ {
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty,
+ statusDescription: "Totally Fine");
+
+ var output = await WriteAsync(response);
+
+ Assert.StartsWith("HTTP/1.1 200 Totally Fine\r\n", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_Head_PreservesOriginContentLength_AndWritesNoBody()
+ {
+ // A HEAD response carries the Content-Length a GET would return but no body
+ // (RFC 9110 §9.3.2). The origin's declared length must survive, not be
+ // overwritten with the (empty) body length.
+ var headers = new HeaderCollection();
+ headers.Add("Content-Length", "1234");
+ headers.Add("Content-Type", "application/json");
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, headers, ReadOnlyMemory.Empty);
+
+ var output = await WriteAsync(response, method: "HEAD");
+
+ Assert.StartsWith("HTTP/1.1 200 OK\r\n", output, StringComparison.Ordinal);
+ Assert.Contains("Content-Length: 1234\r\n", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("Content-Length: 0\r\n", output, StringComparison.Ordinal);
+ // The head ends at the blank line and nothing follows it (no body).
+ Assert.EndsWith("\r\n\r\n", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_Head_SuppressesBody_EvenIfResponseCarriesOne()
+ {
+ // Defensive: if a plugin attaches a body to a HEAD response, the bytes must
+ // still not reach the client, but Content-Length reflects that body's size.
+ var headers = new HeaderCollection();
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, headers, Encoding.ASCII.GetBytes("hello"));
+
+ var output = await WriteAsync(response, method: "HEAD");
+
+ Assert.Contains("Content-Length: 5\r\n", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("hello", output, StringComparison.Ordinal);
+ Assert.EndsWith("\r\n\r\n", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_Head_FallsBackToBodyLength_WhenNoOriginContentLength()
+ {
+ // No Content-Length from the origin (e.g. it was chunked or compressed-then-
+ // stripped). Fall back to the body length rather than omitting the header.
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty);
+
+ var output = await WriteAsync(response, method: "HEAD");
+
+ Assert.Contains("Content-Length: 0\r\n", output, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_Get_RecomputesContentLength_IgnoringOrigin()
+ {
+ // The HEAD preservation must not leak into other methods: a GET still gets a
+ // recomputed Content-Length from the actual body.
+ var headers = new HeaderCollection();
+ headers.Add("Content-Length", "1234");
+ var response = new MutableHttpResponse(
+ HttpStatusCode.OK, HttpVersion.Version11, headers, Encoding.ASCII.GetBytes("abc"));
+
+ var output = await WriteAsync(response, method: "GET");
+
+ Assert.Contains("Content-Length: 3\r\n", output, StringComparison.Ordinal);
+ Assert.DoesNotContain("Content-Length: 1234", output, StringComparison.Ordinal);
+ Assert.EndsWith("abc", output, StringComparison.Ordinal);
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs b/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs
new file mode 100644
index 00000000..a8ee1576
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs
@@ -0,0 +1,73 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class ShouldKeepAliveTests
+{
+ private static ParsedRequestHead Head(string version, params (string Name, string Value)[] headers) =>
+ new("GET", "/", version, headers);
+
+ [Fact]
+ public void Http11_DefaultsToKeepAlive()
+ {
+ Assert.True(ProxyConnectionHandler.ShouldKeepAlive(Head("HTTP/1.1")));
+ }
+
+ [Fact]
+ public void Http10_DefaultsToClose()
+ {
+ Assert.False(ProxyConnectionHandler.ShouldKeepAlive(Head("HTTP/1.0")));
+ }
+
+ [Fact]
+ public void Http10_WithKeepAliveHeader_KeepsAlive()
+ {
+ Assert.True(ProxyConnectionHandler.ShouldKeepAlive(
+ Head("HTTP/1.0", ("Connection", "keep-alive"))));
+ }
+
+ [Fact]
+ public void Http11_WithConnectionClose_Closes()
+ {
+ Assert.False(ProxyConnectionHandler.ShouldKeepAlive(
+ Head("HTTP/1.1", ("Connection", "close"))));
+ }
+
+ [Fact]
+ public void ConnectionHeaderIsCaseInsensitive()
+ {
+ Assert.False(ProxyConnectionHandler.ShouldKeepAlive(
+ Head("HTTP/1.1", ("connection", "Close"))));
+ }
+
+ [Fact]
+ public void TransferEncoding_KeepsAlive_NowThatChunkedIsReframed()
+ {
+ // The chunked body is decoded and re-framed with Content-Length before this
+ // runs, so a chunked request no longer forces the connection closed.
+ Assert.True(ProxyConnectionHandler.ShouldKeepAlive(
+ Head("HTTP/1.1", ("Transfer-Encoding", "chunked"))));
+ }
+
+ [Fact]
+ public void ExpectHeader_KeepsAlive_NowThat100ContinueIsHandled()
+ {
+ // The proxy answers Expect: 100-continue and reads the body, so the connection
+ // stays framable and may be kept alive.
+ Assert.True(ProxyConnectionHandler.ShouldKeepAlive(
+ Head("HTTP/1.1", ("Expect", "100-continue"))));
+ }
+
+ [Fact]
+ public void ConnectionClose_StillWins_OverChunked()
+ {
+ Assert.False(ProxyConnectionHandler.ShouldKeepAlive(
+ Head("HTTP/1.1", ("Transfer-Encoding", "chunked"), ("Connection", "close"))));
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/StreamRelayTests.cs b/DevProxy.Proxy.Kestrel.Tests/StreamRelayTests.cs
new file mode 100644
index 00000000..98728a8b
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/StreamRelayTests.cs
@@ -0,0 +1,57 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class StreamRelayTests
+{
+ [Fact]
+ public async Task RelayBidirectional_CopiesBothDirections()
+ {
+ // a1<->a2 is the "client" pair; b1<->b2 is the "origin" pair. The relay splices
+ // a2<->b1, so bytes written to a1 surface on b2 and vice versa.
+ var (a1, a2) = await TestSockets.ConnectedPairAsync();
+ var (b1, b2) = await TestSockets.ConnectedPairAsync();
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var relay = StreamRelay.RelayBidirectionalAsync(a2, b1, cts.Token);
+
+ await a1.WriteAsync(Encoding.ASCII.GetBytes("client-to-origin"), cts.Token);
+ await a1.FlushAsync(cts.Token);
+ Assert.Equal("client-to-origin", await ReadTextAsync(b2, "client-to-origin".Length, cts.Token));
+
+ await b2.WriteAsync(Encoding.ASCII.GetBytes("origin-to-client"), cts.Token);
+ await b2.FlushAsync(cts.Token);
+ Assert.Equal("origin-to-client", await ReadTextAsync(a1, "origin-to-client".Length, cts.Token));
+
+ // Closing one client end tears down the relay.
+ a1.Dispose();
+ await relay;
+
+ a2.Dispose();
+ b1.Dispose();
+ b2.Dispose();
+ }
+
+ private static async Task ReadTextAsync(Stream stream, int byteCount, CancellationToken ct)
+ {
+ var buffer = new byte[byteCount];
+ var offset = 0;
+ while (offset < byteCount)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(offset), ct);
+ if (read == 0)
+ {
+ break;
+ }
+ offset += read;
+ }
+ return Encoding.ASCII.GetString(buffer, 0, offset);
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/StreamingResponseWriterTests.cs b/DevProxy.Proxy.Kestrel.Tests/StreamingResponseWriterTests.cs
new file mode 100644
index 00000000..7167b368
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/StreamingResponseWriterTests.cs
@@ -0,0 +1,179 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Net;
+using System.Text;
+using DevProxy.Abstractions.Proxy.Http;
+using DevProxy.Proxy.Kestrel.Http;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class StreamingResponseWriterTests
+{
+ private static MutableHttpResponse Response(params (string Name, string Value)[] headers)
+ {
+ var collection = new HeaderCollection();
+ foreach (var (name, value) in headers)
+ {
+ collection.Add(name, value);
+ }
+ return new MutableHttpResponse(HttpStatusCode.OK, HttpVersion.Version11, collection, ReadOnlyMemory.Empty);
+ }
+
+ private static byte[] Seg(string text) => Encoding.ASCII.GetBytes(text);
+
+ private static async Task<(string Raw, ReadOnlyMemory Accumulated)> WriteAsync(
+ MutableHttpResponse response, IReadOnlyList segments, bool keepAlive = true, int accumulateCap = 1024)
+ {
+ using var client = new MemoryStream();
+ using var origin = new ScriptedReadStream(segments);
+ var accumulated = await StreamingResponseWriter.WriteAsync(
+ client, response, origin, keepAlive, accumulateCap, CancellationToken.None);
+ return (Encoding.ASCII.GetString(client.ToArray()), accumulated);
+ }
+
+ // Splits the wire output into head and the decoded chunked body.
+ private static (string Head, byte[] Body) Parse(string raw)
+ {
+ var marker = raw.IndexOf("\r\n\r\n", StringComparison.Ordinal);
+ var head = raw[..(marker + 4)];
+ var rest = Encoding.ASCII.GetBytes(raw[(marker + 4)..]);
+
+ var body = new List();
+ var pos = 0;
+ while (true)
+ {
+ var lineEnd = IndexOfCrlf(rest, pos);
+ var size = int.Parse(Encoding.ASCII.GetString(rest, pos, lineEnd - pos), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ pos = lineEnd + 2;
+ if (size == 0)
+ {
+ break;
+ }
+ body.AddRange(rest[pos..(pos + size)]);
+ pos += size + 2; // data + trailing CRLF
+ }
+ return (head, [.. body]);
+ }
+
+ private static int IndexOfCrlf(byte[] data, int start)
+ {
+ for (var i = start; i < data.Length - 1; i++)
+ {
+ if (data[i] == (byte)'\r' && data[i + 1] == (byte)'\n')
+ {
+ return i;
+ }
+ }
+ throw new InvalidOperationException("CRLF not found.");
+ }
+
+ [Fact]
+ public async Task WriteAsync_EmitsChunkedHead_NoContentLength()
+ {
+ var response = Response(("Content-Type", "text/event-stream"));
+
+ var (raw, _) = await WriteAsync(response, [Seg("data: 1\n\n")]);
+ var (head, _) = Parse(raw);
+
+ Assert.StartsWith("HTTP/1.1 200 OK\r\n", head, StringComparison.Ordinal);
+ Assert.Contains("Content-Type: text/event-stream\r\n", head, StringComparison.Ordinal);
+ Assert.Contains("Transfer-Encoding: chunked\r\n", head, StringComparison.Ordinal);
+ Assert.DoesNotContain("Content-Length", head, StringComparison.Ordinal);
+ Assert.Contains("Connection: keep-alive\r\n", head, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_KeepAliveFalse_EmitsConnectionClose()
+ {
+ var (raw, _) = await WriteAsync(Response(), [Seg("x")], keepAlive: false);
+ var (head, _) = Parse(raw);
+
+ Assert.Contains("Connection: close\r\n", head, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_ForwardsEachSegmentAsItsOwnChunk()
+ {
+ // Two distinct SSE events arrive in two reads → two chunks on the wire.
+ var (raw, _) = await WriteAsync(Response(), [Seg("data: a\n\n"), Seg("data: bb\n\n")]);
+
+ // "data: a\n\n" is 9 bytes (0x9), "data: bb\n\n" is 10 bytes (0xA).
+ Assert.Contains("9\r\ndata: a\n\n\r\n", raw, StringComparison.Ordinal);
+ Assert.Contains("A\r\ndata: bb\n\n\r\n", raw, StringComparison.Ordinal);
+ Assert.EndsWith("0\r\n\r\n", raw, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_DecodedBody_EqualsConcatenatedSegments()
+ {
+ var (raw, _) = await WriteAsync(Response(), [Seg("hello "), Seg("world")]);
+ var (_, body) = Parse(raw);
+
+ Assert.Equal("hello world", Encoding.ASCII.GetString(body));
+ }
+
+ [Fact]
+ public async Task WriteAsync_AccumulatesFullBody_WhenUnderCap()
+ {
+ var (_, accumulated) = await WriteAsync(Response(), [Seg("abc"), Seg("def")], accumulateCap: 1024);
+
+ Assert.Equal("abcdef", Encoding.ASCII.GetString(accumulated.Span));
+ }
+
+ [Fact]
+ public async Task WriteAsync_DropsAccumulation_WhenOverCap_ButStillRelaysFullBody()
+ {
+ // cap=4, body=6 bytes → accumulation abandoned, but the client still gets it all.
+ var (raw, accumulated) = await WriteAsync(Response(), [Seg("abc"), Seg("def")], accumulateCap: 4);
+ var (_, body) = Parse(raw);
+
+ Assert.True(accumulated.IsEmpty);
+ Assert.Equal("abcdef", Encoding.ASCII.GetString(body));
+ }
+
+ [Fact]
+ public async Task WriteAsync_NoAccumulation_WhenCapZero()
+ {
+ var (raw, accumulated) = await WriteAsync(Response(), [Seg("abc")], accumulateCap: 0);
+ var (_, body) = Parse(raw);
+
+ Assert.True(accumulated.IsEmpty);
+ Assert.Equal("abc", Encoding.ASCII.GetString(body));
+ }
+
+ [Fact]
+ public async Task WriteAsync_StripsFramingAndEncodingHeaders()
+ {
+ var response = Response(
+ ("Content-Length", "99"),
+ ("Content-Encoding", "gzip"),
+ ("Transfer-Encoding", "chunked"),
+ ("Cache-Control", "no-cache"));
+
+ var (raw, _) = await WriteAsync(response, [Seg("x")]);
+ var (head, _) = Parse(raw);
+
+ Assert.DoesNotContain("Content-Length: 99", head, StringComparison.Ordinal);
+ Assert.DoesNotContain("Content-Encoding", head, StringComparison.Ordinal);
+ // Exactly one Transfer-Encoding (the chunked one we add), not the origin's.
+ Assert.Equal("no-cache", response.Headers.GetFirst("Cache-Control")!.Value);
+ Assert.Contains("Cache-Control: no-cache\r\n", head, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task WriteAsync_EmptyBody_WritesOnlyTerminatingChunk()
+ {
+ var (raw, accumulated) = await WriteAsync(Response(), []);
+ var (_, body) = Parse(raw);
+
+ Assert.Empty(body);
+ Assert.True(accumulated.IsEmpty);
+ Assert.EndsWith("0\r\n\r\n", raw, StringComparison.Ordinal);
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/TestSockets.cs b/DevProxy.Proxy.Kestrel.Tests/TestSockets.cs
new file mode 100644
index 00000000..5d34996f
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/TestSockets.cs
@@ -0,0 +1,81 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Net.Sockets;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+///
+/// Loopback-socket helpers for exercising the raw stream relays (blind tunnel /
+/// WebSocket) end-to-end without mocking semantics.
+///
+internal static class TestSockets
+{
+ ///
+ /// Creates a connected pair of TCP streams over the loopback interface. Writing to
+ /// one end is readable on the other. Dispose both streams (and they own their
+ /// sockets) when done.
+ ///
+ public static async Task<(NetworkStream Left, NetworkStream Right)> ConnectedPairAsync()
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+ var connectTask = ConnectAsync(port);
+ var accepted = await listener.AcceptTcpClientAsync();
+ var connected = await connectTask;
+
+ return (connected.GetStream(), accepted.GetStream());
+
+ static async Task ConnectAsync(int port)
+ {
+ var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, port);
+ return client;
+ }
+ }
+}
+
+///
+/// A read-only stream that returns one scripted segment per ReadAsync call,
+/// simulating an origin that emits its body in discrete pieces (e.g. SSE events). Lets
+/// tests assert that each piece is forwarded as its own chunk rather than coalesced.
+///
+internal sealed class ScriptedReadStream(IReadOnlyList segments) : Stream
+{
+ private int _index;
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ if (_index >= segments.Count)
+ {
+ return ValueTask.FromResult(0);
+ }
+
+ var segment = segments[_index++];
+ if (segment.Length > buffer.Length)
+ {
+ throw new InvalidOperationException("Test buffer smaller than a scripted segment.");
+ }
+
+ segment.CopyTo(buffer.Span);
+ return ValueTask.FromResult(segment.Length);
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
+
+ public override void Flush() { }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/TlsClientHelloTests.cs b/DevProxy.Proxy.Kestrel.Tests/TlsClientHelloTests.cs
new file mode 100644
index 00000000..84c05268
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/TlsClientHelloTests.cs
@@ -0,0 +1,226 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Text;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class TlsClientHelloTests
+{
+ [Fact]
+ public void Parse_NonTlsBytes_ReturnsNotTls()
+ {
+ var http = Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: x\r\n\r\n");
+
+ var result = TlsClientHello.Parse(new ReadOnlySequence(http));
+
+ Assert.Equal(TlsClientHello.ParseStatus.NotTls, result.Status);
+ }
+
+ [Fact]
+ public void Parse_FewerThanFiveBytes_ReturnsNeedMore()
+ {
+ var result = TlsClientHello.Parse(new ReadOnlySequence([0x16, 0x03]));
+
+ Assert.Equal(TlsClientHello.ParseStatus.NeedMore, result.Status);
+ }
+
+ [Fact]
+ public void Parse_TruncatedRecord_ReturnsNeedMore()
+ {
+ // Valid record header advertising a longer body than is present.
+ var hello = BuildClientHello("example.com", ["h2", "http/1.1"]);
+ var truncated = hello.AsSpan(0, hello.Length - 10).ToArray();
+
+ var result = TlsClientHello.Parse(new ReadOnlySequence(truncated));
+
+ Assert.Equal(TlsClientHello.ParseStatus.NeedMore, result.Status);
+ }
+
+ [Fact]
+ public void Parse_ExtractsSniAndAlpn()
+ {
+ var hello = BuildClientHello("api.contoso.com", ["h2", "http/1.1"]);
+
+ var result = TlsClientHello.Parse(new ReadOnlySequence(hello));
+
+ Assert.Equal(TlsClientHello.ParseStatus.Ok, result.Status);
+ Assert.Equal("api.contoso.com", result.ServerName);
+ Assert.Equal(["h2", "http/1.1"], result.Alpn);
+ Assert.True(result.OffersH2);
+ Assert.True(result.OffersHttp11);
+ Assert.False(result.IsH2Only);
+ }
+
+ [Fact]
+ public void Parse_H2OnlyAlpn_IsH2Only()
+ {
+ var hello = BuildClientHello("grpc.contoso.com", ["h2"]);
+
+ var result = TlsClientHello.Parse(new ReadOnlySequence(hello));
+
+ Assert.Equal(TlsClientHello.ParseStatus.Ok, result.Status);
+ Assert.True(result.IsH2Only);
+ }
+
+ [Fact]
+ public void Parse_Http11OnlyAlpn_IsNotH2Only()
+ {
+ var hello = BuildClientHello("contoso.com", ["http/1.1"]);
+
+ var result = TlsClientHello.Parse(new ReadOnlySequence(hello));
+
+ Assert.Equal(TlsClientHello.ParseStatus.Ok, result.Status);
+ Assert.False(result.OffersH2);
+ Assert.False(result.IsH2Only);
+ }
+
+ [Fact]
+ public void Parse_NoAlpnExtension_IsNotH2Only()
+ {
+ var hello = BuildClientHello("contoso.com", alpn: null);
+
+ var result = TlsClientHello.Parse(new ReadOnlySequence(hello));
+
+ Assert.Equal(TlsClientHello.ParseStatus.Ok, result.Status);
+ Assert.Equal("contoso.com", result.ServerName);
+ Assert.Empty(result.Alpn);
+ Assert.False(result.IsH2Only);
+ }
+
+ [Fact]
+ public void Parse_NoExtensionsBlock_ReturnsOkWithNoSniOrAlpn()
+ {
+ var hello = BuildClientHello(serverName: null, alpn: null);
+
+ var result = TlsClientHello.Parse(new ReadOnlySequence(hello));
+
+ Assert.Equal(TlsClientHello.ParseStatus.Ok, result.Status);
+ Assert.Null(result.ServerName);
+ Assert.Empty(result.Alpn);
+ }
+
+ [Fact]
+ public void Parse_WorksAcrossSegmentedSequence()
+ {
+ var hello = BuildClientHello("split.contoso.com", ["h2", "http/1.1"]);
+ // Split the bytes across two pipe segments to prove the parser tolerates
+ // a non-contiguous ReadOnlySequence (the shape PipeReader delivers).
+ var sequence = CreateSegmented(hello, hello.Length / 2);
+
+ var result = TlsClientHello.Parse(sequence);
+
+ Assert.Equal(TlsClientHello.ParseStatus.Ok, result.Status);
+ Assert.Equal("split.contoso.com", result.ServerName);
+ Assert.Equal(["h2", "http/1.1"], result.Alpn);
+ }
+
+ private static ReadOnlySequence CreateSegmented(byte[] data, int splitAt)
+ {
+ var first = new MemorySegment(data.AsMemory(0, splitAt));
+ var second = first.Append(data.AsMemory(splitAt));
+ return new ReadOnlySequence(first, 0, second, second.Memory.Length);
+ }
+
+ // Builds a minimal but well-formed TLS 1.2 ClientHello record carrying the given
+ // SNI (optional) and ALPN protocol list (optional). Only the fields the parser
+ // reads are populated; everything else uses valid placeholder values.
+ private static byte[] BuildClientHello(string? serverName, IReadOnlyList? alpn)
+ {
+ var extensions = new List();
+ if (serverName is not null)
+ {
+ var nameBytes = Encoding.ASCII.GetBytes(serverName);
+ var serverNameList = new List { 0x00 }; // host_name type
+ AppendUInt16(serverNameList, (ushort)nameBytes.Length);
+ serverNameList.AddRange(nameBytes);
+
+ var sniData = new List();
+ AppendUInt16(sniData, (ushort)serverNameList.Count); // server_name_list length
+ sniData.AddRange(serverNameList);
+
+ AppendExtension(extensions, 0x0000, sniData);
+ }
+
+ if (alpn is not null)
+ {
+ var protocolList = new List();
+ foreach (var proto in alpn)
+ {
+ var protoBytes = Encoding.ASCII.GetBytes(proto);
+ protocolList.Add((byte)protoBytes.Length);
+ protocolList.AddRange(protoBytes);
+ }
+
+ var alpnData = new List();
+ AppendUInt16(alpnData, (ushort)protocolList.Count); // ProtocolNameList length
+ alpnData.AddRange(protocolList);
+
+ AppendExtension(extensions, 0x0010, alpnData);
+ }
+
+ var body = new List();
+ body.AddRange([0x03, 0x03]); // client_version TLS 1.2
+ body.AddRange(new byte[32]); // random
+ body.Add(0x00); // session_id length
+ AppendUInt16(body, 2); // cipher_suites length
+ body.AddRange([0x00, 0x2f]); // one cipher suite
+ body.Add(0x01); // compression methods length
+ body.Add(0x00); // null compression
+
+ if (serverName is not null || alpn is not null)
+ {
+ AppendUInt16(body, (ushort)extensions.Count); // extensions length
+ body.AddRange(extensions);
+ }
+
+ var handshake = new List { 0x01 }; // ClientHello
+ AppendUInt24(handshake, body.Count);
+ handshake.AddRange(body);
+
+ var record = new List { 0x16, 0x03, 0x01 }; // handshake, TLS 1.0 record version
+ AppendUInt16(record, (ushort)handshake.Count);
+ record.AddRange(handshake);
+
+ return [.. record];
+ }
+
+ private static void AppendExtension(List extensions, ushort type, List data)
+ {
+ AppendUInt16(extensions, type);
+ AppendUInt16(extensions, (ushort)data.Count);
+ extensions.AddRange(data);
+ }
+
+ private static void AppendUInt16(List target, ushort value)
+ {
+ Span buffer = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16BigEndian(buffer, value);
+ target.AddRange(buffer.ToArray());
+ }
+
+ private static void AppendUInt24(List target, int value)
+ {
+ target.Add((byte)((value >> 16) & 0xFF));
+ target.Add((byte)((value >> 8) & 0xFF));
+ target.Add((byte)(value & 0xFF));
+ }
+
+ private sealed class MemorySegment : ReadOnlySequenceSegment
+ {
+ public MemorySegment(ReadOnlyMemory memory) => Memory = memory;
+
+ public MemorySegment Append(ReadOnlyMemory memory)
+ {
+ var segment = new MemorySegment(memory) { RunningIndex = RunningIndex + Memory.Length };
+ Next = segment;
+ return segment;
+ }
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/UpstreamFailureTests.cs b/DevProxy.Proxy.Kestrel.Tests/UpstreamFailureTests.cs
new file mode 100644
index 00000000..55cc1673
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/UpstreamFailureTests.cs
@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Net.Sockets;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+// Covers the two parity rows that an engine swap could silently regress:
+// • upstream timeout → 504 Gateway Timeout (NOT a silent client-style teardown)
+// • invalid upstream cert / any other origin failure → 502 Bad Gateway
+// plus the genuine-client-cancellation path, which must stay silent.
+public class UpstreamFailureTests
+{
+ [Fact]
+ public void Timeout_TokenNotCancelled_IsGatewayTimeout()
+ {
+ // An HttpClient request timeout surfaces as TaskCanceledException while the
+ // connection's own token is NOT cancelled.
+ var outcome = UpstreamFailure.Classify(new TaskCanceledException(), clientCancelled: false);
+
+ Assert.NotNull(outcome);
+ Assert.Equal(HttpStatusCode.GatewayTimeout, outcome!.Value.Status);
+ Assert.Equal("Upstream request timed out", outcome.Value.Message);
+ }
+
+ [Fact]
+ public void OperationCanceled_TokenNotCancelled_IsGatewayTimeout()
+ {
+ var outcome = UpstreamFailure.Classify(new OperationCanceledException(), clientCancelled: false);
+
+ Assert.NotNull(outcome);
+ Assert.Equal(HttpStatusCode.GatewayTimeout, outcome!.Value.Status);
+ }
+
+ [Fact]
+ public void ClientCancellation_TokenCancelled_IsSilent()
+ {
+ // The client went away mid-forward — normal teardown, no response written.
+ var outcome = UpstreamFailure.Classify(new TaskCanceledException(), clientCancelled: true);
+
+ Assert.Null(outcome);
+ }
+
+ [Fact]
+ public void InvalidUpstreamCertificate_IsBadGateway()
+ {
+ // A failed TLS handshake to the origin (e.g. untrusted/expired upstream cert)
+ // surfaces as HttpRequestException from SocketsHttpHandler.
+ var tlsFailure = new HttpRequestException("The remote certificate is invalid.");
+
+ var outcome = UpstreamFailure.Classify(tlsFailure, clientCancelled: false);
+
+ Assert.NotNull(outcome);
+ Assert.Equal(HttpStatusCode.BadGateway, outcome!.Value.Status);
+ Assert.Equal("Upstream request failed", outcome.Value.Message);
+ }
+
+ [Fact]
+ public void ConnectionRefused_IsBadGateway()
+ {
+ var outcome = UpstreamFailure.Classify(
+ new HttpRequestException("connect failed", new SocketException((int)SocketError.ConnectionRefused)),
+ clientCancelled: false);
+
+ Assert.NotNull(outcome);
+ Assert.Equal(HttpStatusCode.BadGateway, outcome!.Value.Status);
+ }
+
+ [Fact]
+ public void NonCancellation_IsBadGateway_EvenWhenClientCancelled()
+ {
+ // A real origin fault is reported as a gateway error regardless of the token —
+ // only OperationCanceledException is treated as a possible client teardown.
+ var outcome = UpstreamFailure.Classify(new HttpRequestException("boom"), clientCancelled: true);
+
+ Assert.NotNull(outcome);
+ Assert.Equal(HttpStatusCode.BadGateway, outcome!.Value.Status);
+ }
+
+ [Fact]
+ public void NullException_Throws() =>
+ Assert.Throws(() => UpstreamFailure.Classify(null!, clientCancelled: false));
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs b/DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs
new file mode 100644
index 00000000..c88d32ed
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs
@@ -0,0 +1,106 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using DevProxy.Abstractions.Proxy.Http;
+using DevProxy.Proxy.Kestrel.Http;
+using DevProxy.Proxy.Kestrel.Internal;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class UpstreamForwarderTests
+{
+ private static MutableHttpRequest Request(string method = "GET") =>
+ new(method, new Uri("https://origin.test/sse"), HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty);
+
+ private static UpstreamForwarder ForwarderReturning(HttpResponseMessage response) =>
+ new(new HttpClient(new StubHandler(response)));
+
+ [Fact]
+ public async Task ForwardAsync_EventStream_IsStreaming_WithOpenBody()
+ {
+ var content = new StreamContent(new MemoryStream(Encoding.ASCII.GetBytes("data: 1\n\n")));
+ content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream");
+ var message = new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
+
+ await using var origin = await ForwarderReturning(message).ForwardAsync(Request(), CancellationToken.None);
+
+ Assert.True(origin.IsStreaming);
+ Assert.NotNull(origin.BodyStream);
+ Assert.False(origin.Response.HasBody);
+
+ using var reader = new StreamReader(origin.BodyStream!);
+ Assert.Equal("data: 1\n\n", await reader.ReadToEndAsync());
+ }
+
+ [Fact]
+ public async Task ForwardAsync_NonStreaming_IsBuffered()
+ {
+ var content = new ByteArrayContent(Encoding.ASCII.GetBytes("hello"));
+ content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
+ var message = new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
+
+ await using var origin = await ForwarderReturning(message).ForwardAsync(Request(), CancellationToken.None);
+
+ Assert.False(origin.IsStreaming);
+ Assert.Null(origin.BodyStream);
+ Assert.Equal("hello", origin.Response.BodyString);
+ }
+
+ [Fact]
+ public async Task ForwardAsync_StripsFramingHeaders()
+ {
+ var content = new ByteArrayContent(Encoding.ASCII.GetBytes("x"));
+ content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
+ var message = new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
+
+ await using var origin = await ForwarderReturning(message).ForwardAsync(Request(), CancellationToken.None);
+
+ Assert.Null(origin.Response.Headers.GetFirst("Transfer-Encoding"));
+ Assert.Null(origin.Response.Headers.GetFirst("Content-Encoding"));
+ // Content-Length is recomputed on write-back, not carried from the origin.
+ Assert.Null(origin.Response.Headers.GetFirst("Content-Length"));
+ }
+
+ [Fact]
+ public async Task ForwardAsync_Head_PreservesContentLength()
+ {
+ // A HEAD response has no body but reports the resource's Content-Length; it
+ // must survive forwarding so the client sees the real size (RFC 9110 §9.3.2).
+ var content = new ByteArrayContent([]);
+ content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
+ content.Headers.ContentLength = 4096;
+ var message = new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
+
+ await using var origin = await ForwarderReturning(message).ForwardAsync(Request("HEAD"), CancellationToken.None);
+
+ Assert.False(origin.IsStreaming);
+ Assert.Equal("4096", origin.Response.Headers.GetFirst("Content-Length")?.Value);
+ }
+
+ [Fact]
+ public async Task ForwardAsync_Head_EventStream_IsNotStreaming()
+ {
+ // A HEAD to an SSE endpoint has no body to stream — it must take the buffered
+ // path, never the streaming one.
+ var content = new ByteArrayContent([]);
+ content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream");
+ var message = new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
+
+ await using var origin = await ForwarderReturning(message).ForwardAsync(Request("HEAD"), CancellationToken.None);
+
+ Assert.False(origin.IsStreaming);
+ Assert.Null(origin.BodyStream);
+ }
+
+ private sealed class StubHandler(HttpResponseMessage response) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ Task.FromResult(response);
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/WebSocketMockResponderTests.cs b/DevProxy.Proxy.Kestrel.Tests/WebSocketMockResponderTests.cs
new file mode 100644
index 00000000..e14ac178
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/WebSocketMockResponderTests.cs
@@ -0,0 +1,227 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Net.WebSockets;
+using System.Security.Cryptography;
+using System.Text;
+using DevProxy.Abstractions.Proxy.Http;
+using DevProxy.Proxy.Kestrel.Http;
+using DevProxy.Proxy.Kestrel.Internal;
+using Microsoft.Extensions.Logging.Abstractions;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+///
+/// Exercises end-to-end over a loopback socket pair:
+/// the responder plays the WebSocket SERVER (proxy as mock), a real framework
+/// -equivalent (
+/// in client mode) plays the CLIENT. Proves the handshake (101 + correct
+/// Sec-WebSocket-Accept) and the scripted on-connect / reactive / close flow.
+///
+///
+/// client (test) responder (under test)
+/// ───────────── ──────────────────────
+/// read 101 + verify Accept ◀────── write 101 verbatim
+/// recv "welcome" ◀────── handler: SendText("welcome")
+/// send "ping" ──────▶ handler: recv → SendText("pong")
+/// recv "pong" ◀──────
+/// send "hi" ──────▶ handler: recv → SendText("echo:hi")
+/// recv "echo:hi" ◀──────
+/// Close ──────▶ handler: recv Close → break → CloseAsync
+/// recv Close ◀──────
+///
+///
+public class WebSocketMockResponderTests
+{
+ // RFC 6455 §1.3 worked example: key "dGhlIHNhbXBsZSBub25jZQ==" → this accept token.
+ private const string SampleKey = "dGhlIHNhbXBsZSBub25jZQ==";
+ private const string ExpectedAccept = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=";
+
+ [Fact]
+ public async Task RespondAsync_CompletesHandshake_AndRunsScriptedExchange()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+
+ var (clientSide, proxySide) = await TestSockets.ConnectedPairAsync();
+
+ var request = BuildUpgradeRequest(SampleKey, subProtocol: null);
+
+ // Scripted server handler: greet, then echo with a "pong" special-case until close.
+ static async Task Handler(IWebSocketConnection conn, CancellationToken ct)
+ {
+ await conn.SendTextAsync("welcome", ct);
+ while (true)
+ {
+ var msg = await conn.ReceiveAsync(ct);
+ if (msg is null || msg.Type == WebSocketMessageType.Close)
+ {
+ break;
+ }
+ await conn.SendTextAsync(
+ msg.Text == "ping" ? "pong" : $"echo:{msg.Text}", ct);
+ }
+ }
+
+ MutableHttpResponse? observedHandshake = null;
+ var responder = new WebSocketMockResponder(NullLogger.Instance);
+ var serverTask = responder.RespondAsync(
+ proxySide, request, Handler,
+ r => { observedHandshake = r; return Task.CompletedTask; },
+ cts.Token);
+
+ // ── client: read + verify the raw 101 (one byte at a time, no over-read) ──
+ var head = await ReadUntilDoubleCrlfAsync(clientSide, cts.Token);
+ Assert.StartsWith("HTTP/1.1 101 Switching Protocols", head, StringComparison.Ordinal);
+ Assert.Contains($"Sec-WebSocket-Accept: {ExpectedAccept}", head, StringComparison.Ordinal);
+ Assert.Contains("Upgrade: websocket", head, StringComparison.Ordinal);
+
+ // onHandshakeResponse fired with the parsed 101 (so the pipeline/req-log can run).
+ Assert.NotNull(observedHandshake);
+ Assert.Equal(HttpStatusCode.SwitchingProtocols, observedHandshake!.StatusCode);
+
+ // ── client: drive a real WebSocket over the remaining stream ──
+ using var clientWs = WebSocket.CreateFromStream(
+ clientSide, isServer: false, subProtocol: null, keepAliveInterval: TimeSpan.FromSeconds(30));
+
+ Assert.Equal("welcome", await ReceiveTextAsync(clientWs, cts.Token));
+
+ await SendTextAsync(clientWs, "ping", cts.Token);
+ Assert.Equal("pong", await ReceiveTextAsync(clientWs, cts.Token));
+
+ await SendTextAsync(clientWs, "hi", cts.Token);
+ Assert.Equal("echo:hi", await ReceiveTextAsync(clientWs, cts.Token));
+
+ // Client closes; the responder observes Close, ends the handler, and closes back.
+ await clientWs.CloseAsync(WebSocketCloseStatus.NormalClosure, statusDescription: null, cts.Token);
+
+ await serverTask;
+ Assert.Equal(WebSocketState.Closed, clientWs.State);
+
+ clientSide.Dispose();
+ proxySide.Dispose();
+ }
+
+ [Fact]
+ public async Task RespondAsync_EchoesRequestedSubProtocol()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ var (clientSide, proxySide) = await TestSockets.ConnectedPairAsync();
+
+ var request = BuildUpgradeRequest(SampleKey, subProtocol: "chat, superchat");
+
+ static async Task Handler(IWebSocketConnection conn, CancellationToken ct) =>
+ await conn.SendTextAsync("hi", ct);
+
+ var responder = new WebSocketMockResponder(NullLogger.Instance);
+ var serverTask = responder.RespondAsync(
+ proxySide, request, Handler, _ => Task.CompletedTask, cts.Token);
+
+ var head = await ReadUntilDoubleCrlfAsync(clientSide, cts.Token);
+ // RFC 6455 §4.2.2: only the FIRST offered sub-protocol is echoed.
+ Assert.Contains("Sec-WebSocket-Protocol: chat", head, StringComparison.Ordinal);
+ Assert.DoesNotContain("superchat", head, StringComparison.Ordinal);
+
+ using var clientWs = WebSocket.CreateFromStream(
+ clientSide, isServer: false, subProtocol: "chat", keepAliveInterval: TimeSpan.FromSeconds(30));
+ Assert.Equal("hi", await ReceiveTextAsync(clientWs, cts.Token));
+
+ await clientWs.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
+ await serverTask;
+
+ clientSide.Dispose();
+ proxySide.Dispose();
+ }
+
+ [Fact]
+ public async Task RespondAsync_RefusesHandshake_WhenKeyMissing()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ var (clientSide, proxySide) = await TestSockets.ConnectedPairAsync();
+
+ var headers = new HeaderCollection();
+ headers.Add("Host", "ws.example.test");
+ headers.Add("Upgrade", "websocket");
+ headers.Add("Connection", "Upgrade");
+ // No Sec-WebSocket-Key.
+ var request = new MutableHttpRequest(
+ "GET", new Uri("http://ws.example.test/socket"), HttpVersion.Version11, headers, ReadOnlyMemory.Empty);
+
+ var handlerRan = false;
+ Task Handler(IWebSocketConnection conn, CancellationToken ct) { handlerRan = true; return Task.CompletedTask; }
+
+ var responder = new WebSocketMockResponder(NullLogger.Instance);
+ var serverTask = responder.RespondAsync(
+ proxySide, request, Handler, _ => Task.CompletedTask, cts.Token);
+
+ var head = await ReadUntilDoubleCrlfAsync(clientSide, cts.Token);
+ Assert.StartsWith("HTTP/1.1 400 Bad Request", head, StringComparison.Ordinal);
+ Assert.False(handlerRan);
+
+ await serverTask;
+ clientSide.Dispose();
+ proxySide.Dispose();
+ }
+
+ [Fact]
+ public void ComputeAcceptKey_MatchesRfc6455Example()
+ {
+ // Independent recomputation of the well-known example, guarding the magic GUID.
+ // SHA1 is mandated by the WebSocket handshake (RFC 6455 §4.2.2).
+#pragma warning disable CA5350
+ var hash = SHA1.HashData(Encoding.ASCII.GetBytes(SampleKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"));
+#pragma warning restore CA5350
+ Assert.Equal(ExpectedAccept, Convert.ToBase64String(hash));
+ }
+
+ private static MutableHttpRequest BuildUpgradeRequest(string key, string? subProtocol)
+ {
+ var headers = new HeaderCollection();
+ headers.Add("Host", "ws.example.test");
+ headers.Add("Upgrade", "websocket");
+ headers.Add("Connection", "Upgrade");
+ headers.Add("Sec-WebSocket-Key", key);
+ headers.Add("Sec-WebSocket-Version", "13");
+ if (subProtocol is not null)
+ {
+ headers.Add("Sec-WebSocket-Protocol", subProtocol);
+ }
+ return new MutableHttpRequest(
+ "GET", new Uri("http://ws.example.test/socket"), HttpVersion.Version11, headers, ReadOnlyMemory.Empty);
+ }
+
+ private static Task SendTextAsync(WebSocket ws, string text, CancellationToken ct) =>
+ ws.SendAsync(Encoding.UTF8.GetBytes(text).AsMemory(), WebSocketMessageType.Text, endOfMessage: true, ct).AsTask();
+
+ private static async Task ReceiveTextAsync(WebSocket ws, CancellationToken ct)
+ {
+ var buffer = new byte[8 * 1024];
+ var result = await ws.ReceiveAsync(buffer, ct);
+ return Encoding.UTF8.GetString(buffer, 0, result.Count);
+ }
+
+ private static async Task ReadUntilDoubleCrlfAsync(Stream stream, CancellationToken ct)
+ {
+ var bytes = new List();
+ var buffer = new byte[1];
+ while (true)
+ {
+ var read = await stream.ReadAsync(buffer, ct);
+ if (read == 0)
+ {
+ break;
+ }
+ bytes.Add(buffer[0]);
+ if (bytes.Count >= 4
+ && bytes[^4] == (byte)'\r' && bytes[^3] == (byte)'\n'
+ && bytes[^2] == (byte)'\r' && bytes[^1] == (byte)'\n')
+ {
+ break;
+ }
+ }
+ return Encoding.ASCII.GetString(bytes.ToArray());
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel.Tests/WebSocketRelayTests.cs b/DevProxy.Proxy.Kestrel.Tests/WebSocketRelayTests.cs
new file mode 100644
index 00000000..a2e61d02
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel.Tests/WebSocketRelayTests.cs
@@ -0,0 +1,173 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using DevProxy.Abstractions.Proxy.Http;
+using DevProxy.Proxy.Kestrel.Http;
+using DevProxy.Proxy.Kestrel.Internal;
+using Microsoft.Extensions.Logging.Abstractions;
+
+using Xunit;
+
+namespace DevProxy.Proxy.Kestrel.Tests;
+
+public class WebSocketRelayTests
+{
+ // ── ParseResponseHead (pure) ────────────────────────────────────────────
+
+ [Fact]
+ public void ParseResponseHead_Parses101AndHeaders()
+ {
+ var (status, reason, headers) = WebSocketRelay.ParseResponseHead(
+ "HTTP/1.1 101 Switching Protocols\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
+
+ Assert.Equal(101, status);
+ Assert.Equal("Switching Protocols", reason);
+ Assert.Equal("websocket", headers.GetFirst("Upgrade")?.Value);
+ Assert.Equal("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", headers.GetFirst("Sec-WebSocket-Accept")?.Value);
+ }
+
+ [Fact]
+ public void ParseResponseHead_ParsesStatusWithoutReason()
+ {
+ var (status, reason, _) = WebSocketRelay.ParseResponseHead("HTTP/1.1 204\r\n");
+
+ Assert.Equal(204, status);
+ Assert.Equal(string.Empty, reason);
+ }
+
+ [Fact]
+ public void ParseResponseHead_Throws_OnMalformedStatusLine()
+ {
+ _ = Assert.Throws(() => WebSocketRelay.ParseResponseHead("garbage"));
+ }
+
+ // ── End-to-end relay over loopback ──────────────────────────────────────
+
+ [Fact]
+ public async Task RelayAsync_ReplaysHandshake_RelaysFrames_AndReportsResponse()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+
+ // Fake origin: capture the replayed handshake, answer 101 + a "frame", echo back.
+ using var originListener = new TcpListener(IPAddress.Loopback, 0);
+ originListener.Start();
+ var originPort = ((IPEndPoint)originListener.LocalEndpoint).Port;
+
+ var originHandshakeText = new TaskCompletionSource();
+ var originTask = RunFakeOriginAsync(originListener, originHandshakeText, cts.Token);
+
+ // The proxy holds proxySide; the test plays the client on clientSide.
+ var (clientSide, proxySide) = await TestSockets.ConnectedPairAsync();
+
+ var headers = new HeaderCollection();
+ headers.Add("Host", $"127.0.0.1:{originPort}");
+ headers.Add("Upgrade", "websocket");
+ headers.Add("Connection", "Upgrade");
+ headers.Add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
+ headers.Add("Proxy-Connection", "keep-alive"); // must be stripped from the replay
+ var request = new MutableHttpRequest(
+ "GET", new Uri($"http://127.0.0.1:{originPort}/chat?room=1"), HttpVersion.Version11, headers, ReadOnlyMemory.Empty);
+
+ MutableHttpResponse? observed = null;
+ var relay = new WebSocketRelay(NullLogger.Instance);
+ var relayTask = relay.RelayAsync(
+ proxySide, request, request.RequestUri,
+ r => { observed = r; return Task.CompletedTask; }, cts.Token);
+
+ // Client reads the 101 handshake the proxy wrote back, then the origin's frame.
+ var handshakeBack = await ReadUntilDoubleCrlfAsync(clientSide, cts.Token);
+ Assert.StartsWith("HTTP/1.1 101 Switching Protocols", handshakeBack, StringComparison.Ordinal);
+ Assert.Contains("Sec-WebSocket-Accept: abc123", handshakeBack, StringComparison.Ordinal);
+ Assert.Equal("origin-frame", await ReadTextAsync(clientSide, "origin-frame".Length, cts.Token));
+
+ // Client → origin frame is spliced through.
+ await clientSide.WriteAsync(Encoding.ASCII.GetBytes("client-frame"), cts.Token);
+ await clientSide.FlushAsync(cts.Token);
+
+ var replayed = await originHandshakeText.Task;
+ // Replayed in origin-form, preserving the WebSocket headers, dropping proxy ones.
+ Assert.StartsWith("GET /chat?room=1 HTTP/1.1", replayed, StringComparison.Ordinal);
+ Assert.Contains("Upgrade: websocket", replayed, StringComparison.Ordinal);
+ Assert.Contains("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", replayed, StringComparison.Ordinal);
+ Assert.DoesNotContain("Proxy-Connection", replayed, StringComparison.Ordinal);
+
+ // onHandshakeResponse saw the parsed 101.
+ Assert.NotNull(observed);
+ Assert.Equal(HttpStatusCode.SwitchingProtocols, observed!.StatusCode);
+
+ var echoed = await originTask; // origin returns what it received after the handshake
+ Assert.Equal("client-frame", echoed);
+
+ clientSide.Dispose();
+ proxySide.Dispose();
+ await relayTask;
+ }
+
+ // Fake origin: read the request head, reply 101 + "origin-frame", then read one
+ // post-handshake chunk and return it (so the test can assert client→origin splicing).
+ private static async Task RunFakeOriginAsync(
+ TcpListener listener, TaskCompletionSource handshakeText, CancellationToken ct)
+ {
+ using var client = await listener.AcceptTcpClientAsync(ct);
+ await using var stream = client.GetStream();
+
+ var head = await ReadUntilDoubleCrlfAsync(stream, ct);
+ handshakeText.SetResult(head);
+
+ var response = Encoding.ASCII.GetBytes(
+ "HTTP/1.1 101 Switching Protocols\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Sec-WebSocket-Accept: abc123\r\n\r\n" +
+ "origin-frame");
+ await stream.WriteAsync(response, ct);
+ await stream.FlushAsync(ct);
+
+ return await ReadTextAsync(stream, "client-frame".Length, ct);
+ }
+
+ private static async Task ReadUntilDoubleCrlfAsync(Stream stream, CancellationToken ct)
+ {
+ var bytes = new List();
+ var buffer = new byte[1];
+ while (true)
+ {
+ var read = await stream.ReadAsync(buffer, ct);
+ if (read == 0)
+ {
+ break;
+ }
+ bytes.Add(buffer[0]);
+ if (bytes.Count >= 4
+ && bytes[^4] == (byte)'\r' && bytes[^3] == (byte)'\n'
+ && bytes[^2] == (byte)'\r' && bytes[^1] == (byte)'\n')
+ {
+ break;
+ }
+ }
+ return Encoding.ASCII.GetString(bytes.ToArray());
+ }
+
+ private static async Task ReadTextAsync(Stream stream, int byteCount, CancellationToken ct)
+ {
+ var buffer = new byte[byteCount];
+ var offset = 0;
+ while (offset < byteCount)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(offset), ct);
+ if (read == 0)
+ {
+ break;
+ }
+ offset += read;
+ }
+ return Encoding.ASCII.GetString(buffer, 0, offset);
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj
new file mode 100644
index 00000000..bb6142cf
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ DevProxy.Proxy.Kestrel
+ enable
+ enable
+ 4.0.0
+ false
+ true
+ true
+ AllEnabledByDefault
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs b/DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs
new file mode 100644
index 00000000..acbf9d4b
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs
@@ -0,0 +1,120 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using DevProxy.Abstractions.Proxy.Http;
+
+namespace DevProxy.Proxy.Kestrel.Http;
+
+///
+/// Concrete for the Kestrel engine. One instance per
+/// logical request/response exchange; is stable across the
+/// request and response phases of the same exchange even when the underlying TCP
+/// connection is reused (HTTP keep-alive).
+///
+public sealed class CanonicalProxySession : IProxySession
+{
+ private static readonly Version DefaultHttpVersion = System.Net.HttpVersion.Version11;
+
+ private MutableHttpResponse? _response;
+ private Func? _webSocketHandler;
+
+ public CanonicalProxySession(string sessionId, MutableHttpRequest request, int? processId)
+ : this(sessionId, request, processId, requestId: 0)
+ {
+ }
+
+ public CanonicalProxySession(string sessionId, MutableHttpRequest request, int? processId, int requestId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(sessionId);
+ ArgumentNullException.ThrowIfNull(request);
+
+ SessionId = sessionId;
+ Request = request;
+ ProcessId = processId;
+ RequestId = requestId;
+ }
+
+ ///
+ public string SessionId { get; }
+
+ ///
+ /// A stable per-exchange integer used to group request- and response-phase log
+ /// entries in the console formatter (mirrors the Titanium engine's hashcode key).
+ ///
+ public int RequestId { get; }
+
+ ///
+ public IHttpRequest Request { get; }
+
+ ///
+ public IHttpResponse? Response => _response;
+
+ ///
+ public int? ProcessId { get; }
+
+ ///
+ public bool HasResponse => _response is not null;
+
+ ///
+ /// True when the current was produced by a plugin (via
+ /// )
+ /// rather than received from the origin. The engine uses this to short-circuit
+ /// upstream forwarding.
+ ///
+ public bool RespondedByPlugin { get; private set; }
+
+ /// The concrete response, for engine write-back. Null until set.
+ public MutableHttpResponse? MutableResponse => _response;
+
+ ///
+ /// True when a plugin attached a WebSocket mock handler (via
+ /// ). The engine runs
+ /// instead of relaying the upgrade to the origin.
+ ///
+ public bool WebSocketHandledByPlugin => _webSocketHandler is not null;
+
+ /// The plugin-supplied WebSocket mock handler, or null.
+ public Func? WebSocketHandler => _webSocketHandler;
+
+ ///
+ /// Sets the response received from the origin. Does not flag the exchange as
+ /// plugin-mocked.
+ ///
+ public void SetOriginResponse(MutableHttpResponse response)
+ {
+ ArgumentNullException.ThrowIfNull(response);
+ _response = response;
+ }
+
+ ///
+ public void Respond(string body, HttpStatusCode statusCode, IEnumerable headers)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ ArgumentNullException.ThrowIfNull(headers);
+
+ var response = new MutableHttpResponse(statusCode, DefaultHttpVersion, new HeaderCollection(headers), ReadOnlyMemory.Empty);
+ response.SetBodyString(body);
+ _response = response;
+ RespondedByPlugin = true;
+ }
+
+ ///
+ public void Respond(ReadOnlyMemory body, HttpStatusCode statusCode, IEnumerable headers)
+ {
+ ArgumentNullException.ThrowIfNull(headers);
+
+ var response = new MutableHttpResponse(statusCode, DefaultHttpVersion, new HeaderCollection(headers), ReadOnlyMemory.Empty);
+ response.SetBody(body);
+ _response = response;
+ RespondedByPlugin = true;
+ }
+
+ ///
+ public void HandleWebSocket(Func handler)
+ {
+ ArgumentNullException.ThrowIfNull(handler);
+ _webSocketHandler = handler;
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel/Http/MutableHttpMessage.cs b/DevProxy.Proxy.Kestrel/Http/MutableHttpMessage.cs
new file mode 100644
index 00000000..b20a5ad4
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Http/MutableHttpMessage.cs
@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+using DevProxy.Abstractions.Proxy.Http;
+
+namespace DevProxy.Proxy.Kestrel.Http;
+
+///
+/// In-memory backing the Kestrel engine's canonical
+/// model. Bodies are always stored decompressed (the engine decodes
+/// Content-Encoding on read); writing a body keeps Content-Length
+/// in sync per .
+///
+public abstract class MutableHttpMessage : IHttpMessage
+{
+ private ReadOnlyMemory _body;
+
+ private protected MutableHttpMessage(HeaderCollection headers, ReadOnlyMemory body)
+ {
+ ArgumentNullException.ThrowIfNull(headers);
+ Headers = headers;
+ _body = body;
+ }
+
+ ///
+ public IHeaderCollection Headers { get; }
+
+ ///
+ public string? ContentType => Headers.GetFirst("Content-Type")?.Value;
+
+ ///
+ public bool HasBody => !_body.IsEmpty;
+
+ ///
+ public ReadOnlyMemory Body => _body;
+
+ ///
+ public string BodyString => _body.IsEmpty ? string.Empty : ResolveEncoding().GetString(_body.Span);
+
+ ///
+ public void SetBody(ReadOnlyMemory body, string? contentType = null)
+ {
+ _body = body;
+ if (contentType is not null)
+ {
+ Headers.Replace("Content-Type", contentType);
+ }
+ Headers.Replace("Content-Length", body.Length.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ ///
+ public void SetBodyString(string body, string? contentType = null)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ SetBody(Encoding.UTF8.GetBytes(body), contentType);
+ }
+
+ private Encoding ResolveEncoding()
+ {
+ var contentType = ContentType;
+ if (contentType is not null)
+ {
+ var marker = contentType.IndexOf("charset=", StringComparison.OrdinalIgnoreCase);
+ if (marker >= 0)
+ {
+ var charset = contentType[(marker + "charset=".Length)..].Trim().Trim('"');
+ var separator = charset.IndexOf(';', StringComparison.Ordinal);
+ if (separator >= 0)
+ {
+ charset = charset[..separator].Trim();
+ }
+
+ try
+ {
+ return Encoding.GetEncoding(charset);
+ }
+ catch (ArgumentException)
+ {
+ // Unknown charset: fall back to UTF-8.
+ }
+ }
+ }
+
+ return Encoding.UTF8;
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel/Http/MutableHttpRequest.cs b/DevProxy.Proxy.Kestrel/Http/MutableHttpRequest.cs
new file mode 100644
index 00000000..3ee77b87
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Http/MutableHttpRequest.cs
@@ -0,0 +1,63 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Proxy.Http;
+
+namespace DevProxy.Proxy.Kestrel.Http;
+
+///
+/// In-memory parsed from the wire by the Kestrel engine.
+///
+public sealed class MutableHttpRequest : MutableHttpMessage, IHttpRequest
+{
+ private Uri _requestUri;
+
+ public MutableHttpRequest(
+ string method,
+ Uri requestUri,
+ Version httpVersion,
+ HeaderCollection headers,
+ ReadOnlyMemory body)
+ : base(headers, body)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(method);
+ ArgumentNullException.ThrowIfNull(requestUri);
+ ArgumentNullException.ThrowIfNull(httpVersion);
+
+ Method = method.ToUpperInvariant();
+ _requestUri = requestUri;
+ HttpVersion = httpVersion;
+ }
+
+ ///
+ public Uri RequestUri => _requestUri;
+
+ ///
+ public string Url
+ {
+ get => _requestUri.AbsoluteUri;
+ set
+ {
+ ArgumentException.ThrowIfNullOrEmpty(value);
+ _requestUri = new Uri(value, UriKind.Absolute);
+ }
+ }
+
+ ///
+ public string Method { get; }
+
+ ///
+ public Version HttpVersion { get; }
+
+ ///
+ public bool IsWebSocketRequest
+ {
+ get
+ {
+ var upgrade = Headers.GetFirst("Upgrade")?.Value;
+ return upgrade is not null
+ && upgrade.Contains("websocket", StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel/Http/MutableHttpResponse.cs b/DevProxy.Proxy.Kestrel/Http/MutableHttpResponse.cs
new file mode 100644
index 00000000..f146558d
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Http/MutableHttpResponse.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using DevProxy.Abstractions.Proxy.Http;
+
+namespace DevProxy.Proxy.Kestrel.Http;
+
+///
+/// In-memory — either received from the origin and
+/// decompressed, or synthesized by a plugin via
+/// .
+///
+public sealed class MutableHttpResponse : MutableHttpMessage, IHttpResponse
+{
+ public MutableHttpResponse(
+ HttpStatusCode statusCode,
+ Version httpVersion,
+ HeaderCollection headers,
+ ReadOnlyMemory body,
+ string? statusDescription = null)
+ : base(headers, body)
+ {
+ ArgumentNullException.ThrowIfNull(httpVersion);
+ StatusCode = statusCode;
+ HttpVersion = httpVersion;
+ StatusDescription = statusDescription;
+ }
+
+ ///
+ public HttpStatusCode StatusCode { get; set; }
+
+ ///
+ public string? StatusDescription { get; set; }
+
+ ///
+ public Version HttpVersion { get; }
+}
diff --git a/DevProxy.Proxy.Kestrel/IServiceCollectionExtensions.cs b/DevProxy.Proxy.Kestrel/IServiceCollectionExtensions.cs
new file mode 100644
index 00000000..65ebc503
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/IServiceCollectionExtensions.cs
@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Security.Cryptography.X509Certificates;
+using DevProxy.Proxy.Kestrel.Internal;
+using Microsoft.Extensions.Logging;
+
+#pragma warning disable IDE0130
+namespace Microsoft.Extensions.DependencyInjection;
+#pragma warning restore IDE0130
+
+///
+/// DI wiring for the Kestrel proxy engine's certificate authority.
+///
+///
+/// Registers the as a single shared singleton and
+/// exposes its root — so the engine (TLS termination),
+/// the cert command (trust/remove), the cert-download API, and the Entra mock
+/// plugin (signing-key chain) all resolve the same certificate rather than each
+/// loading its own copy.
+///
+///
+public static class KestrelProxyServiceCollectionExtensions
+{
+ public static IServiceCollection AddKestrelCertificateAuthority(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ _ = services.AddSingleton(sp =>
+ CertificateAuthority.CreateDefault(
+ sp.GetRequiredService().CreateLogger()));
+ _ = services.AddSingleton(sp => sp.GetRequiredService().RootCertificate);
+
+ return services;
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs
new file mode 100644
index 00000000..c77391af
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs
@@ -0,0 +1,373 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace DevProxy.Proxy.Kestrel.Internal;
+
+///
+/// A persistent certificate authority. It loads a self-signed root CA from disk
+/// (creating + saving one on first run), then mints and caches a leaf certificate
+/// per host on demand so the proxy can terminate TLS and inspect decrypted traffic.
+///
+///
+/// The on-disk layout intentionally mirrors the Titanium engine's
+/// CertificateDiskCache: the root lives at <configDir>/rootCert.pfx
+/// (overridable via DEV_PROXY_CERT_PATH) and leaves at
+/// <configDir>/crts/<host>.pfx, both PKCS#12 with an empty password.
+/// Because the format + path match, this CA will load a root that the Titanium
+/// engine already created and the OS already trusts — so existing users get
+/// interception without re-trusting. Per migration decision #5 there is no
+/// cross-version compatibility contract: an absent/invalid/expired root is simply
+/// regenerated (and the stale leaf cache purged).
+///
+///
+///
+/// OS-trust installation (mac keychain / Windows root store) + the first-run prompt
+/// are deliberately NOT handled here — that is Slice 5b, wired in the host where the
+/// existing trust helpers live. This slice only makes the root persistent.
+///
+///
+///
+/// LoadOrCreateRoot(rootPfxPath)
+/// ┌─ file exists? ──no──┐
+/// │ yes │
+/// ▼ ▼
+/// try load (empty pw) create fresh root
+/// ├─ ok + isCA ├─ save to rootPfxPath (best-effort)
+/// │ + has key └─ purge stale crts/ (old root's leaves)
+/// │ + not expired ─► use
+/// └─ otherwise ───────► create fresh root (same as above)
+///
+/// GetCertificateForHost(host) [ConcurrentDictionary.GetOrAdd]
+/// ┌─ crts/<host>.pfx exists & valid? ──yes──► load + use
+/// └─ no ──► mint leaf signed by root ──► save (best-effort) ──► use
+///
+///
+public sealed class CertificateAuthority : IDisposable
+{
+ private const string RootCertCommonName = "Dev Proxy CA";
+ private const string ConfigFolderName = "dev-proxy";
+ private const string RootCertFileName = "rootCert.pfx";
+ private const string LeafDirectoryName = "crts";
+ private const int LeafValidityDays = 365; // < 397 to avoid Edge ERR_CERT_VALIDITY_TOO_LONG
+
+ private readonly string _rootPfxPath;
+ private readonly string _leafDirectory;
+ private readonly ILogger _logger;
+ private readonly X509Certificate2 _ca;
+ private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase);
+
+ public CertificateAuthority(string rootPfxPath, string leafDirectory, ILogger? logger = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(rootPfxPath);
+ ArgumentException.ThrowIfNullOrEmpty(leafDirectory);
+
+ _rootPfxPath = rootPfxPath;
+ _leafDirectory = leafDirectory;
+ _logger = logger ?? NullLogger.Instance;
+ _ca = LoadOrCreateRoot();
+ }
+
+ ///
+ /// Builds a CA rooted at the real Dev Proxy config directory, mirroring the
+ /// Titanium engine's path resolution so an already-trusted root is reused.
+ ///
+ public static CertificateAuthority CreateDefault(ILogger? logger = null)
+ {
+ var configDirectory = ResolveConfigDirectory();
+
+ var envPath = Environment.GetEnvironmentVariable("DEV_PROXY_CERT_PATH");
+ var rootPfxPath = !string.IsNullOrEmpty(envPath) && Path.IsPathRooted(envPath)
+ ? envPath
+ : Path.Combine(configDirectory, RootCertFileName);
+
+ var leafDirectory = Path.Combine(configDirectory, LeafDirectoryName);
+ return new CertificateAuthority(rootPfxPath, leafDirectory, logger);
+ }
+
+ /// The root CA certificate clients must trust to allow interception.
+ public X509Certificate2 RootCertificate => _ca;
+
+ public X509Certificate2 GetCertificateForHost(string host) => _cache.GetOrAdd(host, LoadOrCreateLeaf);
+
+ // Mirrors Titanium's CertificateDiskCache.GetRootCertificateDirectory: on Windows the
+ // root lives next to the executable; on mac/Linux under ApplicationData/dev-proxy.
+ private static string ResolveConfigDirectory()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return AppContext.BaseDirectory;
+ }
+
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ ConfigFolderName);
+ }
+
+ private X509Certificate2 LoadOrCreateRoot()
+ {
+ if (File.Exists(_rootPfxPath))
+ {
+ var existing = TryLoadRoot(_rootPfxPath);
+ if (existing is not null)
+ {
+ _logger.LogInformation("Loaded persisted root certificate from {Path}", _rootPfxPath);
+ return existing;
+ }
+
+ _logger.LogWarning(
+ "Persisted root certificate at {Path} is missing/invalid/expired; regenerating.",
+ _rootPfxPath);
+ }
+
+ var root = CreateRootCertificate();
+ SaveRoot(root);
+
+ // A fresh root invalidates every previously minted leaf (they were signed by the
+ // old root), so drop the on-disk leaf cache to avoid serving untrusted leaves.
+ PurgeLeafCache();
+ return root;
+ }
+
+ private static X509Certificate2? TryLoadRoot(string path)
+ {
+ try
+ {
+ var cert = X509CertificateLoader.LoadPkcs12(
+ File.ReadAllBytes(path),
+ string.Empty,
+ X509KeyStorageFlags.Exportable);
+
+ var isCa = cert.Extensions
+ .OfType()
+ .FirstOrDefault()?.CertificateAuthority == true;
+
+ if (cert.HasPrivateKey && isCa && cert.NotAfter > DateTime.Now)
+ {
+ return cert;
+ }
+
+ cert.Dispose();
+ return null;
+ }
+ catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private void SaveRoot(X509Certificate2 root)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(_rootPfxPath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ _ = Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllBytes(_rootPfxPath, root.Export(X509ContentType.Pkcs12, string.Empty));
+ _logger.LogInformation("Saved new root certificate to {Path}", _rootPfxPath);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Best-effort: an unwritable location (e.g. read-only install dir) means the
+ // root is in-memory only and won't survive a restart, but interception still
+ // works this run.
+ _logger.LogWarning(ex, "Could not persist root certificate to {Path}", _rootPfxPath);
+ }
+ }
+
+ private void PurgeLeafCache()
+ {
+ try
+ {
+ if (Directory.Exists(_leafDirectory))
+ {
+ Directory.Delete(_leafDirectory, recursive: true);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogWarning(ex, "Could not purge stale leaf certificate cache at {Path}", _leafDirectory);
+ }
+ }
+
+ private X509Certificate2 LoadOrCreateLeaf(string host)
+ {
+ var leafPath = Path.Combine(_leafDirectory, SanitizeFileName(host) + ".pfx");
+
+ var existing = TryLoadLeaf(leafPath);
+ if (existing is not null)
+ {
+ return existing;
+ }
+
+ var leaf = CreateLeafCertificate(host);
+ SaveLeaf(leafPath, leaf);
+ return leaf;
+ }
+
+ private static X509Certificate2? TryLoadLeaf(string path)
+ {
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ try
+ {
+ var cert = X509CertificateLoader.LoadPkcs12(
+ File.ReadAllBytes(path),
+ string.Empty,
+ X509KeyStorageFlags.Exportable);
+
+ if (cert.HasPrivateKey && cert.NotAfter > DateTime.Now)
+ {
+ return cert;
+ }
+
+ cert.Dispose();
+ return null;
+ }
+ catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private void SaveLeaf(string path, X509Certificate2 leaf)
+ {
+ try
+ {
+ _ = Directory.CreateDirectory(_leafDirectory);
+ File.WriteAllBytes(path, leaf.Export(X509ContentType.Pkcs12, string.Empty));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Best-effort cache; the in-memory leaf is still returned and used this run.
+ _logger.LogTrace(ex, "Could not persist leaf certificate to {Path}", path);
+ }
+ }
+
+ // Hosts are filename-unsafe on some platforms (IPv6 literals contain ':', wildcard
+ // hosts contain '*'). The leaf filename is a local cache key only — it doesn't need
+ // to match Titanium's naming since trust derives from the (shared) root, not the leaf.
+ private static string SanitizeFileName(string host)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ var chars = host.ToCharArray();
+ for (var i = 0; i < chars.Length; i++)
+ {
+ if (Array.IndexOf(invalid, chars[i]) >= 0)
+ {
+ chars[i] = '_';
+ }
+ }
+
+ return new string(chars);
+ }
+
+ private static X509Certificate2 CreateRootCertificate()
+ {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(
+ $"CN={RootCertCommonName}",
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
+ request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
+
+ // Round-trip through PKCS#12 so the in-memory root carries an exportable private
+ // key on every platform (required both to sign leaves and to persist to disk).
+ using var selfSigned = request.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddYears(10));
+ return X509CertificateLoader.LoadPkcs12(
+ selfSigned.Export(X509ContentType.Pkcs12, string.Empty),
+ string.Empty,
+ X509KeyStorageFlags.Exportable);
+ }
+
+ private X509Certificate2 CreateLeafCertificate(string host)
+ {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(
+ $"CN={host}",
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
+ request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
+ [new Oid("1.3.6.1.5.5.7.3.1")], false)); // serverAuth
+
+ var sanBuilder = new SubjectAlternativeNameBuilder();
+ if (System.Net.IPAddress.TryParse(host, out var ip))
+ {
+ sanBuilder.AddIpAddress(ip);
+ }
+ else
+ {
+ sanBuilder.AddDnsName(host);
+ }
+ request.CertificateExtensions.Add(sanBuilder.Build());
+ request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
+
+ var serialNumber = new byte[8];
+ RandomNumberGenerator.Fill(serialNumber);
+
+ // A leaf may not outlive its issuer. When reusing an existing (possibly
+ // near-expiry) root, clamp the leaf's validity window to the root's.
+ var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
+ var rootNotBefore = new DateTimeOffset(_ca.NotBefore.ToUniversalTime());
+ if (notBefore < rootNotBefore)
+ {
+ notBefore = rootNotBefore;
+ }
+
+ var notAfter = DateTimeOffset.UtcNow.AddDays(LeafValidityDays);
+ var rootNotAfter = new DateTimeOffset(_ca.NotAfter.ToUniversalTime());
+ if (notAfter > rootNotAfter)
+ {
+ notAfter = rootNotAfter;
+ }
+
+ using var leaf = request.Create(
+ _ca,
+ notBefore,
+ notAfter,
+ serialNumber);
+
+ using var leafWithKey = leaf.CopyWithPrivateKey(rsa);
+
+ // Round-trip through PKCS#12 so the certificate is usable as a server
+ // certificate by SslStream on every platform.
+ return X509CertificateLoader.LoadPkcs12(
+ leafWithKey.Export(X509ContentType.Pkcs12, string.Empty),
+ string.Empty,
+ X509KeyStorageFlags.Exportable);
+ }
+
+ public void Dispose()
+ {
+ _ca.Dispose();
+ foreach (var leaf in _cache.Values)
+ {
+ leaf.Dispose();
+ }
+ _cache.Clear();
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel/Internal/ConnectAuthorityParser.cs b/DevProxy.Proxy.Kestrel/Internal/ConnectAuthorityParser.cs
new file mode 100644
index 00000000..0929243d
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Internal/ConnectAuthorityParser.cs
@@ -0,0 +1,128 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+
+namespace DevProxy.Proxy.Kestrel.Internal;
+
+///
+/// A parsed CONNECT authority. is the bare host used for socket
+/// connect / certificate minting (IPv6 literals are UNbracketed, e.g. ::1);
+/// re-adds the brackets so it can be dropped into an absolute URL
+/// (https://[::1]:8443/path), which would otherwise be ambiguous.
+///
+internal readonly record struct ConnectAuthority(string Host, int Port, bool IsIPv6)
+{
+ /// The host in URL form: bracketed for IPv6 literals, bare otherwise.
+ public string UrlHost => IsIPv6 ? $"[{Host}]" : Host;
+}
+
+///
+/// Parses the authority component of a CONNECT host:port request target. The naive
+/// "split on the last colon" approach breaks on IPv6 literals (which are full of colons)
+/// and silently accepts malformed input, so this is a small explicit state machine instead.
+///
+///
+/// authority
+/// │
+/// ├─ starts with '[' ──► IPv6 literal: [addr] or [addr]:port
+/// │ • require a closing ']'
+/// │ • after ']' allow nothing or ":port"
+/// │ • addr must parse as a real IPv6 address
+/// │
+/// └─ otherwise ────────► reg-name / IPv4: host or host:port
+/// • at most ONE colon (a bare IPv6 like ::1 is rejected —
+/// clients must bracket it)
+/// • host must be a valid host name / IPv4 literal
+///
+/// port (when present): integer in 1..65535, else reject (→ 400)
+///
+///
+internal static class ConnectAuthorityParser
+{
+ public static bool TryParse(string authority, int defaultPort, out ConnectAuthority result)
+ {
+ result = default;
+ if (string.IsNullOrWhiteSpace(authority))
+ {
+ return false;
+ }
+
+ string host;
+ string? portText;
+ bool isIPv6;
+
+ if (authority[0] == '[')
+ {
+ var close = authority.IndexOf(']', StringComparison.Ordinal);
+ if (close < 0)
+ {
+ return false; // unterminated '['
+ }
+
+ host = authority[1..close];
+ isIPv6 = true;
+
+ var rest = authority[(close + 1)..];
+ if (rest.Length == 0)
+ {
+ portText = null;
+ }
+ else if (rest[0] == ':')
+ {
+ portText = rest[1..];
+ }
+ else
+ {
+ return false; // junk after ']'
+ }
+
+ if (!IPAddress.TryParse(host, out var ip) || ip.AddressFamily != AddressFamily.InterNetworkV6)
+ {
+ return false; // not a real IPv6 literal
+ }
+ }
+ else
+ {
+ var firstColon = authority.IndexOf(':', StringComparison.Ordinal);
+ var lastColon = authority.LastIndexOf(':');
+ if (firstColon != lastColon)
+ {
+ return false; // multiple colons without brackets (bare IPv6 / garbage)
+ }
+
+ isIPv6 = false;
+ if (firstColon < 0)
+ {
+ host = authority;
+ portText = null;
+ }
+ else
+ {
+ host = authority[..firstColon];
+ portText = authority[(firstColon + 1)..];
+ }
+
+ if (host.Length == 0 || Uri.CheckHostName(host) == UriHostNameType.Unknown)
+ {
+ return false; // empty or invalid host name / IPv4 literal
+ }
+ }
+
+ var port = defaultPort;
+ if (portText is not null)
+ {
+ if (!int.TryParse(portText, NumberStyles.Integer, CultureInfo.InvariantCulture, out port)
+ || port < 1 || port > 65535)
+ {
+ return false; // missing, non-numeric, or out-of-range port
+ }
+ }
+
+ result = new ConnectAuthority(host, port, isIPv6);
+ return true;
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel/Internal/ConnectionTeardown.cs b/DevProxy.Proxy.Kestrel/Internal/ConnectionTeardown.cs
new file mode 100644
index 00000000..66d55345
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Internal/ConnectionTeardown.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Net.Sockets;
+using Microsoft.AspNetCore.Connections;
+
+namespace DevProxy.Proxy.Kestrel.Internal;
+
+///
+/// Classifies exceptions that are the normal consequence of a peer closing or resetting
+/// the connection (or the connection being cancelled) rather than a real fault. Every
+/// read/copy/write boundary against the client or origin socket can surface one of these
+/// when the other end goes away mid-exchange; treating them as EOF/close keeps client
+/// disconnects from showing up as noisy "unhandled exception" errors.
+///
+///
+/// client/origin closes ─┬─ ConnectionResetException (: IOException) ─┐
+/// client/origin aborts ─┼─ ConnectionAbortedException (: Operation…) ─┤
+/// cancellation token ───┼─ OperationCanceledException ├─► IsExpected = true
+/// socket layer error ───┼─ SocketException │
+/// stream EOF/reset ─────┴─ IOException ─┘
+/// anything else ──────────────────────────────────────────────────────► IsExpected = false
+///
+///
+///
+/// ConnectionResetException derives from and
+/// ConnectionAbortedException from , so the
+/// base arms already cover them; they are listed explicitly for readability.
+///
+///
+internal static class ConnectionTeardown
+{
+ public static bool IsExpected(Exception exception) => exception switch
+ {
+ ConnectionResetException => true,
+ ConnectionAbortedException => true,
+ OperationCanceledException => true,
+ IOException => true,
+ SocketException => true,
+ _ => false,
+ };
+}
diff --git a/DevProxy.Proxy.Kestrel/Internal/DuplexPipeStream.cs b/DevProxy.Proxy.Kestrel/Internal/DuplexPipeStream.cs
new file mode 100644
index 00000000..b503098d
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Internal/DuplexPipeStream.cs
@@ -0,0 +1,77 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Buffers;
+using System.IO.Pipelines;
+
+namespace DevProxy.Proxy.Kestrel.Internal;
+
+///
+/// Adapts an (Kestrel's connection transport) to a
+/// so it can be wrapped by SslStream and read/written
+/// with the usual stream helpers.
+///
+internal sealed class DuplexPipeStream(IDuplexPipe pipe) : Stream
+{
+ private readonly PipeReader _input = pipe.Input;
+ private readonly PipeWriter _output = pipe.Output;
+
+ public override bool CanRead => true;
+ public override bool CanWrite => true;
+ public override bool CanSeek => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ while (true)
+ {
+ var result = await _input.ReadAsync(cancellationToken).ConfigureAwait(false);
+ var sequence = result.Buffer;
+
+ if (sequence.Length > 0)
+ {
+ var toCopy = (int)Math.Min(sequence.Length, buffer.Length);
+ sequence.Slice(0, toCopy).CopyTo(buffer.Span);
+ _input.AdvanceTo(sequence.GetPosition(toCopy));
+ return toCopy;
+ }
+
+ if (result.IsCompleted)
+ {
+ _input.AdvanceTo(sequence.End);
+ return 0;
+ }
+
+ _input.AdvanceTo(sequence.Start, sequence.End);
+ }
+ }
+
+ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) =>
+ _ = await _output.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
+
+ public override void Write(byte[] buffer, int offset, int count) =>
+ WriteAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
+ ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+
+ public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
+ WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
+
+ public override void Flush() { }
+
+ public override Task FlushAsync(CancellationToken cancellationToken) =>
+ _output.FlushAsync(cancellationToken).AsTask();
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+}
diff --git a/DevProxy.Proxy.Kestrel/Internal/HostWatchList.cs b/DevProxy.Proxy.Kestrel/Internal/HostWatchList.cs
new file mode 100644
index 00000000..01d1c90c
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Internal/HostWatchList.cs
@@ -0,0 +1,46 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using DevProxy.Abstractions.Proxy;
+
+namespace DevProxy.Proxy.Kestrel.Internal;
+
+///
+/// Host-level view of the watched-URL set, used to decide at CONNECT time whether
+/// to terminate TLS (watched → MITM) or blind-tunnel (non-watched → relay bytes).
+/// Host derivation is shared with the Titanium engine via
+/// so the two engines match identically.
+///
+internal sealed class HostWatchList
+{
+ private readonly List _hosts;
+
+ private HostWatchList(List hosts) => _hosts = hosts;
+
+ public static HostWatchList FromUrls(IEnumerable urlsToWatch)
+ {
+ ArgumentNullException.ThrowIfNull(urlsToWatch);
+
+ var hosts = new List();
+ foreach (var urlToWatch in urlsToWatch)
+ {
+ var regex = WatchedHostExtractor.ToHostRegex(urlToWatch.Url);
+
+ if (!hosts.Exists(h => h.Url.ToString() == regex.ToString()))
+ {
+ hosts.Add(new UrlToWatch(regex, urlToWatch.Exclude));
+ }
+ }
+
+ return new HostWatchList(hosts);
+ }
+
+ /// True when the host matches a non-excluded watch entry.
+ public bool IsWatched(string host)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ var match = _hosts.Find(h => h.Url.IsMatch(host));
+ return match is not null && !match.Exclude;
+ }
+}
diff --git a/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs b/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs
new file mode 100644
index 00000000..bc971ac5
--- /dev/null
+++ b/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs
@@ -0,0 +1,261 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Text;
+
+namespace DevProxy.Proxy.Kestrel.Internal;
+
+///
+/// Reads a sequence of HTTP/1.x request messages off one connection stream, owning
+/// the buffer of bytes that have been read but not yet consumed. This is what makes
+/// keep-alive correct: bytes read past one message's body (a pipelined next request)
+/// are retained and handed to the following instead of
+/// being dropped.
+///
+///
+/// stream ──read 4096──► _pending ──► [find CRLFCRLF] ──► head
+/// │ leftover
+/// ▼
+/// ReadBodyAsync(Content-Length) ─┐ consume leftover first,
+/// ReadChunkedBodyAsync() ─┘ then the stream; any
+/// surplus stays in _pending for the next request (pipelining).
+///
+///
+///
+/// Chunked decoding () buffers the decoded body and
+/// drops trailers — the body is re-framed with Content-Length on forward, so
+/// trailers have no place to go. Reading them off the wire still keeps the connection
+/// correctly framed for the next pipelined request.
+///
+///
+///
+/// One instance per connection (or per decrypted TLS session). Not thread-safe:
+/// the connection handler drives it sequentially (read head → read body → repeat).
+///
+///
+internal sealed class Http1ConnectionReader(Stream stream)
+{
+ private const int ReadChunkBytes = 4096;
+ private const int MaxChunkLineBytes = 64 * 1024;
+ private byte[] _pending = [];
+
+ ///
+ /// Reads the next request head, or null on a clean EOF (the peer closed the
+ /// connection between requests, or mid-headers before a complete block arrived).
+ ///
+ public async Task ReadHeadAsync(CancellationToken ct)
+ {
+ var accumulator = new List(_pending);
+ _pending = [];
+ var buffer = new byte[ReadChunkBytes];
+
+ while (true)
+ {
+ var terminator = Http1RequestReader.IndexOfDoubleCrlf(accumulator);
+ if (terminator >= 0)
+ {
+ var headerText = Encoding.ASCII.GetString(accumulator.ToArray(), 0, terminator);
+ _pending = Slice(accumulator, terminator + 4);
+ return Http1RequestReader.ParseHead(headerText);
+ }
+
+ if (accumulator.Count > Http1RequestReader.MaxHeaderBlockBytes)
+ {
+ throw new InvalidOperationException("Request header block too large.");
+ }
+
+ var read = await stream.ReadAsync(buffer, ct).ConfigureAwait(false);
+ if (read == 0)
+ {
+ // EOF: a clean close between requests, or a truncated header block.
+ return null;
+ }
+ accumulator.AddRange(buffer.AsSpan(0, read));
+ }
+ }
+
+ ///
+ /// Reads exactly body bytes, consuming buffered
+ /// bytes first and then the stream. Any bytes buffered beyond the body (a pipelined
+ /// next request) are retained for the following . On a
+ /// premature EOF the partial body read so far is returned.
+ ///
+ public async Task ReadBodyAsync(int contentLength, CancellationToken ct)
+ {
+ if (contentLength <= 0)
+ {
+ return [];
+ }
+
+ var body = new byte[contentLength];
+ var fromPending = TakeFromPending(Math.Min(_pending.Length, contentLength));
+ fromPending.CopyTo(body.AsSpan());
+
+ var offset = fromPending.Length;
+ while (offset < contentLength)
+ {
+ var read = await stream.ReadAsync(body.AsMemory(offset, contentLength - offset), ct).ConfigureAwait(false);
+ if (read == 0)
+ {
+ break;
+ }
+ offset += read;
+ }
+
+ return body;
+ }
+
+ ///
+ /// Decodes a Transfer-Encoding: chunked request body into a single buffer.
+ /// Consumes the terminating zero-length chunk and any trailer section (dropping the
+ /// trailers), leaving the reader positioned at the next pipelined request.
+ ///
+ ///
+ /// A chunk size line is malformed, a chunk is not followed by CRLF, or the stream
+ /// ends before the body is complete.
+ ///
+ ///
+ ///
+ /// ┌─ "1a;ext=v\r\n" chunk-size [;extensions]
+ /// ├─ <1a bytes>"\r\n" chunk data + CRLF
+ /// ├─ ... (repeat)
+ /// ├─ "0\r\n" last chunk (size 0)
+ /// ├─ "X: y\r\n" optional trailer headers (consumed, dropped)
+ /// └─ "\r\n" terminating blank line
+ ///
+ ///
+ public async Task ReadChunkedBodyAsync(CancellationToken ct)
+ {
+ var body = new List();
+
+ while (true)
+ {
+ var sizeLine = await ReadLineAsync(ct).ConfigureAwait(false);
+ var semicolon = sizeLine.IndexOf(';', StringComparison.Ordinal);
+ var hex = (semicolon >= 0 ? sizeLine[..semicolon] : sizeLine).Trim();
+ if (!int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var size) || size < 0)
+ {
+ throw new InvalidOperationException($"Malformed chunk size: '{sizeLine}'.");
+ }
+
+ if (size == 0)
+ {
+ // Last chunk: consume the (possibly empty) trailer section up to the
+ // terminating blank line. Trailers are dropped — the body is re-framed
+ // with Content-Length on forward, so they have nowhere to go.
+ while ((await ReadLineAsync(ct).ConfigureAwait(false)).Length > 0)
+ {
+ }
+ break;
+ }
+
+ var chunk = await ReadExactlyAsync(size, ct).ConfigureAwait(false);
+ body.AddRange(chunk);
+
+ var crlf = await ReadExactlyAsync(2, ct).ConfigureAwait(false);
+ if (crlf[0] != (byte)'\r' || crlf[1] != (byte)'\n')
+ {
+ throw new InvalidOperationException("Missing CRLF after chunk data.");
+ }
+ }
+
+ return [.. body];
+ }
+
+ /// Reads exactly bytes, throwing on premature EOF.
+ private async Task ReadExactlyAsync(int count, CancellationToken ct)
+ {
+ if (count == 0)
+ {
+ return [];
+ }
+
+ while (_pending.Length < count)
+ {
+ if (await PullAsync(ct).ConfigureAwait(false) == 0)
+ {
+ throw new InvalidOperationException("Unexpected end of stream while reading chunked body.");
+ }
+ }
+
+ return TakeFromPending(count);
+ }
+
+ ///
+ /// Reads a single CRLF-terminated line (without the CRLF). Used for chunk-size and
+ /// trailer lines, which are small — capped at