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 . + /// + private async Task ReadLineAsync(CancellationToken ct) + { + var scanFrom = 0; + while (true) + { + var crlf = IndexOfCrlf(_pending, scanFrom); + if (crlf >= 0) + { + var line = Encoding.ASCII.GetString(_pending, 0, crlf); + _pending = _pending.Length > crlf + 2 ? _pending[(crlf + 2)..] : []; + return line; + } + + if (_pending.Length > MaxChunkLineBytes) + { + throw new InvalidOperationException("Chunk header line too large."); + } + + // A CRLF can straddle two reads, so resume the scan one byte back. + scanFrom = Math.Max(0, _pending.Length - 1); + if (await PullAsync(ct).ConfigureAwait(false) == 0) + { + throw new InvalidOperationException("Unexpected end of stream while reading chunk header."); + } + } + } + + /// Reads one block from the stream and appends it to _pending; returns bytes read. + private async Task PullAsync(CancellationToken ct) + { + var buffer = new byte[ReadChunkBytes]; + var read = await stream.ReadAsync(buffer, ct).ConfigureAwait(false); + if (read == 0) + { + return 0; + } + + var combined = new byte[_pending.Length + read]; + Array.Copy(_pending, combined, _pending.Length); + Array.Copy(buffer, 0, combined, _pending.Length, read); + _pending = combined; + return read; + } + + /// Removes and returns the first bytes of _pending. + private byte[] TakeFromPending(int count) + { + if (count == 0) + { + return []; + } + + var taken = _pending[..count]; + _pending = _pending.Length > count ? _pending[count..] : []; + return taken; + } + + private static int IndexOfCrlf(byte[] data, int start) + { + for (var i = Math.Max(0, start); i + 1 < data.Length; i++) + { + if (data[i] == (byte)'\r' && data[i + 1] == (byte)'\n') + { + return i; + } + } + return -1; + } + + private static byte[] Slice(List accumulator, int start) => + start >= accumulator.Count ? [] : accumulator.GetRange(start, accumulator.Count - start).ToArray(); +} diff --git a/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs b/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs new file mode 100644 index 00000000..cb41bde5 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs @@ -0,0 +1,171 @@ +// 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.Proxy.Kestrel.Internal; + +/// +/// A single HTTP/1.x message head parsed off the wire: request line plus headers. +/// +/// Request method token (e.g. GET, CONNECT). +/// Request target (origin-form path, absolute-form URL, or CONNECT authority). +/// HTTP version token (e.g. HTTP/1.1). +/// Headers in wire order (names/values trimmed). +internal sealed record ParsedRequestHead( + string Method, + string Target, + string Version, + IReadOnlyList<(string Name, string Value)> Headers); + +/// +/// How a request body is framed on the wire, resolved from the request headers. +/// +/// +/// Transfer-Encoding: chunked AND Content-Length ──► Conflicting (smuggling risk) +/// Transfer-Encoding: chunked (only) ──► Chunked +/// Content-Length (only) ──► ContentLength +/// neither ──► None +/// +/// +internal enum RequestBodyFraming +{ + /// No body framing headers — no request body to read. + None, + + /// A Content-Length-delimited body. + ContentLength, + + /// A Transfer-Encoding: chunked body. + Chunked, + + /// + /// Both Content-Length and Transfer-Encoding: chunked are present. + /// The two disagree on where the body ends — a request-smuggling vector that a + /// proxy must refuse (RFC 9112 §6.3.3). + /// + Conflicting, +} + +/// +/// Stateless HTTP/1.x parsing helpers shared by . +/// Kept separate so the byte-level framing rules have one implementation and one +/// test surface, independent of any particular stream/connection. +/// +internal static class Http1RequestReader +{ + public const int MaxHeaderBlockBytes = 1024 * 1024; + + /// Parses a CRLF-delimited header block (request line + header lines). + /// The request line is malformed. + public static ParsedRequestHead ParseHead(string headerText) + { + ArgumentNullException.ThrowIfNull(headerText); + + var lines = headerText.Split("\r\n"); + var startLine = lines[0].Split(' ', 3); + if (startLine.Length < 3) + { + throw new InvalidOperationException($"Malformed HTTP request line: '{lines[0]}'."); + } + + var headers = new List<(string Name, string Value)>(lines.Length - 1); + for (var i = 1; i < lines.Length; i++) + { + var separator = lines[i].IndexOf(':', StringComparison.Ordinal); + if (separator <= 0) + { + continue; + } + headers.Add((lines[i][..separator].Trim(), lines[i][(separator + 1)..].Trim())); + } + + return new ParsedRequestHead(startLine[0], startLine[1], startLine[2], headers); + } + + /// Reads the Content-Length header value, defaulting to 0. + public static int GetContentLength(IReadOnlyList<(string Name, string Value)> headers) + { + ArgumentNullException.ThrowIfNull(headers); + + foreach (var (name, value) in headers) + { + if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) + && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + } + return 0; + } + + /// + /// Classifies how the request body is framed (see ). + /// A message that declares both Content-Length and chunked + /// Transfer-Encoding is — the + /// caller must refuse it rather than guess a body boundary. + /// + public static RequestBodyFraming DetectBodyFraming(IReadOnlyList<(string Name, string Value)> headers) + { + ArgumentNullException.ThrowIfNull(headers); + + var hasContentLength = false; + var hasChunked = false; + foreach (var (name, value) in headers) + { + if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase)) + { + hasContentLength = true; + } + else if (string.Equals(name, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase) + && value.Contains("chunked", StringComparison.OrdinalIgnoreCase)) + { + hasChunked = true; + } + } + + return (hasChunked, hasContentLength) switch + { + (true, true) => RequestBodyFraming.Conflicting, + (true, false) => RequestBodyFraming.Chunked, + (false, true) => RequestBodyFraming.ContentLength, + _ => RequestBodyFraming.None, + }; + } + + /// + /// Whether the request asks the proxy to acknowledge with 100 Continue + /// before sending its body (Expect: 100-continue, RFC 9110 §10.1.1). + /// + public static bool HasExpectContinue(IReadOnlyList<(string Name, string Value)> headers) + { + ArgumentNullException.ThrowIfNull(headers); + + foreach (var (name, value) in headers) + { + if (string.Equals(name, "Expect", StringComparison.OrdinalIgnoreCase) + && value.Contains("100-continue", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + /// Index of the first CRLFCRLF in , or -1. + public static int IndexOfDoubleCrlf(IReadOnlyList data) + { + ArgumentNullException.ThrowIfNull(data); + + for (var i = 0; i + 3 < data.Count; i++) + { + if (data[i] == (byte)'\r' && data[i + 1] == (byte)'\n' + && data[i + 2] == (byte)'\r' && data[i + 3] == (byte)'\n') + { + return i; + } + } + return -1; + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs b/DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs new file mode 100644 index 00000000..0e610179 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs @@ -0,0 +1,258 @@ +// 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.Globalization; +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Proxy.Kestrel.Http; +using Microsoft.Extensions.Logging; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// Outcome of the request phase, telling the engine how to proceed. +internal enum RequestPhase +{ + /// URL/headers not watched: forward upstream untouched, no response-phase plugins. + NotWatched, + + /// Watched and no plugin produced a response: forward upstream, then run response plugins. + Watched, + + /// A plugin produced a mock response: skip upstream, write the response directly. + Mocked, +} + +/// +/// Runs the Dev Proxy plugin lifecycle (BeforeRequestBeforeResponse +/// → AfterResponse) against the canonical model for the Kestrel engine. +/// This mirrors the request/response handling that the Titanium-bound +/// ProxyEngine performs today; at cut-over it becomes the single pipeline. +/// +/// +/// Per-exchange plugin state is keyed on +/// (stable across request and response phases of the same exchange) — never on +/// object identity — so reusing a connection cannot leak state between exchanges. +/// +/// +internal sealed class PluginPipeline +{ + private readonly IEnumerable _plugins; + private readonly ISet _urlsToWatch; + private readonly IProxyConfiguration _config; + private readonly ILogger _logger; + private readonly Dictionary _globalData; + private readonly HostWatchList _hosts; + private readonly ConcurrentDictionary> _sessionData = new(StringComparer.Ordinal); + + public PluginPipeline( + IEnumerable plugins, + ISet urlsToWatch, + IProxyConfiguration config, + Dictionary globalData, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(plugins); + ArgumentNullException.ThrowIfNull(urlsToWatch); + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(globalData); + ArgumentNullException.ThrowIfNull(logger); + + _plugins = plugins; + _urlsToWatch = urlsToWatch; + _config = config; + _globalData = globalData; + _logger = logger; + _hosts = HostWatchList.FromUrls(urlsToWatch); + } + + /// + /// True when a CONNECT to this host should be intercepted (TLS terminated). A + /// non-watched host is blind-tunnelled byte-for-byte. + /// + public bool IsProxiedHost(string host) => _hosts.IsWatched(host); + + public async Task RunRequestAsync(CanonicalProxySession session, CancellationToken ct) + { + var request = session.Request; + if (!IsProxiedHost(request.RequestUri.Host) || !IsIncludedByHeaders(request)) + { + return RequestPhase.NotWatched; + } + + var responseState = new ResponseState(); + var sessionData = _sessionData.GetOrAdd(session.SessionId, static _ => []); + var args = new ProxyRequestArgs(session, responseState) + { + SessionData = sessionData, + GlobalData = _globalData, + }; + + if (!args.HasRequestUrlMatch(_urlsToWatch)) + { + _ = _sessionData.TryRemove(session.SessionId, out _); + return RequestPhase.NotWatched; + } + + var loggingContext = new LoggingContext(session); + + // Open the requestId scope for the whole request phase so that plugin logs + // (emitted under the plugin's own logger category) are grouped/flushed with + // the engine's request-lifecycle lines by the console formatter — mirroring + // the Titanium engine's BeforeRequest scope. + using (BeginRequestScope(session)) + { + _logger.LogRequest($"{request.Method} {request.Url}", MessageType.InterceptedRequest, loggingContext); + _logger.LogRequest(DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture), MessageType.Timestamp, loggingContext); + + foreach (var plugin in _plugins.Where(p => p.Enabled)) + { + ct.ThrowIfCancellationRequested(); + try + { + await plugin.BeforeRequestAsync(args, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred in a plugin"); + } + } + + if (responseState.HasBeenSet || session.HasResponse) + { + return RequestPhase.Mocked; + } + + _logger.LogRequest("Passed through", MessageType.PassedThrough, loggingContext); + } + return RequestPhase.Watched; + } + + public Task RunResponseAsync(CanonicalProxySession session, CancellationToken ct) + => RunResponseCoreAsync(session, betweenPhases: null, ct); + + /// + /// Runs the response lifecycle for a streamed (chunked) response. Identical to + /// except — which + /// writes the response head and pumps the body to the client — runs AFTER + /// BeforeResponse (so plugins can mutate status/headers before they go on the + /// wire) and BEFORE AfterResponse (so read-only inspectors see the accumulated + /// body, and observe the response only after it has been delivered — matching the + /// Titanium engine's "after the response is sent" semantics). + /// + public Task RunStreamingResponseAsync( + CanonicalProxySession session, Func betweenPhases, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(betweenPhases); + return RunResponseCoreAsync(session, betweenPhases, ct); + } + + private async Task RunResponseCoreAsync( + CanonicalProxySession session, Func? betweenPhases, CancellationToken ct) + { + if (!_sessionData.TryGetValue(session.SessionId, out var sessionData)) + { + sessionData = []; + } + + var beforeArgs = new ProxyResponseArgs(session, new ResponseState()) + { + SessionData = sessionData, + GlobalData = _globalData, + }; + + var loggingContext = new LoggingContext(session); + var message = $"{session.Request.Method} {session.Request.Url}"; + + try + { + // Single requestId scope across the whole response phase so plugin logs + // (BeforeResponse/AfterResponse) are grouped with the engine's lines and + // flushed together on FinishedProcessingRequest. + using (BeginRequestScope(session)) + { + foreach (var plugin in _plugins.Where(p => p.Enabled)) + { + ct.ThrowIfCancellationRequested(); + try + { + await plugin.BeforeResponseAsync(beforeArgs, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred in a plugin"); + } + } + + _logger.LogRequest(message, MessageType.InterceptedResponse, loggingContext); + + if (betweenPhases is not null) + { + // Streamed response: write the head + pump the body to the client now. + await betweenPhases(ct).ConfigureAwait(false); + } + + foreach (var plugin in _plugins.Where(p => p.Enabled)) + { + ct.ThrowIfCancellationRequested(); + try + { + await plugin.AfterResponseAsync(beforeArgs, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred in a plugin"); + } + } + + _logger.LogRequest(message, MessageType.FinishedProcessingRequest, loggingContext); + } + } + finally + { + _ = _sessionData.TryRemove(session.SessionId, out _); + } + } + + // The console formatter buffers request-log lines by an integer "requestId" scope + // and flushes the group on FinishedProcessingRequest. Mirror the Titanium engine by + // opening that scope (method + url + stable RequestId) around every request-log emit. + private IDisposable? BeginRequestScope(CanonicalProxySession session) => + _logger.BeginScope(new Dictionary + { + ["method"] = session.Request.Method, + ["url"] = session.Request.Url, + ["requestId"] = session.RequestId, + }); + + /// Drops any per-exchange state for a session that never reached the response phase. + public void Forget(string sessionId) => _sessionData.TryRemove(sessionId, out _); + + private bool IsIncludedByHeaders(Abstractions.Proxy.Http.IHttpRequest request) + { + if (_config.FilterByHeaders is null) + { + return true; + } + + foreach (var header in _config.FilterByHeaders) + { + if (request.Headers.Contains(header.Name)) + { + if (string.IsNullOrEmpty(header.Value)) + { + return true; + } + + if (request.Headers.GetAll(header.Name) + .Any(h => h.Value.Contains(header.Value, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + } + + return false; + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs b/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs new file mode 100644 index 00000000..3066c557 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs @@ -0,0 +1,215 @@ +// 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.Text.RegularExpressions; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// Decides whether a connection's owning process is one the user asked to watch +/// (--watch-pids / --watch-process-names). Mirrors the Titanium engine's +/// IsProxiedProcess: when no process filter is configured every process is +/// watched; otherwise the client connection's source port is resolved to a PID and +/// matched against the configured pids/names. A process that cannot be resolved is NOT +/// watched (the connection is blind-tunnelled rather than decrypted). +/// +/// +/// Like the Titanium engine, this is applied only at the CONNECT (HTTPS) decision +/// point — plain-HTTP requests are never process-filtered. +/// +/// +/// +/// The PID resolver and name resolver are injectable so the decision logic can be +/// unit-tested without spawning real processes; the defaults shell out to +/// and . +/// +/// +internal sealed class ProcessFilter +{ + private readonly HashSet _pids; + // Ordinal (case-sensitive) to match the Titanium engine's IEnumerable.Contains. + private readonly HashSet _names; + private readonly Func _resolvePid; + private readonly Func _resolveName; + + public ProcessFilter( + IEnumerable watchPids, + IEnumerable watchProcessNames, + Func? resolvePid = null, + Func? resolveName = null) + { + ArgumentNullException.ThrowIfNull(watchPids); + ArgumentNullException.ThrowIfNull(watchProcessNames); + _pids = [.. watchPids]; + _names = new HashSet(watchProcessNames, StringComparer.Ordinal); + _resolvePid = resolvePid ?? ConnectionProcessResolver.ResolveProcessId; + _resolveName = resolveName ?? DefaultResolveName; + } + + /// True when no pid/name filter is configured — every process is watched. + public bool IsEmpty => _pids.Count == 0 && _names.Count == 0; + + /// + /// Whether the process owning the connection with the given client source port is + /// watched. Returns true immediately when no filter is configured. + /// + public bool IsWatchedProcess(int clientPort) + { + if (IsEmpty) + { + return true; + } + + var pid = _resolvePid(clientPort); + if (pid is null or -1) + { + // Couldn't identify the owning process — don't decrypt it. + return false; + } + + if (_pids.Contains(pid.Value)) + { + return true; + } + + if (_names.Count > 0) + { + var name = _resolveName(pid.Value); + if (name is not null && _names.Contains(name)) + { + return true; + } + } + + return false; + } + + private static string? DefaultResolveName(int pid) + { + try + { + return Process.GetProcessById(pid).ProcessName; + } + catch (ArgumentException) + { + // Process has already exited. + return null; + } + } +} + +/// +/// Resolves the PID owning a TCP connection by its client (source) port, by shelling out +/// to the platform's connection-listing tool and parsing the output: +/// lsof -i :PORT on Unix, netstat -ano -p tcp on Windows. Returns +/// when the tool fails or no matching connection is found. +/// +internal static class ConnectionProcessResolver +{ + public static int? ResolveProcessId(int clientPort) + { + try + { + return OperatingSystem.IsWindows() + ? RunAndParse("netstat", "-ano -p tcp", o => NetstatParser.ParsePid(o, clientPort)) + : RunAndParse("lsof", $"-i :{clientPort.ToString(CultureInfo.InvariantCulture)}", + o => LsofParser.ParsePid(o, clientPort)); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // The listing tool is missing or could not be launched. + return null; + } + } + + private static int? RunAndParse(string fileName, string arguments, Func parse) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + + using var proc = Process.Start(psi); + if (proc is null) + { + return null; + } + + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + return parse(output); + } +} + +/// +/// Pure parser for lsof -i :PORT output. The client's connection appears as a +/// …:CLIENTPORT->… entry (the proxy's own socket is the reverse, +/// …->…:CLIENTPORT, so anchoring on CLIENTPORT-> selects the client's +/// process). The PID is the second whitespace-delimited column (COMMAND PID …). +/// +internal static partial class LsofParser +{ + public static int? ParsePid(string lsofOutput, int clientPort) + { + ArgumentNullException.ThrowIfNull(lsofOutput); + + var marker = $"{clientPort.ToString(CultureInfo.InvariantCulture)}->"; + foreach (var line in lsofOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!line.Contains(marker, StringComparison.Ordinal)) + { + continue; + } + + var match = PidColumn().Match(line); + if (match.Success && int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid)) + { + return pid; + } + } + + return null; + } + + // COMMAND token, then whitespace, then the PID digits. + [GeneratedRegex(@"^\S+\s+(\d+)")] + private static partial Regex PidColumn(); +} + +/// +/// Pure parser for Windows netstat -ano -p tcp output. Each connection row is +/// Proto LocalAddress ForeignAddress State PID; the client's socket is the row +/// whose LOCAL address ends with the client source port, and its PID is the last column. +/// +internal static class NetstatParser +{ + public static int? ParsePid(string netstatOutput, int clientPort) + { + ArgumentNullException.ThrowIfNull(netstatOutput); + + var suffix = $":{clientPort.ToString(CultureInfo.InvariantCulture)}"; + foreach (var line in netstatOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 5 || !parts[0].StartsWith("TCP", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (parts[1].EndsWith(suffix, StringComparison.Ordinal) + && int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid)) + { + return pid; + } + } + + return null; + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs new file mode 100644 index 00000000..c4a99f88 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -0,0 +1,600 @@ +// 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.Globalization; +using System.IO.Pipelines; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Text; +using DevProxy.Abstractions.Proxy.Http; +using DevProxy.Proxy.Kestrel.Http; +using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Logging; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// Handles one raw TCP connection: parses the proxy request, decides at CONNECT +/// time whether to terminate TLS (watched + downgradable client → MITM) or relay the +/// encrypted bytes untouched (non-watched host, or h2-only/gRPC client → blind-tunnel), +/// runs the plugin pipeline against the canonical model for intercepted traffic, forwards +/// to the origin, and writes the response back. +/// +/// +/// CONNECT decision flow: +/// +/// CONNECT host:port +/// │ write 200 Connection Established +/// ▼ +/// peek ClientHello (non-destructive: SNI + ALPN) +/// │ +/// ├─ host not watched ............→ blind-tunnel (never decrypt) +/// ├─ ALPN is h2-only (gRPC) ......→ blind-tunnel (can't downgrade) +/// ├─ process not watched ........→ blind-tunnel (--watch-pids/-process-names) +/// └─ otherwise ..................→ MITM, advertise http/1.1 so h2 clients downgrade +/// +/// +/// +/// +/// Scope: keep-alive HTTP/1.1 (multiple requests per intercepted connection) for +/// plain HTTP + HTTPS-via-CONNECT, mocking short-circuit, selective decrypt + ALPN +/// blind-tunnel, and transparent WebSocket relay (handshake replayed, frames spliced +/// opaque — see ). Streamed (text/event-stream) +/// responses are forwarded incrementally (chunked) with a capped tee to inspectors — +/// see . Deferred hardening (tracked): +/// WebSocket frame inspection/mocking (plan §7). +/// +/// +internal sealed class ProxyConnectionHandler( + CertificateAuthority ca, + UpstreamForwarder forwarder, + PluginPipeline pipeline, + HostWatchList watchList, + ProcessFilter processFilter, + ILogger logger) : ConnectionHandler +{ + // Largest streamed-response body retained in memory for read-only AfterResponse + // inspectors (e.g. OpenAI telemetry). Beyond this, inspectors simply see no body; + // the full body is still forwarded to the client. 4 MiB. + // + // NOTE (memory): non-streaming watched responses are currently buffered in full by + // UpstreamForwarder before plugins run, with no upper bound — a large watched download + // can spike RAM. A capability-driven body-handling design (stream/spool large bodies, + // per-plugin BodyCapabilities) was prototyped but never wired; it lives at git + // a2afac1 (DevProxy.Abstractions/Proxy/Http/BodyModeResolver.cs + BodyHandling.cs) and + // can be restored with `git show a2afac1:`. See plan.md follow-ups. + private const int InMemoryInspectionCapBytes = 4 * 1024 * 1024; + + private static int _requestCounter; + private readonly WebSocketRelay _webSocketRelay = new(logger); + private readonly WebSocketMockResponder _webSocketMockResponder = new(logger); + + public override async Task OnConnectedAsync(ConnectionContext connection) + { + var ct = connection.ConnectionClosed; + await using var clientStream = new DuplexPipeStream(connection.Transport); + var reader = new Http1ConnectionReader(clientStream); + + try + { + var head = await reader.ReadHeadAsync(ct).ConfigureAwait(false); + if (head is null) + { + return; + } + + if (string.Equals(head.Method, "CONNECT", StringComparison.OrdinalIgnoreCase)) + { + await HandleConnectAsync(connection, clientStream, head, ct).ConfigureAwait(false); + } + else + { + // Plain HTTP proxy request: the target is an absolute URL. Serve this + // and any subsequent keep-alive requests on the same connection. + await ServeConnectionAsync(reader, clientStream, head, httpsHost: null, httpsPort: 0, ct) + .ConfigureAwait(false); + } + } + catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) + { + // Client disconnect / cancellation — normal teardown, not an error. + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling proxy connection"); + } + } + + private async Task HandleConnectAsync( + ConnectionContext connection, Stream clientStream, ParsedRequestHead connect, CancellationToken ct) + { + // Parse + validate the authority BEFORE acknowledging the tunnel, so a malformed + // target (bad port, unbracketed IPv6, junk) is refused with a 400 rather than + // establishing a tunnel we can't actually use. + if (!ConnectAuthorityParser.TryParse(connect.Target, defaultPort: 443, out var authority)) + { + await WriteErrorAsync(clientStream, HttpStatusCode.BadRequest, "Malformed CONNECT target", ct).ConfigureAwait(false); + return; + } + + var host = authority.Host; + var port = authority.Port; + + // Acknowledge the tunnel so the client begins its TLS handshake. + await WriteAsciiAsync(clientStream, "HTTP/1.1 200 Connection Established\r\n\r\n", ct).ConfigureAwait(false); + + // Non-destructively peek the ClientHello (SNI + ALPN) before deciding whether + // to terminate TLS. The bytes stay buffered for SslStream / the blind tunnel. + var hello = await PeekClientHelloAsync(connection.Transport.Input, ct).ConfigureAwait(false); + var isWatched = watchList.IsWatched(host); + var isH2Only = hello.Status == TlsClientHello.ParseStatus.Ok && hello.IsH2Only; + + if (!isWatched) + { + logger.LogDebug("CONNECT {Host}:{Port} → blind-tunnel (host not watched)", host, port); + await BlindTunnelAsync(clientStream, host, port, ct).ConfigureAwait(false); + return; + } + + if (isH2Only) + { + logger.LogDebug("CONNECT {Host}:{Port} → blind-tunnel (h2-only/gRPC, never MITM)", host, port); + await BlindTunnelAsync(clientStream, host, port, ct).ConfigureAwait(false); + return; + } + + // Process filter (--watch-pids / --watch-process-names): like the Titanium engine, + // a watched host whose owning process isn't watched is blind-tunnelled, never + // decrypted. Resolving the PID shells out, so only do it when a filter is set. + if (!processFilter.IsEmpty && !processFilter.IsWatchedProcess(GetClientPort(connection))) + { + logger.LogDebug("CONNECT {Host}:{Port} → blind-tunnel (process not watched)", host, port); + await BlindTunnelAsync(clientStream, host, port, ct).ConfigureAwait(false); + return; + } + + logger.LogDebug("CONNECT {Host}:{Port} → MITM (decrypt as http/1.1)", host, port); + var certificate = ca.GetCertificateForHost(host); + await using var tls = new SslStream(clientStream, leaveInnerStreamOpen: false); + await tls.AuthenticateAsServerAsync( + new SslServerAuthenticationOptions + { + ServerCertificate = certificate, + // Advertise http/1.1 only so any h2-capable client that also offers + // http/1.1 downgrades and we intercept it as HTTP/1.1. + ApplicationProtocols = [SslApplicationProtocol.Http11], + }, ct).ConfigureAwait(false); + + var tlsReader = new Http1ConnectionReader(tls); + var head = await tlsReader.ReadHeadAsync(ct).ConfigureAwait(false); + if (head is null) + { + return; + } + + await ServeConnectionAsync(tlsReader, tls, head, authority.UrlHost, port, ct).ConfigureAwait(false); + } + + /// + /// Serves the first request and then every subsequent keep-alive request on the + /// same (plain or decrypted) connection. Each iteration gets a fresh request id + /// and a fresh , so no per-request plugin state + /// leaks between pipelined/keep-alive requests on the connection. + /// + /// Non-null for a decrypted CONNECT tunnel; null for plain HTTP. + private async Task ServeConnectionAsync( + Http1ConnectionReader reader, + Stream clientStream, + ParsedRequestHead firstHead, + string? httpsHost, + int httpsPort, + CancellationToken ct) + { + var head = firstHead; + while (head is not null) + { + var url = httpsHost is null + ? head.Target // plain HTTP proxy request: absolute-form target + : httpsPort == 443 + ? $"https://{httpsHost}{head.Target}" + : $"https://{httpsHost}:{httpsPort.ToString(CultureInfo.InvariantCulture)}{head.Target}"; + + var keepAlive = await ExchangeAsync(reader, clientStream, head, url, ct).ConfigureAwait(false); + if (!keepAlive) + { + break; + } + + head = await reader.ReadHeadAsync(ct).ConfigureAwait(false); + } + } + + /// + /// Relays the raw (still-encrypted) byte stream between the client and the origin + /// without decrypting, for hosts the proxy must not intercept. The peeked + /// ClientHello bytes remain buffered on and are + /// forwarded as the first bytes of the tunnel. + /// + private async Task BlindTunnelAsync(Stream clientStream, string host, int port, CancellationToken ct) + { + using var tcp = new TcpClient(); + try + { + await tcp.ConnectAsync(host, port, ct).ConfigureAwait(false); + } + catch (SocketException ex) + { + logger.LogDebug(ex, "Blind-tunnel connect to {Host}:{Port} failed", host, port); + return; + } + + await using var origin = tcp.GetStream(); + + // Relay both directions (still-encrypted bytes) until either side closes. + await StreamRelay.RelayBidirectionalAsync(clientStream, origin, ct).ConfigureAwait(false); + } + + /// + /// Reads, without consuming, just enough of the buffered TLS ClientHello to extract + /// SNI + ALPN. AdvanceTo(buffer.Start) marks nothing consumed and nothing + /// examined, so the very same bytes are returned to the next reader (SslStream or + /// the blind tunnel). Using examined = End on the terminal branch would + /// deadlock — the pipe would wait for bytes the client won't send until it sees a + /// ServerHello that never comes. + /// + private static async Task PeekClientHelloAsync(PipeReader reader, CancellationToken ct) + { + while (true) + { + var result = await reader.ReadAsync(ct).ConfigureAwait(false); + var buffer = result.Buffer; + var parsed = TlsClientHello.Parse(buffer); + + if (parsed.Status != TlsClientHello.ParseStatus.NeedMore) + { + reader.AdvanceTo(buffer.Start); + return parsed; + } + + // Need more bytes: examine everything so the next read waits for additions. + reader.AdvanceTo(buffer.Start, buffer.End); + if (result.IsCompleted) + { + return new(TlsClientHello.ParseStatus.NeedMore, null, []); + } + } + } + + /// + /// Runs one request/response exchange and returns whether the connection may be + /// kept alive for a following request. Always consumes the request body (even on + /// mock/error) so the reader is positioned at the next request when keep-alive + /// continues. + /// + private async Task ExchangeAsync( + Http1ConnectionReader reader, Stream clientStream, ParsedRequestHead head, string absoluteUrl, CancellationToken ct) + { + if (!Uri.TryCreate(absoluteUrl, UriKind.Absolute, out var requestUri)) + { + await WriteErrorAsync(clientStream, HttpStatusCode.BadRequest, "Malformed request target", ct).ConfigureAwait(false); + return false; + } + + var framing = Http1RequestReader.DetectBodyFraming(head.Headers); + if (framing == RequestBodyFraming.Conflicting) + { + // Content-Length and chunked Transfer-Encoding disagree on where the body + // ends — a request-smuggling vector (RFC 9112 §6.3.3). Refuse it. + await WriteErrorAsync(clientStream, HttpStatusCode.BadRequest, + "Conflicting Content-Length and Transfer-Encoding", ct).ConfigureAwait(false); + return false; + } + + if (Http1RequestReader.HasExpectContinue(head.Headers)) + { + // The client is waiting for a go-ahead before sending its body. We always + // buffer and forward the body, so answer the expectation ourselves. + await ResponseWriter.WriteContinueAsync(clientStream, ct).ConfigureAwait(false); + } + + byte[] body; + try + { + body = framing == RequestBodyFraming.Chunked + ? await reader.ReadChunkedBodyAsync(ct).ConfigureAwait(false) + : await reader.ReadBodyAsync(Http1RequestReader.GetContentLength(head.Headers), ct).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + // Malformed chunked framing (bad chunk size, missing CRLF, truncated). The + // connection's byte stream is no longer framable, so close after replying. + logger.LogWarning(ex, "Malformed request body framing"); + await WriteErrorAsync(clientStream, HttpStatusCode.BadRequest, "Malformed request body", ct).ConfigureAwait(false); + return false; + } + + var keepAlive = ShouldKeepAlive(head); + + var headers = new HeaderCollection(); + foreach (var (name, value) in head.Headers) + { + headers.Add(name, value); + } + + var version = ParseHttpVersion(head.Version); + var request = new MutableHttpRequest(head.Method, requestUri, version, headers, body); + var session = new CanonicalProxySession( + Guid.NewGuid().ToString("n"), + request, + processId: null, + requestId: Interlocked.Increment(ref _requestCounter)); + + RequestPhase phase; + try + { + phase = await pipeline.RunRequestAsync(session, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + pipeline.Forget(session.SessionId); + logger.LogError(ex, "Error running request pipeline"); + await WriteErrorAsync(clientStream, HttpStatusCode.BadGateway, "Plugin pipeline error", ct).ConfigureAwait(false); + return false; + } + + // Mocked: a plugin produced the response during the request phase. Skip the + // upstream forward, but still run the response pipeline so reporters/loggers + // observe the mock and the console formatter flushes its buffered request log. + if (phase == RequestPhase.Mocked) + { + await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); + await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, keepAlive, head.Method, ct).ConfigureAwait(false); + return keepAlive; + } + + // WebSocket upgrade: HttpClient can't carry a 101, so replay the handshake on a + // raw socket and splice frames. The relay BLOCKS in the splice until the socket + // closes, so the response pipeline runs inside the handshake callback (before the + // splice) — that way a watched request is logged and reporters observe it + // immediately, not when the WebSocket eventually closes. Either way the + // connection is consumed (no keep-alive after an upgrade). + // + // If a plugin attached a WebSocket mock handler during BeforeRequest, the proxy + // becomes the WebSocket server (no origin is dialed) — see WebSocketMockResponder. + if (request.IsWebSocketRequest) + { + var handshakeObserved = false; + async Task OnHandshakeAsync(MutableHttpResponse handshakeResponse) + { + handshakeObserved = true; + if (phase == RequestPhase.Watched) + { + session.SetOriginResponse(handshakeResponse); + await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); + } + } + + try + { + if (session.WebSocketHandledByPlugin) + { + await _webSocketMockResponder.RespondAsync( + clientStream, request, session.WebSocketHandler!, OnHandshakeAsync, ct).ConfigureAwait(false); + } + else + { + await _webSocketRelay.RelayAsync(clientStream, request, requestUri, OnHandshakeAsync, ct).ConfigureAwait(false); + } + } + catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) + { + // Client or origin closed the WebSocket mid-handshake/relay — normal teardown. + logger.LogDebug(ex, "WebSocket relay to {Url} ended on connection close", absoluteUrl); + } + catch (Exception ex) + { + logger.LogError(ex, "Error relaying WebSocket to {Url}", absoluteUrl); + } + + // The relay never reached a handshake response (origin connect failed or it + // closed early). Flush the buffered request log for a watched session so its + // lines don't linger in the console formatter; otherwise just drop state. + if (!handshakeObserved) + { + if (phase == RequestPhase.Watched) + { + session.SetOriginResponse(new MutableHttpResponse( + HttpStatusCode.BadGateway, HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty)); + await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); + } + else + { + pipeline.Forget(session.SessionId); + } + } + return false; + } + + OriginResponse origin; + try + { + origin = await forwarder.ForwardAsync(request, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + pipeline.Forget(session.SessionId); + + // An HttpClient timeout throws TaskCanceledException (an OperationCanceledException), + // the same type a client disconnect produces — UpstreamFailure tells them apart by + // the connection token so a stalled origin returns 504 instead of silently dropping. + var outcome = UpstreamFailure.Classify(ex, ct.IsCancellationRequested); + if (outcome is null) + { + return false; + } + + logger.LogError(ex, "Error forwarding to origin {Url}", absoluteUrl); + await WriteErrorAsync(clientStream, outcome.Value.Status, outcome.Value.Message, ct).ConfigureAwait(false); + return false; + } + + await using (origin.ConfigureAwait(false)) + { + // text/event-stream: forward the body to the client piece-by-piece (chunked) + // instead of buffering it, so events arrive live and an unbounded stream never + // hangs the engine. + if (origin.IsStreaming) + { + return await WriteStreamingResponseAsync(clientStream, session, origin, phase, keepAlive, ct) + .ConfigureAwait(false); + } + + if (phase == RequestPhase.Watched) + { + session.SetOriginResponse(origin.Response); + await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); + await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, keepAlive, head.Method, ct).ConfigureAwait(false); + } + else + { + // NotWatched: pure passthrough, no response-phase plugins. + await ResponseWriter.WriteAsync(clientStream, origin.Response, keepAlive, head.Method, ct).ConfigureAwait(false); + } + + return keepAlive; + } + } + + /// + /// Forwards a streamed (text/event-stream) response to the client incrementally. + /// For a watched session the response head + body are written between the + /// BeforeResponse and AfterResponse plugin phases, and a capped copy of + /// the body is exposed to read-only AfterResponse inspectors (e.g. OpenAI + /// telemetry). BeforeResponse body replacement is not supported on streamed + /// responses — the live origin body is always forwarded. + /// + private async Task WriteStreamingResponseAsync( + Stream clientStream, + CanonicalProxySession session, + OriginResponse origin, + RequestPhase phase, + bool keepAlive, + CancellationToken ct) + { + const int accumulateCap = InMemoryInspectionCapBytes; + + if (phase == RequestPhase.Watched) + { + session.SetOriginResponse(origin.Response); + await pipeline.RunStreamingResponseAsync(session, async innerCt => + { + var accumulated = await StreamingResponseWriter.WriteAsync( + clientStream, session.MutableResponse!, origin.BodyStream!, keepAlive, accumulateCap, innerCt) + .ConfigureAwait(false); + + // Hand the captured stream to AfterResponse inspectors. Empty when the + // stream exceeded the cap — those plugins then simply see no body. + if (!accumulated.IsEmpty) + { + session.MutableResponse!.SetBody(accumulated); + } + }, ct).ConfigureAwait(false); + } + else + { + // NotWatched: incremental passthrough, no plugins, no need to accumulate. + await StreamingResponseWriter.WriteAsync( + clientStream, origin.Response, origin.BodyStream!, keepAlive, accumulateCap: 0, ct) + .ConfigureAwait(false); + } + + return keepAlive; + } + + /// + /// Decides whether the connection may serve another request after this one. + /// Persistent by default on HTTP/1.1 (RFC 9112 §9.3), opt-in on HTTP/1.0, and + /// forced closed by Connection: close. Chunked bodies and + /// Expect: 100-continue are now read/handled before this runs (the body is + /// re-framed with Content-Length on forward), so they no longer force a close. + /// + internal static bool ShouldKeepAlive(ParsedRequestHead head) + { + string? connection = null; + foreach (var (name, value) in head.Headers) + { + if (string.Equals(name, "Connection", StringComparison.OrdinalIgnoreCase)) + { + connection = value; + break; + } + } + + var isHttp10 = head.Version.EndsWith("1.0", StringComparison.Ordinal); + if (connection is not null) + { + if (connection.Contains("close", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + if (isHttp10 && connection.Contains("keep-alive", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return !isHttp10; + } + + private static Version ParseHttpVersion(string token) + { + // token like "HTTP/1.1" + var slash = token.IndexOf('/', StringComparison.Ordinal); + if (slash >= 0 && Version.TryParse(token[(slash + 1)..], out var version)) + { + return version; + } + return HttpVersion.Version11; + } + + private static async Task WriteErrorAsync(Stream clientStream, HttpStatusCode status, string message, CancellationToken ct) + { + var body = Encoding.UTF8.GetBytes(message); + var head = new StringBuilder() + .Append(CultureInfo.InvariantCulture, $"HTTP/1.1 {(int)status} {ReasonPhrase(status)}\r\n") + .Append("Content-Type: text/plain; charset=utf-8\r\n") + .Append(CultureInfo.InvariantCulture, $"Content-Length: {body.Length}\r\n") + .Append("Connection: close\r\n\r\n") + .ToString(); + + try + { + await clientStream.WriteAsync(Encoding.ASCII.GetBytes(head), ct).ConfigureAwait(false); + await clientStream.WriteAsync(body, ct).ConfigureAwait(false); + await clientStream.FlushAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) + { + // Client already gone. + } + } + + private static string ReasonPhrase(HttpStatusCode status) => status switch + { + HttpStatusCode.BadRequest => "Bad Request", + HttpStatusCode.BadGateway => "Bad Gateway", + HttpStatusCode.GatewayTimeout => "Gateway Timeout", + _ => status.ToString(), + }; + + // The client's source port — the remote end of the connection the proxy accepted. + // Used to resolve the owning process for the --watch-pids/--watch-process-names filter. + private static int GetClientPort(ConnectionContext connection) => + connection.RemoteEndPoint is IPEndPoint endpoint ? endpoint.Port : 0; + + private static Task WriteAsciiAsync(Stream stream, string text, CancellationToken ct) => + stream.WriteAsync(Encoding.ASCII.GetBytes(text), ct).AsTask(); +} diff --git a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs new file mode 100644 index 00000000..d35b1d70 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs @@ -0,0 +1,91 @@ +// 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 DevProxy.Abstractions.Proxy.Http; +using Microsoft.AspNetCore.WebUtilities; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// Serializes a canonical response back to the client. Recomputes +/// Content-Length from the (decompressed) body and strips hop-by-hop / +/// framing / encoding headers so the client always receives a valid message +/// (). +/// +/// Non-streaming responses are buffered, so Content-Length is recomputed +/// and the client can frame the response unambiguously, allowing keep-alive when the +/// request permits. Streamed (text/event-stream) responses are re-framed as +/// chunked transfer by instead. +/// +internal static class ResponseWriter +{ + private static readonly byte[] s_continue = Encoding.ASCII.GetBytes("HTTP/1.1 100 Continue\r\n\r\n"); + + /// + /// Writes an interim 100 Continue so a client that sent + /// Expect: 100-continue proceeds to send its request body. The proxy always + /// intends to read the body (it buffers and forwards it), so it answers the + /// expectation itself rather than round-tripping to the origin first. + /// + public static async Task WriteContinueAsync(Stream clientStream, CancellationToken ct) + { + await clientStream.WriteAsync(s_continue, ct).ConfigureAwait(false); + await clientStream.FlushAsync(ct).ConfigureAwait(false); + } + + public static async Task WriteAsync( + Stream clientStream, IHttpResponse response, bool keepAlive, string requestMethod, CancellationToken ct) + { + // A response to HEAD carries the same headers a GET would — including the + // resource's Content-Length — but never a message body (RFC 9110 §9.3.2). + // For every other method the body IS the response, so Content-Length is + // recomputed from it and any stale origin value is dropped. + var isHead = string.Equals(requestMethod, "HEAD", StringComparison.OrdinalIgnoreCase); + + var head = new StringBuilder(); + var statusCode = (int)response.StatusCode; + var reason = string.IsNullOrEmpty(response.StatusDescription) + ? ReasonPhrases.GetReasonPhrase(statusCode) + : response.StatusDescription; + + _ = head.Append(CultureInfo.InvariantCulture, $"HTTP/1.1 {statusCode} {reason}\r\n"); + + string? preservedContentLength = null; + foreach (var header in response.Headers) + { + if (ForwardingInvariants.HopByHopHeaders.Contains(header.Name) + || string.Equals(header.Name, "Content-Encoding", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (string.Equals(header.Name, "Content-Length", StringComparison.OrdinalIgnoreCase)) + { + // For HEAD, keep the origin's declared length (the size a GET would + // return); otherwise it is recomputed below from the actual body. + if (isHead) + { + preservedContentLength ??= header.Value; + } + continue; + } + _ = head.Append(CultureInfo.InvariantCulture, $"{header.Name}: {header.Value}\r\n"); + } + + var body = response.Body; + var contentLength = isHead + ? preservedContentLength ?? body.Length.ToString(CultureInfo.InvariantCulture) + : body.Length.ToString(CultureInfo.InvariantCulture); + _ = head.Append(CultureInfo.InvariantCulture, $"Content-Length: {contentLength}\r\n"); + _ = head.Append(keepAlive ? "Connection: keep-alive\r\n\r\n" : "Connection: close\r\n\r\n"); + + await clientStream.WriteAsync(Encoding.ASCII.GetBytes(head.ToString()), ct).ConfigureAwait(false); + if (!isHead && !body.IsEmpty) + { + await clientStream.WriteAsync(body, ct).ConfigureAwait(false); + } + await clientStream.FlushAsync(ct).ConfigureAwait(false); + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs b/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs new file mode 100644 index 00000000..d1cfec9a --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs @@ -0,0 +1,50 @@ +// 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.Proxy.Kestrel.Internal; + +/// +/// Splices two streams together, copying bytes verbatim in both directions until +/// either side closes — the raw relay shared by the blind tunnel (non-watched TLS) +/// and the WebSocket frame relay (after a 101 handshake). +/// +/// +/// a ──────────────►──────────────► b +/// ◄──────────────◄────────────── +/// When EITHER direction ends, the other is cancelled; the method returns only +/// after BOTH copy tasks have finished, so neither outlives the streams it uses +/// (CA2025). +/// +/// +internal static class StreamRelay +{ + public static async Task RelayBidirectionalAsync(Stream a, Stream b, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); +#pragma warning disable CA2025 // The finally awaits both copies before the streams are disposed by the caller. + var aToB = a.CopyToAsync(b, linked.Token); + var bToA = b.CopyToAsync(a, linked.Token); + + try + { + _ = await Task.WhenAny(aToB, bToA).ConfigureAwait(false); + await linked.CancelAsync().ConfigureAwait(false); + } + finally + { + try + { + await Task.WhenAll(aToB, bToA).ConfigureAwait(false); + } + catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) + { + // Either peer closed the connection — normal relay teardown. + } + } +#pragma warning restore CA2025 + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/StreamingResponseWriter.cs b/DevProxy.Proxy.Kestrel/Internal/StreamingResponseWriter.cs new file mode 100644 index 00000000..63db216a --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/StreamingResponseWriter.cs @@ -0,0 +1,143 @@ +// 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.Globalization; +using System.Text; +using DevProxy.Abstractions.Proxy.Http; +using Microsoft.AspNetCore.WebUtilities; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// Writes a streaming (text/event-stream) response to the client incrementally, +/// re-framing the origin's decompressed body as HTTP/1.1 chunked transfer so each piece +/// reaches the client as it arrives rather than all-at-once at the end. +/// +/// +/// While pumping, a capped copy of the body is accumulated and returned so a watched +/// session's read-only AfterResponse inspectors (e.g. OpenAI telemetry) still see +/// the complete stream. If the body exceeds the cap — a long-lived or unbounded stream — +/// accumulation is abandoned (empty is returned) but relaying continues uninterrupted, so +/// the engine never hangs or exhausts memory on an infinite stream. +/// +/// +/// +/// origin body stream client (chunked) +/// ───────────────── ──────────────── +/// read N bytes ──┬─ write "{N:X}\r\n…\r\n" + FLUSH ──► event delivered now +/// └─ append to cap'd copy (until cap) +/// …repeat until EOF… +/// EOF ──────────── write "0\r\n\r\n" ───────────────► stream terminated +/// +/// +internal static class StreamingResponseWriter +{ + private static readonly byte[] s_lastChunk = Encoding.ASCII.GetBytes("0\r\n\r\n"); + private static readonly byte[] s_crlf = Encoding.ASCII.GetBytes("\r\n"); + + /// + /// Streams to as chunked + /// transfer and returns the accumulated body (empty when accumulation is disabled or + /// the body exceeded ). + /// + /// + /// Maximum bytes to retain for inspectors. Pass 0 to disable accumulation + /// (pure pass-through, e.g. for non-watched traffic). + /// + public static async Task> WriteAsync( + Stream clientStream, + IHttpResponse response, + Stream originBody, + bool keepAlive, + int accumulateCap, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(clientStream); + ArgumentNullException.ThrowIfNull(response); + ArgumentNullException.ThrowIfNull(originBody); + + await WriteHeadAsync(clientStream, response, keepAlive, ct).ConfigureAwait(false); + + var buffer = ArrayPool.Shared.Rent(8192); + var accumulator = accumulateCap > 0 ? new ArrayBufferWriter() : null; + var truncated = false; + try + { + while (true) + { + var read = await originBody.ReadAsync(buffer.AsMemory(), ct).ConfigureAwait(false); + if (read == 0) + { + break; + } + + await WriteChunkAsync(clientStream, buffer.AsMemory(0, read), ct).ConfigureAwait(false); + await clientStream.FlushAsync(ct).ConfigureAwait(false); + + if (accumulator is not null && !truncated) + { + if (accumulator.WrittenCount + read <= accumulateCap) + { + accumulator.Write(buffer.AsSpan(0, read)); + } + else + { + // Exceeded what we are willing to hold for inspectors. Stop + // accumulating but keep relaying — a partial body would mislead + // inspectors, so it is dropped entirely. + truncated = true; + } + } + } + + await clientStream.WriteAsync(s_lastChunk, ct).ConfigureAwait(false); + await clientStream.FlushAsync(ct).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return accumulator is null || truncated ? ReadOnlyMemory.Empty : accumulator.WrittenMemory; + } + + private static async Task WriteHeadAsync(Stream clientStream, IHttpResponse response, bool keepAlive, CancellationToken ct) + { + var head = new StringBuilder(); + var statusCode = (int)response.StatusCode; + var reason = string.IsNullOrEmpty(response.StatusDescription) + ? ReasonPhrases.GetReasonPhrase(statusCode) + : response.StatusDescription; + + _ = head.Append(CultureInfo.InvariantCulture, $"HTTP/1.1 {statusCode} {reason}\r\n"); + + foreach (var header in response.Headers) + { + if (ForwardingInvariants.HopByHopHeaders.Contains(header.Name) + || string.Equals(header.Name, "Content-Length", StringComparison.OrdinalIgnoreCase) + || string.Equals(header.Name, "Content-Encoding", StringComparison.OrdinalIgnoreCase) + || string.Equals(header.Name, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + _ = head.Append(CultureInfo.InvariantCulture, $"{header.Name}: {header.Value}\r\n"); + } + + _ = head.Append("Transfer-Encoding: chunked\r\n"); + _ = head.Append(keepAlive ? "Connection: keep-alive\r\n\r\n" : "Connection: close\r\n\r\n"); + + await clientStream.WriteAsync(Encoding.ASCII.GetBytes(head.ToString()), ct).ConfigureAwait(false); + await clientStream.FlushAsync(ct).ConfigureAwait(false); + } + + private static async Task WriteChunkAsync(Stream clientStream, ReadOnlyMemory data, CancellationToken ct) + { + var sizeLine = Encoding.ASCII.GetBytes( + data.Length.ToString("X", CultureInfo.InvariantCulture) + "\r\n"); + await clientStream.WriteAsync(sizeLine, ct).ConfigureAwait(false); + await clientStream.WriteAsync(data, ct).ConfigureAwait(false); + await clientStream.WriteAsync(s_crlf, ct).ConfigureAwait(false); + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/TlsClientHello.cs b/DevProxy.Proxy.Kestrel/Internal/TlsClientHello.cs new file mode 100644 index 00000000..c1c24e9b --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/TlsClientHello.cs @@ -0,0 +1,199 @@ +// 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; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// Minimal, tolerant TLS ClientHello parser. Extracts the SNI (server name) +/// and the ALPN protocol list so the proxy can decide — before terminating +/// TLS — whether to MITM (advertise http/1.1) or blind-tunnel (h2-only / gRPC). +/// Deliberately NOT a TLS stack: it reads just enough of the first handshake record +/// to make the decrypt-or-tunnel decision. +/// +/// +/// Wire layout walked here (all multi-byte integers big-endian): +/// +/// TLS record: type(1)=0x16 version(2) length(2) ── fragment ── +/// handshake: type(1)=0x01(ClientHello) length(3) +/// client_version(2) random(32) +/// session_id: len(1) bytes +/// cipher_suites: len(2) bytes +/// compression: len(1) bytes +/// extensions: len(2) [ type(2) len(2) data ]* +/// SNI ext 0x0000: list_len(2) type(1)=0 name_len(2) name +/// ALPN ext 0x0010: list_len(2) [ proto_len(1) proto ]* +/// +/// +/// +internal static class TlsClientHello +{ + private const byte HandshakeRecordType = 0x16; + private const byte ClientHelloType = 0x01; + private const ushort ServerNameExtension = 0x0000; + private const ushort AlpnExtension = 0x0010; + private const int MaxScanBytes = 8192; + + internal enum ParseStatus + { + /// Not enough bytes buffered yet; read more and retry. + NeedMore, + + /// The first bytes are not a TLS handshake record (e.g. plain HTTP). + NotTls, + + /// A ClientHello was parsed (SNI/ALPN may still be empty). + Ok, + } + + internal readonly record struct Result(ParseStatus Status, string? ServerName, IReadOnlyList Alpn) + { + public bool OffersH2 => Alpn.Contains("h2"); + public bool OffersHttp11 => Alpn.Contains("http/1.1"); + + /// + /// True when the client offers h2 with no http/1.1 fallback. Such + /// clients (notably gRPC) cannot be downgraded, so the proxy must blind-tunnel + /// them or they break. + /// + public bool IsH2Only => OffersH2 && !OffersHttp11; + } + + public static Result Parse(ReadOnlySequence sequence) + { + // ClientHellos are small; work on a contiguous copy capped to MaxScanBytes. + var data = sequence.Length > MaxScanBytes ? sequence.Slice(0, MaxScanBytes).ToArray() : sequence.ToArray(); + var s = new ReadOnlySpan(data); + + if (s.Length < 5) + { + return new(ParseStatus.NeedMore, null, []); + } + if (s[0] != HandshakeRecordType) + { + return new(ParseStatus.NotTls, null, []); + } + + var recordLength = BinaryPrimitives.ReadUInt16BigEndian(s.Slice(3, 2)); + if (s.Length < 5 + recordLength) + { + return new(ParseStatus.NeedMore, null, []); + } + + var body = s.Slice(5, recordLength); + if (body.Length < 4 || body[0] != ClientHelloType) + { + return new(ParseStatus.NotTls, null, []); + } + + var handshakeLength = (body[1] << 16) | (body[2] << 8) | body[3]; + var p = 4; + if (body.Length < p + handshakeLength) + { + return new(ParseStatus.NeedMore, null, []); + } + + p += 2; // client_version + p += 32; // random + if (p >= body.Length) + { + return new(ParseStatus.NeedMore, null, []); + } + + int sessionIdLength = body[p]; + p += 1 + sessionIdLength; + if (p + 2 > body.Length) + { + return new(ParseStatus.NeedMore, null, []); + } + + var cipherSuitesLength = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(p, 2)); + p += 2 + cipherSuitesLength; + if (p >= body.Length) + { + return new(ParseStatus.NeedMore, null, []); + } + + int compressionLength = body[p]; + p += 1 + compressionLength; + if (p + 2 > body.Length) + { + // No extensions block — valid, but no SNI/ALPN to read. + return new(ParseStatus.Ok, null, []); + } + + var extensionsTotal = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(p, 2)); + p += 2; + + string? serverName = null; + var alpn = new List(); + var extensionsEnd = Math.Min(body.Length, p + extensionsTotal); + + while (p + 4 <= extensionsEnd) + { + var extensionType = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(p, 2)); + var extensionLength = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(p + 2, 2)); + p += 4; + if (p + extensionLength > body.Length) + { + break; + } + + var extension = body.Slice(p, extensionLength); + if (extensionType == ServerNameExtension) + { + serverName = ReadServerName(extension); + } + else if (extensionType == AlpnExtension) + { + ReadAlpn(extension, alpn); + } + + p += extensionLength; + } + + return new(ParseStatus.Ok, serverName, alpn); + } + + private static string? ReadServerName(ReadOnlySpan extension) + { + // server_name_list: list_len(2) name_type(1)=host_name(0) name_len(2) name + if (extension.Length < 5 || extension[2] != 0x00) + { + return null; + } + + var nameLength = BinaryPrimitives.ReadUInt16BigEndian(extension.Slice(3, 2)); + return extension.Length >= 5 + nameLength + ? Encoding.ASCII.GetString(extension.Slice(5, nameLength)) + : null; + } + + private static void ReadAlpn(ReadOnlySpan extension, List alpn) + { + // ProtocolNameList: list_len(2) [ proto_len(1) proto ]* + if (extension.Length < 2) + { + return; + } + + var listLength = BinaryPrimitives.ReadUInt16BigEndian(extension.Slice(0, 2)); + var q = 2; + var end = Math.Min(extension.Length, 2 + listLength); + while (q < end) + { + int protocolLength = extension[q]; + q += 1; + if (q + protocolLength > extension.Length) + { + break; + } + alpn.Add(Encoding.ASCII.GetString(extension.Slice(q, protocolLength))); + q += protocolLength; + } + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/UpstreamFailure.cs b/DevProxy.Proxy.Kestrel/Internal/UpstreamFailure.cs new file mode 100644 index 00000000..b56826f2 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/UpstreamFailure.cs @@ -0,0 +1,48 @@ +// 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.Proxy.Kestrel.Internal; + +/// +/// Decides how an exception thrown while forwarding a request to the origin should be +/// surfaced to the client. The key subtlety is that an request +/// timeout throws — which derives from +/// , the same type a genuine client-driven +/// cancellation produces. They are told apart by whether the connection's own token was +/// cancelled: if it was, the client went away (silent teardown); if it was not, the +/// origin timed out and the client deserves a gateway error. +/// +/// +/// forward throws +/// │ +/// ├─ OperationCanceledException ──┬─ client token cancelled ─► null (silent teardown) +/// │ └─ token NOT cancelled ─────► 504 Gateway Timeout +/// │ +/// └─ anything else (TLS failure, ─────────────────────────────► 502 Bad Gateway +/// DNS, connection refused, …) +/// +/// +internal static class UpstreamFailure +{ + /// + /// Maps a forward exception to the status/message the client should see, or + /// when it is a genuine client cancellation that should be + /// treated as a silent connection teardown (no response written). + /// + public static (HttpStatusCode Status, string Message)? Classify(Exception exception, bool clientCancelled) + { + ArgumentNullException.ThrowIfNull(exception); + + if (exception is OperationCanceledException) + { + return clientCancelled + ? null + : (HttpStatusCode.GatewayTimeout, "Upstream request timed out"); + } + + return (HttpStatusCode.BadGateway, "Upstream request failed"); + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs new file mode 100644 index 00000000..526ac170 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs @@ -0,0 +1,177 @@ +// 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; +using DevProxy.Proxy.Kestrel.Http; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// Forwards a canonical request to its origin with and +/// projects the origin response back onto the canonical model. Honors the +/// : hop-by-hop headers are stripped, Expect +/// is dropped (already satisfied at the proxy), the body is delivered to plugins +/// decompressed, and framing headers are recomputed on write-back. +/// +/// +/// Most responses are fully buffered ( false). +/// A text/event-stream response is left UNBUFFERED instead — its body stream +/// stays open in the returned so the caller can forward +/// it to the client incrementally (Server-Sent Events). Buffering such a stream would +/// withhold every event until the stream ends, and an unbounded one would never end. +/// +/// +internal sealed class UpstreamForwarder(HttpClient httpClient) +{ + private readonly HttpClient _httpClient = httpClient; + + public async Task ForwardAsync(IHttpRequest request, CancellationToken ct) + { + using var outgoing = new HttpRequestMessage(new HttpMethod(request.Method), request.RequestUri); + + ByteArrayContent? content = null; + if (request.HasBody) + { + content = new ByteArrayContent(request.Body.ToArray()); + outgoing.Content = content; + } + + foreach (var header in request.Headers) + { + if (IsHopByHop(header.Name) + || string.Equals(header.Name, "Host", StringComparison.OrdinalIgnoreCase) + || string.Equals(header.Name, "Expect", StringComparison.OrdinalIgnoreCase) + || string.Equals(header.Name, "Content-Length", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!outgoing.Headers.TryAddWithoutValidation(header.Name, header.Value)) + { + _ = content?.Headers.TryAddWithoutValidation(header.Name, header.Value); + } + } + + HttpResponseMessage? originResponse = await _httpClient + .SendAsync(outgoing, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + try + { + var headers = new HeaderCollection(); + CopyHeaders(originResponse.Headers, headers); + CopyHeaders(originResponse.Content.Headers, headers); + + // A HEAD response has no body but reports the Content-Length a GET would — + // keep it so the client sees the real resource size (RFC 9110 §9.3.2). It + // can never take the streaming path (there is nothing to stream). + var isHead = string.Equals(request.Method, "HEAD", StringComparison.OrdinalIgnoreCase); + var isStreaming = !isHead && IsEventStream(headers); + + // Body is (or will be) delivered decompressed; advertise nothing stale — + // a buffered body gets a real Content-Length on write-back, a streamed one + // is re-framed as chunked. HEAD keeps the origin's Content-Length. + _ = headers.Remove("Content-Encoding"); + _ = headers.Remove("Transfer-Encoding"); + if (!isHead) + { + _ = headers.Remove("Content-Length"); + } + + if (isStreaming) + { + // Leave the body unbuffered: hand the live stream to the caller and + // transfer ownership of the response message so it stays open until the + // stream is fully relayed. + var bodyStream = await originResponse.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var streamingResponse = new MutableHttpResponse( + originResponse.StatusCode, + originResponse.Version, + headers, + ReadOnlyMemory.Empty, + originResponse.ReasonPhrase); + + var result = new OriginResponse(streamingResponse, bodyStream, originResponse); + originResponse = null; + return result; + } + + // AutomaticDecompression on the shared handler means the bytes here are + // already decompressed; the Content-Encoding header is removed for us. + var body = await originResponse.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + var response = new MutableHttpResponse( + originResponse.StatusCode, + originResponse.Version, + headers, + body, + originResponse.ReasonPhrase); + + return new OriginResponse(response, bodyStream: null, message: null); + } + finally + { + originResponse?.Dispose(); + } + } + + private static bool IsEventStream(HeaderCollection headers) => + headers.GetFirst("Content-Type")?.Value + .Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) == true; + + private static void CopyHeaders(System.Net.Http.Headers.HttpHeaders source, HeaderCollection destination) + { + foreach (var header in source) + { + if (IsHopByHop(header.Key)) + { + continue; + } + + foreach (var value in header.Value) + { + destination.Add(header.Key, value); + } + } + } + + private static bool IsHopByHop(string name) => ForwardingInvariants.HopByHopHeaders.Contains(name); +} + +/// +/// The origin response projected onto the canonical model, owning the lifetime of the +/// underlying when the body is streamed. +/// +/// +/// Buffered case ( false): already +/// carries the full body; is null and there is nothing to +/// dispose. +/// +/// +/// Streaming case ( true): carries +/// headers only and is the live, decompressed origin body the +/// caller must pump to the client. The response message stays open until this is +/// disposed. +/// +/// +internal sealed class OriginResponse(MutableHttpResponse response, Stream? bodyStream, HttpResponseMessage? message) + : IAsyncDisposable +{ + private readonly HttpResponseMessage? _message = message; + + public MutableHttpResponse Response { get; } = response; + + public Stream? BodyStream { get; } = bodyStream; + + public bool IsStreaming => BodyStream is not null; + + public async ValueTask DisposeAsync() + { + if (BodyStream is not null) + { + await BodyStream.DisposeAsync().ConfigureAwait(false); + } + _message?.Dispose(); + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/WebSocketMockResponder.cs b/DevProxy.Proxy.Kestrel/Internal/WebSocketMockResponder.cs new file mode 100644 index 00000000..67f60654 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/WebSocketMockResponder.cs @@ -0,0 +1,219 @@ +// 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.Security.Cryptography; +using System.Text; +using DevProxy.Abstractions.Proxy.Http; +using DevProxy.Proxy.Kestrel.Http; +using Microsoft.Extensions.Logging; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// Completes a WebSocket upgrade on the client's behalf and runs a plugin-supplied +/// handler over the live connection — the WebSocket analogue of a mocked HTTP response. +/// Unlike , the origin is NEVER contacted: the proxy itself +/// is the WebSocket server. +/// +/// +/// client proxy (mock server) origin +/// │ GET … Upgrade: websocket │ │ +/// │ ────────────────────────────────▶ │ compute Sec-WebSocket-Accept (never +/// │ ◀── 101 Switching Protocols ───────│ WebSocket.CreateFromStream dialed) +/// │ ◀═══ scripted frames ════════════▶ │ run plugin handler +/// +/// +/// +/// Framing is delegated to the framework +/// (server mode) — no hand-rolled frame codec. The handshake 101 is written to the +/// client verbatim BEFORE wrapping the stream, exactly as a real WebSocket server would. +/// +/// +internal sealed class WebSocketMockResponder(ILogger logger) +{ + // RFC 6455 §1.3 — the magic GUID appended to Sec-WebSocket-Key before hashing. + private const string HandshakeMagicGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30); + + /// + /// Computes the handshake accept token, writes the 101 to the client verbatim, + /// invokes (so the caller can run the response + /// pipeline / flush the request log), wraps the stream as a server-side + /// , and runs until it returns. + /// + public async Task RespondAsync( + Stream clientStream, + IHttpRequest request, + Func handler, + Func onHandshakeResponse, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(clientStream); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(handler); + ArgumentNullException.ThrowIfNull(onHandshakeResponse); + + var key = request.Headers.GetFirst("Sec-WebSocket-Key")?.Value; + if (string.IsNullOrEmpty(key)) + { + // Not a valid handshake (no client nonce). Refuse rather than upgrade. + logger.LogDebug("WebSocket mock: request to {Url} is missing Sec-WebSocket-Key", request.Url); + await WriteRawAsync(clientStream, BuildBadRequest(), ct).ConfigureAwait(false); + return; + } + + // RFC 6455 §4.2.2: if the client offered sub-protocols, echo the first one. + var subProtocol = request.Headers.GetFirst("Sec-WebSocket-Protocol")?.Value? + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(); + + var accept = ComputeAcceptKey(key); + var (rawHead, headers) = BuildHandshakeResponse(accept, subProtocol); + + var response = new MutableHttpResponse( + HttpStatusCode.SwitchingProtocols, HttpVersion.Version11, headers, + ReadOnlyMemory.Empty, "Switching Protocols"); + await onHandshakeResponse(response).ConfigureAwait(false); + + await WriteRawAsync(clientStream, rawHead, ct).ConfigureAwait(false); + + // The stream now carries WebSocket frames; let the framework own the codec. + // Ownership of the WebSocket transfers to FramedWebSocketConnection, which is + // disposed by the `await using` below. +#pragma warning disable CA2000 + var webSocket = WebSocket.CreateFromStream( + clientStream, isServer: true, subProtocol: subProtocol, keepAliveInterval: KeepAliveInterval); +#pragma warning restore CA2000 + await using var connection = new FramedWebSocketConnection(webSocket); + + logger.LogDebug("WebSocket mock established for {Url}", request.Url); + await handler(connection, ct).ConfigureAwait(false); + await connection.CloseAsync(ct).ConfigureAwait(false); + } + + private static string ComputeAcceptKey(string clientKey) + { + // SHA1 is mandated by the WebSocket handshake (RFC 6455 §4.2.2) — this is a + // protocol constant, not a security-sensitive hash. +#pragma warning disable CA5350 + var hash = SHA1.HashData(Encoding.ASCII.GetBytes(clientKey + HandshakeMagicGuid)); +#pragma warning restore CA5350 + return Convert.ToBase64String(hash); + } + + private static (byte[] RawHead, HeaderCollection Headers) BuildHandshakeResponse(string accept, string? subProtocol) + { + var headers = new HeaderCollection(); + headers.Add("Upgrade", "websocket"); + headers.Add("Connection", "Upgrade"); + headers.Add("Sec-WebSocket-Accept", accept); + if (!string.IsNullOrEmpty(subProtocol)) + { + headers.Add("Sec-WebSocket-Protocol", subProtocol); + } + + var builder = new StringBuilder("HTTP/1.1 101 Switching Protocols\r\n"); + foreach (var header in headers) + { + _ = builder.Append(CultureInfo.InvariantCulture, $"{header.Name}: {header.Value}\r\n"); + } + _ = builder.Append("\r\n"); + + return (Encoding.ASCII.GetBytes(builder.ToString()), headers); + } + + private static byte[] BuildBadRequest() => Encoding.ASCII.GetBytes( + "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); + + private static async Task WriteRawAsync(Stream stream, byte[] bytes, CancellationToken ct) + { + await stream.WriteAsync(bytes, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + } +} + +/// +/// Adapts a framework server-side to +/// : sends are one-shot complete messages; receives +/// reassemble fragments into a single message. Close is sent via +/// (fire-and-forget) so a mock never blocks +/// waiting for the client's close echo. +/// +internal sealed class FramedWebSocketConnection(WebSocket webSocket) : IWebSocketConnection, IAsyncDisposable +{ + private const int ReceiveChunkSize = 8 * 1024; + + public Task SendTextAsync(string message, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(message); + return webSocket.SendAsync( + Encoding.UTF8.GetBytes(message), WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + } + + public Task SendBinaryAsync(ReadOnlyMemory message, CancellationToken cancellationToken) => + webSocket.SendAsync(message, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken).AsTask(); + + public async Task ReceiveAsync(CancellationToken cancellationToken) + { + if (webSocket.State is not (WebSocketState.Open or WebSocketState.CloseSent)) + { + return null; + } + + var buffer = new byte[ReceiveChunkSize]; + var assembled = new List(ReceiveChunkSize); + WebSocketReceiveResult result; + do + { + try + { + result = await webSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + } + catch (WebSocketException) + { + // Connection ended abruptly without a clean close frame. + return null; + } + + if (result.MessageType == WebSocketMessageType.Close) + { + return new WebSocketMessage(WebSocketMessageType.Close, ReadOnlyMemory.Empty); + } + + assembled.AddRange(buffer.AsSpan(0, result.Count)); + } + while (!result.EndOfMessage); + + return new WebSocketMessage(result.MessageType, assembled.ToArray()); + } + + public async Task CloseAsync(CancellationToken cancellationToken) + { + if (webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + try + { + await webSocket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, statusDescription: null, cancellationToken).ConfigureAwait(false); + } + catch (WebSocketException) + { + // Peer already gone; nothing to close. + } + catch (OperationCanceledException) + { + // Shutting down. + } + } + } + + public ValueTask DisposeAsync() + { + webSocket.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/WebSocketRelay.cs b/DevProxy.Proxy.Kestrel/Internal/WebSocketRelay.cs new file mode 100644 index 00000000..9e1b514f --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/WebSocketRelay.cs @@ -0,0 +1,269 @@ +// 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.Security; +using System.Net.Sockets; +using System.Text; +using DevProxy.Abstractions.Proxy.Http; +using DevProxy.Proxy.Kestrel.Http; +using Microsoft.Extensions.Logging; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// Relays a WebSocket connection between the client and the origin. A WebSocket +/// handshake cannot go through (pooled +/// ): it needs the raw, long-lived socket that survives the +/// 101 Switching Protocols response. This mirrors what the Titanium engine +/// does today — frames are relayed verbatim and opaque; no plugin inspects them. +/// +/// +/// client proxy origin +/// │ GET … Upgrade: websocket │ │ +/// │ ───────────────────────────► │ (replay handshake, origin- │ +/// │ │ form, preserving Upgrade/ │ +/// │ │ Connection/Sec-WebSocket-*) ─► +/// │ │ ◄─ 101 Switching Protocols ──│ +/// │ ◄── 101 (verbatim) ──────────│ │ +/// │ ◄═══════════ raw WebSocket frames spliced both ways ═══════► │ +/// +/// +/// +/// The 101 is written to the client verbatim (never via +/// , which strips Upgrade/Connection and +/// injects Content-Length — that would corrupt the handshake). +/// +/// +/// +/// Deferred (tracked, see plan §7): decoding frames into messages and exposing +/// them to plugins for inspection/mocking. Until then this is a transparent relay. +/// +/// +internal sealed class WebSocketRelay(ILogger logger) +{ + private const int MaxResponseHeadBytes = 64 * 1024; + + /// + /// Connects to the origin, replays the upgrade handshake, invokes + /// with the origin's parsed response (so the + /// caller can run the response pipeline / log it), writes that response back to the + /// client verbatim, and — on 101 — splices frames in both directions until + /// either peer closes. + /// + public async Task RelayAsync( + Stream clientStream, + IHttpRequest request, + Uri origin, + Func onHandshakeResponse, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(clientStream); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(origin); + ArgumentNullException.ThrowIfNull(onHandshakeResponse); + + var useTls = string.Equals(origin.Scheme, "https", StringComparison.OrdinalIgnoreCase) + || string.Equals(origin.Scheme, "wss", StringComparison.OrdinalIgnoreCase); + + using var tcp = new TcpClient(); + try + { + await tcp.ConnectAsync(origin.Host, origin.Port, ct).ConfigureAwait(false); + } + catch (SocketException ex) + { + logger.LogDebug(ex, "WebSocket connect to {Host}:{Port} failed", origin.Host, origin.Port); + return; + } + + // leaveInnerStreamOpen: false on the SslStream disposes the NetworkStream; the + // NetworkStream is also owned by the `using` TcpClient. `await using` here + // disposes whichever stream we end up relaying over. + await using var originStream = await OpenOriginStreamAsync(tcp, origin, useTls, ct).ConfigureAwait(false); + + await WriteHandshakeAsync(originStream, request, origin, ct).ConfigureAwait(false); + + var head = await ReadResponseHeadAsync(originStream, ct).ConfigureAwait(false); + if (head is null) + { + logger.LogDebug("WebSocket origin {Host} closed before sending a handshake response", origin.Host); + return; + } + + var (statusCode, reason, headers, rawHead, leftover) = head.Value; + + var response = new MutableHttpResponse( + (HttpStatusCode)statusCode, HttpVersion.Version11, headers, ReadOnlyMemory.Empty, reason); + await onHandshakeResponse(response).ConfigureAwait(false); + + // Write the origin's handshake response to the client verbatim. + await clientStream.WriteAsync(rawHead, ct).ConfigureAwait(false); + if (leftover.Length > 0) + { + await clientStream.WriteAsync(leftover, ct).ConfigureAwait(false); + } + await clientStream.FlushAsync(ct).ConfigureAwait(false); + + if (statusCode != (int)HttpStatusCode.SwitchingProtocols) + { + // Origin declined the upgrade. We've relayed its response; there's no + // tunnel to splice. Close (a non-101 may carry a body we don't frame yet). + logger.LogDebug("WebSocket origin {Host} declined upgrade with {Status}", origin.Host, statusCode); + return; + } + + logger.LogDebug("WebSocket {Scheme}://{Host}:{Port}{Path} established", + useTls ? "wss" : "ws", origin.Host, origin.Port, origin.PathAndQuery); + + await StreamRelay.RelayBidirectionalAsync(clientStream, originStream, ct).ConfigureAwait(false); + } + + /// + /// Opens the origin stream, layering TLS (with http/1.1 ALPN, matching our downgrade + /// policy) for wss. On a TLS failure the half-built is + /// disposed before the exception propagates. + /// + private static async Task OpenOriginStreamAsync(TcpClient tcp, Uri origin, bool useTls, CancellationToken ct) + { + var network = tcp.GetStream(); + if (!useTls) + { + return network; + } + + var ssl = new SslStream(network, leaveInnerStreamOpen: false); + try + { + await ssl.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions + { + TargetHost = origin.Host, + ApplicationProtocols = [SslApplicationProtocol.Http11], + }, ct).ConfigureAwait(false); + } + catch + { + await ssl.DisposeAsync().ConfigureAwait(false); + throw; + } + return ssl; + } + + /// + /// Replays the handshake to the origin in origin-form. WebSocket-essential headers + /// (Upgrade, Connection, Sec-WebSocket-*) MUST be preserved — + /// they are normally hop-by-hop but are exactly what the handshake needs — so only + /// the proxy-scoped headers are dropped. + /// + private static async Task WriteHandshakeAsync(Stream origin, IHttpRequest request, Uri target, CancellationToken ct) + { + var builder = new StringBuilder() + .Append(CultureInfo.InvariantCulture, $"{request.Method} {target.PathAndQuery} HTTP/1.1\r\n"); + + foreach (var header in request.Headers) + { + if (string.Equals(header.Name, "Proxy-Connection", StringComparison.OrdinalIgnoreCase) + || string.Equals(header.Name, "Proxy-Authorization", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + _ = builder.Append(CultureInfo.InvariantCulture, $"{header.Name}: {header.Value}\r\n"); + } + _ = builder.Append("\r\n"); + + await origin.WriteAsync(Encoding.ASCII.GetBytes(builder.ToString()), ct).ConfigureAwait(false); + await origin.FlushAsync(ct).ConfigureAwait(false); + } + + /// + /// Reads the origin's HTTP response head (status line + headers) up to the + /// terminating CRLFCRLF, returning the parsed status/headers, the exact raw head + /// bytes (to relay verbatim) and any bytes read past the head (the first frame). + /// Returns null on EOF before a complete head arrived. + /// + private static async Task<(int StatusCode, string Reason, HeaderCollection Headers, byte[] RawHead, byte[] Leftover)?> + ReadResponseHeadAsync(Stream origin, CancellationToken ct) + { + var accumulator = new List(512); + var buffer = new byte[4096]; + + while (true) + { + var terminator = Http1RequestReader.IndexOfDoubleCrlf(accumulator); + if (terminator >= 0) + { + var headEnd = terminator + 4; + var rawHead = accumulator.GetRange(0, headEnd).ToArray(); + var leftover = accumulator.Count > headEnd + ? accumulator.GetRange(headEnd, accumulator.Count - headEnd).ToArray() + : []; + + var headerText = Encoding.ASCII.GetString(accumulator.ToArray(), 0, terminator); + var (statusCode, reason, headers) = ParseResponseHead(headerText); + return (statusCode, reason, headers, rawHead, leftover); + } + + if (accumulator.Count > MaxResponseHeadBytes) + { + throw new InvalidOperationException("WebSocket origin response head too large."); + } + + var read = await origin.ReadAsync(buffer, ct).ConfigureAwait(false); + if (read == 0) + { + return null; + } + accumulator.AddRange(buffer.AsSpan(0, read)); + } + } + + /// + /// Parses a response head block: HTTP/1.1 101 Switching Protocols followed + /// by header lines. + /// + internal static (int StatusCode, string Reason, HeaderCollection Headers) ParseResponseHead(string headerText) + { + var lines = headerText.Split("\r\n"); + if (lines.Length == 0) + { + throw new InvalidOperationException("Empty WebSocket origin response head."); + } + + var statusLine = lines[0]; + var firstSpace = statusLine.IndexOf(' ', StringComparison.Ordinal); + if (firstSpace < 0) + { + throw new InvalidOperationException($"Malformed status line: '{statusLine}'."); + } + + var afterVersion = statusLine[(firstSpace + 1)..].TrimStart(); + var secondSpace = afterVersion.IndexOf(' ', StringComparison.Ordinal); + var codeToken = secondSpace < 0 ? afterVersion : afterVersion[..secondSpace]; + if (!int.TryParse(codeToken, NumberStyles.Integer, CultureInfo.InvariantCulture, out var statusCode)) + { + throw new InvalidOperationException($"Malformed status code in '{statusLine}'."); + } + var reason = secondSpace < 0 ? string.Empty : afterVersion[(secondSpace + 1)..]; + + var headers = new HeaderCollection(); + for (var i = 1; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Length == 0) + { + continue; + } + var colon = line.IndexOf(':', StringComparison.Ordinal); + if (colon <= 0) + { + continue; + } + headers.Add(line[..colon].Trim(), line[(colon + 1)..].Trim()); + } + + return (statusCode, reason, headers); + } +} diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs new file mode 100644 index 00000000..68b46437 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.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.Globalization; +using System.Net; +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Proxy.Kestrel.Internal; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace DevProxy.Proxy.Kestrel; + +/// +/// A forward-proxy engine built on ASP.NET Core Kestrel — Dev Proxy's HTTP(S) +/// interception engine. Hosts a raw TCP endpoint (Kestrel's HTTP middleware is +/// bypassed; a forward proxy speaks the CONNECT protocol and owns the byte stream) +/// and runs the Dev Proxy plugin pipeline against the canonical HTTP model. +/// +public sealed class KestrelProxyEngine( + CertificateAuthority certificateAuthority, + IEnumerable plugins, + ISet urlsToWatch, + IProxyConfiguration configuration, + Dictionary globalData, + ILoggerFactory loggerFactory, + IRootCertificateTrust? rootCertificateTrust = null, + ISystemProxyManager? systemProxyManager = null) : BackgroundService +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var ipAddress = string.IsNullOrWhiteSpace(configuration.IPAddress) + ? IPAddress.Loopback + : IPAddress.Parse(configuration.IPAddress); + var port = configuration.Port; + + // The certificate authority is owned by DI (shared with the cert command, the + // Entra mock plugin, and the cert-download API) — do NOT dispose it here. + var ca = certificateAuthority; + rootCertificateTrust?.EnsureTrusted(ca.RootCertificate); + using var httpHandler = new SocketsHttpHandler + { + UseProxy = false, + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.All, + }; + using var httpClient = new HttpClient(httpHandler, disposeHandler: false); + var forwarder = new UpstreamForwarder(httpClient); + var watchList = HostWatchList.FromUrls(urlsToWatch); + var processFilter = new ProcessFilter(configuration.WatchPids, configuration.WatchProcessNames); + var pipeline = new PluginPipeline( + plugins, + urlsToWatch, + configuration, + globalData, + loggerFactory.CreateLogger()); + var handler = new ProxyConnectionHandler(ca, forwarder, pipeline, watchList, processFilter, _logger); + + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + _ = builder.WebHost.UseKestrelCore(); + _ = builder.Services.AddSingleton(handler); + Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions? listenOptions = null; + builder.WebHost.ConfigureKestrel(options => + options.Listen(ipAddress, port, listen => + { + listen.UseConnectionHandler(); + listenOptions = listen; + })); + + await using var app = builder.Build(); + + await app.StartAsync(stoppingToken).ConfigureAwait(false); + // When --port 0 is used the OS assigns a free port; Kestrel rewrites the + // ListenOptions endpoint to the bound port after StartAsync, so log THAT + // (not the configured 0) so the user can actually connect. + var boundPort = listenOptions?.IPEndPoint?.Port ?? port; + // Publish the actually-bound port back to the shared configuration so the + // host can persist it in the daemon state file (resolves --port 0 to the + // OS-assigned port). This is the channel the host's readiness check, + // `devproxy stop`, and `devproxy status` rely on. + configuration.Port = boundPort; + _logger.LogInformation( + "Dev Proxy (Kestrel engine) listening on {Address}:{Port}", + ipAddress.ToString(), + boundPort.ToString(CultureInfo.InvariantCulture)); + + var systemProxyEnabled = false; + if (configuration.AsSystemProxy && systemProxyManager is not null) + { + // Register with the OS using the actually-bound port (matters for --port 0). + systemProxyManager.Enable(configuration.IPAddress, boundPort); + systemProxyEnabled = true; + } + + try + { + await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Normal shutdown. + } + finally + { + if (systemProxyEnabled) + { + systemProxyManager!.Disable(); + } + + // Bound the graceful drain so lingering client keep-alive connections (or an + // idle CONNECT tunnel) can't stall process shutdown — without this the proxy + // exits cleanly when idle but takes the full Kestrel drain after real traffic, + // which makes `devproxy stop` falsely report "did not stop in time". After the + // grace window Kestrel force-closes any remaining connections. + using var shutdownCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await app.StopAsync(shutdownCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Drain window elapsed; remaining connections were force-closed. + } + } + } +} diff --git a/DevProxy.Proxy.Kestrel/packages.lock.json b/DevProxy.Proxy.Kestrel/packages.lock.json new file mode 100644 index 00000000..f4fcf016 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/packages.lock.json @@ -0,0 +1,161 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Markdig": { + "type": "Transitive", + "resolved": "1.3.0", + "contentHash": "1cWDY3Rhd24SVe66p2ekhEPhaSAXuH3WgGn6EPNjqXL0Y4ycK7GXtq0UE5oeBYircNlqJIEQk9W2vz60hRaezA==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "iZrONyMKPjxfVZnUktqO30QjzNwAGH+AxM61s8lKQnVhgbQ3bn0hiXI129ZmVicEbIcwljyy2OVsIYUR51ZHKQ==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "tu85SRzOT021V7EQlViCiAE7TqldVn469Y6lt5TEn/+XC4/MeNCHgMRSxqYuWqvF4zAQZUhCmtNEZuM3ss4LeA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.9", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.9" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "GRMaiPkqYna/gCsyDffYDWmefGPC3hDrdMw+2rrGcQwhs6uZOsaMQXMJnoXQ35tx9SkBV2ieRRU9N/jLOO6BZw==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "aiEFB+C5EsZGqxvMPazE07hbWsp4iPaufJpanGt5O+lrwv7mJLrqma5haVIgFAPCyhQkmk75XSCEubT1zUjxtA==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "dEoyYKgiaZHHgOFm1WMWm1sFEsEuhPWufX4L9PekKtqd/RaIcPjkCjvbrVvJtApErb5wPSJhYvnTlxhH+p9h2g==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.9" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "HH9/nnRF/YmRrc3hUlgXjMBYKH5kFmd5UWC81l9U0ySQhwHTcgvDPSewB8DyQHzFJzNGgG7VFK6ynC6+XQz9WA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.9", + "Microsoft.Extensions.DependencyModel": "10.0.9", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "3bPEmACAaPJJSw+m5XwTSi3yZnVtaifa4d8gLsNMzW0Qu28jS5kADSfgJRBlq49RJ1K098VCzEDRJwM8gE6f2w==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.9", + "Microsoft.EntityFrameworkCore.Relational": "10.0.9", + "Microsoft.Extensions.DependencyModel": "10.0.9", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "SCDTQ6HubnRvTUjR7dgMKHZvNoCb03t44ttHL8trlFTGgfDteWn/0nRdOxDhcI+lTWhKgd/flCVJEtAOPhSLNg==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "3.7.0", + "contentHash": "7Ld/wUsxSEKBjAk8nPZ13qkju5kNoh20gf0JHPeHrK41tMZRpIq9amXFAOHncicjg0U5M035I+6/z3cBsYBHfg==" + }, + "Microsoft.OpenApi.YamlReader": { + "type": "Transitive", + "resolved": "3.7.0", + "contentHash": "+KSHfoEiXDFmCeIG6T5xAuYNFulwfxxBh4AJOY6dvGrDeFVV4eL4/xP/RNEaFYvcSZpLkj3ZoQ8Vn3vtUViu5g==", + "dependencies": { + "Microsoft.OpenApi": "3.7.0", + "SharpYaml": "2.1.4" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Newtonsoft.Json.Schema": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "rbHUKp5WTIbqmLEeJ21nTTDGcfR0LA7bVMzm0bYc3yx6NFKiCIHzzvYbwA4Sqgs7+wNldc5nBlkbithWj8IZig==", + "dependencies": { + "Newtonsoft.Json": "13.0.3" + } + }, + "Scriban": { + "type": "Transitive", + "resolved": "7.2.4", + "contentHash": "hx7WeBo0aObZ3v9ZzicZYQtu7fH+I1pRRnzQbv8r0blUhiH9Ay+60/GwkAJZJ7133dr3ZWkzqUqnSloczOf+jw==" + }, + "SharpYaml": { + "type": "Transitive", + "resolved": "2.1.4", + "contentHash": "/iwULhVBpTjD4wPZhLU+eUWBanDvri/2AGx5YbaAj5kp9kXzhqUfJEy56H5Yi+c+OXsdm/oKD1aTKB24BFp8cw==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.CommandLine": { + "type": "Transitive", + "resolved": "2.0.9", + "contentHash": "SW0WhEk4NFVZ4lOnsLrHQOV/7s0eTidezNybHQWXfqhuXWB17X3RXbrifeWBbUx1iu+NcYchVSufmW7svjUEnA==" + }, + "YamlDotNet": { + "type": "Transitive", + "resolved": "18.0.0", + "contentHash": "ptHVgcYmLejGuWXV7RMFoEqFKYMXnieOlWLPzEslfDtzZ9ngMhjYwykfqjBN2+fMEAEyobozkj07lKEpR4dssA==" + }, + "devproxy.abstractions": { + "type": "Project", + "dependencies": { + "Markdig": "[1.3.0, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.9, )", + "Microsoft.OpenApi": "[3.7.0, )", + "Microsoft.OpenApi.YamlReader": "[3.7.0, )", + "Newtonsoft.Json.Schema": "[4.0.1, )", + "Scriban": "[7.2.4, )", + "System.CommandLine": "[2.0.9, )", + "YamlDotNet": "[18.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/DevProxy.Tests/ConsoleHotkeyHandlerTests.cs b/DevProxy.Tests/ConsoleHotkeyHandlerTests.cs new file mode 100644 index 00000000..4df2799f --- /dev/null +++ b/DevProxy.Tests/ConsoleHotkeyHandlerTests.cs @@ -0,0 +1,148 @@ +// 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 DevProxy.Proxy; +using Xunit; + +namespace DevProxy.Tests; + +/// +/// Verifies the interactive hotkey dispatch and banners restored after the +/// Kestrel cut-over (the legacy engine's ReadKeysAsync/PrintHotkeys behavior). +/// +public sealed class ConsoleHotkeyHandlerTests +{ + private static (ConsoleHotkeyHandler handler, FakeProxyStateController controller, RecordingConsole console) + CreateHandler(OutputFormat output = OutputFormat.Text) + { + var controller = new FakeProxyStateController(); + var console = new RecordingConsole(); + var configuration = new FakeProxyConfiguration { Output = output }; + var handler = new ConsoleHotkeyHandler(controller, configuration, console); + return (handler, controller, console); + } + + [Fact] + public async Task HandleKeyAsync_R_StartsRecording() + { + var (handler, controller, _) = CreateHandler(); + + await handler.HandleKeyAsync(ConsoleKey.R, CancellationToken.None); + + Assert.Equal(1, controller.StartRecordingCalls); + Assert.Equal(0, controller.StopRecordingCalls); + Assert.Equal(0, controller.MockRequestCalls); + } + + [Fact] + public async Task HandleKeyAsync_S_StopsRecording() + { + var (handler, controller, _) = CreateHandler(); + + await handler.HandleKeyAsync(ConsoleKey.S, CancellationToken.None); + + Assert.Equal(1, controller.StopRecordingCalls); + Assert.Equal(0, controller.StartRecordingCalls); + } + + [Fact] + public async Task HandleKeyAsync_W_IssuesMockRequest() + { + var (handler, controller, _) = CreateHandler(); + + await handler.HandleKeyAsync(ConsoleKey.W, CancellationToken.None); + + Assert.Equal(1, controller.MockRequestCalls); + } + + [Fact] + public async Task HandleKeyAsync_C_ClearsAndReprintsHotkeys_InTextMode() + { + var (handler, controller, console) = CreateHandler(OutputFormat.Text); + + await handler.HandleKeyAsync(ConsoleKey.C, CancellationToken.None); + + Assert.Equal(1, console.ClearCount); + Assert.Contains(console.Lines, l => l.Contains("Hotkeys:", StringComparison.Ordinal)); + // C is purely a display action — no recording/mock side effects. + Assert.Equal(0, controller.StartRecordingCalls); + Assert.Equal(0, controller.MockRequestCalls); + } + + [Fact] + public async Task HandleKeyAsync_C_ClearsAndReprintsApiInstructions_InJsonMode() + { + var (handler, _, console) = CreateHandler(OutputFormat.Json); + + await handler.HandleKeyAsync(ConsoleKey.C, CancellationToken.None); + + Assert.Equal(1, console.ClearCount); + Assert.Contains(console.Lines, l => l.Contains("/proxy/mockRequest", StringComparison.Ordinal)); + Assert.DoesNotContain(console.Lines, l => l.Contains("Hotkeys:", StringComparison.Ordinal)); + } + + [Fact] + public async Task HandleKeyAsync_UnknownKey_DoesNothing() + { + var (handler, controller, console) = CreateHandler(); + + await handler.HandleKeyAsync(ConsoleKey.X, CancellationToken.None); + + Assert.Equal(0, controller.StartRecordingCalls); + Assert.Equal(0, controller.StopRecordingCalls); + Assert.Equal(0, controller.MockRequestCalls); + Assert.Equal(0, console.ClearCount); + Assert.Empty(console.Lines); + } + + [Fact] + public void PrintHotkeys_WritesHotkeyHints() + { + var (handler, _, console) = CreateHandler(); + + handler.PrintHotkeys(); + + Assert.Contains(console.Lines, l => l.Contains("(w)eb request", StringComparison.Ordinal)); + Assert.Contains(console.Lines, l => l.Contains("(r)ecord", StringComparison.Ordinal)); + Assert.Contains(console.Lines, l => l.Contains("(s)top recording", StringComparison.Ordinal)); + Assert.Contains(console.Lines, l => l.Contains("(c)lear screen", StringComparison.Ordinal)); + Assert.Contains(console.Lines, l => l.Contains("CTRL+C", StringComparison.Ordinal)); + } + + [Fact] + public void PrintApiInstructions_WritesAllApiCommands() + { + var (handler, _, console) = CreateHandler(OutputFormat.Json); + + handler.PrintApiInstructions(); + + var joined = string.Join('\n', console.Lines); + Assert.Contains("/proxy/mockRequest", joined, StringComparison.Ordinal); + Assert.Contains("\\\"recording\\\": true", joined, StringComparison.Ordinal); + Assert.Contains("\\\"recording\\\": false", joined, StringComparison.Ordinal); + Assert.Contains("/proxy/stopProxy", joined, StringComparison.Ordinal); + } + + [Fact] + public void PrintBanner_TextMode_PrintsHotkeys() + { + var (handler, _, console) = CreateHandler(OutputFormat.Text); + + handler.PrintBanner(); + + Assert.Contains(console.Lines, l => l.Contains("Hotkeys:", StringComparison.Ordinal)); + } + + [Fact] + public void PrintBanner_JsonMode_PrintsApiInstructions() + { + var (handler, _, console) = CreateHandler(OutputFormat.Json); + + handler.PrintBanner(); + + Assert.Contains(console.Lines, l => l.Contains("/proxy/mockRequest", StringComparison.Ordinal)); + Assert.DoesNotContain(console.Lines, l => l.Contains("Hotkeys:", StringComparison.Ordinal)); + } +} diff --git a/DevProxy.Tests/DevProxy.Tests.csproj b/DevProxy.Tests/DevProxy.Tests.csproj new file mode 100644 index 00000000..c2295da3 --- /dev/null +++ b/DevProxy.Tests/DevProxy.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + $(NoWarn);CA1707;CA1861 + + + + + + + + + + + + + + + + + + diff --git a/DevProxy.Tests/Fakes.cs b/DevProxy.Tests/Fakes.cs new file mode 100644 index 00000000..6913784c --- /dev/null +++ b/DevProxy.Tests/Fakes.cs @@ -0,0 +1,92 @@ +// 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.Models; +using DevProxy.Abstractions.Proxy; +using DevProxy.Proxy; +using Microsoft.Extensions.Logging; + +namespace DevProxy.Tests; + +/// Records hotkey-driven controller calls so tests can assert dispatch. +internal sealed class FakeProxyStateController : IProxyStateController +{ + public int StartRecordingCalls { get; private set; } + public int StopRecordingCalls { get; private set; } + public int MockRequestCalls { get; private set; } + public int StopProxyCalls { get; private set; } + + public IProxyState ProxyState { get; } = new FakeProxyState(); + + public void StartRecording() => StartRecordingCalls++; + + public Task StopRecordingAsync(CancellationToken cancellationToken) + { + StopRecordingCalls++; + return Task.CompletedTask; + } + + public Task MockRequestAsync(CancellationToken cancellationToken) + { + MockRequestCalls++; + return Task.CompletedTask; + } + + public void StopProxy() => StopProxyCalls++; +} + +internal sealed class FakeProxyState : IProxyState +{ + public Dictionary GlobalData { get; } = []; + public bool IsRecording { get; set; } + public ConcurrentQueue RequestLogs { get; } = new(); +} + +/// In-memory capturing output for assertions. +internal sealed class RecordingConsole : ISystemConsole +{ + private readonly Queue _keys = new(); + + public List Lines { get; } = []; + public int ClearCount { get; private set; } + public bool IsInputRedirected { get; set; } + + public bool KeyAvailable => _keys.Count > 0; + + public ConsoleKey ReadKey() => _keys.Dequeue(); + + public void Clear() => ClearCount++; + + public void WriteLine(string value) => Lines.Add(value); + + public void EnqueueKey(ConsoleKey key) => _keys.Enqueue(key); +} + +/// +/// Minimal ; only Output/IPAddress/ApiPort/Record +/// are read by the interactive console, the rest carry inert defaults. +/// +internal sealed class FakeProxyConfiguration : IProxyConfiguration +{ + public int ApiPort { get; set; } = 8897; + 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; } = 8000; + 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.sln b/DevProxy.sln index 9a4c219d..9df1c538 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -16,24 +16,122 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevProxy.Plugins", "DevProx EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevProxy.Abstractions", "DevProxy.Abstractions\DevProxy.Abstractions.csproj", "{E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Abstractions.Tests", "DevProxy.Abstractions.Tests\DevProxy.Abstractions.Tests.csproj", "{CCC4F886-0DB7-418A-BE8E-B540D173D39D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Kestrel", "DevProxy.Proxy.Kestrel\DevProxy.Proxy.Kestrel.csproj", "{E357B2FB-0A62-4DCF-AFA0-D258647EA664}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Kestrel.Tests", "DevProxy.Proxy.Kestrel.Tests\DevProxy.Proxy.Kestrel.Tests.csproj", "{2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Integration.Tests", "DevProxy.Integration.Tests\DevProxy.Integration.Tests.csproj", "{90E10CFA-DEF0-456B-8641-102C7072931B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Tests", "DevProxy.Tests\DevProxy.Tests.csproj", "{C9A32664-0227-4F43-8980-CF9BB75052AD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A1047491-5E52-4755-B5D7-17B3774E35FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1047491-5E52-4755-B5D7-17B3774E35FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1047491-5E52-4755-B5D7-17B3774E35FC}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1047491-5E52-4755-B5D7-17B3774E35FC}.Debug|x64.Build.0 = Debug|Any CPU + {A1047491-5E52-4755-B5D7-17B3774E35FC}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1047491-5E52-4755-B5D7-17B3774E35FC}.Debug|x86.Build.0 = Debug|Any CPU {A1047491-5E52-4755-B5D7-17B3774E35FC}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1047491-5E52-4755-B5D7-17B3774E35FC}.Release|Any CPU.Build.0 = Release|Any CPU + {A1047491-5E52-4755-B5D7-17B3774E35FC}.Release|x64.ActiveCfg = Release|Any CPU + {A1047491-5E52-4755-B5D7-17B3774E35FC}.Release|x64.Build.0 = Release|Any CPU + {A1047491-5E52-4755-B5D7-17B3774E35FC}.Release|x86.ActiveCfg = Release|Any CPU + {A1047491-5E52-4755-B5D7-17B3774E35FC}.Release|x86.Build.0 = Release|Any CPU {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Debug|x64.ActiveCfg = Debug|Any CPU + {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Debug|x64.Build.0 = Debug|Any CPU + {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Debug|x86.ActiveCfg = Debug|Any CPU + {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Debug|x86.Build.0 = Debug|Any CPU {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Release|Any CPU.ActiveCfg = Release|Any CPU {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Release|Any CPU.Build.0 = Release|Any CPU + {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Release|x64.ActiveCfg = Release|Any CPU + {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Release|x64.Build.0 = Release|Any CPU + {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Release|x86.ActiveCfg = Release|Any CPU + {CEEEAB75-B242-47CC-9956-2C4311EF010A}.Release|x86.Build.0 = Release|Any CPU {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Debug|x64.Build.0 = Debug|Any CPU + {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Debug|x86.Build.0 = Debug|Any CPU {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Release|Any CPU.ActiveCfg = Release|Any CPU {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Release|Any CPU.Build.0 = Release|Any CPU + {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Release|x64.ActiveCfg = Release|Any CPU + {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Release|x64.Build.0 = Release|Any CPU + {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Release|x86.ActiveCfg = Release|Any CPU + {E9AADB3C-A855-4E1A-8D91-036FC0EAC85A}.Release|x86.Build.0 = Release|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Debug|x64.ActiveCfg = Debug|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Debug|x64.Build.0 = Debug|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Debug|x86.ActiveCfg = Debug|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Debug|x86.Build.0 = Debug|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Release|Any CPU.Build.0 = Release|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Release|x64.ActiveCfg = Release|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Release|x64.Build.0 = Release|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Release|x86.ActiveCfg = Release|Any CPU + {CCC4F886-0DB7-418A-BE8E-B540D173D39D}.Release|x86.Build.0 = Release|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Debug|x64.ActiveCfg = Debug|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Debug|x64.Build.0 = Debug|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Debug|x86.ActiveCfg = Debug|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Debug|x86.Build.0 = Debug|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Release|Any CPU.Build.0 = Release|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Release|x64.ActiveCfg = Release|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Release|x64.Build.0 = Release|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Release|x86.ActiveCfg = Release|Any CPU + {E357B2FB-0A62-4DCF-AFA0-D258647EA664}.Release|x86.Build.0 = Release|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Debug|x64.ActiveCfg = Debug|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Debug|x64.Build.0 = Debug|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Debug|x86.ActiveCfg = Debug|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Debug|x86.Build.0 = Debug|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Release|Any CPU.Build.0 = Release|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Release|x64.ActiveCfg = Release|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Release|x64.Build.0 = Release|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Release|x86.ActiveCfg = Release|Any CPU + {2154ED80-D4A3-4DDD-B5E0-919F6FBABF2E}.Release|x86.Build.0 = Release|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Debug|x64.ActiveCfg = Debug|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Debug|x64.Build.0 = Debug|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Debug|x86.ActiveCfg = Debug|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Debug|x86.Build.0 = Debug|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Release|Any CPU.Build.0 = Release|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Release|x64.ActiveCfg = Release|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Release|x64.Build.0 = Release|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Release|x86.ActiveCfg = Release|Any CPU + {90E10CFA-DEF0-456B-8641-102C7072931B}.Release|x86.Build.0 = Release|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Debug|x64.Build.0 = Debug|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Debug|x86.Build.0 = Debug|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Release|Any CPU.Build.0 = Release|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Release|x64.ActiveCfg = Release|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Release|x64.Build.0 = Release|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Release|x86.ActiveCfg = Release|Any CPU + {C9A32664-0227-4F43-8980-CF9BB75052AD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DevProxy/ApiControllers/ProxyController.cs b/DevProxy/ApiControllers/ProxyController.cs index d5dd36ee..31b55b0c 100644 --- a/DevProxy/ApiControllers/ProxyController.cs +++ b/DevProxy/ApiControllers/ProxyController.cs @@ -15,12 +15,12 @@ namespace DevProxy.ApiControllers; [ApiController] [Route("[controller]")] #pragma warning disable CA1515 // required for the API controller -public sealed class ProxyController(IProxyStateController proxyStateController, IProxyConfiguration proxyConfiguration, ILoggerFactory loggerFactory) : ControllerBase +public sealed class ProxyController(IProxyStateController proxyStateController, IProxyConfiguration proxyConfiguration, X509Certificate2 rootCertificate) : ControllerBase #pragma warning restore CA1515 { private readonly IProxyStateController _proxyStateController = proxyStateController; private readonly IProxyConfiguration _proxyConfiguration = proxyConfiguration; - private readonly ILoggerFactory _loggerFactory = loggerFactory; + private readonly X509Certificate2 _rootCertificate = rootCertificate; [HttpGet] public ProxyInfo Get() => ProxyInfo.From(_proxyStateController.ProxyState, _proxyConfiguration); @@ -116,20 +116,7 @@ public IActionResult GetRootCertificate([FromQuery][Required] string format) return ValidationProblem(ModelState); } - // Ensure ProxyServer is initialized with LoggerFactory for Unobtanium logging - ProxyEngine.EnsureProxyServerInitialized(_loggerFactory); - - var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate; - if (certificate == null) - { - var problemDetails = new ProblemDetails - { - Title = "Certificate Not Found", - Detail = "No root certificate found.", - Status = StatusCodes.Status404NotFound - }; - return NotFound(problemDetails); - } + var certificate = _rootCertificate; var certBytes = certificate.Export(X509ContentType.Cert); var base64Cert = Convert.ToBase64String(certBytes, Base64FormattingOptions.InsertLineBreaks); diff --git a/DevProxy/Commands/CertCommand.cs b/DevProxy/Commands/CertCommand.cs index b30077e4..4c2a4e16 100644 --- a/DevProxy/Commands/CertCommand.cs +++ b/DevProxy/Commands/CertCommand.cs @@ -2,27 +2,33 @@ // 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.Proxy; using System.CommandLine; using System.CommandLine.Parsing; -using Titanium.Web.Proxy.Helpers; +using System.Security.Cryptography.X509Certificates; namespace DevProxy.Commands; sealed class CertCommand : Command { private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; + private readonly X509Certificate2 _rootCertificate; + private readonly IRootCertificateTrust _rootCertificateTrust; private readonly Option _forceOption = new("--force", "-f") { Description = "Don't prompt for confirmation when removing the certificate. Required for non-interactive use (CI, piped stdin, automation)." }; - public CertCommand(ILogger logger, ILoggerFactory loggerFactory) : + public CertCommand( + ILogger logger, + X509Certificate2 rootCertificate, + IRootCertificateTrust rootCertificateTrust) : base("cert", "Manage the Dev Proxy certificate") { _logger = logger; - _loggerFactory = loggerFactory; + _rootCertificate = rootCertificate; + _rootCertificateTrust = rootCertificateTrust; ConfigureCommand(); } @@ -48,27 +54,15 @@ private void ConfigureCommand() ]); } - private async Task EnsureCertAsync() + private Task EnsureCertAsync() { _logger.LogTrace("EnsureCertAsync() called"); try { - // Ensure ProxyServer is initialized with LoggerFactory for Unobtanium logging - ProxyEngine.EnsureProxyServerInitialized(_loggerFactory); - + // Resolving the shared root certificate creates + persists it on first run. _logger.LogInformation("Ensuring certificate exists and is trusted..."); - await ProxyEngine.ProxyServer.CertificateManager.EnsureRootCertificateAsync(); - - if (RunTime.IsMac) - { - var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate; - if (certificate is not null) - { - MacCertificateHelper.TrustCertificate(certificate, _logger); - } - } - + _rootCertificateTrust.Trust(_rootCertificate); _logger.LogInformation("DONE"); } catch (Exception ex) @@ -77,6 +71,7 @@ private async Task EnsureCertAsync() } _logger.LogTrace("EnsureCertAsync() finished"); + return Task.CompletedTask; } public int RemoveCert(ParseResult parseResult) @@ -104,23 +99,7 @@ public int RemoveCert(ParseResult parseResult) _logger.LogInformation("Uninstalling the root certificate..."); - // Ensure ProxyServer is initialized with LoggerFactory for Unobtanium logging - ProxyEngine.EnsureProxyServerInitialized(_loggerFactory); - - if (RunTime.IsMac) - { - var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate; - if (certificate is not null) - { - MacCertificateHelper.RemoveTrustedCertificate(certificate, _logger); - } - - HasRunFlag.Remove(); - } - else - { - ProxyEngine.ProxyServer.CertificateManager.RemoveTrustedRootCertificate(machineTrusted: false); - } + _rootCertificateTrust.Untrust(_rootCertificate); _logger.LogInformation("DONE"); return 0; diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index 4308bc65..6cde4062 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -316,11 +316,12 @@ private async Task InvokeAsync(ParseResult parseResult, CancellationToken c var address = serverAddresses?.Addresses.FirstOrDefault() ?? $"http://{_proxyConfiguration.IPAddress}:{_proxyConfiguration.ApiPort}"; _logger.LogInformation("Dev Proxy API listening on {Address}...", address); - // Update state file with the actual Kestrel API address - // (resolves port 0 to OS-assigned port) + // Persist the daemon state so the parent process's readiness check, + // `devproxy stop`, and `devproxy status` can find this instance + // (resolves port 0 to the OS-assigned ports for both proxy and API). if (IsInternalDaemon) { - _ = UpdateStateWithApiUrlAsync(address); + _ = WriteDaemonStateAsync(address); } }); await _app.RunAsync(cancellationToken); @@ -736,13 +737,29 @@ private async Task CheckForNewVersionAsync() } } - private static async Task UpdateStateWithApiUrlAsync(string apiUrl) + private async Task WriteDaemonStateAsync(string apiUrl) { - var state = await StateManager.LoadStateByPidAsync(Environment.ProcessId); - if (state is not null) + // The proxy engine publishes its actually-bound port back to the shared + // configuration once it binds. Wait briefly for it so the persisted state + // carries the real proxy port (matters for --port 0); the parent's readiness + // poll requires Port > 0 before it reports the daemon as started. + var deadline = Environment.TickCount64 + 10_000; + while (_proxyConfiguration.Port <= 0 && Environment.TickCount64 < deadline) { - state.ApiUrl = apiUrl; - await StateManager.SaveStateAsync(state); + await Task.Delay(50); } + + var state = new ProxyInstanceState + { + Pid = Environment.ProcessId, + ApiUrl = apiUrl, + LogFile = DetachedLogFilePath, + StartedAt = DateTimeOffset.UtcNow, + ConfigFile = _proxyConfiguration.ConfigFile, + Port = _proxyConfiguration.Port, + AsSystemProxy = _proxyConfiguration.AsSystemProxy + }; + + await StateManager.SaveStateAsync(state); } } \ No newline at end of file diff --git a/DevProxy/Commands/StopCommand.cs b/DevProxy/Commands/StopCommand.cs index 5767f34d..59c3d34e 100644 --- a/DevProxy/Commands/StopCommand.cs +++ b/DevProxy/Commands/StopCommand.cs @@ -2,7 +2,9 @@ // 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; using DevProxy.State; +using Microsoft.Extensions.Logging.Abstractions; using System.CommandLine; using System.Diagnostics; @@ -146,7 +148,10 @@ private static async Task StopInstanceAsync(ProxyInstanceState state, bool private static async Task ForceStopAsync(ProxyInstanceState state, CancellationToken cancellationToken) { - DisableSystemProxy(); + // Best-effort: restore the OS proxy in case the daemon is killed before it can + // deregister itself (SIGKILL can't be caught; a crashed daemon never cleans up). + // Engine-agnostic and cross-platform (Windows WinINET + macOS toggle-proxy.sh). + new SystemProxyManager(NullLogger.Instance).Disable(); try { @@ -172,46 +177,4 @@ private static async Task ForceStopAsync(ProxyInstanceState state, Cancella await StateManager.DeleteStateAsync(state.Pid, cancellationToken); return 0; } - - /// - /// Disables the system proxy on macOS by calling toggle-proxy.sh off. - /// This ensures the system proxy settings are cleaned up even when the - /// daemon process is killed forcefully (SIGKILL cannot be caught). - /// - private static void DisableSystemProxy() - { - if (!OperatingSystem.IsMacOS()) - { - return; - } - - var bashScriptPath = Path.Join(AppContext.BaseDirectory, "toggle-proxy.sh"); - if (!File.Exists(bashScriptPath)) - { - return; - } - - var startInfo = new ProcessStartInfo - { - FileName = "/bin/bash", - Arguments = $"{bashScriptPath} off", - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - try - { - using var process = new Process { StartInfo = startInfo }; - process.Start(); - if (!process.WaitForExit(TimeSpan.FromSeconds(10))) - { - process.Kill(); - } - } - catch - { - // Best-effort cleanup — don't block the stop flow - } - } } \ No newline at end of file diff --git a/DevProxy/DevProxy.csproj b/DevProxy/DevProxy.csproj index 64e1f019..81ad32b9 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -8,7 +8,7 @@ enable LICENSE Dev Proxy - 3.1.0 + 4.0.0 .NET Foundation Dev Proxy devproxy @@ -42,11 +42,15 @@ - + + + + + diff --git a/DevProxy/Extensions/ILoggingBuilderExtensions.cs b/DevProxy/Extensions/ILoggingBuilderExtensions.cs index 8f0b0081..50ebafaf 100644 --- a/DevProxy/Extensions/ILoggingBuilderExtensions.cs +++ b/DevProxy/Extensions/ILoggingBuilderExtensions.cs @@ -68,8 +68,7 @@ public static ILoggingBuilder ConfigureDevProxyLogging( .AddFilter("Microsoft.Hosting.*", LogLevel.Error) .AddFilter("Microsoft.AspNetCore.*", LogLevel.Error) .AddFilter("Microsoft.Extensions.*", LogLevel.Error) - .AddFilter("System.*", LogLevel.Error) - .AddFilter("Titanium.Web.Proxy.*", LogLevel.Warning) + .AddFilter("System.*", LogLevel.Error) // Only show plugin messages when no global options are set .AddFilter("DevProxy.Plugins.*", level => level >= configuredLogLevel && @@ -106,8 +105,7 @@ public static ILoggingBuilder ConfigureDevProxyLogging( .AddFilter("Microsoft.Hosting.*", LogLevel.Error) .AddFilter("Microsoft.AspNetCore.*", LogLevel.Error) .AddFilter("Microsoft.Extensions.*", LogLevel.Error) - .AddFilter("System.*", LogLevel.Error) - .AddFilter("Titanium.Web.Proxy.*", LogLevel.Warning) + .AddFilter("System.*", LogLevel.Error) .AddFilter("DevProxy.Plugins.*", LogLevel.None) .AddConsole(consoleOptions => { diff --git a/DevProxy/Extensions/IServiceCollectionExtensions.cs b/DevProxy/Extensions/IServiceCollectionExtensions.cs index 13d10cfb..cdf53e50 100644 --- a/DevProxy/Extensions/IServiceCollectionExtensions.cs +++ b/DevProxy/Extensions/IServiceCollectionExtensions.cs @@ -5,9 +5,13 @@ using DevProxy; using DevProxy.Abstractions.Data; using DevProxy.Abstractions.LanguageModel; +using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; using DevProxy.Commands; using DevProxy.Proxy; +using DevProxy.Proxy.Kestrel; +using DevProxy.Proxy.Kestrel.Internal; +using Microsoft.Extensions.Logging; #pragma warning disable IDE0130 namespace Microsoft.Extensions.DependencyInjection; @@ -33,12 +37,33 @@ public static IServiceCollection ConfigureDevProxyServices( }); _ = services .AddApplicationServices(configuration, options) - .AddHostedService() + .AddProxyEngine() .Configure(options => options.LowercaseUrls = true); return services; } + // The Kestrel engine is the sole proxy engine. It receives the shared + // CertificateAuthority (registered via AddKestrelCertificateAuthority) plus the + // host's platform trust + system-proxy implementations. + static IServiceCollection AddProxyEngine(this IServiceCollection services) + { + _ = services.AddSingleton(); + _ = services.AddSingleton(); + _ = services.AddKestrelCertificateAuthority(); + _ = services.AddHostedService(sp => new KestrelProxyEngine( + sp.GetRequiredService(), + sp.GetServices(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService().GlobalData, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + return services; + } + static IServiceCollection AddApplicationServices( this IServiceCollection services, ConfigurationManager configuration, @@ -49,11 +74,11 @@ static IServiceCollection AddApplicationServices( .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddHostedService() - .AddSingleton(sp => ProxyEngine.Certificate!) + .AddHostedService() .AddSingleton(sp => LanguageModelClientFactory.Create(sp, configuration)) .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddHttpClient(); diff --git a/DevProxy/Logging/ProxyConsoleFormatter.cs b/DevProxy/Logging/ProxyConsoleFormatter.cs index f1e3b3ad..62b4604c 100644 --- a/DevProxy/Logging/ProxyConsoleFormatter.cs +++ b/DevProxy/Logging/ProxyConsoleFormatter.cs @@ -4,6 +4,7 @@ using DevProxy.Abstractions.Proxy; using DevProxy.Proxy; +using DevProxy.Proxy.Kestrel; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Options; @@ -247,7 +248,7 @@ private void WritePluginName(TextWriter textWriter, string? categoryOrPluginName } var pluginName = categoryOrPluginName[(categoryOrPluginName.LastIndexOf('.') + 1)..]; - if (pluginName != nameof(ProxyEngine)) + if (pluginName != nameof(KestrelProxyEngine)) { textWriter.Write($"{pluginName}: "); } diff --git a/DevProxy/Plugins/PluginServiceExtensions.cs b/DevProxy/Plugins/PluginServiceExtensions.cs index 90f48b23..c8a292e3 100644 --- a/DevProxy/Plugins/PluginServiceExtensions.cs +++ b/DevProxy/Plugins/PluginServiceExtensions.cs @@ -250,7 +250,7 @@ private static UrlToWatch ConvertToRegex(string stringMatcher) } return new UrlToWatch( - new Regex($"^{Regex.Escape(stringMatcher).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase)}$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + new Regex($"^{Regex.Escape(ProxyUtils.NormalizeWebSocketScheme(stringMatcher)).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase)}$", RegexOptions.Compiled | RegexOptions.IgnoreCase), exclude ); } diff --git a/DevProxy/Proxy/CertificateDiskCache.cs b/DevProxy/Proxy/CertificateDiskCache.cs deleted file mode 100644 index e7442a63..00000000 --- a/DevProxy/Proxy/CertificateDiskCache.cs +++ /dev/null @@ -1,142 +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. - -using System.Security.Cryptography.X509Certificates; -using Titanium.Web.Proxy.Certificates.Cache; -using Titanium.Web.Proxy.Helpers; - -namespace DevProxy.Proxy; - -// based on https://github.com/justcoding121/titanium-web-proxy/blob/9e71608d204e5b67085656dd6b355813929801e4/src/Titanium.Web.Proxy/Certificates/Cache/DefaultCertificateDiskCache.cs -internal sealed class CertificateDiskCache : ICertificateCache -{ - private const string DefaultCertificateDirectoryName = "crts"; - private const string DefaultCertificateFileExtension = ".pfx"; - private const string DefaultRootCertificateFileName = "rootCert" + DefaultCertificateFileExtension; - private const string ProxyConfigurationFolderName = "dev-proxy"; - - private string? rootCertificatePath; - - public Task LoadRootCertificateAsync(string pathOrName, string password, X509KeyStorageFlags storageFlags, CancellationToken cancellationToken) - { - var path = GetRootCertificatePath(pathOrName, false); - return Task.FromResult(LoadCertificate(path, password, storageFlags)); - } - - public async Task SaveRootCertificateAsync(string pathOrName, string password, X509Certificate2 certificate, CancellationToken cancellationToken) - { - var path = GetRootCertificatePath(pathOrName, true); - var exported = certificate.Export(X509ContentType.Pkcs12, password); - await File.WriteAllBytesAsync(path, exported, cancellationToken); - } - - public Task LoadCertificateAsync(string subjectName, X509KeyStorageFlags storageFlags, CancellationToken cancellationToken) - { - var filePath = Path.Combine(GetCertificatePath(false), subjectName + DefaultCertificateFileExtension); - return Task.FromResult(LoadCertificate(filePath, string.Empty, storageFlags)); - } - - public async Task SaveCertificateAsync(string subjectName, X509Certificate2 certificate, CancellationToken cancellationToken) - { - var filePath = Path.Combine(GetCertificatePath(true), subjectName + DefaultCertificateFileExtension); - var exported = certificate.Export(X509ContentType.Pkcs12); - await File.WriteAllBytesAsync(filePath, exported, cancellationToken); - } - - public void Clear() - { - try - { - var path = GetCertificatePath(false); - if (Directory.Exists(path)) - { - Directory.Delete(path, true); - } - } - catch (Exception) - { - // do nothing - } - } - - private string GetRootCertificatePath(string pathOrName, bool create) - { - if (Path.IsPathRooted(pathOrName)) - { - return pathOrName; - } - - return Path.Combine(GetRootCertificateDirectory(create), - string.IsNullOrEmpty(pathOrName) ? DefaultRootCertificateFileName : pathOrName); - } - - private string GetCertificatePath(bool create) - { - var path = GetRootCertificateDirectory(create); - - var certPath = Path.Combine(path, DefaultCertificateDirectoryName); - if (create && !Directory.Exists(certPath)) - { - _ = Directory.CreateDirectory(certPath); - } - - return certPath; - } - - private string GetRootCertificateDirectory(bool create) - { - if (rootCertificatePath == null) - { - if (RunTime.IsUwpOnWindows) - { - rootCertificatePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ProxyConfigurationFolderName); - } - else if (RunTime.IsLinux) - { - rootCertificatePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ProxyConfigurationFolderName); - } - else if (RunTime.IsMac) - { - rootCertificatePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ProxyConfigurationFolderName); - } - else - { - var assemblyLocation = AppContext.BaseDirectory; - - var path = Path.GetDirectoryName(assemblyLocation); - - rootCertificatePath = path ?? throw new InvalidOperationException("Unable to resolve root certificate directory path."); - } - } - - if (create && !Directory.Exists(rootCertificatePath)) - { - _ = Directory.CreateDirectory(rootCertificatePath); - } - - return rootCertificatePath; - } - - private static X509Certificate2? LoadCertificate(string path, string password, X509KeyStorageFlags storageFlags) - { - byte[] exported; - - if (!File.Exists(path)) - { - return null; - } - - try - { - exported = File.ReadAllBytes(path); - } - catch (IOException) - { - // file or directory not found - return null; - } - - return X509CertificateLoader.LoadPkcs12(exported, password, storageFlags); - } -} \ No newline at end of file diff --git a/DevProxy/Proxy/ConsoleHotkeyHandler.cs b/DevProxy/Proxy/ConsoleHotkeyHandler.cs new file mode 100644 index 00000000..d804280a --- /dev/null +++ b/DevProxy/Proxy/ConsoleHotkeyHandler.cs @@ -0,0 +1,91 @@ +// 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 DevProxy.Abstractions.Proxy; + +namespace DevProxy.Proxy; + +/// +/// Renders the startup banner and maps interactive key presses to proxy actions. +/// This is the testable core of the interactive console — it has no console-loop +/// or host-lifetime concerns, so it can be exercised with a fake +/// and a fake . +/// +/// Key → action dispatch: +/// +/// key +/// ├─ R ──► controller.StartRecording() +/// ├─ S ──► controller.StopRecordingAsync() +/// ├─ W ──► controller.MockRequestAsync() (issue a (w)eb request) +/// ├─ C ──► console.Clear() + reprint banner (Json → API help, else hotkeys) +/// └─ * ──► (ignored) +/// +/// +internal sealed class ConsoleHotkeyHandler( + IProxyStateController controller, + IProxyConfiguration configuration, + ISystemConsole console) +{ + /// + /// Prints the banner appropriate for the current output mode: machine-readable + /// API instructions in JSON mode, human hotkey hints otherwise. + /// + public void PrintBanner() + { + if (configuration.Output == OutputFormat.Json) + { + PrintApiInstructions(); + } + else + { + PrintHotkeys(); + } + } + + public void PrintHotkeys() + { + console.WriteLine(""); + console.WriteLine("Hotkeys: issue (w)eb request, (r)ecord, (s)top recording, (c)lear screen"); + console.WriteLine("Press CTRL+C to stop Dev Proxy"); + console.WriteLine(""); + } + + public void PrintApiInstructions() + { + var baseUrl = $"http://{configuration.IPAddress}:{configuration.ApiPort}/proxy"; + var timestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + console.WriteLine(""); + console.WriteLine($"{{\"type\":\"log\",\"level\":\"info\",\"message\":\"Issue web request: curl -X POST {baseUrl}/mockRequest\",\"category\":\"ProxyEngine\",\"timestamp\":\"{timestamp}\"}}"); + console.WriteLine($"{{\"type\":\"log\",\"level\":\"info\",\"message\":\"Start recording: curl -X POST {baseUrl} -H \\\"Content-Type: application/json\\\" -d '{{\\\"recording\\\": true}}'\",\"category\":\"ProxyEngine\",\"timestamp\":\"{timestamp}\"}}"); + console.WriteLine($"{{\"type\":\"log\",\"level\":\"info\",\"message\":\"Stop recording: curl -X POST {baseUrl} -H \\\"Content-Type: application/json\\\" -d '{{\\\"recording\\\": false}}'\",\"category\":\"ProxyEngine\",\"timestamp\":\"{timestamp}\"}}"); + console.WriteLine($"{{\"type\":\"log\",\"level\":\"info\",\"message\":\"Stop Dev Proxy: curl -X POST {baseUrl}/stopProxy\",\"category\":\"ProxyEngine\",\"timestamp\":\"{timestamp}\"}}"); + console.WriteLine(""); + } + + /// + /// Dispatches a single key press. Unknown keys are ignored. + /// + public async Task HandleKeyAsync(ConsoleKey key, CancellationToken cancellationToken) + { + switch (key) + { + case ConsoleKey.R: + controller.StartRecording(); + break; + case ConsoleKey.S: + await controller.StopRecordingAsync(cancellationToken); + break; + case ConsoleKey.W: + await controller.MockRequestAsync(cancellationToken); + break; + case ConsoleKey.C: + console.Clear(); + PrintBanner(); + break; + default: + break; + } + } +} diff --git a/DevProxy/Proxy/ISystemConsole.cs b/DevProxy/Proxy/ISystemConsole.cs new file mode 100644 index 00000000..17f3b606 --- /dev/null +++ b/DevProxy/Proxy/ISystemConsole.cs @@ -0,0 +1,29 @@ +// 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.Proxy; + +/// +/// A thin seam over so the interactive hotkey +/// handling can be unit-tested without a real terminal. Named +/// ISystemConsole (not IConsole) to avoid colliding with +/// System.CommandLine.IConsole, which is also referenced by the host. +/// +internal interface ISystemConsole +{ + /// Whether stdin is redirected (piped/non-interactive). + bool IsInputRedirected { get; } + + /// Whether a key press is waiting to be read. + bool KeyAvailable { get; } + + /// Reads the next key without echoing it to the terminal. + ConsoleKey ReadKey(); + + /// Clears the terminal. + void Clear(); + + /// Writes a line to stdout. + void WriteLine(string value); +} diff --git a/DevProxy/Proxy/InteractiveConsoleService.cs b/DevProxy/Proxy/InteractiveConsoleService.cs new file mode 100644 index 00000000..a371f50a --- /dev/null +++ b/DevProxy/Proxy/InteractiveConsoleService.cs @@ -0,0 +1,139 @@ +// 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 DevProxy.Commands; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; + +namespace DevProxy.Proxy; + +/// +/// Host-side interactive console: prints the startup banner, honors the +/// --record flag, and (in an interactive terminal) listens for hotkeys +/// to drive recording and mock requests. +/// +/// This lives in the host — not the Kestrel engine — on purpose. The engine +/// is intentionally headless (it depends only on the HTTP/proxy abstractions and +/// must not reach into host concerns like ). +/// The legacy Titanium engine owned this loop only because it happened to live in +/// the host assembly; the canonical home is here. +/// +/// Startup sequence (after the host has fully started, so the banner appears +/// below the engine's "listening on ..." log): +/// +/// ApplicationStarted +/// │ +/// ├─ --record set? ──► controller.StartRecording() +/// │ +/// ├─ Output == Json? ──► print API instructions (even non-interactive, +/// │ so agents can drive the HTTP API) +/// ├─ else interactive? ──► print hotkeys +/// │ +/// └─ interactive? ── no ──► return (no key loop when piped/daemon/CI) +/// └─ yes ─► poll KeyAvailable → ReadKey → HandleKeyAsync +/// +/// +internal sealed class InteractiveConsoleService( + IProxyStateController controller, + IProxyConfiguration configuration, + ISystemConsole console, + IServer server, + IHostApplicationLifetime lifetime, + ILogger logger) : BackgroundService +{ + // Matches the legacy engine's poll cadence; small enough to feel instant, + // large enough to keep the idle CPU cost negligible. + private static readonly TimeSpan _keyPollInterval = TimeSpan.FromMilliseconds(10); + + private readonly ConsoleHotkeyHandler _handler = new(controller, configuration, console); + + /// + /// Hotkeys are only usable from a real terminal driven by a human. Skip the + /// loop when stdin is redirected (piped/tests), when running as the internal + /// detached daemon, or under CI — reading keys there would either throw or + /// busy-spin against an input that never produces a human key press. + /// + private bool IsInteractive => + !console.IsInputRedirected && + !DevProxyCommand.IsInternalDaemon && + Environment.GetEnvironmentVariable("CI") is null; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!await WaitForApplicationStartedAsync(stoppingToken)) + { + return; + } + + // When --api-port 0 is used, the OS assigns a random port. Resolve it from + // the bound server address now that the host has started, so the banner's + // curl commands reference the actual API port rather than the literal 0. + var apiAddress = server.Features.Get()?.Addresses.FirstOrDefault(); + if (Uri.TryCreate(apiAddress, UriKind.Absolute, out var apiUri) && apiUri.Port > 0) + { + configuration.ApiPort = apiUri.Port; + } + + if (configuration.Record) + { + controller.StartRecording(); + } + + if (configuration.Output == OutputFormat.Json) + { + // Always print API instructions in machine mode so LLMs/agents can use + // the HTTP API even when there's no interactive terminal. + _handler.PrintApiInstructions(); + } + else if (IsInteractive) + { + _handler.PrintHotkeys(); + } + + if (!IsInteractive) + { + return; + } + + logger.LogDebug("Interactive hotkeys enabled."); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + while (!console.KeyAvailable) + { + await Task.Delay(_keyPollInterval, stoppingToken); + } + + await _handler.HandleKeyAsync(console.ReadKey(), stoppingToken); + } + } + catch (OperationCanceledException) + { + // Normal shutdown. + } + } + + /// + /// Waits for the host to finish starting so the banner is printed after the + /// engine's startup logs (and the bound port is known). Returns false + /// if shutdown was requested before the host started. + /// + private async Task WaitForApplicationStartedAsync(CancellationToken stoppingToken) + { + var startedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = lifetime.ApplicationStarted.Register(() => startedTcs.TrySetResult()); + try + { + await startedTcs.Task.WaitAsync(stoppingToken); + return true; + } + catch (OperationCanceledException) + { + return false; + } + } +} diff --git a/DevProxy/Proxy/ProxyEngine.cs b/DevProxy/Proxy/ProxyEngine.cs deleted file mode 100755 index c4c25751..00000000 --- a/DevProxy/Proxy/ProxyEngine.cs +++ /dev/null @@ -1,799 +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. - -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using DevProxy.Commands; -using DevProxy.State; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.VisualStudio.Threading; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Net; -using System.Security.Cryptography.X509Certificates; -using System.Text.RegularExpressions; -using Titanium.Web.Proxy; -using Titanium.Web.Proxy.EventArguments; -using Titanium.Web.Proxy.Helpers; -using Titanium.Web.Proxy.Http; -using Titanium.Web.Proxy.Models; - -namespace DevProxy.Proxy; - -enum ToggleSystemProxyAction -{ - On, - Off -} - -sealed class ProxyEngine( - IEnumerable plugins, - IProxyConfiguration proxyConfiguration, - ISet urlsToWatch, - IProxyStateController proxyController, - ILogger logger, - ILoggerFactory loggerFactory, - IServer server) : BackgroundService, IDisposable -{ - private readonly IEnumerable _plugins = plugins; - private readonly ILogger _logger = logger; - private readonly IProxyConfiguration _config = proxyConfiguration; - - internal static ProxyServer ProxyServer { get; private set; } = null!; - private static bool _isProxyServerInitialized; - private static readonly object _initLock = new(); - private ExplicitProxyEndPoint? _explicitEndPoint; - // lists of URLs to watch, used for intercepting requests - private readonly ISet _urlsToWatch = urlsToWatch; - // lists of hosts to watch extracted from urlsToWatch, - // used for deciding which URLs to decrypt for further inspection - private readonly HashSet _hostsToWatch = []; - private readonly IProxyStateController _proxyController = proxyController; - // Dictionary for plugins to store data between requests - // the key is HashObject of the SessionEventArgs object - private readonly ConcurrentDictionary> _pluginData = []; - private InactivityTimer? _inactivityTimer; - private CancellationToken? _cancellationToken; - - public static X509Certificate2? Certificate => ProxyServer?.CertificateManager.RootCertificate; - - private ExceptionHandler ExceptionHandler => ex => _logger.LogError(ex, "An error occurred in a plugin"); - - static ProxyEngine() - { - // ProxyServer initialization moved to EnsureProxyServerInitialized - // to enable passing ILoggerFactory for Unobtanium logging - } - - // Ensure ProxyServer is initialized with the given ILoggerFactory - // This method can be called from multiple places (ProxyEngine, CertCommand, etc.) - internal static void EnsureProxyServerInitialized(ILoggerFactory? loggerFactory = null) - { - if (_isProxyServerInitialized) - { - return; - } - - lock (_initLock) - { - if (_isProxyServerInitialized) - { - return; - } - - // On macOS/Linux, don't let Unobtanium try to install the cert - // in the Root store via .NET's X509Store API — it requires admin - // privileges and fails with "Access is denied". - // On macOS, Dev Proxy handles trust via MacCertificateHelper instead. - ProxyServer = new(userTrustRootCertificate: RunTime.IsWindows, loggerFactory: loggerFactory); - ProxyServer.CertificateManager.PfxFilePath = Environment.GetEnvironmentVariable("DEV_PROXY_CERT_PATH") ?? string.Empty; - ProxyServer.CertificateManager.RootCertificateName = "Dev Proxy CA"; - ProxyServer.CertificateManager.CertificateStorage = new CertificateDiskCache(); - // we need to change this to a value lower than 397 - // to avoid the ERR_CERT_VALIDITY_TOO_LONG error in Edge - ProxyServer.CertificateManager.CertificateValidDays = 365; - - using var joinableTaskContext = new JoinableTaskContext(); - var joinableTaskFactory = new JoinableTaskFactory(joinableTaskContext); - _ = joinableTaskFactory.Run(async () => await ProxyServer.CertificateManager.LoadOrCreateRootCertificateAsync()); - - _isProxyServerInitialized = true; - } - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _cancellationToken = stoppingToken; - - // Initialize ProxyServer with LoggerFactory for Unobtanium logging - EnsureProxyServerInitialized(loggerFactory); - - Debug.Assert(ProxyServer is not null, "Proxy server is not initialized"); - - if (!_urlsToWatch.Any()) - { - _logger.LogError("No URLs to watch configured. Please add URLs to watch in the devproxyrc.json config file."); - return; - } - - LoadHostNamesFromUrls(); - - ProxyServer.BeforeRequest += OnRequestAsync; - ProxyServer.BeforeResponse += OnBeforeResponseAsync; - ProxyServer.AfterResponse += OnAfterResponseAsync; - ProxyServer.ServerCertificateValidationCallback += OnCertificateValidationAsync; - ProxyServer.ClientCertificateSelectionCallback += OnCertificateSelectionAsync; - - var ipAddress = string.IsNullOrEmpty(_config.IPAddress) ? IPAddress.Any : IPAddress.Parse(_config.IPAddress); - _explicitEndPoint = new(ipAddress, _config.Port, true); - // Fired when a CONNECT request is received - _explicitEndPoint.BeforeTunnelConnectRequest += OnBeforeTunnelConnectRequestAsync; - if (_config.InstallCert) - { - await ProxyServer.CertificateManager.EnsureRootCertificateAsync(stoppingToken); - } - else - { - _explicitEndPoint.GenericCertificate = await ProxyServer - .CertificateManager - .LoadRootCertificateAsync(stoppingToken); - } - - ProxyServer.AddEndPoint(_explicitEndPoint); - await ProxyServer.StartAsync(cancellationToken: stoppingToken); - - // Save state with actual bound ports so other commands can find us - if (DevProxyCommand.IsInternalDaemon) - { - await SaveInstanceStateAsync(); - } - - // run first-run setup on macOS - FirstRunSetup(); - - foreach (var endPoint in ProxyServer.ProxyEndPoints) - { - _logger.LogInformation("Dev Proxy listening on {IPAddress}:{Port}...", endPoint.IpAddress, endPoint.Port); - } - - if (_config.AsSystemProxy) - { - if (RunTime.IsWindows) - { - ProxyServer.SetAsSystemHttpProxy(_explicitEndPoint); - ProxyServer.SetAsSystemHttpsProxy(_explicitEndPoint); - } - else if (RunTime.IsMac) - { - ToggleSystemProxy(ToggleSystemProxyAction.On, _config.IPAddress, _config.Port); - } - else - { - _logger.LogWarning("Configure your operating system to use this proxy's port and address {IPAddress}:{Port}", _config.IPAddress, _config.Port); - } - } - else - { - _logger.LogInformation("Configure your application to use this proxy's port and address"); - } - - var isInteractive = !Console.IsInputRedirected && - !DevProxyCommand.IsInternalDaemon && - Environment.GetEnvironmentVariable("CI") is null; - - if (_config.Output == OutputFormat.Json) - { - // Always print API instructions in machine mode - // since LLMs/agents can use the API even in non-interactive mode - PrintApiInstructions(_config); - } - else if (isInteractive) - { - // Print hotkeys only when they can be used (interactive terminal, human mode) - PrintHotkeys(); - } - - if (_config.Record) - { - StartRecording(); - } - - if (_config.TimeoutSeconds.HasValue) - { - _inactivityTimer = new(_config.TimeoutSeconds.Value, _proxyController.StopProxy); - } - - if (!isInteractive) - { - return; - } - - try - { - while (!stoppingToken.IsCancellationRequested && ProxyServer.ProxyRunning) - { - while (!Console.KeyAvailable) - { - await Task.Delay(10, stoppingToken); - } - - await ReadKeysAsync(stoppingToken); - } - } - catch (TaskCanceledException) - { - throw; - } - } - - private async Task SaveInstanceStateAsync() - { - var proxyPort = _explicitEndPoint?.Port ?? _config.Port; - var ipAddress = _config.IPAddress; - var loopbackAddress = ipAddress is "0.0.0.0" or "::" ? "127.0.0.1" : ipAddress; - - // Get real API port from Kestrel - var serverAddresses = server.Features.Get(); - var apiAddress = serverAddresses?.Addresses.FirstOrDefault(); - var apiUrl = apiAddress ?? $"http://{loopbackAddress}:{_config.ApiPort}"; - - var state = new ProxyInstanceState - { - Pid = Environment.ProcessId, - ApiUrl = apiUrl, - LogFile = DevProxyCommand.DetachedLogFilePath, - StartedAt = DateTimeOffset.UtcNow, - ConfigFile = _config.ConfigFile, - Port = proxyPort, - AsSystemProxy = _config.AsSystemProxy - }; - - await StateManager.SaveStateAsync(state); - } - - private void FirstRunSetup() - { - if (!RunTime.IsMac || - _config.NoFirstRun || - !HasRunFlag.CreateIfMissing() || - !_config.InstallCert) - { - return; - } - - Console.WriteLine(); - Console.WriteLine("Dev Proxy uses a self-signed certificate to intercept and inspect HTTPS traffic."); - - string? answer; - if (Console.IsInputRedirected || - Environment.GetEnvironmentVariable("CI") is not null) - { - // Non-interactive mode, default to trusting the certificate - _logger.LogInformation("Non-interactive mode detected. Defaulting to trusting the certificate."); - answer = "y"; - } - else - { - Console.Write("Update the certificate in your Keychain so that it's trusted by your browser? (Y/n): "); - answer = Console.ReadLine()?.Trim(); - } - - if (string.Equals(answer, "n", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning("Trust the certificate in your Keychain manually to avoid errors."); - return; - } - - var certificate = ProxyServer.CertificateManager.RootCertificate; - if (certificate is null) - { - _logger.LogError("Root certificate not found. Cannot trust certificate."); - return; - } - - MacCertificateHelper.TrustCertificate(certificate, _logger); - _logger.LogInformation("Certificate trusted successfully."); - } - - private async Task ReadKeysAsync(CancellationToken cancellationToken) - { - var key = Console.ReadKey(true).Key; -#pragma warning disable IDE0010 - switch (key) -#pragma warning restore IDE0010 - { - case ConsoleKey.R: - StartRecording(); - break; - case ConsoleKey.S: - await StopRecordingAsync(cancellationToken); - break; - case ConsoleKey.C: - Console.Clear(); - if (_config.Output == OutputFormat.Json) - { - PrintApiInstructions(_config); - } - else - { - PrintHotkeys(); - } - break; - case ConsoleKey.W: - await _proxyController.MockRequestAsync(cancellationToken); - break; - } - } - - private void StartRecording() - { - if (_proxyController.ProxyState.IsRecording) - { - return; - } - - _proxyController.StartRecording(); - } - - private async Task StopRecordingAsync(CancellationToken cancellationToken) - { - if (!_proxyController.ProxyState.IsRecording) - { - return; - } - - await _proxyController.StopRecordingAsync(cancellationToken); - } - - // Convert strings from config to regexes. - // From the list of URLs, extract host names and convert them to regexes. - // We need this because before we decrypt a request, we only have access - // to the host name, not the full URL. - private void LoadHostNamesFromUrls() - { - foreach (var urlToWatch in _urlsToWatch) - { - // extract host from the URL - var urlToWatchPattern = Regex.Unescape(urlToWatch.Url.ToString()) - .Trim('^', '$') - .Replace(".*", "*", StringComparison.OrdinalIgnoreCase); - string hostToWatch; - if (urlToWatchPattern.Contains("://", StringComparison.OrdinalIgnoreCase)) - { - // if the URL contains a protocol, extract the host from the URL - var urlChunks = urlToWatchPattern.Split("://"); - var slashPos = urlChunks[1].IndexOf('/', StringComparison.OrdinalIgnoreCase); - hostToWatch = slashPos < 0 ? urlChunks[1] : urlChunks[1][..slashPos]; - } - else - { - // if the URL doesn't contain a protocol, - // we assume the whole URL is a host name - hostToWatch = urlToWatchPattern; - } - - // remove port number if present - var portPos = hostToWatch.IndexOf(':', StringComparison.OrdinalIgnoreCase); - if (portPos > 0) - { - hostToWatch = hostToWatch[..portPos]; - } - - var hostToWatchRegexString = Regex.Escape(hostToWatch).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase); - Regex hostRegex = new($"^{hostToWatchRegexString}$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - // don't add the same host twice - if (!_hostsToWatch.Any(h => h.Url.ToString() == hostRegex.ToString())) - { - _ = _hostsToWatch.Add(new(hostRegex, urlToWatch.Exclude)); - } - } - } - - private void StopProxy() - { - // Unsubscribe & Quit - try - { - _explicitEndPoint?.BeforeTunnelConnectRequest -= OnBeforeTunnelConnectRequestAsync; - - if (ProxyServer is not null) - { - ProxyServer.BeforeRequest -= OnRequestAsync; - ProxyServer.BeforeResponse -= OnBeforeResponseAsync; - ProxyServer.AfterResponse -= OnAfterResponseAsync; - ProxyServer.ServerCertificateValidationCallback -= OnCertificateValidationAsync; - ProxyServer.ClientCertificateSelectionCallback -= OnCertificateSelectionAsync; - - if (ProxyServer.ProxyRunning) - { - ProxyServer.Stop(); - } - - if (_explicitEndPoint != null && ProxyServer.ProxyEndPoints.Contains(_explicitEndPoint)) - { - ProxyServer.RemoveEndPoint(_explicitEndPoint); - } - } - - _inactivityTimer?.Stop(); - - if (RunTime.IsMac && _config.AsSystemProxy) - { - ToggleSystemProxy(ToggleSystemProxyAction.Off); - } - - // Signal that proxy has fully stopped (including system proxy deregistration) - ConfigFileWatcher.SignalProxyStopped(); - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while stopping the proxy"); - } - } - - public override async Task StopAsync(CancellationToken cancellationToken) - { - await StopRecordingAsync(cancellationToken); - StopProxy(); - - await base.StopAsync(cancellationToken); - } - - async Task OnBeforeTunnelConnectRequestAsync(object sender, TunnelConnectSessionEventArgs e) - { - // Ensures that only the targeted Https domains are proxyied - if (!IsProxiedHost(e.HttpClient.Request.RequestUri.Host) || - !IsProxiedProcess(e)) - { - e.DecryptSsl = false; - } - await Task.CompletedTask; - } - - private bool IsProxiedProcess(TunnelConnectSessionEventArgs e) - { - // If no process names or IDs are specified, we proxy all processes - if (!_config.WatchPids.Any() && - !_config.WatchProcessNames.Any()) - { - return true; - } - - var processId = GetProcessId(e); - if (processId == -1) - { - return false; - } - - if (_config.WatchPids.Any() && - _config.WatchPids.Contains(processId)) - { - return true; - } - - if (_config.WatchProcessNames.Any()) - { - var processName = Process.GetProcessById(processId).ProcessName; - if (_config.WatchProcessNames.Contains(processName)) - { - return true; - } - } - - return false; - } - - async Task OnRequestAsync(object sender, SessionEventArgs e) - { - _inactivityTimer?.Reset(); - if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host) && - IsIncludedByHeaders(e.HttpClient.Request.Headers)) - { - if (!_pluginData.TryAdd(e.GetHashCode(), [])) - { - throw new InvalidOperationException($"Unable to initialize the plugin data storage for hash key {e.GetHashCode()}"); - } - var responseState = new ResponseState(); - var proxyRequestArgs = new ProxyRequestArgs(e, responseState) - { - SessionData = _pluginData[e.GetHashCode()], - GlobalData = _proxyController.ProxyState.GlobalData - }; - if (!proxyRequestArgs.HasRequestUrlMatch(_urlsToWatch)) - { - return; - } - - // we need to keep the request body for further processing - // by plugins - e.HttpClient.Request.KeepBody = true; - if (e.HttpClient.Request.HasBody) - { - _ = await e.GetRequestBodyAsString(); - } - - using var scope = _logger.BeginScope(e.HttpClient.Request.Method ?? "", e.HttpClient.Request.Url, e.GetHashCode()); - - e.UserData = e.HttpClient.Request; - - var loggingContext = new LoggingContext(e); - _logger.LogRequest($"{e.HttpClient.Request.Method} {e.HttpClient.Request.Url}", MessageType.InterceptedRequest, loggingContext); - _logger.LogRequest($"{DateTimeOffset.UtcNow}", MessageType.Timestamp, loggingContext); - - await HandleRequestAsync(e, proxyRequestArgs); - } - } - - private async Task HandleRequestAsync(SessionEventArgs e, ProxyRequestArgs proxyRequestArgs) - { - foreach (var plugin in _plugins.Where(p => p.Enabled)) - { - _cancellationToken?.ThrowIfCancellationRequested(); - - try - { - await plugin.BeforeRequestAsync(proxyRequestArgs, _cancellationToken ?? CancellationToken.None); - } - catch (Exception ex) - { - ExceptionHandler(ex); - } - } - - // We only need to set the proxy header if the proxy has not set a response and the request is going to be sent to the target. - if (!proxyRequestArgs.ResponseState.HasBeenSet) - { - _logger?.LogRequest("Passed through", MessageType.PassedThrough, new LoggingContext(e)); - AddProxyHeader(e.HttpClient.Request); - } - } - - private bool IsProxiedHost(string hostName) - { - var urlMatch = _hostsToWatch.FirstOrDefault(h => h.Url.IsMatch(hostName)); - return urlMatch is not null && !urlMatch.Exclude; - } - - private bool IsIncludedByHeaders(HeaderCollection requestHeaders) - { - if (_config.FilterByHeaders is null) - { - return true; - } - - foreach (var header in _config.FilterByHeaders) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Checking header {Header} with value {Value}...", - header.Name, - string.IsNullOrEmpty(header.Value) ? "(any)" : header.Value - ); - } - - if (requestHeaders.HeaderExists(header.Name)) - { - if (string.IsNullOrEmpty(header.Value)) - { - _logger.LogDebug("Request has header {Header}", header.Name); - return true; - } - - if (requestHeaders.GetHeaders(header.Name)!.Any(h => h.Value.Contains(header.Value, StringComparison.OrdinalIgnoreCase))) - { - _logger.LogDebug("Request header {Header} contains value {Value}", header.Name, header.Value); - return true; - } - } - else - { - _logger.LogDebug("Request doesn't have header {Header}", header.Name); - } - } - - _logger.LogDebug("Request doesn't match any header filter. Ignoring"); - return false; - } - - // Modify response - async Task OnBeforeResponseAsync(object sender, SessionEventArgs e) - { - // read response headers - if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host)) - { - var proxyResponseArgs = new ProxyResponseArgs(e, new()) - { - SessionData = _pluginData[e.GetHashCode()], - GlobalData = _proxyController.ProxyState.GlobalData - }; - if (!proxyResponseArgs.HasRequestUrlMatch(_urlsToWatch)) - { - return; - } - - using var scope = _logger.BeginScope(e.HttpClient.Request.Method ?? "", e.HttpClient.Request.Url, e.GetHashCode()); - - // necessary to make the response body available to plugins - e.HttpClient.Response.KeepBody = true; - if (e.HttpClient.Response.HasBody) - { - _ = await e.GetResponseBody(); - } - - foreach (var plugin in _plugins.Where(p => p.Enabled)) - { - _cancellationToken?.ThrowIfCancellationRequested(); - - try - { - await plugin.BeforeResponseAsync(proxyResponseArgs, _cancellationToken ?? CancellationToken.None); - } - catch (Exception ex) - { - ExceptionHandler(ex); - } - } - } - } - async Task OnAfterResponseAsync(object sender, SessionEventArgs e) - { - // read response headers - if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host)) - { - var proxyResponseArgs = new ProxyResponseArgs(e, new()) - { - SessionData = _pluginData[e.GetHashCode()], - GlobalData = _proxyController.ProxyState.GlobalData - }; - if (!proxyResponseArgs.HasRequestUrlMatch(_urlsToWatch)) - { - // clean up - _ = _pluginData.Remove(e.GetHashCode(), out _); - return; - } - - // necessary to repeat to make the response body - // of mocked requests available to plugins - e.HttpClient.Response.KeepBody = true; - - using var scope = _logger.BeginScope(e.HttpClient.Request.Method ?? "", e.HttpClient.Request.Url, e.GetHashCode()); - - var message = $"{e.HttpClient.Request.Method} {e.HttpClient.Request.Url}"; - var loggingContext = new LoggingContext(e); - _logger.LogRequest(message, MessageType.InterceptedResponse, loggingContext); - - foreach (var plugin in _plugins.Where(p => p.Enabled)) - { - _cancellationToken?.ThrowIfCancellationRequested(); - - try - { - await plugin.AfterResponseAsync(proxyResponseArgs, _cancellationToken ?? CancellationToken.None); - } - catch (Exception ex) - { - ExceptionHandler(ex); - } - } - - _logger.LogRequest(message, MessageType.FinishedProcessingRequest, loggingContext); - - // clean up - _ = _pluginData.Remove(e.GetHashCode(), out _); - } - } - - // Allows overriding default certificate validation logic - Task OnCertificateValidationAsync(object sender, CertificateValidationEventArgs e) - { - // set IsValid to true/false based on Certificate Errors - if (e.SslPolicyErrors == System.Net.Security.SslPolicyErrors.None) - { - e.IsValid = true; - } - - return Task.CompletedTask; - } - - // Allows overriding default client certificate selection logic during mutual authentication - Task OnCertificateSelectionAsync(object sender, CertificateSelectionEventArgs e) => - // set e.clientCertificate to override - Task.CompletedTask; - - private static void PrintHotkeys() - { - Console.WriteLine(""); - Console.WriteLine("Hotkeys: issue (w)eb request, (r)ecord, (s)top recording, (c)lear screen"); - Console.WriteLine("Press CTRL+C to stop Dev Proxy"); - Console.WriteLine(""); - } - - private static void PrintApiInstructions(IProxyConfiguration config) - { - var baseUrl = $"http://{config.IPAddress}:{config.ApiPort}/proxy"; - var timestamp = DateTime.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture); - Console.WriteLine(""); - Console.WriteLine($"{{\"type\":\"log\",\"level\":\"info\",\"message\":\"Issue web request: curl -X POST {baseUrl}/mockRequest\",\"category\":\"ProxyEngine\",\"timestamp\":\"{timestamp}\"}}"); - Console.WriteLine($"{{\"type\":\"log\",\"level\":\"info\",\"message\":\"Start recording: curl -X POST {baseUrl} -H \\\"Content-Type: application/json\\\" -d '{{\\\"recording\\\": true}}'\",\"category\":\"ProxyEngine\",\"timestamp\":\"{timestamp}\"}}"); - Console.WriteLine($"{{\"type\":\"log\",\"level\":\"info\",\"message\":\"Stop recording: curl -X POST {baseUrl} -H \\\"Content-Type: application/json\\\" -d '{{\\\"recording\\\": false}}'\",\"category\":\"ProxyEngine\",\"timestamp\":\"{timestamp}\"}}"); - Console.WriteLine($"{{\"type\":\"log\",\"level\":\"info\",\"message\":\"Stop Dev Proxy: curl -X POST {baseUrl}/stopProxy\",\"category\":\"ProxyEngine\",\"timestamp\":\"{timestamp}\"}}"); - Console.WriteLine(""); - } - - private static void ToggleSystemProxy(ToggleSystemProxyAction toggle, string? ipAddress = null, int? port = null) - { - var bashScriptPath = Path.Join(ProxyUtils.AppFolder, "toggle-proxy.sh"); - var args = toggle switch - { - ToggleSystemProxyAction.On => $"on {ipAddress} {port}", - ToggleSystemProxyAction.Off => "off", - _ => throw new NotImplementedException() - }; - - var startInfo = new ProcessStartInfo() - { - FileName = "/bin/bash", - Arguments = $"{bashScriptPath} {args}", - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process() { StartInfo = startInfo }; - _ = process.Start(); - if (!process.WaitForExit(TimeSpan.FromSeconds(10))) - { - process.Kill(); - } - } - - private static int GetProcessId(TunnelConnectSessionEventArgs e) - { - if (RunTime.IsWindows) - { - return e.HttpClient.ProcessId.Value; - } - - var psi = new ProcessStartInfo - { - FileName = "lsof", - Arguments = $"-i :{e.ClientRemoteEndPoint?.Port}", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - }; - using var proc = new Process - { - StartInfo = psi - }; - _ = proc.Start(); - var output = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(); - - var lines = output.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); - var matchingLine = lines.FirstOrDefault(l => l.Contains($"{e.ClientRemoteEndPoint?.Port}->", StringComparison.OrdinalIgnoreCase)); - if (matchingLine is null) - { - return -1; - } - var pidString = Regex.Matches(matchingLine, @"^.*?\s+(\d+)")?.FirstOrDefault()?.Groups[1]?.Value; - if (pidString is null) - { - return -1; - } - - return int.TryParse(pidString, out var pid) ? pid : -1; - } - - private static void AddProxyHeader(Request r) => r.Headers?.AddHeader("Via", $"{r.HttpVersion} dev-proxy/{ProxyUtils.ProductVersion}"); - - public override void Dispose() - { - base.Dispose(); - - _inactivityTimer?.Dispose(); - } -} diff --git a/DevProxy/Proxy/ProxyStateController.cs b/DevProxy/Proxy/ProxyStateController.cs index ba76deb8..3e0a65c4 100644 --- a/DevProxy/Proxy/ProxyStateController.cs +++ b/DevProxy/Proxy/ProxyStateController.cs @@ -5,7 +5,6 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; using DevProxy.Commands; -using Titanium.Web.Proxy; namespace DevProxy.Proxy; @@ -22,7 +21,7 @@ sealed class ProxyStateController( private readonly IEnumerable _plugins = plugins; private readonly IHostApplicationLifetime _hostApplicationLifetime = hostApplicationLifetime; private readonly ILogger _logger = logger; - private ExceptionHandler ExceptionHandler => ex => _logger.LogError(ex, "An error occurred in a plugin"); + private Action ExceptionHandler => ex => _logger.LogError(ex, "An error occurred in a plugin"); public void StartRecording() { diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs new file mode 100644 index 00000000..d3db001a --- /dev/null +++ b/DevProxy/Proxy/RootCertificateTrust.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.Security.Cryptography.X509Certificates; +using DevProxy.Abstractions.Proxy; + +namespace DevProxy.Proxy; + +/// +/// Host-side for the Kestrel engine. The engine mints +/// and persists its root, then calls ; this performs the actual +/// platform trust install (macOS keychain via , Windows +/// CurrentUser root store) gated by the user's install/first-run configuration. The trust +/// decision itself lives in (pure + unit-tested); this class +/// is only the I/O around it. +/// +internal sealed class RootCertificateTrust( + IProxyConfiguration configuration, + ILogger logger) : IRootCertificateTrust +{ + public void EnsureTrusted(X509Certificate2 rootCertificate) + { + ArgumentNullException.ThrowIfNull(rootCertificate); + + var isMac = OperatingSystem.IsMacOS(); + var isWindows = OperatingSystem.IsWindows(); + + // Only consult the first-run flag / prompt the user when we're actually on the + // macOS first-run path, so non-mac platforms have no side effects. + var isFirstRun = false; + string? answer = null; + if (isMac && configuration.InstallCert && !configuration.NoFirstRun) + { + isFirstRun = HasRunFlag.CreateIfMissing(); + if (isFirstRun) + { + answer = PromptForTrust(); + } + } + + var action = RootTrustPolicy.Decide( + isMac, + isWindows, + configuration.InstallCert, + configuration.NoFirstRun, + isFirstRun, + answer); + + switch (action) + { + case RootTrustAction.TrustMacKeychain: + MacCertificateHelper.TrustCertificate(rootCertificate, logger); + logger.LogInformation("Certificate trusted successfully."); + break; + + case RootTrustAction.TrustWindowsStore: + InstallIntoWindowsRootStore(rootCertificate); + break; + + case RootTrustAction.ManualLinux: + logger.LogWarning( + "Trust the Dev Proxy root certificate manually so your tools accept intercepted HTTPS traffic."); + break; + + case RootTrustAction.Skip: + default: + break; + } + } + + public void Trust(X509Certificate2 rootCertificate) + { + ArgumentNullException.ThrowIfNull(rootCertificate); + + if (OperatingSystem.IsMacOS()) + { + MacCertificateHelper.TrustCertificate(rootCertificate, logger); + logger.LogInformation("Certificate trusted successfully."); + } + else if (OperatingSystem.IsWindows()) + { + InstallIntoWindowsRootStore(rootCertificate); + } + else + { + logger.LogWarning( + "Trust the Dev Proxy root certificate manually so your tools accept intercepted HTTPS traffic."); + } + } + + public void Untrust(X509Certificate2 rootCertificate) + { + ArgumentNullException.ThrowIfNull(rootCertificate); + + if (OperatingSystem.IsMacOS()) + { + MacCertificateHelper.RemoveTrustedCertificate(rootCertificate, logger); + HasRunFlag.Remove(); + } + else if (OperatingSystem.IsWindows()) + { + RemoveFromWindowsRootStore(rootCertificate); + } + else + { + logger.LogWarning( + "Remove the Dev Proxy root certificate from your trust store manually."); + } + } + + private static string? PromptForTrust() + { + Console.WriteLine(); + Console.WriteLine("Dev Proxy uses a self-signed certificate to intercept and inspect HTTPS traffic."); + + if (Console.IsInputRedirected || Environment.GetEnvironmentVariable("CI") is not null) + { + // Non-interactive (CI / piped stdin): default to trusting. + return "y"; + } + + Console.Write("Update the certificate in your Keychain so that it's trusted by your browser? (Y/n): "); + return Console.ReadLine()?.Trim(); + } + + private void InstallIntoWindowsRootStore(X509Certificate2 rootCertificate) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + // Install the public certificate only — the private key never belongs in a + // trust store. Idempotent: skip if an identical cert is already present. + using var publicCert = X509CertificateLoader.LoadCertificate( + rootCertificate.Export(X509ContentType.Cert)); + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (!store.Certificates.Contains(publicCert)) + { +#pragma warning disable CA5380 // Installing the Dev Proxy root is the explicit, user-consented purpose of the proxy. + store.Add(publicCert); +#pragma warning restore CA5380 + logger.LogInformation("Certificate installed into the current user's root store."); + } + } + catch (Exception ex) when (ex is System.Security.Cryptography.CryptographicException or IOException or UnauthorizedAccessException) + { + logger.LogError(ex, "Failed to install the root certificate into the Windows root store."); + } + } + + private void RemoveFromWindowsRootStore(X509Certificate2 rootCertificate) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + using var publicCert = X509CertificateLoader.LoadCertificate( + rootCertificate.Export(X509ContentType.Cert)); + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (store.Certificates.Contains(publicCert)) + { + store.Remove(publicCert); + logger.LogInformation("Certificate removed from the current user's root store."); + } + } + catch (Exception ex) when (ex is System.Security.Cryptography.CryptographicException or IOException or UnauthorizedAccessException) + { + logger.LogError(ex, "Failed to remove the root certificate from the Windows root store."); + } + } +} diff --git a/DevProxy/Proxy/SystemConsole.cs b/DevProxy/Proxy/SystemConsole.cs new file mode 100644 index 00000000..382adb06 --- /dev/null +++ b/DevProxy/Proxy/SystemConsole.cs @@ -0,0 +1,23 @@ +// 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.Proxy; + +/// +/// Default implementation backed by +/// . Keys are read with intercept: true so +/// they aren't echoed to the terminal (matching the legacy engine's behavior). +/// +internal sealed class SystemConsole : ISystemConsole +{ + public bool IsInputRedirected => Console.IsInputRedirected; + + public bool KeyAvailable => Console.KeyAvailable; + + public ConsoleKey ReadKey() => Console.ReadKey(intercept: true).Key; + + public void Clear() => Console.Clear(); + + public void WriteLine(string value) => Console.WriteLine(value); +} diff --git a/DevProxy/Proxy/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs new file mode 100644 index 00000000..28ebf961 --- /dev/null +++ b/DevProxy/Proxy/SystemProxyManager.cs @@ -0,0 +1,163 @@ +// 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.Runtime.InteropServices; +using System.Runtime.Versioning; +using DevProxy.Abstractions.Proxy; +using Microsoft.Win32; + +namespace DevProxy.Proxy; + +/// +/// Host-side . Engine-agnostic OS proxy on/off, shared by +/// the Kestrel engine and the stop --force crash-cleanup path. +/// +/// +/// Enable(ip, port) Disable() +/// ──────────────── ───────── +/// Windows → registry ProxyServer Windows → registry ProxyEnable = 0 +/// + ProxyEnable = 1 + WinINET refresh +/// + WinINET refresh +/// macOS → toggle-proxy.sh on … macOS → toggle-proxy.sh off +/// Linux → log warning Linux → no-op +/// +/// +/// +/// The Windows path uses WinINET: it writes the per-user Internet Settings registry values +/// and then broadcasts INTERNET_OPTION_SETTINGS_CHANGED + INTERNET_OPTION_REFRESH so running +/// applications re-read the proxy without a restart. This is the standard WinINET mechanism; +/// it cannot be exercised on non-Windows hosts. +/// +/// +internal sealed class SystemProxyManager(ILogger logger) : ISystemProxyManager +{ + private const string InternetSettingsKey = + @"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; + private const int InternetOptionSettingsChanged = 39; + private const int InternetOptionRefresh = 37; + + public void Enable(string? ipAddress, int port) + { + if (OperatingSystem.IsWindows()) + { + EnableWindows(SystemProxyAddress.ToHostPort(ipAddress, port)); + } + else if (OperatingSystem.IsMacOS()) + { + RunToggleScript($"on {SystemProxyAddress.ResolveHost(ipAddress)} {port}"); + } + else + { + logger.LogWarning( + "Configure your operating system to use this proxy's port and address {Address}:{Port}", + SystemProxyAddress.ResolveHost(ipAddress), + port); + } + } + + public void Disable() + { + if (OperatingSystem.IsWindows()) + { + DisableWindows(); + } + else if (OperatingSystem.IsMacOS()) + { + RunToggleScript("off"); + } + } + + [SupportedOSPlatform("windows")] + private void EnableWindows(string hostPort) + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: true); + if (key is null) + { + logger.LogError("Could not open the Windows Internet Settings registry key."); + return; + } + + key.SetValue("ProxyServer", hostPort, RegistryValueKind.String); + key.SetValue("ProxyEnable", 1, RegistryValueKind.DWord); + NotifyWinInetSettingsChanged(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + logger.LogError(ex, "Failed to set the system proxy via the Windows registry."); + } + } + + [SupportedOSPlatform("windows")] + private void DisableWindows() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: true); + if (key is null) + { + return; + } + + key.SetValue("ProxyEnable", 0, RegistryValueKind.DWord); + NotifyWinInetSettingsChanged(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + logger.LogError(ex, "Failed to clear the system proxy via the Windows registry."); + } + } + + [SupportedOSPlatform("windows")] + private static void NotifyWinInetSettingsChanged() + { + // Tell WinINET-based applications to re-read proxy settings without a restart. + _ = InternetSetOption(IntPtr.Zero, InternetOptionSettingsChanged, IntPtr.Zero, 0); + _ = InternetSetOption(IntPtr.Zero, InternetOptionRefresh, IntPtr.Zero, 0); + } + + // DllImport (not LibraryImport) is used deliberately: LibraryImport's source generator + // requires AllowUnsafeBlocks project-wide, which we avoid for this single system call. +#pragma warning disable SYSLIB1054 + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("wininet.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InternetSetOption(IntPtr hInternet, int dwOption, IntPtr lpBuffer, int dwBufferLength); +#pragma warning restore SYSLIB1054 + + private void RunToggleScript(string arguments) + { + var bashScriptPath = Path.Join(AppContext.BaseDirectory, "toggle-proxy.sh"); + if (!File.Exists(bashScriptPath)) + { + logger.LogWarning("Could not find {Script} to toggle the system proxy.", "toggle-proxy.sh"); + return; + } + + var startInfo = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"{bashScriptPath} {arguments}", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + try + { + using var process = new Process { StartInfo = startInfo }; + _ = process.Start(); + if (!process.WaitForExit(TimeSpan.FromSeconds(10))) + { + process.Kill(); + } + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) + { + logger.LogError(ex, "Failed to toggle the system proxy via toggle-proxy.sh."); + } + } +} diff --git a/DevProxy/config/m365-mocks.json b/DevProxy/config/m365-mocks.json index 9f16cf68..b252737e 100644 --- a/DevProxy/config/m365-mocks.json +++ b/DevProxy/config/m365-mocks.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/mockresponseplugin.mocksfile.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/mockresponseplugin.mocksfile.schema.json", "mocks": [ { "request": { diff --git a/DevProxy/config/m365.json b/DevProxy/config/m365.json index f11788f0..3f96a222 100644 --- a/DevProxy/config/m365.json +++ b/DevProxy/config/m365.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/rc.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/rc.schema.json", "plugins": [ { "name": "DevToolsPlugin", @@ -173,11 +173,11 @@ "https://*.sharepoint-df.*/*_vti_bin/*" ], "mocksPlugin": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/mockresponseplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/mockresponseplugin.schema.json", "mocksFile": "m365-mocks.json" }, "graphRandomErrorsPlugin": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/graphrandomerrorplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/graphrandomerrorplugin.schema.json", "allowedErrors": [ 429, 500, @@ -189,28 +189,28 @@ "rate": 50 }, "executionSummaryPlugin": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/executionsummaryplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/executionsummaryplugin.schema.json", "groupBy": "url" }, "graphMinimalPermissionsPlugin": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/graphminimalpermissionsplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/graphminimalpermissionsplugin.schema.json", "type": "delegated" }, "cachingGuidance": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/cachingguidanceplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/cachingguidanceplugin.schema.json", "cacheThresholdSeconds": 5 }, "latencyPlugin": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/latencyplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/latencyplugin.schema.json", "minMs": 200, "maxMs": 10000 }, "devTools": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/devtoolsplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/devtoolsplugin.schema.json", "preferredBrowser": "Edge" }, "rateLimiting": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/ratelimitingplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/ratelimitingplugin.schema.json", "costPerRequest": 2, "rateLimit": 120, "resetTimeWindowSeconds": 5 diff --git a/DevProxy/config/microsoft-graph-rate-limiting.json b/DevProxy/config/microsoft-graph-rate-limiting.json index 744b34d5..f2b4fe51 100644 --- a/DevProxy/config/microsoft-graph-rate-limiting.json +++ b/DevProxy/config/microsoft-graph-rate-limiting.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/rc.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/rc.schema.json", "plugins": [ { "name": "RateLimitingPlugin", diff --git a/DevProxy/config/microsoft-graph.json b/DevProxy/config/microsoft-graph.json index c7a1e1ea..ccad5b7c 100644 --- a/DevProxy/config/microsoft-graph.json +++ b/DevProxy/config/microsoft-graph.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/rc.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/rc.schema.json", "plugins": [ { "name": "GraphSelectGuidancePlugin", @@ -67,7 +67,7 @@ "https://microsoftgraph.chinacloudapi.cn/beta/*" ], "graphRandomErrorsPlugin": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/graphrandomerrorplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/graphrandomerrorplugin.schema.json", "allowedErrors": [ 429, 500, @@ -79,7 +79,7 @@ "rate": 50 }, "executionSummaryPlugin": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/executionsummaryplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/executionsummaryplugin.schema.json", "groupBy": "url" }, "labelMode": "text", diff --git a/DevProxy/config/spo-csom-types.json b/DevProxy/config/spo-csom-types.json index 4236a302..528502d1 100644 --- a/DevProxy/config/spo-csom-types.json +++ b/DevProxy/config/spo-csom-types.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/minimalcsompermissions.types.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/minimalcsompermissions.types.schema.json", "types": { "268004ae-ef6b-4e9b-8425-127220d84719": "Microsoft.Online.SharePoint.TenantAdministration.Tenant", "3747adcd-a3c3-41b9-bfab-4a64dd2f1e0a": "Microsoft.SharePoint.Client.RequestContext" diff --git a/DevProxy/devproxy-errors.json b/DevProxy/devproxy-errors.json index 0b7c9577..6200efe1 100644 --- a/DevProxy/devproxy-errors.json +++ b/DevProxy/devproxy-errors.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/genericrandomerrorplugin.errorsfile.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/genericrandomerrorplugin.errorsfile.schema.json", "errors": [ { "request": { diff --git a/DevProxy/devproxyrc.json b/DevProxy/devproxyrc.json index 163fb9ff..dcc528f0 100644 --- a/DevProxy/devproxyrc.json +++ b/DevProxy/devproxyrc.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/rc.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/rc.schema.json", "plugins": [ { "name": "RetryAfterPlugin", @@ -17,7 +17,7 @@ "https://jsonplaceholder.typicode.com/*" ], "genericRandomErrorPlugin": { - "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/genericrandomerrorplugin.schema.json", + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/genericrandomerrorplugin.schema.json", "errorsFile": "devproxy-errors.json", "rate": 50 }, diff --git a/DevProxy/packages.lock.json b/DevProxy/packages.lock.json index a7b5a9ac..ffadc8de 100644 --- a/DevProxy/packages.lock.json +++ b/DevProxy/packages.lock.json @@ -84,15 +84,6 @@ "Microsoft.IdentityModel.Tokens": "8.19.1" } }, - "Unobtanium.Web.Proxy": { - "type": "Direct", - "requested": "[0.1.5, )", - "resolved": "0.1.5", - "contentHash": "HiICGm0e44+i4aVHpLn+aphmSC2eQnDvlTttw1rE0hntOZKoLGRy37sydqqbRP1ZokMf3Mt0GEgSWxDwnucKGg==", - "dependencies": { - "BouncyCastle.Cryptography": "2.4.0" - } - }, "Azure.Core": { "type": "Transitive", "resolved": "1.53.0", @@ -105,11 +96,6 @@ "System.Memory.Data": "10.0.3" } }, - "BouncyCastle.Cryptography": { - "type": "Transitive", - "resolved": "2.4.0", - "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" - }, "Markdig": { "type": "Transitive", "resolved": "1.3.0", @@ -354,9 +340,14 @@ "Newtonsoft.Json.Schema": "[4.0.1, )", "Scriban": "[7.2.4, )", "System.CommandLine": "[2.0.9, )", - "Unobtanium.Web.Proxy": "[0.1.5, )", "YamlDotNet": "[18.0.0, )" } + }, + "devproxy.proxy.kestrel": { + "type": "Project", + "dependencies": { + "DevProxy.Abstractions": "[4.0.0, )" + } } } } diff --git a/Dockerfile b/Dockerfile index 41b76c1a..551f1a42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM ubuntu:26.04 -ARG DEVPROXY_VERSION=3.1.0 +ARG DEVPROXY_VERSION=4.0.0 ARG USERNAME=devproxy ENV DEVPROXY_VERSION=${DEVPROXY_VERSION} diff --git a/Dockerfile_beta b/Dockerfile_beta index ca94173b..20429de7 100644 --- a/Dockerfile_beta +++ b/Dockerfile_beta @@ -1,6 +1,6 @@ FROM ubuntu:26.04 -ARG DEVPROXY_VERSION=3.1.0 +ARG DEVPROXY_VERSION=4.0.0 ARG USERNAME=devproxy ENV DEVPROXY_VERSION=${DEVPROXY_VERSION} diff --git a/THIRD PARTY NOTICES b/THIRD PARTY NOTICES deleted file mode 100644 index e1884ea8..00000000 --- a/THIRD PARTY NOTICES +++ /dev/null @@ -1,27 +0,0 @@ -This file is based on or incorporates material from the projects listed below (Third Party OSS). The original copyright notice and the license under which Microsoft received such Third Party OSS, are set forth below. Such licenses and notices are provided for informational purposes only. Microsoft licenses the Third Party OSS to you under the licensing terms for the Microsoft product or service. Microsoft reserves all other rights not expressly granted under this agreement, whether by implication, estoppel or otherwise. - -Unobtanium.Web.Proxy -Copyright (c) 2015-2023 titanium007 -Copyright (c) 2024-... Stephan van Rooij - -Provided for Informational Purposes Only - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/WINDOWS-VERIFICATION.md b/WINDOWS-VERIFICATION.md new file mode 100644 index 00000000..e5ba7465 --- /dev/null +++ b/WINDOWS-VERIFICATION.md @@ -0,0 +1,180 @@ +# Windows verification checklist — Kestrel engine + +Dev Proxy's HTTP(S) engine was migrated from Titanium/Unobtanium.Web.Proxy to a +Kestrel-based engine. Every Windows-specific code path is covered by unit tests, but the +**runtime behavior has never been live-verified on a real Windows host**. This checklist +walks through those paths end-to-end so we can sign off on a Windows release. + +The three Windows-specific paths this checklist exists to prove are: + +1. **System proxy on/off** via the WinINET registry + refresh (section 3). +2. **Root certificate trust** via the current-user Windows root store (section 2). +3. **Process filter** via `netstat` connection→PID resolution (section 5). + +Everything else (build/tests, daemon lifecycle, interactive console, core proxy + plugin +smoke) is cross-platform but worth re-confirming on Windows. + +Run it on a clean Windows 10/11 machine (or VM). Tick each box; record the actual result +in the **Notes** column. Anything that fails or surprises you is a finding worth filing. + +- **Branch under test:** `waldekmastykarz-special-invention` +- **Tester:** _______________ **Date:** _______________ **Windows build:** _______________ +- **.NET SDK:** `dotnet --version` → _______________ (expect .NET 10.x) + +> Conventions used below: +> - `devproxy` means the built CLI. During verification you can run it from source with +> `dotnet run --project DevProxy -- ` instead of installing. +> - PowerShell is assumed. Run an **elevated** prompt only where a step says so. + +--- + +## 0. Prerequisites + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 0.1 | Install .NET 10 SDK; `dotnet --version` | Prints 10.x | ☐ | | +| 0.2 | `git clone` the repo, `git checkout waldekmastykarz-special-invention` | Branch checked out | ☐ | | +| 0.3 | Close other proxies/VPNs that set a system proxy | None active | ☐ | | +| 0.4 | Note current proxy state: `reg query "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyEnable` | Records baseline (usually `0x0`) | ☐ | | + +--- + +## 1. Build & automated tests + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 1.1 | `dotnet build` at repo root | 0 errors (pre-existing CA2201 warning in `DevProxy.Proxy.Kestrel.Tests` is known/ignorable) | ☐ | | +| 1.2 | `dotnet test` | All projects green (~293 tests). Confirms `NetstatParser`, `RootTrustPolicy`, `SystemProxyAddress` unit tests pass **on Windows** | ☐ | | + +If 1.2 fails, capture the failing test names before continuing — a parser that fails to +build on Windows invalidates later manual steps. + +--- + +## 2. Root certificate trust (Windows root store) + +Code: `DevProxy/Proxy/RootCertificateTrust.cs` → installs the **public** cert into +`X509Store(StoreName.Root, StoreLocation.CurrentUser)`. `cert remove`/`Untrust` removes it. + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 2.1 | Open `certmgr.msc` → **Trusted Root Certification Authorities → Certificates**. Search for a Dev Proxy cert | None present (clean machine) | ☐ | | +| 2.2 | Run `devproxy` once (any config) and accept the trust prompt | CLI reports the cert was trusted | ☐ | | +| 2.3 | Refresh `certmgr.msc` | A Dev Proxy root cert now appears under current-user Trusted Root | ☐ | | +| 2.4 | With proxy running + watching a host, `curl https:///` **without** `-k` (or use Edge/Chrome, which use the Windows store) | Succeeds, no cert warning → MITM is trusted | ☐ | | +| 2.5 | Stop proxy. `devproxy cert remove` | CLI reports removal | ☐ | | +| 2.6 | Refresh `certmgr.msc` | Dev Proxy root cert is gone | ☐ | | +| 2.7 | Re-run `devproxy`; confirm a **new** root is minted + trusted and HTTPS still works | Regenerate-on-trust works | ☐ | | + +--- + +## 3. System proxy on/off (WinINET registry) + +Code: `DevProxy/Proxy/SystemProxyManager.cs` → sets HKCU `…\Internet Settings` `ProxyServer` += `host:port` and `ProxyEnable` = `1` on start, `ProxyEnable` = `0` on stop, then calls +`InternetSetOption` so WinINET apps re-read without restart. + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 3.1 | Start with system proxy ON, random port: `dotnet run --project DevProxy -- --as-system-proxy true --port 0` | Engine logs the bound port | ☐ | | +| 3.2 | `reg query "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyServer` | Shows `127.0.0.1:` | ☐ | | +| 3.3 | Same query for `/v ProxyEnable` | `0x1` | ☐ | | +| 3.4 | **Settings → Network & Internet → Proxy** | "Use a proxy server" ON, pointing at `127.0.0.1:` | ☐ | | +| 3.5 | In a **new** browser/app session, browse a watched URL | Request is intercepted + logged (proves WinINET refresh took effect without restart) | ☐ | | +| 3.6 | Stop the proxy (Ctrl+C, or `devproxy stop` from another terminal) | Engine exits | ☐ | | +| 3.7 | Re-query `ProxyEnable` | Back to `0x0` | ☐ | | +| 3.8 | Settings → Proxy UI | Proxy toggled OFF | ☐ | | + +--- + +## 4. Detached daemon lifecycle (`--detach`, `status`, `stop`) + +Regression-sensitive: the cut-over once orphaned the daemon on Windows-like flows. Confirm +the host writes daemon state and `status`/`stop` find it. + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 4.1 | `dotnet run --project DevProxy -- --detach --as-system-proxy false --port 0 --api-port 0` | Parent returns; daemon keeps running | ☐ | | +| 4.2 | `devproxy status` | Shows running state with PID + bound port (fully populated) | ☐ | | +| 4.3 | `devproxy stop` | Daemon stops cleanly; status now shows stopped | ☐ | | +| 4.4 | Start detached again **with** `--as-system-proxy true --port 0`; then `devproxy stop` | System proxy is turned back OFF on stop (re-check reg `ProxyEnable` = `0x0`) | ☐ | | +| 4.5 | Start detached with system proxy ON, then kill the process hard (Task Manager → End task) and run `devproxy stop --force` | State self-heals; no orphaned proxy left in the registry/Settings | ☐ | | + +--- + +## 5. Process filter (`netstat`-based) + +Code: `DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs` → +`ConnectionProcessResolver.ResolveProcessId` runs `netstat -ano -p tcp` and feeds +`NetstatParser.ParsePid` to map a client source port → owning PID → process name. +Options: `--watch-pids`, `--watch-process-names`. + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 5.1 | Find a target process PID (e.g. a specific browser): Task Manager → Details | Note the PID | ☐ | | +| 5.2 | Start: `dotnet run --project DevProxy -- --as-system-proxy false --port 0 --watch-pids ` | Engine starts | ☐ | | +| 5.3 | Configure that process (or use its proxy settings) to send traffic through `127.0.0.1:` and browse a watched URL | Requests from the watched PID are intercepted + logged | ☐ | | +| 5.4 | Send traffic from a **different** process through the same proxy port | Those requests are **not** acted on (filtered out) | ☐ | | +| 5.5 | Restart with `--watch-process-names ` (e.g. `msedge`) instead of PID | Only that process's traffic is watched | ☐ | | + +> If 5.3/5.4 misbehave, capture raw `netstat -ano -p tcp` output for the relevant port — +> it's the input to `NetstatParser` and pinpoints parser vs. resolution issues. + +--- + +## 6. Interactive console (restored regression) + +Code: `DevProxy/Proxy/InteractiveConsoleService.cs` + `ConsoleHotkeyHandler.cs`. Requires a +**real terminal** (not a redirected/piped stdin). Run directly, not detached. + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 6.1 | `dotnet run --project DevProxy -- --as-system-proxy false --port 0` in an interactive terminal | After the "listening" log, the **hotkeys banner** prints | ☐ | | +| 6.2 | Press `r` | Recording starts (`◉ Recording...` indicator) | ☐ | | +| 6.3 | Make a request through the proxy, then press `s` | Recording stops; recorded requests are processed/output | ☐ | | +| 6.4 | Press `c` | Console clears and the banner reprints | ☐ | | +| 6.5 | Press `w` | Mock-request flow triggers | ☐ | | +| 6.6 | Ctrl+C | Proxy shuts down cleanly | ☐ | | +| 6.7 | Restart with `--record` | Recording is **already on** at launch (no keypress needed) | ☐ | | +| 6.8 | Restart with `--output json` (redirected/JSON mode) | API-instructions banner prints instead of hotkeys; key loop inactive | ☐ | | + +--- + +## 7. Core proxy + plugin smoke + +Confirm the engine itself behaves on Windows across protocols. + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 7.1 | Plain HTTP through the proxy to an `http://` site | 200, logged | ☐ | | +| 7.2 | HTTPS MITM to a **watched** host (no `-k`) | Decrypted + logged; cert trusted (from §2) | ☐ | | +| 7.3 | HTTPS to a **non-watched** host | Passes through (blind tunnel), still works, not decrypted | ☐ | | +| 7.4 | An HTTP/2 / gRPC endpoint | Works (downgrade+MITM if watched, else tunneled) | ☐ | | +| 7.5 | A WebSocket (`wss://`) endpoint | Connects and relays frames | ☐ | | +| 7.6 | Run with a config using `MockResponsePlugin` + a mocks file | Mocked response returned; origin not contacted | ☐ | | +| 7.7 | Run with `GenericRandomErrorPlugin` (or RateLimiting) | Simulated failures/limits observed | ☐ | | + +--- + +## 8. Cleanup + +| # | Step | Expected | Pass | Notes | +|---|------|----------|------|-------| +| 8.1 | `devproxy stop` (if anything still running) | Stopped | ☐ | | +| 8.2 | Confirm system proxy OFF (reg `ProxyEnable` = `0x0`, Settings UI) | OFF | ☐ | | +| 8.3 | `devproxy cert remove` (if you don't want the dev root left trusted) | Removed from `certmgr.msc` | ☐ | | +| 8.4 | Delete generated `devproxy-*.log` files | Removed | ☐ | | + +--- + +## Sign-off + +- [ ] All sections passed → **Windows runtime parity confirmed** (system proxy, root-store + trust, and process filter all work live on Windows). +- [ ] Failures found (list below) → file findings against the migration branch. + +**Findings / notes:** + +``` +(record any failures, surprises, raw netstat/registry output, or environment quirks here) +``` diff --git a/install-beta.iss b/install-beta.iss index e5609d7f..4cb02da6 100644 --- a/install-beta.iss +++ b/install-beta.iss @@ -3,8 +3,8 @@ #define MyAppName "Dev Proxy Beta" ; for local use only. In production replaced by a command line arg -#define MyAppSetupExeName "dev-proxy-installer-win-x64-3.1.0-beta.1" -#define MyAppVersion "3.1.0-beta.1" +#define MyAppSetupExeName "dev-proxy-installer-win-x64-4.0.0-beta.1" +#define MyAppVersion "4.0.0-beta.1" #define MyAppPublisher ".NET Foundation" #define MyAppURL "https://aka.ms/devproxy" #define DevProxyExecutable "devproxy-beta.exe" diff --git a/install.iss b/install.iss index d3d5f732..14f8143b 100644 --- a/install.iss +++ b/install.iss @@ -3,8 +3,8 @@ #define MyAppName "Dev Proxy" ; for local use only. In production replaced by a command line arg -#define MyAppSetupExeName "dev-proxy-installer-win-x64-3.1.0" -#define MyAppVersion "3.1.0" +#define MyAppSetupExeName "dev-proxy-installer-win-x64-4.0.0" +#define MyAppVersion "4.0.0" #define MyAppPublisher ".NET Foundation" #define MyAppURL "https://aka.ms/devproxy" #define DevProxyExecutable "devproxy.exe" diff --git a/schemas/v3.1.0/websocketmockresponseplugin.mocksfile.schema.json b/schemas/v3.1.0/websocketmockresponseplugin.mocksfile.schema.json new file mode 100644 index 00000000..b3768b60 --- /dev/null +++ b/schemas/v3.1.0/websocketmockresponseplugin.mocksfile.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy WebSocketMockResponsePlugin mocks", + "description": "Mocks for the Dev Proxy WebSocketMockResponsePlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mocks": { + "type": "array", + "description": "Array of WebSocket mock definitions.", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The WebSocket URL to match. Accepts ws://, wss://, http:// or https:// (schemes are normalized when matching) and supports * wildcards." + }, + "onConnect": { + "type": "array", + "description": "Messages sent to the client immediately after the handshake, in order.", + "items": { + "$ref": "#/$defs/message" + } + }, + "rules": { + "type": "array", + "description": "Reactive rules evaluated in order against each inbound client message.", + "items": { + "type": "object", + "properties": { + "match": { + "type": "object", + "description": "How to match an inbound client message. Omit (or leave empty) to match any message. When more than one is set, precedence is bodyJson > bodyRegex > bodyFragment.", + "properties": { + "bodyFragment": { + "type": "string", + "description": "A case-insensitive substring matched against the inbound message text." + }, + "bodyRegex": { + "type": "string", + "description": "A regular expression matched against the inbound message text." + }, + "bodyJson": { + "type": [ + "object", + "array", + "string", + "number", + "boolean" + ], + "description": "A JSON value compared structurally (key-order-independent) against the inbound message parsed as JSON." + } + }, + "additionalProperties": false + }, + "responses": { + "type": "array", + "description": "Messages to send to the client when this rule matches.", + "items": { + "$ref": "#/$defs/message" + } + }, + "closeAfter": { + "type": "boolean", + "description": "When true, the mock server closes the connection after replying. Default is false." + } + }, + "additionalProperties": false + } + }, + "closeOnUnmatched": { + "type": "boolean", + "description": "When true, the mock server closes if an inbound message matches no rule. Default is false." + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + } + }, + "required": [ + "mocks" + ], + "additionalProperties": true, + "$defs": { + "message": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "The message payload. Sent verbatim for text, base64-decoded for binary." + }, + "messageType": { + "type": "string", + "enum": [ + "text", + "binary" + ], + "description": "Whether body is text or base64 binary. Default is text." + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + } +} diff --git a/schemas/v3.1.0/websocketmockresponseplugin.schema.json b/schemas/v3.1.0/websocketmockresponseplugin.schema.json new file mode 100644 index 00000000..fbe9c1d4 --- /dev/null +++ b/schemas/v3.1.0/websocketmockresponseplugin.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy WebSocketMockResponsePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mocksFile": { + "type": "string", + "description": "Path to the file containing the WebSocket mock responses." + } + }, + "required": [ + "mocksFile" + ], + "additionalProperties": false +} diff --git a/schemas/v4.0.0/apicenterminimalpermissionsplugin.schema.json b/schemas/v4.0.0/apicenterminimalpermissionsplugin.schema.json new file mode 100644 index 00000000..582fe633 --- /dev/null +++ b/schemas/v4.0.0/apicenterminimalpermissionsplugin.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy ApiCenterMinimalPermissionsPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "resourceGroupName": { + "type": "string", + "description": "Name of the resource group where the Azure API Center is located." + }, + "serviceName": { + "type": "string", + "description": "Name of the Azure API Center instance that Dev Proxy should use to check if the APIs used in the app are registered." + }, + "subscriptionId": { + "type": "string", + "description": "ID of the Azure subscription where the Azure API Center instance is located." + }, + "workspaceName": { + "type": "string", + "description": "Name of the Azure API Center workspace to use. Default is 'default'.", + "default": "default" + }, + "schemeName": { + "type": "string", + "description": "The name of the security scheme definition. Used to determine minimal permissions required for API calls." + } + }, + "required": [ + "resourceGroupName", + "serviceName", + "subscriptionId" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/apicenteronboardingplugin.schema.json b/schemas/v4.0.0/apicenteronboardingplugin.schema.json new file mode 100644 index 00000000..35ce03d2 --- /dev/null +++ b/schemas/v4.0.0/apicenteronboardingplugin.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy ApiCenterOnboardingPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "createApicEntryForNewApis": { + "type": "boolean", + "description": "Set to true to have Dev Proxy create new API entries for APIs detected but not yet registered in API Center. When false, Dev Proxy only lists unregistered APIs. Default is true." + }, + "resourceGroupName": { + "type": "string", + "description": "Name of the resource group where the Azure API Center is located." + }, + "serviceName": { + "type": "string", + "description": "Name of the Azure API Center instance that Dev Proxy should use to check if the APIs used in the app are registered." + }, + "subscriptionId": { + "type": "string", + "description": "ID of the Azure subscription where the Azure API Center instance is located." + }, + "workspaceName": { + "type": "string", + "description": "Name of the Azure API Center workspace to use. Default is 'default'." + } + }, + "required": [ + "resourceGroupName", + "serviceName", + "subscriptionId" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/apicenterproductionversionplugin.schema.json b/schemas/v4.0.0/apicenterproductionversionplugin.schema.json new file mode 100644 index 00000000..5caacc50 --- /dev/null +++ b/schemas/v4.0.0/apicenterproductionversionplugin.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy ApiCenterProductionVersionPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "resourceGroupName": { + "type": "string", + "description": "Name of the resource group where the Azure API Center is located." + }, + "serviceName": { + "type": "string", + "description": "Name of the Azure API Center instance that Dev Proxy should use to check if the APIs used in the app are registered." + }, + "subscriptionId": { + "type": "string", + "description": "ID of the Azure subscription where the Azure API Center instance is located." + }, + "workspaceName": { + "type": "string", + "description": "Name of the Azure API Center workspace to use. Default is 'default'." + } + }, + "required": [ + "resourceGroupName", + "serviceName", + "subscriptionId" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/authplugin.schema.json b/schemas/v4.0.0/authplugin.schema.json new file mode 100644 index 00000000..900d07ae --- /dev/null +++ b/schemas/v4.0.0/authplugin.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy AuthPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "apiKey": { + "type": "object", + "description": "Configuration for API key authentication and authorization.", + "properties": { + "allowedKeys": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed API keys." + }, + "parameters": { + "type": "array", + "description": "List of parameters that contain the API key.", + "items": { + "type": "object", + "properties": { + "in": { + "type": "string", + "enum": [ + "header", + "query", + "cookie" + ], + "description": "Where the parameter is expected to be found. Allowed values: header, query, cookie." + }, + "name": { + "type": "string", + "description": "Name of the parameter." + } + }, + "required": [ + "in", + "name" + ] + } + } + }, + "required": [ + "allowedKeys", + "parameters" + ] + }, + "oauth2": { + "type": "object", + "description": "Configuration for OAuth2 authentication and authorization.", + "properties": { + "metadataUrl": { + "type": "string", + "description": "URL to the OpenID Connect metadata document." + }, + "allowedApplications": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed application IDs. Leave empty to not validate the application (appid or azp claim) for which the token is issued." + }, + "allowedAudiences": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed audiences. Leave empty to not validate the audience (aud claim) for which the token is issued." + }, + "allowedPrincipals": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed principals. Leave empty to not validate the principal (oid claim) for which the token is issued." + }, + "allowedTenants": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed tenants. Leave empty to not validate the tenant (tid claim) for which the token is issued." + }, + "issuer": { + "type": "string", + "description": "Allowed token issuer. Leave empty to not validate the token issuer." + }, + "roles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed roles. Leave empty to not validate the roles (roles claim) on the token." + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed scopes. Leave empty to not validate the scopes (scp claim) on the token." + }, + "validateLifetime": { + "type": "boolean", + "description": "Set to false to disable validating the token lifetime. Default is true." + }, + "validateSigningKey": { + "type": "boolean", + "description": "Set to false to disable validating the token signature. Default is true." + } + }, + "required": [ + "metadataUrl" + ] + }, + "type": { + "type": "string", + "enum": [ + "apiKey", + "oauth2" + ], + "description": "Type of authentication and authorization that Dev Proxy should use. Allowed values: apiKey, oauth2." + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/cachingguidanceplugin.schema.json b/schemas/v4.0.0/cachingguidanceplugin.schema.json new file mode 100644 index 00000000..18116180 --- /dev/null +++ b/schemas/v4.0.0/cachingguidanceplugin.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy CachingGuidancePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "cacheThresholdSeconds": { + "type": "integer", + "description": "The number of seconds between the same request that triggers the guidance warning. Default is 5." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/crudapiplugin.apifile.schema.json b/schemas/v4.0.0/crudapiplugin.apifile.schema.json new file mode 100644 index 00000000..5f3b58bd --- /dev/null +++ b/schemas/v4.0.0/crudapiplugin.apifile.schema.json @@ -0,0 +1,177 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CRUD API plugin API definition", + "description": "API definition for use with the CRUD API Dev Proxy plugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "baseUrl": { + "type": "string", + "description": "Base URL where Dev Proxy exposes the API. Dev Proxy prepends this base URL to the URLs defined in actions." + }, + "enableCors": { + "type": "boolean", + "description": "Set to true to enable CORS for the API. Default is true." + }, + "dataFile": { + "type": "string", + "description": "Path to the file that contains the data for the API. The file must define a JSON array." + }, + "actions": { + "type": "array", + "description": "List of actions that the API supports. Each action defines how Dev Proxy interacts with the data.", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create", + "getAll", + "getOne", + "getMany", + "merge", + "update", + "delete" + ], + "description": "Defines the type of action. Possible values: getAll, getOne, getMany, create, merge, update, delete." + }, + "url": { + "type": "string", + "description": "URL where Dev Proxy exposes the action. Appended to the baseUrl. Can contain parameters in curly braces." + }, + "query": { + "type": "string", + "description": "JSONPath query (using Newtonsoft.Json) that Dev Proxy uses to find the data in the data file. Parameters can be referenced using curly braces." + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "description": "HTTP method that Dev Proxy uses to expose the action. Defaults depend on the action type." + }, + "auth": { + "type": "string", + "enum": [ + "none", + "entra" + ], + "description": "Determines if the action is secured. Allowed values: none, entra. Default is none." + }, + "entraAuthConfig": { + "type": "object", + "description": "Configuration for Microsoft Entra authentication for this action. Overrides the root entraAuthConfig if specified.", + "properties": { + "audience": { + "type": "string", + "description": "Valid audience for the token. If specified, the token's audience must match." + }, + "issuer": { + "type": "string", + "description": "Valid token issuer. If specified, the token's issuer must match." + }, + "scopes": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of valid scopes. At least one must be present in the token." + }, + "roles": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of valid roles. At least one must be present in the token." + }, + "validateLifetime": { + "type": "boolean", + "description": "Set to true to validate that the token hasn't expired." + }, + "validateSigningKey": { + "type": "boolean", + "description": "Set to true to validate the token's signature." + } + } + } + }, + "required": [ + "action" + ], + "additionalProperties": false + } + }, + "auth": { + "type": "string", + "enum": [ + "none", + "entra", + "apiKey" + ], + "description": "Determines if the API is secured. Allowed values: none, entra, apiKey. Default is none." + }, + "apiKeyAuthConfig": { + "type": "object", + "description": "Configuration for API Key authentication. Applies to all actions unless overridden at the action level.", + "properties": { + "apiKey": { + "type": "string", + "description": "The valid API key that must be present in the request." + }, + "headerName": { + "type": "string", + "description": "The HTTP header name to read the API key from." + }, + "queryParameterName": { + "type": "string", + "description": "The name of the query-string parameter to read the API key from." + } + }, + "required": [ + "apiKey" + ] + }, + "entraAuthConfig": { + "type": "object", + "description": "Configuration for Microsoft Entra authentication. Applies to all actions unless overridden at the action level.", + "properties": { + "audience": { + "type": "string", + "description": "Valid audience for the token. If specified, the token's audience must match." + }, + "issuer": { + "type": "string", + "description": "Valid token issuer. If specified, the token's issuer must match." + }, + "scopes": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of valid scopes. At least one must be present in the token." + }, + "roles": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of valid roles. At least one must be present in the token." + }, + "validateLifetime": { + "type": "boolean", + "description": "Set to true to validate that the token hasn't expired. Default is false." + }, + "validateSigningKey": { + "type": "boolean", + "description": "Set to true to validate the token's signature. Default is false." + } + } + } + }, + "required": [ + "baseUrl", + "dataFile", + "actions" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/schemas/v4.0.0/crudapiplugin.schema.json b/schemas/v4.0.0/crudapiplugin.schema.json new file mode 100644 index 00000000..8c6d499c --- /dev/null +++ b/schemas/v4.0.0/crudapiplugin.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy CrudApiPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "apiFile": { + "type": "string", + "description": "Path to the file that contains the definition of the CRUD API." + } + }, + "required": [ + "apiFile" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/devtoolsplugin.schema.json b/schemas/v4.0.0/devtoolsplugin.schema.json new file mode 100644 index 00000000..e451af1f --- /dev/null +++ b/schemas/v4.0.0/devtoolsplugin.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy DevToolsPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "preferredBrowser": { + "type": "string", + "enum": [ + "Edge", + "EdgeDev", + "Chrome" + ], + "description": "Which browser to use to launch Dev Tools. Supported values: Edge, EdgeDev, Chrome. Default: Edge." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/executionsummaryplugin.schema.json b/schemas/v4.0.0/executionsummaryplugin.schema.json new file mode 100644 index 00000000..e370b052 --- /dev/null +++ b/schemas/v4.0.0/executionsummaryplugin.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy ExecutionSummaryPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "groupBy": { + "type": "string", + "enum": [ + "url", + "messageType" + ], + "description": "How proxy should group the information in the summary. Available options: url, messageType. Default: url." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/genericrandomerrorplugin.errorsfile.schema.json b/schemas/v4.0.0/genericrandomerrorplugin.errorsfile.schema.json new file mode 100644 index 00000000..0ff4d13e --- /dev/null +++ b/schemas/v4.0.0/genericrandomerrorplugin.errorsfile.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy GenericRandomErrorPlugin responses", + "description": "Error responses for the Dev Proxy GenericRandomErrorPlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "errors": { + "type": "array", + "description": "List of error response definitions to simulate. Each entry defines a request pattern and possible error responses.", + "items": { + "type": "object", + "properties": { + "request": { + "type": "object", + "description": "Request pattern to match for simulating an error.", + "properties": { + "url": { + "type": "string", + "description": "URL pattern to match for the request. Supports wildcards." + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "CONNECT", + "TRACE" + ], + "description": "HTTP method to match for the request. Optional." + }, + "bodyFragment": { + "type": "string", + "description": "Fragment of the request body to match. Optional." + } + }, + "required": [ + "url" + ] + }, + "responses": { + "type": "array", + "description": "Possible error responses to return for the matched request.", + "items": { + "type": "object", + "properties": { + "body": { + "type": [ + "object", + "array", + "string" + ], + "description": "Response body to return. Can be an object, array, or string." + }, + "statusCode": { + "type": "integer", + "description": "HTTP status code to return." + }, + "headers": { + "type": "array", + "description": "List of headers to include in the response.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Header name." + }, + "value": { + "type": "string", + "description": "Header value." + } + }, + "required": [ + "name", + "value" + ] + } + } + } + } + } + }, + "required": [ + "request", + "responses" + ] + } + } + }, + "required": [ + "errors" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/schemas/v4.0.0/genericrandomerrorplugin.schema.json b/schemas/v4.0.0/genericrandomerrorplugin.schema.json new file mode 100644 index 00000000..ec9facf0 --- /dev/null +++ b/schemas/v4.0.0/genericrandomerrorplugin.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy GenericRandomErrorPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "errorsFile": { + "type": "string", + "description": "Path to the file that contains error responses." + }, + "rate": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "The percentage of requests to fail with a random error. Value between 0 and 100." + }, + "retryAfterInSeconds": { + "type": "integer", + "minimum": 1, + "description": "The number of seconds to wait before retrying the request. Included on the Retry-After response header for dynamic throttling. Default: 5." + } + }, + "required": [ + "errorsFile" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/graphminimalpermissionsguidanceplugin.schema.json b/schemas/v4.0.0/graphminimalpermissionsguidanceplugin.schema.json new file mode 100644 index 00000000..72199451 --- /dev/null +++ b/schemas/v4.0.0/graphminimalpermissionsguidanceplugin.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy GraphMinimalPermissionsGuidancePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON schema definition." + }, + "permissionsToExclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The scopes to ignore and not include in the report. Default: ['profile', 'openid', 'offline_access', 'email'].", + "default": ["profile", "openid", "offline_access", "email"] + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/graphminimalpermissionsplugin.schema.json b/schemas/v4.0.0/graphminimalpermissionsplugin.schema.json new file mode 100644 index 00000000..d72dd68f --- /dev/null +++ b/schemas/v4.0.0/graphminimalpermissionsplugin.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy GraphMinimalPermissionsPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON schema definition." + }, + "type": { + "type": "string", + "enum": [ + "delegated", + "application" + ], + "description": "Determines which type of permission scopes to return. Can be 'delegated' or 'application'. Default: 'delegated'." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/graphrandomerrorplugin.schema.json b/schemas/v4.0.0/graphrandomerrorplugin.schema.json new file mode 100644 index 00000000..e74fae9d --- /dev/null +++ b/schemas/v4.0.0/graphrandomerrorplugin.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy GraphRandomErrorPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON schema definition." + }, + "allowedErrors": { + "type": "array", + "description": "Array of HTTP status codes (integers between 400 and 599) that the plugin can use to simulate errors. For example, [429] to simulate throttling.", + "items": { + "type": "integer", + "minimum": 400, + "maximum": 599 + } + }, + "rate": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "The percentage (0-100) of requests that should be failed with a random error." + }, + "retryAfterInSeconds": { + "type": "integer", + "minimum": 0, + "description": "The number of seconds to set in the Retry-After header for throttling responses." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/hargeneratorplugin.schema.json b/schemas/v4.0.0/hargeneratorplugin.schema.json new file mode 100644 index 00000000..a7aa5b5e --- /dev/null +++ b/schemas/v4.0.0/hargeneratorplugin.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy HarGeneratorPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON schema definition." + }, + "includeSensitiveInformation": { + "type": "boolean", + "description": "Determines whether to include sensitive information (such as authentication headers, and cookies) in the generated HAR file. When set to false, sensitive information will be redacted. Default: false." + }, + "includeResponse": { + "type": "boolean", + "description": "Determines whether to include HTTP response body in the generated HAR file. When set to false, only request information will be included. Default: false." + } + }, + "additionalProperties": false +} diff --git a/schemas/v4.0.0/httpfilegeneratorplugin.schema.json b/schemas/v4.0.0/httpfilegeneratorplugin.schema.json new file mode 100644 index 00000000..f25784f1 --- /dev/null +++ b/schemas/v4.0.0/httpfilegeneratorplugin.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy HttpFileGeneratorPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON schema definition." + }, + "includeOptionsRequests": { + "type": "boolean", + "description": "Determines whether to include OPTIONS requests in the generated HTTP file. Default: false." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/languagemodelfailureplugin.schema.json b/schemas/v4.0.0/languagemodelfailureplugin.schema.json new file mode 100644 index 00000000..db6dc115 --- /dev/null +++ b/schemas/v4.0.0/languagemodelfailureplugin.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy LanguageModelFailurePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "failures": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of failure types to simulate in language model responses. If not specified, uses default failure types including AmbiguityVagueness, BiasStereotyping, CircularReasoning, ContradictoryInformation, FailureDisclaimHedge, FailureFollowInstructions, Hallucination, IncorrectFormatStyle, Misinterpretation, OutdatedInformation, OverSpecification, OverconfidenceUncertainty, Overgeneralization, OverreliancePriorConversation, and PlausibleIncorrect." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/languagemodelratelimitingplugin.customresponsefile.schema.json b/schemas/v4.0.0/languagemodelratelimitingplugin.customresponsefile.schema.json new file mode 100644 index 00000000..b8e9121f --- /dev/null +++ b/schemas/v4.0.0/languagemodelratelimitingplugin.customresponsefile.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy LanguageModelRateLimitingPlugin response", + "description": "Mock for the Dev Proxy LanguageModelRateLimitingPlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the JSON schema used to validate this custom response file." + }, + "body": { + "type": [ + "object", + "array", + "string" + ], + "description": "The body of the custom response returned when the token limit is exceeded. Can be an object, array, or string." + }, + "statusCode": { + "type": "integer", + "description": "HTTP status code to return when the token limit is exceeded (e.g., 429)." + }, + "headers": { + "type": "array", + "description": "List of headers to include in the custom response.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Header name." + }, + "value": { + "type": "string", + "description": "Header value. Use '@dynamic' for the Retry-After header to automatically calculate seconds until reset." + } + }, + "required": [ + "name", + "value" + ] + } + } + }, + "additionalProperties": true +} diff --git a/schemas/v4.0.0/languagemodelratelimitingplugin.schema.json b/schemas/v4.0.0/languagemodelratelimitingplugin.schema.json new file mode 100644 index 00000000..1cc836bb --- /dev/null +++ b/schemas/v4.0.0/languagemodelratelimitingplugin.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy LanguageModelRateLimitingPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the JSON schema used to validate this configuration file." + }, + "headerRetryAfter": { + "type": "string", + "description": "Name of the response header that communicates the retry-after period (e.g., 'Retry-After')." + }, + "resetTimeWindowSeconds": { + "type": "integer", + "minimum": 1, + "description": "How long in seconds until the next token limit reset." + }, + "promptTokenLimit": { + "type": "integer", + "minimum": 1, + "description": "Number of prompt tokens allowed per time window." + }, + "completionTokenLimit": { + "type": "integer", + "minimum": 1, + "description": "Number of completion tokens allowed per time window." + }, + "whenLimitExceeded": { + "type": "string", + "enum": [ + "Throttle", + "Custom" + ], + "description": "Behavior when the token limit is exceeded: 'Throttle' (default throttling) or 'Custom' (custom response)." + }, + "customResponseFile": { + "type": "string", + "description": "Path to a file containing a custom error response to use when the token limit is exceeded." + } + }, + "additionalProperties": false +} diff --git a/schemas/v4.0.0/latencyplugin.schema.json b/schemas/v4.0.0/latencyplugin.schema.json new file mode 100644 index 00000000..82fdd5c9 --- /dev/null +++ b/schemas/v4.0.0/latencyplugin.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy LatencyPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON schema definition." + }, + "minMs": { + "type": "integer", + "minimum": 0, + "description": "The minimum amount of delay (in milliseconds) added to a request. Default: 0." + }, + "maxMs": { + "type": "integer", + "minimum": 0, + "description": "The maximum amount of delay (in milliseconds) added to a request. Default: 5000." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/minimalcsompermissions.types.schema.json b/schemas/v4.0.0/minimalcsompermissions.types.schema.json new file mode 100644 index 00000000..c212cdb5 --- /dev/null +++ b/schemas/v4.0.0/minimalcsompermissions.types.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SharePoint CSOM Types and Permissions Schema", + "description": "Schema for defining SharePoint CSOM types, return types, and their required permissions", + "type": "object", + "required": ["types", "returnTypes", "actions"], + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON schema definition." + }, + "types": { + "type": "object", + "description": "Mapping of GUIDs to their corresponding SharePoint CSOM types. Used for readability and easier mapping.", + "patternProperties": { + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$": { + "type": "string", + "description": "Fully qualified name of a SharePoint CSOM type." + } + } + }, + "returnTypes": { + "type": "object", + "description": "Mapping of method signatures to their return types. Used to traverse the CSOM API hierarchy.", + "patternProperties": { + "^[A-Za-z0-9.]+\\.[A-Za-z0-9.]+$": { + "type": "string", + "description": "Fully qualified name of the return type." + } + } + }, + "actions": { + "type": "object", + "description": "Mapping of method signatures to their required permissions. Each action lists the delegated and application permissions needed, sorted by least privilege first.", + "patternProperties": { + "^[A-Za-z0-9.]+\\.[A-Za-z0-9.]+$": { + "type": "object", + "properties": { + "delegated": { + "type": "array", + "description": "Required delegated permissions.", + "items": { + "type": "string" + } + }, + "application": { + "type": "array", + "description": "Required application permissions.", + "items": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/schemas/v4.0.0/minimalcsompermissionsplugin.schema.json b/schemas/v4.0.0/minimalcsompermissionsplugin.schema.json new file mode 100644 index 00000000..f4b3eba1 --- /dev/null +++ b/schemas/v4.0.0/minimalcsompermissionsplugin.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MinimalCsomPermissionsPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to the JSON schema definition." + }, + "typesFilePath": { + "type": "string", + "description": "Path to the file that lists permissions required to call SharePoint CSOM APIs. Default: ~appFolder/config/spo-csom-types.json." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v4.0.0/minimalpermissionsguidanceplugin.schema.json new file mode 100644 index 00000000..967657c7 --- /dev/null +++ b/schemas/v4.0.0/minimalpermissionsguidanceplugin.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MinimalPermissionsGuidancePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "apiSpecsFolderPath": { + "type": "string", + "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." + }, + "permissionsToExclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The scopes to ignore and not include in the report. Default: ['profile', 'openid', 'offline_access', 'email'].", + "default": ["profile", "openid", "offline_access", "email"] + }, + "schemeName": { + "type": "string", + "description": "The name of the security scheme definition. Used to determine minimal permissions required for API calls." + } + }, + "required": [ + "apiSpecsFolderPath" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/minimalpermissionsplugin.schema.json b/schemas/v4.0.0/minimalpermissionsplugin.schema.json new file mode 100644 index 00000000..84d1d24d --- /dev/null +++ b/schemas/v4.0.0/minimalpermissionsplugin.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MinimalPermissionsPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "apiSpecsFolderPath": { + "type": "string", + "description": "Relative or absolute path to the folder with API specs. Used to determine minimal permissions required for API calls." + }, + "schemeName": { + "type": "string", + "description": "The name of the security scheme definition. Used to determine minimal permissions required for API calls." + } + }, + "required": [ + "apiSpecsFolderPath" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/mockrequestplugin.mockfile.schema.json b/schemas/v4.0.0/mockrequestplugin.mockfile.schema.json new file mode 100644 index 00000000..642ca7e6 --- /dev/null +++ b/schemas/v4.0.0/mockrequestplugin.mockfile.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MockRequestPlugin mocks", + "description": "Mock request for the Dev Proxy MockRequestPlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "request": { + "type": "object", + "description": "The request to issue.", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to call." + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "CONNECT", + "TRACE" + ], + "description": "HTTP method to use (default: POST)." + }, + "body": { + "type": ["object", "string"], + "description": "Body of the request (object or string)." + }, + "headers": { + "type": "array", + "description": "Array of request headers (name/value pairs).", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Request header name." + }, + "value": { + "type": "string", + "description": "Request header value." + } + }, + "required": [ + "name", + "value" + ] + } + } + }, + "required": [ + "url" + ] + } + }, + "required": [ + "request" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/schemas/v4.0.0/mockrequestplugin.schema.json b/schemas/v4.0.0/mockrequestplugin.schema.json new file mode 100644 index 00000000..08355e53 --- /dev/null +++ b/schemas/v4.0.0/mockrequestplugin.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MockRequestPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mockFile": { + "type": "string", + "description": "Path to the file containing the mock request." + } + }, + "required": [ + "mockFile" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/mockresponseplugin.mocksfile.schema.json b/schemas/v4.0.0/mockresponseplugin.mocksfile.schema.json new file mode 100644 index 00000000..4b948879 --- /dev/null +++ b/schemas/v4.0.0/mockresponseplugin.mocksfile.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MockResponsePlugin mocks", + "description": "Mocks for the Dev Proxy MockResponsePlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mocks": { + "type": "array", + "description": "Array of mock definitions.", + "items": { + "type": "object", + "properties": { + "request": { + "type": "object", + "description": "The request to match.", + "properties": { + "url": { + "type": "string", + "description": "The URL to match. Supports wildcards." + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", + "CONNECT", + "TRACE" + ], + "description": "HTTP method to match." + }, + "nth": { + "type": "integer", + "description": "(Optional) Match the nth occurrence of the request." + }, + "bodyFragment": { + "type": "string", + "description": "(Optional) A fragment of the request body to match." + } + }, + "required": [ + "url" + ] + }, + "response": { + "type": "object", + "description": "The response to return.", + "properties": { + "body": { + "type": [ + "object", + "array", + "string" + ], + "description": "The response body (object, array, or string; can reference a file with '@filename')." + }, + "statusCode": { + "type": "integer", + "description": "HTTP status code to return." + }, + "headers": { + "type": "array", + "description": "Array of response headers (name/value pairs).", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Header name." + }, + "value": { + "type": "string", + "description": "Header value." + } + }, + "required": [ + "name", + "value" + ] + } + } + } + } + }, + "required": [ + "request", + "response" + ] + } + } + }, + "required": [ + "mocks" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/schemas/v4.0.0/mockresponseplugin.schema.json b/schemas/v4.0.0/mockresponseplugin.schema.json new file mode 100644 index 00000000..5343240c --- /dev/null +++ b/schemas/v4.0.0/mockresponseplugin.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MockResponsePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mocksFile": { + "type": "string", + "description": "Path to the file containing the mock responses." + }, + "blockUnmockedRequests": { + "type": "boolean", + "description": "Set to true to return 502 Bad Gateway response for requests that aren't mocked. Default is false." + } + }, + "required": [ + "mocksFile" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/mockstdioresponseplugin.mocksfile.schema.json b/schemas/v4.0.0/mockstdioresponseplugin.mocksfile.schema.json new file mode 100644 index 00000000..958cd909 --- /dev/null +++ b/schemas/v4.0.0/mockstdioresponseplugin.mocksfile.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MockStdioResponsePlugin mocks", + "description": "Mocks for the Dev Proxy MockStdioResponsePlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mocks": { + "type": "array", + "description": "Array of stdio mock definitions.", + "items": { + "type": "object", + "properties": { + "request": { + "type": "object", + "description": "The request pattern to match against stdin.", + "properties": { + "bodyFragment": { + "type": "string", + "description": "(Optional) A fragment of the stdin body to match (case-insensitive contains). You can specify bodyFragment, bodyRegex, or both; if both are specified, bodyRegex takes precedence. If neither is specified, the mock matches any stdin or is applied immediately on startup." + }, + "bodyRegex": { + "type": "string", + "description": "(Optional) A regular expression pattern to match against the stdin body (case-insensitive). You can specify bodyRegex, bodyFragment, or both; if both are specified, bodyRegex takes precedence. If neither is specified, the mock matches any stdin or is applied immediately on startup." + }, + "nth": { + "type": "integer", + "description": "(Optional) Match the nth occurrence. If not specified, matches every occurrence." + } + } + }, + "response": { + "type": "object", + "description": "The mock response to return.", + "properties": { + "stdout": { + "type": [ + "object", + "array", + "string" + ], + "description": "The stdout content to return. Can be a string, object, or array. If the value starts with @, it's treated as a file path." + }, + "stderr": { + "type": [ + "object", + "array", + "string" + ], + "description": "The stderr content to return. Can be a string, object, or array. If the value starts with @, it's treated as a file path." + } + } + } + } + } + } + }, + "required": [ + "mocks" + ], + "additionalProperties": true +} diff --git a/schemas/v4.0.0/mockstdioresponseplugin.schema.json b/schemas/v4.0.0/mockstdioresponseplugin.schema.json new file mode 100644 index 00000000..42e5ca0e --- /dev/null +++ b/schemas/v4.0.0/mockstdioresponseplugin.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy MockStdioResponsePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mocksFile": { + "type": "string", + "description": "Path to the file containing the stdio mock responses." + }, + "blockUnmockedRequests": { + "type": "boolean", + "description": "Set to true to block stdin requests that aren't mocked. Default is false." + } + }, + "required": [ + "mocksFile" + ], + "additionalProperties": false +} diff --git a/schemas/v4.0.0/openaitelemetryplugin.pricesfile.schema.json b/schemas/v4.0.0/openaitelemetryplugin.pricesfile.schema.json new file mode 100644 index 00000000..c03e0c1b --- /dev/null +++ b/schemas/v4.0.0/openaitelemetryplugin.pricesfile.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OpenAI Telemetry Plugin language model prices file schema", + "description": "Schema for the language model prices file used by the OpenAI Telemetry plugin.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "prices": { + "type": "object", + "description": "Map of model names to their pricing information.", + "additionalProperties": { + "type": "object", + "properties": { + "input": { + "type": "number", + "description": "The price per million tokens for input/prompt tokens." + }, + "output": { + "type": "number", + "description": "The price per million tokens for output/completion tokens." + } + }, + "required": [ + "input", + "output" + ] + } + } + }, + "required": [ + "prices" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/schemas/v4.0.0/openaitelemetryplugin.schema.json b/schemas/v4.0.0/openaitelemetryplugin.schema.json new file mode 100644 index 00000000..e805c7cf --- /dev/null +++ b/schemas/v4.0.0/openaitelemetryplugin.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OpenAI Telemetry Plugin", + "description": "Settings for the OpenAI Telemetry plugin which captures OpenAI API calls and emits OpenTelemetry information.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "application": { + "type": "string", + "description": "The name of the application using the OpenTelemetry plugin.", + "default": "default" + }, + "currency": { + "type": "string", + "description": "The currency used for cost calculations.", + "default": "USD" + }, + "environment": { + "type": "string", + "description": "The environment in which the application is running (e.g., production, staging, development).", + "default": "development" + }, + "exporterEndpoint": { + "type": "string", + "description": "The endpoint of the OpenTelemetry collector to send information to.", + "default": "http://localhost:4318" + }, + "includeCompletion": { + "type": "boolean", + "description": "Whether to include the completion in the OpenTelemetry span. Disable for privacy or security concerns.", + "default": true + }, + "includeCosts": { + "type": "boolean", + "description": "Whether to calculate and include cost information in the spans. Requires prices data.", + "default": true + }, + "includePrompt": { + "type": "boolean", + "description": "Whether to include the prompt in the OpenTelemetry span. Disable for privacy or security concerns.", + "default": true + }, + "pricesFile": { + "type": "string", + "description": "Path to the JSON file containing prices data for language models." + } + }, + "additionalProperties": false +} diff --git a/schemas/v4.0.0/openapispecgeneratorplugin.schema.json b/schemas/v4.0.0/openapispecgeneratorplugin.schema.json new file mode 100644 index 00000000..53d5be29 --- /dev/null +++ b/schemas/v4.0.0/openapispecgeneratorplugin.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy OpenApiSpecGeneratorPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "includeOptionsRequests": { + "type": "boolean", + "description": "Determines whether to include OPTIONS requests in the generated OpenAPI spec. Default: false." + }, + "ignoreResponseTypes": { + "type": "boolean", + "description": "Determines whether to ignore response types in the generated OpenAPI spec. Default: false." + }, + "specVersion": { + "type": "string", + "enum": [ + "v2_0", + "v3_0" + ], + "description": "Specifies the OpenAPI spec version to generate. Allowed values: 'v2_0' or 'v3_0'. Default: 'v3_0'." + }, + "specFormat": { + "type": "string", + "enum": [ + "Json", + "Yaml" + ], + "description": "Specifies the format of the generated OpenAPI spec. Allowed values: 'Json' or 'Yaml'. Default: 'Json'." + }, + "includeParameters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Defines the list of query string parameters to include in the generated OpenAPI spec, along with their default values. Default: []", + "default": [] + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/ratelimitingplugin.customresponsefile.schema.json b/schemas/v4.0.0/ratelimitingplugin.customresponsefile.schema.json new file mode 100644 index 00000000..435ca265 --- /dev/null +++ b/schemas/v4.0.0/ratelimitingplugin.customresponsefile.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy RateLimitingPlugin response", + "description": "Mock for the Dev Proxy RateLimitingPlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the JSON schema used to validate this custom response file." + }, + "body": { + "type": [ + "object", + "array", + "string" + ], + "description": "The body of the custom response returned when the rate limit is exceeded. Can be an object, array, or string." + }, + "statusCode": { + "type": "integer", + "description": "HTTP status code to return when the rate limit is exceeded (e.g., 403)." + }, + "headers": { + "type": "array", + "description": "List of headers to include in the custom response.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Header name." + }, + "value": { + "type": "string", + "description": "Header value." + } + }, + "required": [ + "name", + "value" + ] + } + } + }, + "additionalProperties": true +} \ No newline at end of file diff --git a/schemas/v4.0.0/ratelimitingplugin.schema.json b/schemas/v4.0.0/ratelimitingplugin.schema.json new file mode 100644 index 00000000..62b1422a --- /dev/null +++ b/schemas/v4.0.0/ratelimitingplugin.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy RateLimitingPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the JSON schema used to validate this configuration file." + }, + "headerLimit": { + "type": "string", + "description": "Name of the response header that communicates the rate-limiting limit (e.g., 'RateLimit-Limit')." + }, + "headerRemaining": { + "type": "string", + "description": "Name of the response header that communicates the remaining number of resources before the reset (e.g., 'RateLimit-Remaining')." + }, + "headerReset": { + "type": "string", + "description": "Name of the response header that communicates the time remaining until the reset (e.g., 'RateLimit-Reset')." + }, + "headerRetryAfter": { + "type": "string", + "description": "Name of the response header that communicates the retry-after period (e.g., 'Retry-After')." + }, + "costPerRequest": { + "type": "integer", + "minimum": 1, + "description": "How many resources a single request costs." + }, + "resetTimeWindowSeconds": { + "type": "integer", + "minimum": 1, + "description": "How long in seconds until the next rate limit reset." + }, + "warningThresholdPercent": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "The percentage of the rate limit after which warning headers are returned." + }, + "rateLimit": { + "type": "integer", + "minimum": 1, + "description": "Number of resources allowed per time window." + }, + "whenLimitExceeded": { + "type": "string", + "enum": [ + "Throttle", + "Custom" + ], + "description": "Behavior when the rate limit is exceeded: 'Throttle' (default throttling) or 'Custom' (custom response)." + }, + "resetFormat": { + "type": "string", + "enum": [ + "SecondsLeft", + "UtcEpochSeconds" + ], + "description": "Format for the reset header: 'SecondsLeft' (seconds until reset) or 'UtcEpochSeconds' (UTC epoch seconds)." + }, + "customResponseFile": { + "type": "string", + "description": "Path to a file containing a custom error response to use when the rate limit is exceeded." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/rc.schema.json b/schemas/v4.0.0/rc.schema.json new file mode 100644 index 00000000..812be54f --- /dev/null +++ b/schemas/v4.0.0/rc.schema.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy config", + "description": "Configuration for Dev Proxy", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the JSON schema used to validate this configuration file. Should match the Dev Proxy version." + }, + "apiPort": { + "type": "number", + "minimum": 0, + "maximum": 65535, + "description": "Port for the Dev Proxy API server." + }, + "asSystemProxy": { + "type": "boolean", + "description": "Whether to set Dev Proxy as the system proxy." + }, + "filterByHeaders": { + "type": "array", + "description": "List of headers to filter requests by. Each object specifies a header name and value.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Header name to filter by." + }, + "value": { + "type": "string", + "description": "Header value to filter by." + } + }, + "required": [ + "name", + "value" + ] + } + }, + "ipAddress": { + "type": "string", + "format": "ipv4", + "description": "IP address for Dev Proxy to listen on." + }, + "languageModel": { + "type": "object", + "description": "Configuration for using a local language model with Dev Proxy.", + "properties": { + "cacheResponses": { + "type": "boolean", + "description": "Whether to cache responses from the language model." + }, + "client": { + "type": "string", + "enum": [ + "Ollama", + "OpenAI" + ], + "description": "The client to use for the local language model." + }, + "enabled": { + "type": "boolean", + "description": "Whether the language model integration is enabled." + }, + "model": { + "type": "string", + "description": "The name of the language model to use." + }, + "url": { + "type": "string", + "description": "URL of the local language model server." + } + } + }, + "logLevel": { + "type": "string", + "enum": [ + "debug", + "information", + "warning", + "error", + "trace" + ], + "description": "The minimum log level for Dev Proxy output." + }, + "output": { + "type": "string", + "enum": [ + "text", + "json" + ], + "description": "Output format. Use 'text' for readable console output (default), or 'json' for structured JSON Lines output suitable for LLMs and agents." + }, + "newVersionNotification": { + "type": "string", + "enum": [ + "none", + "stable", + "beta" + ], + "description": "Controls notifications about new Dev Proxy versions." + }, + "plugins": { + "type": "array", + "description": "List of plugins to load. Each object defines a plugin instance.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the plugin." + }, + "enabled": { + "type": "boolean", + "description": "Whether the plugin is enabled." + }, + "pluginPath": { + "type": "string", + "description": "Path to the plugin DLL." + }, + "configSection": { + "type": "string", + "description": "Name of the configuration section for this plugin instance." + }, + "urlsToWatch": { + "type": "array", + "description": "List of URL patterns for the plugin to watch.", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "enabled", + "pluginPath" + ] + } + }, + "port": { + "type": "number", + "minimum": 0, + "maximum": 65535, + "description": "Port for Dev Proxy to listen on." + }, + "record": { + "type": "boolean", + "description": "Whether to record requests and responses." + }, + "showSkipMessages": { + "type": "boolean", + "description": "Show messages for skipped requests." + }, + "urlsToWatch": { + "type": "array", + "description": "List of URL patterns for Dev Proxy to intercept.", + "items": { + "type": "string" + } + }, + "validateSchemas": { + "type": "boolean", + "description": "Whether to validate configuration files against their schemas. Only applies to JSON-based configuration files." + }, + "watchPids": { + "type": "array", + "description": "List of process IDs to watch for network traffic.", + "items": { + "type": "number" + } + }, + "watchProcessNames": { + "type": "array", + "description": "List of process names to watch for network traffic.", + "items": { + "type": "string" + } + }, + "showTimestamps": { + "type": "boolean", + "description": "Show timestamps in log output." + }, + "timeout": { + "type": "number", + "minimum": 1, + "description": "Timeout in seconds for requests passing through Dev Proxy." + } + }, + "required": [ + "plugins" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json b/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json new file mode 100644 index 00000000..b2cbcdd1 --- /dev/null +++ b/schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy RewritePlugin rewrite rules", + "description": "Rewrite rules for the Dev Proxy RewritePlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the JSON schema used to validate this rewrite rules file." + }, + "rewrites": { + "type": "array", + "description": "Array of rewrite rule objects that define the list of rewrite rules the RewritePlugin applies.", + "items": { + "type": "object", + "properties": { + "in": { + "type": "object", + "description": "Pattern to match the incoming request.", + "properties": { + "url": { + "type": "string", + "pattern": "^.+$", + "description": "Regular expression to match the incoming request URL." + } + }, + "required": ["url"] + }, + "out": { + "type": "object", + "description": "Pattern to rewrite the request.", + "properties": { + "url": { + "type": "string", + "pattern": "^.*$", + "description": "URL to rewrite the request to. Can use capture groups from the 'in' pattern." + } + }, + "required": ["url"] + } + }, + "required": ["in", "out"] + } + } + }, + "required": [ + "rewrites" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/schemas/v4.0.0/rewriteplugin.schema.json b/schemas/v4.0.0/rewriteplugin.schema.json new file mode 100644 index 00000000..6cf593e9 --- /dev/null +++ b/schemas/v4.0.0/rewriteplugin.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy RewritePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the JSON schema used to validate this configuration file." + }, + "rewritesFile": { + "type": "string", + "description": "Path to the file containing rewrite definitions (e.g., 'rewrites.json')." + } + }, + "required": [ + "rewritesFile" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/typespecgeneratorplugin.schema.json b/schemas/v4.0.0/typespecgeneratorplugin.schema.json new file mode 100644 index 00000000..db770ca4 --- /dev/null +++ b/schemas/v4.0.0/typespecgeneratorplugin.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy TypeSpecGeneratorPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the JSON schema used to validate this configuration file." + }, + "ignoreResponseTypes": { + "type": "boolean", + "description": "Determines whether to generate types for API responses (false) or to set them to 'string' (true)." + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v4.0.0/websocketmockresponseplugin.mocksfile.schema.json b/schemas/v4.0.0/websocketmockresponseplugin.mocksfile.schema.json new file mode 100644 index 00000000..b3768b60 --- /dev/null +++ b/schemas/v4.0.0/websocketmockresponseplugin.mocksfile.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy WebSocketMockResponsePlugin mocks", + "description": "Mocks for the Dev Proxy WebSocketMockResponsePlugin", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mocks": { + "type": "array", + "description": "Array of WebSocket mock definitions.", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The WebSocket URL to match. Accepts ws://, wss://, http:// or https:// (schemes are normalized when matching) and supports * wildcards." + }, + "onConnect": { + "type": "array", + "description": "Messages sent to the client immediately after the handshake, in order.", + "items": { + "$ref": "#/$defs/message" + } + }, + "rules": { + "type": "array", + "description": "Reactive rules evaluated in order against each inbound client message.", + "items": { + "type": "object", + "properties": { + "match": { + "type": "object", + "description": "How to match an inbound client message. Omit (or leave empty) to match any message. When more than one is set, precedence is bodyJson > bodyRegex > bodyFragment.", + "properties": { + "bodyFragment": { + "type": "string", + "description": "A case-insensitive substring matched against the inbound message text." + }, + "bodyRegex": { + "type": "string", + "description": "A regular expression matched against the inbound message text." + }, + "bodyJson": { + "type": [ + "object", + "array", + "string", + "number", + "boolean" + ], + "description": "A JSON value compared structurally (key-order-independent) against the inbound message parsed as JSON." + } + }, + "additionalProperties": false + }, + "responses": { + "type": "array", + "description": "Messages to send to the client when this rule matches.", + "items": { + "$ref": "#/$defs/message" + } + }, + "closeAfter": { + "type": "boolean", + "description": "When true, the mock server closes the connection after replying. Default is false." + } + }, + "additionalProperties": false + } + }, + "closeOnUnmatched": { + "type": "boolean", + "description": "When true, the mock server closes if an inbound message matches no rule. Default is false." + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + } + }, + "required": [ + "mocks" + ], + "additionalProperties": true, + "$defs": { + "message": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "The message payload. Sent verbatim for text, base64-decoded for binary." + }, + "messageType": { + "type": "string", + "enum": [ + "text", + "binary" + ], + "description": "Whether body is text or base64 binary. Default is text." + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + } +} diff --git a/schemas/v4.0.0/websocketmockresponseplugin.schema.json b/schemas/v4.0.0/websocketmockresponseplugin.schema.json new file mode 100644 index 00000000..fbe9c1d4 --- /dev/null +++ b/schemas/v4.0.0/websocketmockresponseplugin.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy WebSocketMockResponsePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "mocksFile": { + "type": "string", + "description": "Path to the file containing the WebSocket mock responses." + } + }, + "required": [ + "mocksFile" + ], + "additionalProperties": false +} diff --git a/scripts/Dockerfile_local b/scripts/Dockerfile_local index f202dbdd..8cf61f0c 100644 --- a/scripts/Dockerfile_local +++ b/scripts/Dockerfile_local @@ -1,6 +1,6 @@ FROM ubuntu:24.04 -ARG DEVPROXY_VERSION=3.1.0-beta.1 +ARG DEVPROXY_VERSION=4.0.0-beta.1 ARG USERNAME=devproxy ENV DEVPROXY_VERSION=${DEVPROXY_VERSION} diff --git a/scripts/local-setup.ps1 b/scripts/local-setup.ps1 index fc65475b..11b4d479 100644 --- a/scripts/local-setup.ps1 +++ b/scripts/local-setup.ps1 @@ -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. -$versionString = "v3.1.0-beta.1" +$versionString = "v4.0.0-beta.1" $version = $versionString.Substring(1) $isBeta = $version.Contains("-beta") diff --git a/scripts/version.ps1 b/scripts/version.ps1 index e5d4911b..780f41c3 100644 --- a/scripts/version.ps1 +++ b/scripts/version.ps1 @@ -2,4 +2,4 @@ # The .NET Foundation licenses this file to you under the MIT license. # See the LICENSE file in the project root for more information. -$script:versionString = "v3.1.0-beta.1" +$script:versionString = "v4.0.0-beta.1" diff --git a/spikes/kestrel-proxy-spike/CertificateAuthority.cs b/spikes/kestrel-proxy-spike/CertificateAuthority.cs new file mode 100644 index 00000000..eff889f9 --- /dev/null +++ b/spikes/kestrel-proxy-spike/CertificateAuthority.cs @@ -0,0 +1,101 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace KestrelSpike; + +/// +/// Spike CA. Like the POC but PERSISTS the root CA to disk so it survives restarts +/// (validates "regenerate-on-upgrade is fine, but stable within an install" + lets us +/// trust it once on macOS). Leaf certs are cached in-memory per host. +/// +public sealed class CertificateAuthority +{ + private readonly X509Certificate2 _ca; + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + + public X509Certificate2 RootCertificate => _ca; + + public CertificateAuthority(string? rootPath = null) + { + _ca = LoadOrCreateRoot(rootPath); + } + + public X509Certificate2 GetCertificateForHost(string host) => _cache.GetOrAdd(host, CreateLeafCertificate); + + private static X509Certificate2 LoadOrCreateRoot(string? rootPath) + { + if (rootPath is not null && File.Exists(rootPath)) + { + Console.WriteLine($"[spike] loading persisted root CA from {rootPath}"); + return X509CertificateLoader.LoadPkcs12(File.ReadAllBytes(rootPath), null); + } + + var ca = CreateRootCertificate(); + if (rootPath is not null) + { + File.WriteAllBytes(rootPath, ca.Export(X509ContentType.Pkcs12)); + Console.WriteLine($"[spike] created + persisted root CA at {rootPath}"); + Console.WriteLine($"[spike] trust it on macOS with: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain {rootPath}.cer (export the .cer first)"); + } + return ca; + } + + private static X509Certificate2 CreateRootCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=DevProxy Kestrel Spike Root CA, O=DevProxy Kestrel Spike", + 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)); + + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); + } + + 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 OidCollection { 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); + + using var leaf = request.Create( + _ca, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1), + serialNumber); + + using var leafWithKey = leaf.CopyWithPrivateKey(rsa); + + return X509CertificateLoader.LoadPkcs12(leafWithKey.Export(X509ContentType.Pkcs12), null); + } +} diff --git a/spikes/kestrel-proxy-spike/DuplexPipeStream.cs b/spikes/kestrel-proxy-spike/DuplexPipeStream.cs new file mode 100644 index 00000000..ac3adb43 --- /dev/null +++ b/spikes/kestrel-proxy-spike/DuplexPipeStream.cs @@ -0,0 +1,75 @@ +using System.Buffers; +using System.IO.Pipelines; + +namespace KestrelSpike; + +/// +/// Adapts an (Kestrel's connection transport) to a +/// so it can be wrapped by SslStream and read/written +/// with the usual stream helpers. +/// +public 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); + 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); + } + + 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/spikes/kestrel-proxy-spike/KestrelSpike.csproj b/spikes/kestrel-proxy-spike/KestrelSpike.csproj new file mode 100644 index 00000000..474d08ef --- /dev/null +++ b/spikes/kestrel-proxy-spike/KestrelSpike.csproj @@ -0,0 +1,13 @@ + + + + + net10.0 + enable + enable + KestrelSpike + kestrel-spike + + + diff --git a/spikes/kestrel-proxy-spike/Program.cs b/spikes/kestrel-proxy-spike/Program.cs new file mode 100644 index 00000000..ead1d784 --- /dev/null +++ b/spikes/kestrel-proxy-spike/Program.cs @@ -0,0 +1,42 @@ +using KestrelSpike; +using Microsoft.AspNetCore.Connections; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.AddFilter("Microsoft", LogLevel.Warning); + +var port = builder.Configuration.GetValue("port", 8080); + +// Watched hosts: only these are MITM'd; everything else is blind-tunnelled. +// Defaults chosen for the spike test script. +var watchedCsv = builder.Configuration.GetValue("watch", "jsonplaceholder.typicode.com,localhost"); +var watched = new WatchedHosts(watchedCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + +var caPath = Path.Combine(AppContext.BaseDirectory, "spike-root-ca.pfx"); + +builder.Services.AddSingleton(_ => new CertificateAuthority(caPath)); +builder.Services.AddSingleton(watched); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(_ => new HttpClient(new SocketsHttpHandler +{ + UseProxy = false, + AllowAutoRedirect = false, + AutomaticDecompression = System.Net.DecompressionMethods.All, +})); + +builder.WebHost.ConfigureKestrel(options => +{ + options.ListenLocalhost(port, l => l.UseConnectionHandler()); +}); + +var app = builder.Build(); +var ca = app.Services.GetRequiredService(); + +// Export the root CA as DER (.cer) so it can be trusted on the OS for browser tests. +var cerPath = Path.ChangeExtension(caPath, ".cer"); +File.WriteAllBytes(cerPath, ca.RootCertificate.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Cert)); + +Console.WriteLine($"[spike] listening on http://127.0.0.1:{port}"); +Console.WriteLine($"[spike] watched (MITM) hosts: {watchedCsv}"); +Console.WriteLine($"[spike] root CA (DER) for trust: {cerPath}"); + +app.Run(); diff --git a/spikes/kestrel-proxy-spike/ProxyConnectionHandler.cs b/spikes/kestrel-proxy-spike/ProxyConnectionHandler.cs new file mode 100644 index 00000000..5ac5915d --- /dev/null +++ b/spikes/kestrel-proxy-spike/ProxyConnectionHandler.cs @@ -0,0 +1,406 @@ +using System.Buffers; +using System.IO.Pipelines; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; +using Microsoft.AspNetCore.Connections; + +namespace KestrelSpike; + +/// +/// Spike connection handler. Exercises the risky cases from Phase 0B: +/// - selective decrypt: MITM watched hosts, blind-tunnel the rest byte-for-byte; +/// - non-destructive ClientHello ALPN peek → blind-tunnel h2-only (gRPC); +/// - keep-alive: multiple HTTP/1.1 requests per decrypted connection; +/// - SSE streaming pass-through (chunked, unbuffered). +/// +public sealed class ProxyConnectionHandler(CertificateAuthority ca, HttpClient httpClient, WatchedHosts watched) + : ConnectionHandler +{ + private static readonly HashSet HopByHopRequest = new(StringComparer.OrdinalIgnoreCase) + { "Connection","Proxy-Connection","Keep-Alive","Transfer-Encoding","Upgrade","TE","Trailer","Proxy-Authorization","Host","Content-Length" }; + private static readonly HashSet HopByHopResponse = new(StringComparer.OrdinalIgnoreCase) + { "Connection","Proxy-Connection","Keep-Alive","Transfer-Encoding","Upgrade","Trailer","Content-Length","Content-Encoding" }; + + public override async Task OnConnectedAsync(ConnectionContext connection) + { + var ct = connection.ConnectionClosed; + var reader = connection.Transport.Input; + try + { + var firstLine = await ReadRequestLineAndHeadersAsync(reader, ct); + if (firstLine is null) + { + return; + } + + if (string.Equals(firstLine.Method, "CONNECT", StringComparison.OrdinalIgnoreCase)) + { + await HandleConnectAsync(connection, firstLine, ct); + } + else + { + // Plain HTTP proxy request (absolute-form). Forward + keep-alive loop. + var clientStream = new DuplexPipeStream(connection.Transport); + await ForwardAsync(clientStream, firstLine, firstLine.Target, ct); + await PlainKeepAliveLoopAsync(clientStream, ct); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Console.WriteLine($"[spike] error: {ex.GetType().Name}: {ex.Message}"); + } + } + + // ---- CONNECT: decide MITM vs blind tunnel via ALPN peek ------------------ + + private async Task HandleConnectAsync(ConnectionContext connection, ParsedRequest connect, CancellationToken ct) + { + var (host, port) = SplitHostPort(connect.Target, 443); + var reader = connection.Transport.Input; + + // Tell the client the tunnel is open; it will now start the TLS handshake. + var clientStream = new DuplexPipeStream(connection.Transport); + await WriteAsciiAsync(clientStream, "HTTP/1.1 200 Connection Established\r\n\r\n", ct); + + // NON-DESTRUCTIVE peek of the ClientHello (SNI + ALPN). + var hello = await PeekClientHelloAsync(reader, ct); + + var isWatched = watched.IsWatched(host); + var h2Only = hello.Status == TlsClientHello.ParseStatus.Ok && hello.IsH2Only; + + Console.WriteLine($"[spike] CONNECT {host}:{port} watched={isWatched} alpn=[{string.Join(",", hello.Alpn)}] sni={hello.ServerName ?? "-"} hello={hello.Status}"); + + if (!isWatched) + { + Console.WriteLine($"[spike] → blind-tunnel (not watched) {host}:{port}"); + await BlindTunnelAsync(clientStream, host, port, ct); + return; + } + if (h2Only) + { + Console.WriteLine($"[spike] → blind-tunnel (h2-only/gRPC, never MITM) {host}:{port}"); + await BlindTunnelAsync(clientStream, host, port, ct); + return; + } + + // MITM: terminate TLS with our per-host cert, advertise http/1.1 only so any + // h2-capable client downgrades and we intercept it as HTTP/1.1. + Console.WriteLine($"[spike] → MITM (decrypt as http/1.1) {host}:{port}"); + var cert = ca.GetCertificateForHost(host); + await using var tls = new SslStream(clientStream, leaveInnerStreamOpen: false); + await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = cert, + ApplicationProtocols = [SslApplicationProtocol.Http11], + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + }, ct); + + await DecryptedKeepAliveLoopAsync(tls, host, port, ct); + } + + // ---- keep-alive loops ---------------------------------------------------- + + private async Task DecryptedKeepAliveLoopAsync(Stream tls, string host, int port, CancellationToken ct) + { + var n = 0; + while (!ct.IsCancellationRequested) + { + var req = await ReadRequestFromStreamAsync(tls, ct); + if (req is null) + { + break; + } + n++; + var url = port == 443 ? $"https://{host}{req.Target}" : $"https://{host}:{port}{req.Target}"; + Console.WriteLine($"[spike] keep-alive req #{n} on {host}: {req.Method} {req.Target}"); + var keepAlive = await ForwardAsync(tls, req, url, ct); + if (!keepAlive) + { + break; + } + } + Console.WriteLine($"[spike] {host} decrypted connection closed after {n} request(s)"); + } + + private async Task PlainKeepAliveLoopAsync(Stream clientStream, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + var req = await ReadRequestFromStreamAsync(clientStream, ct); + if (req is null) + { + break; + } + var keepAlive = await ForwardAsync(clientStream, req, req.Target, ct); + if (!keepAlive) + { + break; + } + } + } + + // ---- forwarding (with SSE/streaming) ------------------------------------ + + private async Task ForwardAsync(Stream clientStream, ParsedRequest req, string url, CancellationToken ct) + { + using var outgoing = new HttpRequestMessage(new HttpMethod(req.Method), url); + HttpContent? content = null; + if (req.Body.Length > 0) + { + content = new ByteArrayContent(req.Body); + outgoing.Content = content; + } + foreach (var (name, value) in req.Headers) + { + if (HopByHopRequest.Contains(name)) continue; + if (!outgoing.Headers.TryAddWithoutValidation(name, value)) + { + content?.Headers.TryAddWithoutValidation(name, value); + } + } + + using var response = await httpClient.SendAsync(outgoing, HttpCompletionOption.ResponseHeadersRead, ct); + + var sb = new StringBuilder(); + sb.Append($"HTTP/1.1 {(int)response.StatusCode} {response.ReasonPhrase}\r\n"); + foreach (var h in response.Headers) + { + if (HopByHopResponse.Contains(h.Key)) continue; + foreach (var v in h.Value) sb.Append($"{h.Key}: {v}\r\n"); + } + foreach (var h in response.Content.Headers) + { + if (HopByHopResponse.Contains(h.Key)) continue; + foreach (var v in h.Value) sb.Append($"{h.Key}: {v}\r\n"); + } + + var contentLength = response.Content.Headers.ContentLength; + var isEventStream = response.Content.Headers.ContentType?.MediaType == "text/event-stream"; + + // Stream (chunked) when length unknown or SSE; otherwise fixed length. Keep-alive preserved. + if (contentLength is null || isEventStream) + { + sb.Append("Transfer-Encoding: chunked\r\n"); + sb.Append("Connection: keep-alive\r\n\r\n"); + await WriteAsciiAsync(clientStream, sb.ToString(), ct); + await StreamChunkedAsync(clientStream, response, isEventStream, ct); + } + else + { + sb.Append($"Content-Length: {contentLength}\r\n"); + sb.Append("Connection: keep-alive\r\n\r\n"); + await WriteAsciiAsync(clientStream, sb.ToString(), ct); + await using var origin = await response.Content.ReadAsStreamAsync(ct); + await origin.CopyToAsync(clientStream, ct); + await clientStream.FlushAsync(ct); + } + return true; // keep-alive + } + + private static async Task StreamChunkedAsync(Stream clientStream, HttpResponseMessage response, bool logChunks, CancellationToken ct) + { + await using var origin = await response.Content.ReadAsStreamAsync(ct); + var buffer = new byte[16 * 1024]; + int read; + while ((read = await origin.ReadAsync(buffer, ct)) > 0) + { + var sizeLine = Encoding.ASCII.GetBytes($"{read:X}\r\n"); + await clientStream.WriteAsync(sizeLine, ct); + await clientStream.WriteAsync(buffer.AsMemory(0, read), ct); + await clientStream.WriteAsync("\r\n"u8.ToArray(), ct); + await clientStream.FlushAsync(ct); // flush each chunk => SSE arrives incrementally + if (logChunks) + { + var preview = Encoding.UTF8.GetString(buffer, 0, Math.Min(read, 80)).Replace("\n", "\\n"); + Console.WriteLine($"[spike] SSE chunk {read}B: {preview}"); + } + } + await clientStream.WriteAsync("0\r\n\r\n"u8.ToArray(), ct); + await clientStream.FlushAsync(ct); + } + + // ---- blind tunnel -------------------------------------------------------- + + private static async Task BlindTunnelAsync(Stream clientStream, string host, int port, CancellationToken ct) + { + using var tcp = new TcpClient(); + await tcp.ConnectAsync(host, port, ct); + await using var origin = tcp.GetStream(); + var c2o = clientStream.CopyToAsync(origin, ct); + var o2c = origin.CopyToAsync(clientStream, ct); + await Task.WhenAny(c2o, o2c); + } + + // ---- ClientHello peek (non-destructive) --------------------------------- + + private static async Task PeekClientHelloAsync(PipeReader reader, CancellationToken ct) + { + while (true) + { + var result = await reader.ReadAsync(ct); + var buffer = result.Buffer; + var parsed = TlsClientHello.Parse(buffer); + + if (parsed.Status != TlsClientHello.ParseStatus.NeedMore) + { + // CRITICAL: done peeking. AdvanceTo(start) sets consumed=examined=start, + // i.e. nothing consumed AND nothing examined — so the next ReadAsync + // (from SslStream / the tunnel) returns the SAME buffered ClientHello + // immediately. Using examined=End here instead would deadlock: the pipe + // would wait for MORE bytes than arrived before waking the next reader. + reader.AdvanceTo(buffer.Start); + return parsed; + } + + // NeedMore: examine everything so the next ReadAsync waits for additional bytes. + reader.AdvanceTo(buffer.Start, buffer.End); + if (result.IsCompleted) + { + return new(TlsClientHello.ParseStatus.NeedMore, null, []); + } + } + } + + // ---- request parsing ----------------------------------------------------- + + private static async Task ReadRequestLineAndHeadersAsync(PipeReader reader, CancellationToken ct) + { + while (true) + { + var result = await reader.ReadAsync(ct); + var buffer = result.Buffer; + if (TryParseHeaderBlock(buffer, out var headerEnd, out var parsed)) + { + reader.AdvanceTo(headerEnd); // consume exactly the header block + if (parsed!.ContentLength > 0) + { + parsed.Body = await ReadBodyFromPipeAsync(reader, parsed.ContentLength, ct); + } + return parsed; + } + reader.AdvanceTo(buffer.Start, buffer.End); + if (result.IsCompleted) return null; + } + } + + private static bool TryParseHeaderBlock(ReadOnlySequence buffer, out SequencePosition headerEnd, out ParsedRequest? parsed) + { + headerEnd = default; parsed = null; + var arr = buffer.Length > 64 * 1024 ? buffer.Slice(0, 64 * 1024).ToArray() : buffer.ToArray(); + var idx = IndexOfDoubleCrlf(arr); + if (idx < 0) return false; + var headerText = Encoding.ASCII.GetString(arr, 0, idx); + headerEnd = buffer.GetPosition(idx + 4); + parsed = ParseHeaderText(headerText); + return parsed is not null; + } + + private static async Task ReadRequestFromStreamAsync(Stream stream, CancellationToken ct) + { + var acc = new List(2048); + var buf = new byte[2048]; + int sep; + while ((sep = IndexOfDoubleCrlf(acc)) < 0) + { + var read = await stream.ReadAsync(buf, ct); + if (read == 0) return null; + acc.AddRange(buf.AsSpan(0, read).ToArray()); + if (acc.Count > 256 * 1024) return null; + } + var headerText = Encoding.ASCII.GetString(acc.ToArray(), 0, sep); + var parsed = ParseHeaderText(headerText); + if (parsed is null) return null; + + var leftover = acc.GetRange(sep + 4, acc.Count - (sep + 4)).ToArray(); + if (parsed.ContentLength > 0) + { + var body = new byte[parsed.ContentLength]; + var copied = Math.Min(leftover.Length, parsed.ContentLength); + Array.Copy(leftover, body, copied); + var off = copied; + while (off < parsed.ContentLength) + { + var read = await stream.ReadAsync(body.AsMemory(off), ct); + if (read == 0) break; + off += read; + } + parsed.Body = body; + } + return parsed; + } + + private static ParsedRequest? ParseHeaderText(string headerText) + { + var lines = headerText.Split("\r\n"); + var start = lines[0].Split(' ', 3); + if (start.Length < 3) return null; + var headers = new List<(string, string)>(); + var contentLength = 0; + for (var i = 1; i < lines.Length; i++) + { + var c = lines[i].IndexOf(':'); + if (c <= 0) continue; + var name = lines[i][..c].Trim(); + var value = lines[i][(c + 1)..].Trim(); + headers.Add((name, value)); + if (name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var cl)) + { + contentLength = cl; + } + } + return new ParsedRequest(start[0], start[1], start[2], headers, contentLength); + } + + private static async Task ReadBodyFromPipeAsync(PipeReader reader, int length, CancellationToken ct) + { + var body = new byte[length]; + var offset = 0; + while (offset < length) + { + var result = await reader.ReadAsync(ct); + var buffer = result.Buffer; + var toCopy = (int)Math.Min(buffer.Length, length - offset); + buffer.Slice(0, toCopy).CopyTo(body.AsSpan(offset)); + offset += toCopy; + reader.AdvanceTo(buffer.GetPosition(toCopy)); + if (result.IsCompleted && offset < length) break; + } + return body; + } + + // ---- helpers ------------------------------------------------------------- + + private static int IndexOfDoubleCrlf(IReadOnlyList d) + { + for (var i = 0; i + 3 < d.Count; i++) + { + if (d[i] == 13 && d[i + 1] == 10 && d[i + 2] == 13 && d[i + 3] == 10) return i; + } + return -1; + } + + private static (string Host, int Port) SplitHostPort(string authority, int defaultPort) + { + var i = authority.LastIndexOf(':'); + if (i > 0 && int.TryParse(authority[(i + 1)..], out var p)) return (authority[..i], p); + return (authority, defaultPort); + } + + private static Task WriteAsciiAsync(Stream s, string text, CancellationToken ct) => + s.WriteAsync(Encoding.ASCII.GetBytes(text), ct).AsTask(); + + internal sealed record ParsedRequest(string Method, string Target, string Version, List<(string Name, string Value)> Headers, int ContentLength) + { + public byte[] Body { get; set; } = []; + } +} + +public sealed class WatchedHosts(IEnumerable patterns) +{ + private readonly string[] _patterns = patterns.ToArray(); + public bool IsWatched(string host) => + _patterns.Any(p => host.Equals(p, StringComparison.OrdinalIgnoreCase) + || (p.StartsWith("*.") && host.EndsWith(p[1..], StringComparison.OrdinalIgnoreCase))); +} diff --git a/spikes/kestrel-proxy-spike/TlsClientHello.cs b/spikes/kestrel-proxy-spike/TlsClientHello.cs new file mode 100644 index 00000000..44b151a8 --- /dev/null +++ b/spikes/kestrel-proxy-spike/TlsClientHello.cs @@ -0,0 +1,115 @@ +using System.Buffers; +using System.Buffers.Binary; + +namespace KestrelSpike; + +/// +/// Minimal, tolerant TLS ClientHello parser. Extracts SNI (server name) and the ALPN +/// protocol list so the proxy can decide — BEFORE terminating TLS — whether to MITM +/// (advertise http/1.1) or blind-tunnel (h2-only / gRPC). Deliberately not a TLS stack. +/// +public static class TlsClientHello +{ + public enum ParseStatus { NeedMore, NotTls, Ok } + + public readonly record struct Result(ParseStatus Status, string? ServerName, IReadOnlyList Alpn) + { + public bool OffersH2 => Alpn.Any(p => p == "h2"); + public bool OffersHttp11 => Alpn.Any(p => p == "http/1.1"); + // h2-only (no http/1.1 fallback) => must blind-tunnel or it breaks (gRPC). + public bool IsH2Only => OffersH2 && !OffersHttp11; + } + + public static Result Parse(ReadOnlySequence sequence) + { + // Work on a contiguous copy for simplicity (ClientHello is small). + var data = sequence.Length > 8192 ? sequence.Slice(0, 8192).ToArray() : sequence.ToArray(); + var s = new ReadOnlySpan(data); + + if (s.Length < 5) + { + return new(ParseStatus.NeedMore, null, []); + } + // TLS record: handshake (0x16) + if (s[0] != 0x16) + { + return new(ParseStatus.NotTls, null, []); + } + var recordLen = BinaryPrimitives.ReadUInt16BigEndian(s.Slice(3, 2)); + if (s.Length < 5 + recordLen) + { + return new(ParseStatus.NeedMore, null, []); + } + + var body = s.Slice(5, recordLen); + var p = 0; + if (body.Length < 4 || body[p] != 0x01) // ClientHello + { + return new(ParseStatus.NotTls, null, []); + } + // handshake length (3 bytes) + var hsLen = (body[1] << 16) | (body[2] << 8) | body[3]; + p = 4; + if (body.Length < p + hsLen) + { + return new(ParseStatus.NeedMore, null, []); + } + + p += 2; // client_version + p += 32; // random + if (p >= body.Length) return new(ParseStatus.NeedMore, null, []); + int sidLen = body[p]; p += 1 + sidLen; // session id + if (p + 2 > body.Length) return new(ParseStatus.NeedMore, null, []); + int csLen = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(p, 2)); p += 2 + csLen; // cipher suites + if (p >= body.Length) return new(ParseStatus.NeedMore, null, []); + int compLen = body[p]; p += 1 + compLen; // compression methods + if (p + 2 > body.Length) return new(ParseStatus.Ok, null, []); // no extensions + int extTotal = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(p, 2)); p += 2; + + string? sni = null; + var alpn = new List(); + var extEnd = Math.Min(body.Length, p + extTotal); + + while (p + 4 <= extEnd) + { + var extType = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(p, 2)); + var extLen = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(p + 2, 2)); + p += 4; + if (p + extLen > body.Length) break; + var ext = body.Slice(p, extLen); + + if (extType == 0x0000) // server_name + { + // list len(2), type(1)=host_name(0), name len(2), name + if (ext.Length >= 5 && ext[2] == 0x00) + { + var nameLen = BinaryPrimitives.ReadUInt16BigEndian(ext.Slice(3, 2)); + if (ext.Length >= 5 + nameLen) + { + sni = System.Text.Encoding.ASCII.GetString(ext.Slice(5, nameLen)); + } + } + } + else if (extType == 0x0010) // ALPN + { + // list len(2), then [len(1) proto]... + if (ext.Length >= 2) + { + var listLen = BinaryPrimitives.ReadUInt16BigEndian(ext.Slice(0, 2)); + var q = 2; + var end = Math.Min(ext.Length, 2 + listLen); + while (q < end) + { + int protoLen = ext[q]; q += 1; + if (q + protoLen > ext.Length) break; + alpn.Add(System.Text.Encoding.ASCII.GetString(ext.Slice(q, protoLen))); + q += protoLen; + } + } + } + p += extLen; + } + + return new(ParseStatus.Ok, sni, alpn); + } +}