Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d958916
Add throwaway Kestrel proxy spike (Phase 0B de-risking)
waldekmastykarz Jun 27, 2026
0b74ca7
Add canonical HTTP proxy model to DevProxy.Abstractions (Phase 1)
waldekmastykarz Jun 27, 2026
c1521df
Add Titanium adapter mapping engine types onto canonical model (Phase 2)
waldekmastykarz Jun 27, 2026
52273f2
Wire canonical IProxySession through ProxyEvents; migrate first plugi…
waldekmastykarz Jun 27, 2026
5c8299d
Phase 3 Wave 1: add IHttpRequest overloads to ProxyUtils
waldekmastykarz Jun 27, 2026
a0b5e5e
Phase 3 guidance wave: migrate 4 guidance plugins off Titanium
waldekmastykarz Jun 27, 2026
facdb4f
Phase 3 mocking wave: migrate 5 mocking plugins off Titanium
waldekmastykarz Jun 27, 2026
3a3962e
Phase 3 inspection-lite: migrate HttpUtils + OpenAI usage/telemetry s…
waldekmastykarz Jun 27, 2026
49db8c5
Phase 3 throttling group: migrate ThrottlerInfo + 5 throttling plugin…
waldekmastykarz Jun 27, 2026
39b8bef
Migrate plugins off Titanium onto canonical HTTP model (Phase 3 final…
waldekmastykarz Jun 27, 2026
8862ff5
Add Kestrel proxy engine vertical slice (Phase 4)
waldekmastykarz Jun 27, 2026
911c38f
Add Kestrel proxy engine test project (Phase 4)
waldekmastykarz Jun 27, 2026
7d68af7
Add selective decrypt + ALPN blind-tunnel to Kestrel engine (Phase 4)
waldekmastykarz Jun 27, 2026
e269fc7
Add keep-alive + per-request state isolation to Kestrel engine (Phase…
waldekmastykarz Jun 27, 2026
1bc6b3b
Add WebSocket transparent relay to Kestrel engine (Phase 4 slice 4)
waldekmastykarz Jun 27, 2026
481abc1
Add chunked request bodies + 100-continue to Kestrel engine (Phase 4 …
waldekmastykarz Jun 27, 2026
650ff7d
Kestrel engine: incremental SSE streaming with capped tee (Slice 6)
waldekmastykarz Jun 27, 2026
2b5e01d
Phase 4 Slice 7: process filtering at CONNECT (--watch-pids/--watch-p…
waldekmastykarz Jun 27, 2026
f09916d
Phase 5 Slice 5a: persistent certificate authority (disk root + leaf …
waldekmastykarz Jun 27, 2026
1c5b330
Add OS-trust install + first-run wiring for the Kestrel engine (Slice…
waldekmastykarz Jun 27, 2026
eed5b6d
Graceful-teardown audit: DRY connection-close classifier (Slice 5c)
waldekmastykarz Jun 27, 2026
1bdf5cf
Harden CONNECT authority parsing: IPv6, ports, malformed targets (Sli…
waldekmastykarz Jun 27, 2026
499a16a
Slice 5e: --port 0 actual-port logging + DRY host extraction
waldekmastykarz Jun 27, 2026
d0a6ef6
Phase 6 slice: engine-agnostic system-proxy on/off (no Titanium)
waldekmastykarz Jun 27, 2026
74aefce
Phase 6 slice: replace Titanium RunTime with System.OperatingSystem
waldekmastykarz Jun 27, 2026
a59a66c
Add hermetic Kestrel parity-test gate (controllable rows)
waldekmastykarz Jun 27, 2026
81a2b10
Hard cut-over to the Kestrel proxy engine; remove Titanium
waldekmastykarz Jun 27, 2026
23b77ff
Fix detached-daemon state registration and bound graceful-shutdown drain
waldekmastykarz Jun 28, 2026
7ea37f6
Rename Parity.Tests to Integration.Tests post-cut-over
waldekmastykarz Jun 28, 2026
6625996
Add per-plugin integration tests: harness infra + behavior/manipulati…
waldekmastykarz Jun 28, 2026
b90d010
Add CrudApi + MockRequest integration tests with InitializeAsync infra
waldekmastykarz Jun 28, 2026
0848977
Add guidance plugin integration tests (7 hermetic plugins)
waldekmastykarz Jun 28, 2026
a9e2dce
Add reporting plugin integration tests (UrlDiscovery, ExecutionSummar…
waldekmastykarz Jun 28, 2026
fba7fc5
Add generation plugin integration tests (HAR, mock, .http, OpenAPI, T…
waldekmastykarz Jun 28, 2026
a2afac1
Add process-level smoke test for config-driven plugin loading
waldekmastykarz Jun 28, 2026
e5137cb
Remove unwired body-mode subsystem; fix misleading body docs
waldekmastykarz Jun 28, 2026
844a877
Rename IProxyLogger.cs to LoggingContext.cs to match contents
waldekmastykarz Jun 28, 2026
3c3538a
Add WebSocketMockResponsePlugin (reactive WS frame mocking)
waldekmastykarz Jun 28, 2026
945d41f
Align WebSocket mock matching with HTTP/stdio mock vocabulary
waldekmastykarz Jun 28, 2026
5175f45
Restore interactive console (hotkeys + --record) dropped in Kestrel c…
waldekmastykarz Jun 28, 2026
efd678d
Add Windows verification checklist for the Kestrel engine
waldekmastykarz Jun 28, 2026
52200d5
Preserve Content-Length on HEAD responses
waldekmastykarz Jun 28, 2026
7ac1c91
Fix API URL banner showing port 0 with --api-port 0
waldekmastykarz Jun 28, 2026
9f06677
Return 504 on upstream timeout instead of silently dropping the conne…
waldekmastykarz Jun 28, 2026
58b4aed
Clear CA2201 warning in ConnectionTeardownTests
waldekmastykarz Jun 28, 2026
ad9d1df
Bump version to 4.0.0 (breaking: Kestrel engine + canonical plugin API)
waldekmastykarz Jun 28, 2026
a8ee74e
Merge branch 'next' into waldekmastykarz-kestrel-proxy-migration-plan
waldekmastykarz Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 24 additions & 0 deletions DevProxy.Abstractions.Tests/DevProxy.Abstractions.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Underscored Method_Scenario_Expectation test names are idiomatic; inline literal
arrays in assertions are fine in tests. -->
<NoWarn>$(NoWarn);CA1707;CA1861</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\DevProxy.Abstractions\DevProxy.Abstractions.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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<string>)ForwardingInvariants.HopByHopHeaders);

[Theory]
[InlineData("Content-Type")]
[InlineData("Host")]
[InlineData("Set-Cookie")]
[InlineData("Authorization")]
public void HopByHopHeaders_ExcludesEndToEndHeaders(string name) =>
Assert.DoesNotContain(name, (IReadOnlySet<string>)ForwardingInvariants.HopByHopHeaders);
}
105 changes: 105 additions & 0 deletions DevProxy.Abstractions.Tests/Proxy/Http/HeaderCollectionTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
99 changes: 99 additions & 0 deletions DevProxy.Abstractions.Tests/Proxy/RootTrustPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
39 changes: 39 additions & 0 deletions DevProxy.Abstractions.Tests/Proxy/SystemProxyAddressTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
61 changes: 61 additions & 0 deletions DevProxy.Abstractions.Tests/Proxy/WatchedHostExtractorTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentNullException>(() => WatchedHostExtractor.ToHostRegex(null!));
}
Loading
Loading