From d958916fd055cb718a45b248ff98f8d45da3305b Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 15:35:15 +0200 Subject: [PATCH 01/46] Add throwaway Kestrel proxy spike (Phase 0B de-risking) Validates selective decrypt, non-destructive ClientHello ALPN peek, h2-only/gRPC blind-tunnel, keep-alive isolation, and unbuffered streaming. Not part of DevProxy.sln. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CertificateAuthority.cs | 101 +++++ .../kestrel-proxy-spike/DuplexPipeStream.cs | 75 ++++ .../kestrel-proxy-spike/KestrelSpike.csproj | 13 + spikes/kestrel-proxy-spike/Program.cs | 42 ++ .../ProxyConnectionHandler.cs | 406 ++++++++++++++++++ spikes/kestrel-proxy-spike/TlsClientHello.cs | 115 +++++ 6 files changed, 752 insertions(+) create mode 100644 spikes/kestrel-proxy-spike/CertificateAuthority.cs create mode 100644 spikes/kestrel-proxy-spike/DuplexPipeStream.cs create mode 100644 spikes/kestrel-proxy-spike/KestrelSpike.csproj create mode 100644 spikes/kestrel-proxy-spike/Program.cs create mode 100644 spikes/kestrel-proxy-spike/ProxyConnectionHandler.cs create mode 100644 spikes/kestrel-proxy-spike/TlsClientHello.cs 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); + } +} From 0b74ca726530993cc0afebda156ea784616787c3 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 15:35:16 +0200 Subject: [PATCH 02/46] Add canonical HTTP proxy model to DevProxy.Abstractions (Phase 1) Introduce engine-agnostic Dev Proxy model that will replace the leaked Unobtanium/Titanium types in the plugin SDK: - IHttpHeader/IHeaderCollection/IHttpMessage/IHttpRequest/IHttpResponse and IProxySession (logical SessionId, Respond() mocking primitive), with concrete HttpHeader + HeaderCollection. - Bodies exposed as ReadOnlyMemory (decompressed-body contract). - Body strategy: BodyMode, BodyCapabilities, BodyModeResolver (pure, reconciles streaming vs full-body plugin access). - ForwardingInvariants documenting the forwarding contract + shared HopByHopHeaders set. - New DevProxy.Abstractions.Tests (xUnit), 33 tests. Purely additive: existing Titanium-based code is untouched and the full solution builds clean. Adapter wiring follows in Phase 2/3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Abstractions.Tests.csproj | 24 ++++ .../Proxy/Http/BodyModeResolverTests.cs | 114 ++++++++++++++++++ .../Proxy/Http/ForwardingInvariantsTests.cs | 32 +++++ .../Proxy/Http/HeaderCollectionTests.cs | 105 ++++++++++++++++ .../Proxy/Http/BodyHandling.cs | 106 ++++++++++++++++ .../Proxy/Http/BodyModeResolver.cs | 99 +++++++++++++++ .../Proxy/Http/ForwardingInvariants.cs | 71 +++++++++++ .../Proxy/Http/HeaderCollection.cs | 97 +++++++++++++++ .../Proxy/Http/HttpHeader.cs | 12 ++ .../Proxy/Http/IHeaderCollection.cs | 49 ++++++++ .../Proxy/Http/IHttpHeader.cs | 19 +++ .../Proxy/Http/IHttpMessage.cs | 70 +++++++++++ .../Proxy/Http/IHttpRequest.cs | 31 +++++ .../Proxy/Http/IHttpResponse.cs | 22 ++++ .../Proxy/Http/IProxySession.cs | 64 ++++++++++ DevProxy.sln | 42 +++++++ 16 files changed, 957 insertions(+) create mode 100644 DevProxy.Abstractions.Tests/DevProxy.Abstractions.Tests.csproj create mode 100644 DevProxy.Abstractions.Tests/Proxy/Http/BodyModeResolverTests.cs create mode 100644 DevProxy.Abstractions.Tests/Proxy/Http/ForwardingInvariantsTests.cs create mode 100644 DevProxy.Abstractions.Tests/Proxy/Http/HeaderCollectionTests.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/BodyHandling.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/BodyModeResolver.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/HeaderCollection.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/HttpHeader.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/IHeaderCollection.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/IHttpHeader.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/IHttpRequest.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/IProxySession.cs 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/BodyModeResolverTests.cs b/DevProxy.Abstractions.Tests/Proxy/Http/BodyModeResolverTests.cs new file mode 100644 index 00000000..ee9a3ed7 --- /dev/null +++ b/DevProxy.Abstractions.Tests/Proxy/Http/BodyModeResolverTests.cs @@ -0,0 +1,114 @@ +// 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 BodyModeResolverTests +{ + private static BodyContext Bounded(long? length, BodyDirection dir = BodyDirection.Response) => + new(dir, IsUpgrade: false, IsUnboundedStream: false, ContentLength: length); + + [Fact] + public void Upgrade_AlwaysRaw_RegardlessOfCapabilities() + { + var ctx = new BodyContext(BodyDirection.Request, IsUpgrade: true, IsUnboundedStream: false, ContentLength: 10); + var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullRequestBody | BodyCapabilities.CanMutate, ctx); + Assert.Equal(BodyMode.UpgradedRaw, mode); + } + + [Fact] + public void NoBodyNeed_NoInspect_StreamsThrough() + { + var mode = BodyModeResolver.Resolve(BodyCapabilities.None, Bounded(10)); + Assert.Equal(BodyMode.StreamingPassThrough, mode); + } + + [Fact] + public void NoBodyNeed_WithInspect_Tees() + { + var mode = BodyModeResolver.Resolve(BodyCapabilities.CanStreamInspect, Bounded(10)); + Assert.Equal(BodyMode.TeeForInspection, mode); + } + + [Fact] + public void FullBody_SmallBounded_BuffersInMemory() + { + var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, Bounded(1024)); + Assert.Equal(BodyMode.BufferedInMemory, mode); + } + + [Fact] + public void Mutation_ImpliesFullBody_BuffersInMemory() + { + var mode = BodyModeResolver.Resolve(BodyCapabilities.CanMutate, Bounded(1024)); + Assert.Equal(BodyMode.BufferedInMemory, mode); + } + + [Fact] + public void FullBody_MediumBounded_SpoolsToDisk() + { + var length = BodyModeResolver.DefaultInMemoryLimitBytes + 1; + var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, Bounded(length)); + Assert.Equal(BodyMode.SpooledToDisk, mode); + } + + [Fact] + public void FullBody_UnknownLengthButBounded_SpoolsDefensively() + { + var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, Bounded(null)); + Assert.Equal(BodyMode.SpooledToDisk, mode); + } + + [Fact] + public void FullBody_TooLargeToSpool_WithInspect_Tees() + { + var length = BodyModeResolver.DefaultSpoolLimitBytes + 1; + var caps = BodyCapabilities.NeedsFullResponseBody | BodyCapabilities.CanStreamInspect; + var mode = BodyModeResolver.Resolve(caps, Bounded(length)); + Assert.Equal(BodyMode.TeeForInspection, mode); + } + + [Fact] + public void FullBody_TooLargeToSpool_NoInspect_Streams() + { + var length = BodyModeResolver.DefaultSpoolLimitBytes + 1; + var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, Bounded(length)); + Assert.Equal(BodyMode.StreamingPassThrough, mode); + } + + [Fact] + public void FullBody_UnboundedStream_NeverBuffers_DegradesToTee() + { + var ctx = new BodyContext(BodyDirection.Response, IsUpgrade: false, IsUnboundedStream: true, ContentLength: null); + var caps = BodyCapabilities.NeedsFullResponseBody + | BodyCapabilities.CannotRunOnInfiniteStreams + | BodyCapabilities.CanStreamInspect; + var mode = BodyModeResolver.Resolve(caps, ctx); + Assert.Equal(BodyMode.TeeForInspection, mode); + } + + [Fact] + public void FullBody_UnboundedStream_NoInspect_Streams() + { + var ctx = new BodyContext(BodyDirection.Response, IsUpgrade: false, IsUnboundedStream: true, ContentLength: null); + var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, ctx); + Assert.Equal(BodyMode.StreamingPassThrough, mode); + } + + [Fact] + public void Direction_SelectsCorrectFlag() + { + // A plugin that needs the REQUEST body should not force buffering of the RESPONSE. + var responseMode = BodyModeResolver.Resolve( + BodyCapabilities.NeedsFullRequestBody, Bounded(1024, BodyDirection.Response)); + Assert.Equal(BodyMode.StreamingPassThrough, responseMode); + + var requestMode = BodyModeResolver.Resolve( + BodyCapabilities.NeedsFullRequestBody, Bounded(1024, BodyDirection.Request)); + Assert.Equal(BodyMode.BufferedInMemory, requestMode); + } +} 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/Proxy/Http/BodyHandling.cs b/DevProxy.Abstractions/Proxy/Http/BodyHandling.cs new file mode 100644 index 00000000..9ecad7ca --- /dev/null +++ b/DevProxy.Abstractions/Proxy/Http/BodyHandling.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. + +namespace DevProxy.Abstractions.Proxy.Http; + +/// +/// How the engine handles a message body for one exchange. The effective mode is +/// resolved per exchange from the union of declared +/// by the plugins whose URL filter matches, plus the body's size/streaming shape. +/// See . +/// +/// +/// no full-body need ──► CanStreamInspect? ──yes─► TeeForInspection +/// │ └──no──────────────► StreamingPassThrough +/// ▼ (full body needed / mutation) +/// unbounded stream? ──yes─► degrade (Tee or PassThrough) +/// ▼ no +/// fits in memory? ──yes─► BufferedInMemory +/// ▼ no +/// fits on disk? ──yes─► SpooledToDisk +/// ▼ no +/// degrade (Tee or PassThrough) +/// +/// +public enum BodyMode +{ + /// + /// Bytes flow client↔origin without retention. Plugins cannot read or mutate + /// the body. Required for unbounded streams (SSE, long-poll) and oversized + /// payloads. This is the safe default. + /// + StreamingPassThrough, + + /// + /// The full body is buffered in memory; plugins can read and mutate it. Only + /// chosen for bounded bodies within the in-memory limit. + /// + BufferedInMemory, + + /// + /// The full body is spooled to a temporary file; plugins can read and mutate + /// large but finite bodies without exhausting RAM. + /// + SpooledToDisk, + + /// + /// Bytes stream through unbuffered while a copy is delivered to read-only + /// inspectors (e.g. logging). No mutation; the body is not materialized on the + /// forwarding path. + /// + TeeForInspection, + + /// + /// The connection is upgraded (WebSocket) or blind-tunnelled. Opaque byte + /// relay with no HTTP body semantics. + /// + UpgradedRaw, +} + +/// +/// Which side of an exchange a body belongs to. Selects the relevant +/// flag during resolution. +/// +public enum BodyDirection +{ + /// The request body (client → origin). + Request, + + /// The response body (origin → client). + Response, +} + +/// +/// Declares what a plugin needs to do with message bodies. The engine aggregates +/// these across all matching plugins to pick a per exchange, +/// reconciling "stream SSE unbuffered" with "let plugins read full bodies". +/// +[Flags] +public enum BodyCapabilities +{ + /// The plugin does not touch bodies. Compatible with streaming. + None = 0, + + /// The plugin reads the complete request body before forwarding. + NeedsFullRequestBody = 1 << 0, + + /// The plugin reads the complete response body before forwarding. + NeedsFullResponseBody = 1 << 1, + + /// + /// The plugin can inspect body bytes incrementally as they stream (read-only), + /// without requiring the whole body at once. + /// + CanStreamInspect = 1 << 2, + + /// The plugin may modify body bytes (implies the body must be buffered). + CanMutate = 1 << 3, + + /// + /// The plugin must not be run against unbounded streams (it would otherwise + /// buffer forever). On such exchanges the plugin is skipped and the engine + /// degrades to a streaming mode. + /// + CannotRunOnInfiniteStreams = 1 << 4, +} diff --git a/DevProxy.Abstractions/Proxy/Http/BodyModeResolver.cs b/DevProxy.Abstractions/Proxy/Http/BodyModeResolver.cs new file mode 100644 index 00000000..e5c4b7bd --- /dev/null +++ b/DevProxy.Abstractions/Proxy/Http/BodyModeResolver.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. + +namespace DevProxy.Abstractions.Proxy.Http; + +/// +/// Shape of a body as seen at resolution time, plus the engine's buffering limits. +/// +/// Which body (request/response) is being resolved. +/// True for WebSocket upgrade / blind tunnel exchanges. +/// +/// True when the body has no finite end the engine can rely on: text/event-stream, +/// chunked transfer with no declared length, or an explicitly streamed response. +/// +/// Declared body length in bytes, when known. +/// Largest body buffered in memory. +/// Largest body spooled to disk (must be ≥ in-memory limit). +public readonly record struct BodyContext( + BodyDirection Direction, + bool IsUpgrade, + bool IsUnboundedStream, + long? ContentLength, + long InMemoryLimitBytes = BodyModeResolver.DefaultInMemoryLimitBytes, + long SpoolLimitBytes = BodyModeResolver.DefaultSpoolLimitBytes); + +/// +/// Pure decision logic that maps the union of plugin +/// and a to a single for an +/// exchange. Deterministic and side-effect free so it can be unit-tested in +/// isolation and shared by every engine adapter. +/// +public static class BodyModeResolver +{ + /// Default maximum body size buffered in memory (4 MiB). + public const long DefaultInMemoryLimitBytes = 4L * 1024 * 1024; + + /// Default maximum body size spooled to disk (256 MiB). + public const long DefaultSpoolLimitBytes = 256L * 1024 * 1024; + + /// + /// Resolves the effective for an exchange. + /// + /// + /// Bitwise-OR of the of every plugin whose URL + /// filter matches this exchange. + /// + /// The body's shape and the engine's limits. + public static BodyMode Resolve(BodyCapabilities aggregatedCapabilities, BodyContext context) + { + if (context.IsUpgrade) + { + return BodyMode.UpgradedRaw; + } + + var needsFullBody = context.Direction == BodyDirection.Request + ? aggregatedCapabilities.HasFlag(BodyCapabilities.NeedsFullRequestBody) + : aggregatedCapabilities.HasFlag(BodyCapabilities.NeedsFullResponseBody); + + // Mutation implies the body must be buffered before write-back. + needsFullBody |= aggregatedCapabilities.HasFlag(BodyCapabilities.CanMutate); + + var canStreamInspect = aggregatedCapabilities.HasFlag(BodyCapabilities.CanStreamInspect); + + if (!needsFullBody) + { + return canStreamInspect ? BodyMode.TeeForInspection : BodyMode.StreamingPassThrough; + } + + // Full body wanted, but the stream may never end: never buffer unbounded. + if (context.IsUnboundedStream) + { + return Degrade(canStreamInspect); + } + + if (context.ContentLength is long length) + { + if (length <= context.InMemoryLimitBytes) + { + return BodyMode.BufferedInMemory; + } + + if (length <= context.SpoolLimitBytes) + { + return BodyMode.SpooledToDisk; + } + + // Bounded but larger than we are willing to spool. + return Degrade(canStreamInspect); + } + + // Bounded (not an unbounded stream) yet length unknown: spool defensively + // rather than risk an unbounded in-memory buffer. + return BodyMode.SpooledToDisk; + } + + private static BodyMode Degrade(bool canStreamInspect) => + canStreamInspect ? BodyMode.TeeForInspection : BodyMode.StreamingPassThrough; +} diff --git a/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs b/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs new file mode 100644 index 00000000..434c8803 --- /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 every engine adapter must honor so that plugins see a +/// consistent model regardless of the underlying proxy engine. 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..e2d95dfe --- /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 adapters, 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..639045e5 --- /dev/null +++ b/DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs @@ -0,0 +1,70 @@ +// 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 active for +/// the exchange buffers it (driven by plugin ). +/// In a streaming pass-through exchange the body is not retained; 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..314f1ec8 --- /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; } + + /// 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..d0505a57 --- /dev/null +++ b/DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs @@ -0,0 +1,22 @@ +// 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; } +} diff --git a/DevProxy.Abstractions/Proxy/Http/IProxySession.cs b/DevProxy.Abstractions/Proxy/Http/IProxySession.cs new file mode 100644 index 00000000..020dab6c --- /dev/null +++ b/DevProxy.Abstractions/Proxy/Http/IProxySession.cs @@ -0,0 +1,64 @@ +// 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); +} diff --git a/DevProxy.sln b/DevProxy.sln index 9a4c219d..fda730b0 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -16,24 +16,66 @@ 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 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From c1521df3044ec9bef4ff095e6e42c0c49461053b Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 15:50:33 +0200 Subject: [PATCH 03/46] Add Titanium adapter mapping engine types onto canonical model (Phase 2) Introduces DevProxy.Proxy.Titanium, the sole project referencing Unobtanium.Web.Proxy, which projects Titanium request/response/header/session types onto the engine-agnostic canonical model in DevProxy.Abstractions. - TitaniumHeaderCollection, TitaniumHttpMessageAdapter (shared DRY base over RequestResponseBase), TitaniumRequestAdapter, TitaniumResponseAdapter, TitaniumProxySession. - Body access guarded by HasBody (Titanium throws BodyNotFoundException otherwise); mutation routed through the session's SetRequestBody/ SetResponseBody since Titanium exposes no public body setter. - DevProxy.Proxy.Titanium.Tests: 38 adapter-fidelity unit tests. Purely additive and ship-safe: the existing engine and plugins are untouched. Full solution builds 0/0; 71 tests green. Cross-engine parity tests are deferred to Phase 4 (require the Kestrel engine to exist). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Proxy.Titanium.Tests.csproj | 25 ++ .../TitaniumHeaderCollectionTests.cs | 165 +++++++++ .../TitaniumRequestAdapterTests.cs | 106 ++++++ .../TitaniumResponseAdapterTests.cs | 120 +++++++ .../DevProxy.Proxy.Titanium.csproj | 24 ++ .../TitaniumHeaderCollection.cs | 103 ++++++ .../TitaniumHttpMessageAdapter.cs | 92 +++++ .../TitaniumProxySession.cs | 85 +++++ .../TitaniumRequestAdapter.cs | 43 +++ .../TitaniumResponseAdapter.cs | 44 +++ DevProxy.Proxy.Titanium/packages.lock.json | 330 ++++++++++++++++++ DevProxy.sln | 28 ++ 12 files changed, 1165 insertions(+) create mode 100644 DevProxy.Proxy.Titanium.Tests/DevProxy.Proxy.Titanium.Tests.csproj create mode 100644 DevProxy.Proxy.Titanium.Tests/TitaniumHeaderCollectionTests.cs create mode 100644 DevProxy.Proxy.Titanium.Tests/TitaniumRequestAdapterTests.cs create mode 100644 DevProxy.Proxy.Titanium.Tests/TitaniumResponseAdapterTests.cs create mode 100644 DevProxy.Proxy.Titanium/DevProxy.Proxy.Titanium.csproj create mode 100644 DevProxy.Proxy.Titanium/TitaniumHeaderCollection.cs create mode 100644 DevProxy.Proxy.Titanium/TitaniumHttpMessageAdapter.cs create mode 100644 DevProxy.Proxy.Titanium/TitaniumProxySession.cs create mode 100644 DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs create mode 100644 DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs create mode 100644 DevProxy.Proxy.Titanium/packages.lock.json diff --git a/DevProxy.Proxy.Titanium.Tests/DevProxy.Proxy.Titanium.Tests.csproj b/DevProxy.Proxy.Titanium.Tests/DevProxy.Proxy.Titanium.Tests.csproj new file mode 100644 index 00000000..14d41b45 --- /dev/null +++ b/DevProxy.Proxy.Titanium.Tests/DevProxy.Proxy.Titanium.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + true + + $(NoWarn);CA1707;CA1861 + + + + + + + + + + + + + + diff --git a/DevProxy.Proxy.Titanium.Tests/TitaniumHeaderCollectionTests.cs b/DevProxy.Proxy.Titanium.Tests/TitaniumHeaderCollectionTests.cs new file mode 100644 index 00000000..126114eb --- /dev/null +++ b/DevProxy.Proxy.Titanium.Tests/TitaniumHeaderCollectionTests.cs @@ -0,0 +1,165 @@ +// 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; +using DevProxy.Proxy.Titanium; +using TitaniumHeaders = Titanium.Web.Proxy.Http.HeaderCollection; +using TitaniumHttpHeader = Titanium.Web.Proxy.Models.HttpHeader; + +namespace DevProxy.Proxy.Titanium.Tests; + +public class TitaniumHeaderCollectionTests +{ + private static TitaniumHeaderCollection Wrap(params (string Name, string Value)[] headers) + { + var titanium = new TitaniumHeaders(); + foreach (var (name, value) in headers) + { + titanium.AddHeader(name, value); + } + + return new TitaniumHeaderCollection(titanium); + } + + [Fact] + public void Constructor_NullHeaders_Throws() => + Assert.Throws(() => new TitaniumHeaderCollection(null!)); + + [Fact] + public void Count_ReflectsUnderlyingHeaders() + { + var sut = Wrap(("Accept", "application/json"), ("Host", "example.com")); + Assert.Equal(2, sut.Count); + } + + [Fact] + public void Count_CountsDuplicateHeadersSeparately() + { + var sut = Wrap(("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")); + Assert.Equal(2, sut.Count); + } + + [Fact] + public void Contains_IsCaseInsensitive() + { + var sut = Wrap(("Content-Type", "text/plain")); + Assert.True(sut.Contains("content-type")); + Assert.True(sut.Contains("CONTENT-TYPE")); + Assert.False(sut.Contains("X-Missing")); + } + + [Fact] + public void GetFirst_ReturnsFirstMatch() + { + var sut = Wrap(("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")); + var header = sut.GetFirst("set-cookie"); + Assert.NotNull(header); + Assert.Equal("Set-Cookie", header!.Name); + Assert.Equal("a=1", header.Value); + } + + [Fact] + public void GetFirst_MissingHeader_ReturnsNull() + { + var sut = Wrap(("Accept", "application/json")); + Assert.Null(sut.GetFirst("X-Missing")); + } + + [Fact] + public void GetAll_ReturnsEveryOccurrenceInOrder() + { + var sut = Wrap(("Set-Cookie", "a=1"), ("Set-Cookie", "b=2"), ("Set-Cookie", "c=3")); + var values = sut.GetAll("Set-Cookie").Select(h => h.Value).ToArray(); + Assert.Equal(["a=1", "b=2", "c=3"], values); + } + + [Fact] + public void GetAll_MissingHeader_ReturnsEmpty() + { + var sut = Wrap(("Accept", "application/json")); + Assert.Empty(sut.GetAll("X-Missing")); + } + + [Fact] + public void Add_NameValue_AppendsHeader() + { + var sut = Wrap(); + sut.Add("X-Custom", "value"); + Assert.Equal("value", sut.GetFirst("X-Custom")!.Value); + } + + [Fact] + public void Add_Header_AppendsHeader() + { + var sut = Wrap(); + sut.Add(new HttpHeader("X-Custom", "value")); + Assert.Equal("value", sut.GetFirst("X-Custom")!.Value); + } + + [Fact] + public void AddRange_AppendsAllHeaders() + { + var sut = Wrap(); + sut.AddRange([new HttpHeader("A", "1"), new HttpHeader("B", "2")]); + Assert.Equal("1", sut.GetFirst("A")!.Value); + Assert.Equal("2", sut.GetFirst("B")!.Value); + } + + [Fact] + public void Replace_RemovesExistingAndSetsSingleValue() + { + var sut = Wrap(("X-Dup", "old1"), ("X-Dup", "old2")); + sut.Replace("X-Dup", "new"); + var all = sut.GetAll("X-Dup").Select(h => h.Value).ToArray(); + Assert.Equal(["new"], all); + } + + [Fact] + public void Replace_MissingHeader_AddsIt() + { + var sut = Wrap(); + sut.Replace("X-New", "value"); + Assert.Equal("value", sut.GetFirst("X-New")!.Value); + } + + [Fact] + public void Remove_ExistingHeader_ReturnsTrueAndRemoves() + { + var sut = Wrap(("X-Custom", "value")); + Assert.True(sut.Remove("X-Custom")); + Assert.False(sut.Contains("X-Custom")); + } + + [Fact] + public void Remove_MissingHeader_ReturnsFalse() + { + var sut = Wrap(); + Assert.False(sut.Remove("X-Missing")); + } + + [Fact] + public void Enumeration_YieldsAllHeadersIncludingDuplicates() + { + var sut = Wrap(("Set-Cookie", "a=1"), ("Set-Cookie", "b=2"), ("Host", "example.com")); + var pairs = sut.Select(h => (h.Name, h.Value)).ToArray(); + Assert.Equal(3, pairs.Length); + Assert.Contains(("Set-Cookie", "a=1"), pairs); + Assert.Contains(("Set-Cookie", "b=2"), pairs); + Assert.Contains(("Host", "example.com"), pairs); + } + + [Fact] + public void Mutation_IsVisibleOnUnderlyingTitaniumCollection() + { + var titanium = new TitaniumHeaders(); + titanium.AddHeader(new TitaniumHttpHeader("X-Seed", "seed")); + var sut = new TitaniumHeaderCollection(titanium); + + sut.Add("X-Added", "added"); + + Assert.True(titanium.HeaderExists("X-Added")); + Assert.Equal("added", titanium.GetFirstHeader("X-Added")!.Value); + } +} diff --git a/DevProxy.Proxy.Titanium.Tests/TitaniumRequestAdapterTests.cs b/DevProxy.Proxy.Titanium.Tests/TitaniumRequestAdapterTests.cs new file mode 100644 index 00000000..aaffb8db --- /dev/null +++ b/DevProxy.Proxy.Titanium.Tests/TitaniumRequestAdapterTests.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.Text; +using DevProxy.Proxy.Titanium; +using Xunit; +using TitaniumRequest = Titanium.Web.Proxy.Http.Request; + +namespace DevProxy.Proxy.Titanium.Tests; + +public class TitaniumRequestAdapterTests +{ + private static TitaniumRequest NewRequest() => new() + { + Method = "GET", + HttpVersion = new Version(1, 1), + RequestUri = new Uri("https://example.com/api/items?id=1"), + }; + + [Fact] + public void RequestUri_IsProjected() + { + var sut = new TitaniumRequestAdapter(NewRequest()); + Assert.Equal(new Uri("https://example.com/api/items?id=1"), sut.RequestUri); + } + + [Fact] + public void Url_IsProjected() + { + var sut = new TitaniumRequestAdapter(NewRequest()); + Assert.Equal("https://example.com/api/items?id=1", sut.Url); + } + + [Fact] + public void Method_IsProjected() + { + var request = NewRequest(); + request.Method = "POST"; + var sut = new TitaniumRequestAdapter(request); + Assert.Equal("POST", sut.Method); + } + + [Fact] + public void HttpVersion_IsProjected() + { + var sut = new TitaniumRequestAdapter(NewRequest()); + Assert.Equal(new Version(1, 1), sut.HttpVersion); + } + + [Fact] + public void IsWebSocketRequest_DefaultsFalse() + { + var sut = new TitaniumRequestAdapter(NewRequest()); + Assert.False(sut.IsWebSocketRequest); + } + + [Fact] + public void Headers_AreProjected() + { + var request = NewRequest(); + request.Headers.AddHeader("X-Custom", "value"); + var sut = new TitaniumRequestAdapter(request); + Assert.True(sut.Headers.Contains("X-Custom")); + Assert.Equal("value", sut.Headers.GetFirst("X-Custom")!.Value); + } + + [Fact] + public void SetBodyString_WithSetter_RoutesUtf8BytesToSetter() + { + byte[]? captured = null; + var sut = new TitaniumRequestAdapter(NewRequest(), b => captured = b); + + sut.SetBodyString("hello"); + + Assert.NotNull(captured); + Assert.Equal("hello", Encoding.UTF8.GetString(captured!)); + } + + [Fact] + public void SetBody_WithSetter_RoutesBytesToSetter() + { + byte[]? captured = null; + var sut = new TitaniumRequestAdapter(NewRequest(), b => captured = b); + + sut.SetBody(new byte[] { 1, 2, 3 }); + + Assert.Equal(new byte[] { 1, 2, 3 }, captured); + } + + [Fact] + public void SetBody_WithoutSetter_Throws() + { + var sut = new TitaniumRequestAdapter(NewRequest()); + Assert.Throws(() => sut.SetBody(new byte[] { 1 })); + } + + [Fact] + public void BodyAndBodyString_NoBody_ReturnEmpty() + { + var sut = new TitaniumRequestAdapter(NewRequest()); + Assert.False(sut.HasBody); + Assert.True(sut.Body.IsEmpty); + Assert.Equal(string.Empty, sut.BodyString); + } +} diff --git a/DevProxy.Proxy.Titanium.Tests/TitaniumResponseAdapterTests.cs b/DevProxy.Proxy.Titanium.Tests/TitaniumResponseAdapterTests.cs new file mode 100644 index 00000000..75d641ad --- /dev/null +++ b/DevProxy.Proxy.Titanium.Tests/TitaniumResponseAdapterTests.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 System.Text; +using DevProxy.Proxy.Titanium; +using Xunit; +using TitaniumResponse = Titanium.Web.Proxy.Http.Response; + +namespace DevProxy.Proxy.Titanium.Tests; + +public class TitaniumResponseAdapterTests +{ + [Fact] + public void StatusCode_IsMappedToHttpStatusCode() + { + var response = new TitaniumResponse { StatusCode = 404 }; + var sut = new TitaniumResponseAdapter(response); + Assert.Equal(HttpStatusCode.NotFound, sut.StatusCode); + } + + [Fact] + public void StatusCode_SetterWritesIntToTitanium() + { + var response = new TitaniumResponse { StatusCode = 200 }; + var sut = new TitaniumResponseAdapter(response) + { + StatusCode = HttpStatusCode.InternalServerError, + }; + Assert.Equal(500, response.StatusCode); + } + + [Fact] + public void StatusDescription_RoundTrips() + { + var response = new TitaniumResponse { StatusDescription = "OK" }; + var sut = new TitaniumResponseAdapter(response); + Assert.Equal("OK", sut.StatusDescription); + + sut.StatusDescription = "Created"; + Assert.Equal("Created", response.StatusDescription); + } + + [Fact] + public void StatusDescription_NullSetter_WritesEmptyString() + { + var response = new TitaniumResponse { StatusDescription = "OK" }; + var sut = new TitaniumResponseAdapter(response) + { + StatusDescription = null, + }; + Assert.Equal(string.Empty, response.StatusDescription); + } + + [Fact] + public void Body_WhenPresent_IsProjectedAsBytes() + { + var response = new TitaniumResponse(Encoding.UTF8.GetBytes("hello world")); + var sut = new TitaniumResponseAdapter(response); + Assert.True(sut.HasBody); + Assert.Equal("hello world", Encoding.UTF8.GetString(sut.Body.Span)); + } + + [Fact] + public void BodyString_WhenPresent_IsProjected() + { + var response = new TitaniumResponse(Encoding.UTF8.GetBytes("payload")); + var sut = new TitaniumResponseAdapter(response); + Assert.Equal("payload", sut.BodyString); + } + + [Fact] + public void BodyAndBodyString_NoBody_ReturnEmpty() + { + var response = new TitaniumResponse(); + var sut = new TitaniumResponseAdapter(response); + Assert.False(sut.HasBody); + Assert.True(sut.Body.IsEmpty); + Assert.Equal(string.Empty, sut.BodyString); + } + + [Fact] + public void ContentType_IsProjected() + { + var response = new TitaniumResponse { ContentType = "application/json" }; + var sut = new TitaniumResponseAdapter(response); + Assert.Equal("application/json", sut.ContentType); + } + + [Fact] + public void Headers_AreProjected() + { + var response = new TitaniumResponse(); + response.Headers.AddHeader("X-Trace", "abc"); + var sut = new TitaniumResponseAdapter(response); + Assert.Equal("abc", sut.Headers.GetFirst("X-Trace")!.Value); + } + + [Fact] + public void SetBody_WithSetter_RoutesBytesToSetter() + { + byte[]? captured = null; + var response = new TitaniumResponse(Encoding.UTF8.GetBytes("seed")); + var sut = new TitaniumResponseAdapter(response, b => captured = b); + + sut.SetBodyString("updated"); + + Assert.NotNull(captured); + Assert.Equal("updated", Encoding.UTF8.GetString(captured!)); + } + + [Fact] + public void SetBody_WithoutSetter_Throws() + { + var response = new TitaniumResponse(Encoding.UTF8.GetBytes("seed")); + var sut = new TitaniumResponseAdapter(response); + Assert.Throws(() => sut.SetBodyString("nope")); + } +} diff --git a/DevProxy.Proxy.Titanium/DevProxy.Proxy.Titanium.csproj b/DevProxy.Proxy.Titanium/DevProxy.Proxy.Titanium.csproj new file mode 100644 index 00000000..539fad24 --- /dev/null +++ b/DevProxy.Proxy.Titanium/DevProxy.Proxy.Titanium.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + DevProxy.Proxy.Titanium + enable + enable + 3.1.0 + false + true + true + AllEnabledByDefault + false + + + + + + + + + + + diff --git a/DevProxy.Proxy.Titanium/TitaniumHeaderCollection.cs b/DevProxy.Proxy.Titanium/TitaniumHeaderCollection.cs new file mode 100644 index 00000000..8ac59e36 --- /dev/null +++ b/DevProxy.Proxy.Titanium/TitaniumHeaderCollection.cs @@ -0,0 +1,103 @@ +// 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; +using DevProxy.Abstractions.Proxy.Http; +using TitaniumHeaders = Titanium.Web.Proxy.Http.HeaderCollection; + +namespace DevProxy.Proxy.Titanium; + +/// +/// Projects a Titanium onto the canonical +/// . Reads and writes operate directly on the +/// underlying Titanium collection so mutations are visible to the engine. +/// +public sealed class TitaniumHeaderCollection : IHeaderCollection +{ + private readonly TitaniumHeaders _headers; + + /// Wraps an existing Titanium header collection. + public TitaniumHeaderCollection(TitaniumHeaders headers) + { + ArgumentNullException.ThrowIfNull(headers); + _headers = headers; + } + + /// + public int Count => _headers.GetAllHeaders().Count; + + /// + public bool Contains(string name) + { + ArgumentNullException.ThrowIfNull(name); + return _headers.HeaderExists(name); + } + + /// + public IHttpHeader? GetFirst(string name) + { + ArgumentNullException.ThrowIfNull(name); + var header = _headers.GetFirstHeader(name); + return header is null ? null : new HttpHeader(header.Name, header.Value); + } + + /// + public IEnumerable GetAll(string name) + { + ArgumentNullException.ThrowIfNull(name); + var headers = _headers.GetHeaders(name); + return headers is null + ? [] + : headers.Select(h => (IHttpHeader)new HttpHeader(h.Name, h.Value)); + } + + /// + public void Add(string name, string value) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(value); + _headers.AddHeader(name, value); + } + + /// + public void Add(IHttpHeader header) + { + ArgumentNullException.ThrowIfNull(header); + _headers.AddHeader(header.Name, header.Value); + } + + /// + public void AddRange(IEnumerable headers) + { + ArgumentNullException.ThrowIfNull(headers); + foreach (var header in headers) + { + _headers.AddHeader(header.Name, header.Value); + } + } + + /// + public void Replace(string name, string value) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(value); + _ = _headers.RemoveHeader(name); + _headers.AddHeader(name, value); + } + + /// + public bool Remove(string name) + { + ArgumentNullException.ThrowIfNull(name); + return _headers.RemoveHeader(name); + } + + /// + public IEnumerator GetEnumerator() => + _headers.GetAllHeaders() + .Select(h => (IHttpHeader)new HttpHeader(h.Name, h.Value)) + .GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/DevProxy.Proxy.Titanium/TitaniumHttpMessageAdapter.cs b/DevProxy.Proxy.Titanium/TitaniumHttpMessageAdapter.cs new file mode 100644 index 00000000..39d38997 --- /dev/null +++ b/DevProxy.Proxy.Titanium/TitaniumHttpMessageAdapter.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.Text; +using DevProxy.Abstractions.Proxy.Http; +using TitaniumMessage = Titanium.Web.Proxy.Http.RequestResponseBase; + +namespace DevProxy.Proxy.Titanium; + +/// +/// Shared projection of a Titanium (the common base +/// of request and response) onto the canonical surface. +/// +/// +/// Body access mirrors the engine's contract: Titanium throws +/// BodyNotFoundException when a body is accessed before one exists, so +/// / are guarded by HasBody and +/// return empty otherwise. The Dev Proxy engine force-reads watched +/// request/response bodies before invoking plugins, so these synchronous +/// accessors are populated by the time a plugin runs. +/// +/// +/// +/// Titanium exposes no public body setter on the message itself, so mutations are +/// routed through (the session's +/// SetRequestBody/SetResponseBody, which also keep Titanium's +/// internal state consistent). An adapter constructed without a setter is +/// read-only and throws. +/// +/// +public abstract class TitaniumHttpMessageAdapter : IHttpMessage +{ + private readonly TitaniumMessage _message; + private readonly Action? _setBody; + private readonly TitaniumHeaderCollection _headers; + + /// The Titanium request or response to wrap. + /// + /// Optional body setter that keeps the owning session consistent. When + /// null, the adapter is read-only and throws. + /// + protected TitaniumHttpMessageAdapter(TitaniumMessage message, Action? setBody) + { + ArgumentNullException.ThrowIfNull(message); + _message = message; + _setBody = setBody; + _headers = new TitaniumHeaderCollection(message.Headers); + } + + /// + public IHeaderCollection Headers => _headers; + + /// + public string? ContentType => _message.ContentType; + + /// + public bool HasBody => _message.HasBody; + + /// + public ReadOnlyMemory Body => + _message.HasBody && _message.Body is { } bytes ? bytes : ReadOnlyMemory.Empty; + + /// + public string BodyString => _message.HasBody ? _message.BodyString : string.Empty; + + /// + public void SetBody(ReadOnlyMemory body, string? contentType = null) + { + if (_setBody is null) + { + throw new InvalidOperationException( + "This message cannot be mutated because it was not constructed with a body setter. " + + "Body mutation requires a session-bound adapter (the engine supplies the session's " + + "SetRequestBody/SetResponseBody). Titanium exposes no public body setter on the message itself."); + } + + _setBody(body.ToArray()); + + if (contentType is not null) + { + _message.ContentType = contentType; + } + } + + /// + public void SetBodyString(string body, string? contentType = null) + { + ArgumentNullException.ThrowIfNull(body); + SetBody(Encoding.UTF8.GetBytes(body), contentType); + } +} diff --git a/DevProxy.Proxy.Titanium/TitaniumProxySession.cs b/DevProxy.Proxy.Titanium/TitaniumProxySession.cs new file mode 100644 index 00000000..6562ab4f --- /dev/null +++ b/DevProxy.Proxy.Titanium/TitaniumProxySession.cs @@ -0,0 +1,85 @@ +// 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 Titanium.Web.Proxy.EventArguments; +using TitaniumHttpHeader = Titanium.Web.Proxy.Models.HttpHeader; + +namespace DevProxy.Proxy.Titanium; + +/// +/// Projects a Titanium onto the canonical +/// . +/// +/// +/// Titanium always exposes a non-null HttpClient.Response object, even +/// before an upstream response has been received. A response is considered +/// present once its status code is non-zero (the upstream replied, or a plugin +/// produced a mock via ). +/// +/// +public sealed class TitaniumProxySession : IProxySession +{ + private readonly SessionEventArgs _session; + private readonly TitaniumRequestAdapter _request; + private TitaniumResponseAdapter? _response; + + /// The logical session identifier for this exchange. + /// The Titanium session to wrap. + public TitaniumProxySession(string sessionId, SessionEventArgs session) + { + ArgumentNullException.ThrowIfNull(sessionId); + ArgumentNullException.ThrowIfNull(session); + + SessionId = sessionId; + _session = session; + _request = new TitaniumRequestAdapter(session.HttpClient.Request, session.SetRequestBody); + } + + /// + public string SessionId { get; } + + /// + public IHttpRequest Request => _request; + + /// + public IHttpResponse? Response + { + get + { + if (!HasResponse) + { + return null; + } + + _response ??= new TitaniumResponseAdapter(_session.HttpClient.Response, _session.SetResponseBody); + return _response; + } + } + + /// + public int? ProcessId => _session.HttpClient.ProcessId is { } processId ? processId.Value : null; + + /// + public bool HasResponse => _session.HttpClient.Response.StatusCode != 0; + + /// + public void Respond(string body, HttpStatusCode statusCode, IEnumerable headers) + { + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(headers); + _session.GenericResponse(body, statusCode, ToTitaniumHeaders(headers)); + } + + /// + public void Respond(ReadOnlyMemory body, HttpStatusCode statusCode, IEnumerable headers) + { + ArgumentNullException.ThrowIfNull(headers); + _session.GenericResponse(body.ToArray(), statusCode, ToTitaniumHeaders(headers)); + } + + private static IEnumerable ToTitaniumHeaders(IEnumerable headers) => + headers.Select(h => new TitaniumHttpHeader(h.Name, h.Value)); +} diff --git a/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs b/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs new file mode 100644 index 00000000..e5e00338 --- /dev/null +++ b/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.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 DevProxy.Abstractions.Proxy.Http; +using TitaniumRequest = Titanium.Web.Proxy.Http.Request; + +namespace DevProxy.Proxy.Titanium; + +/// +/// Projects a Titanium onto the canonical +/// . +/// +public sealed class TitaniumRequestAdapter : TitaniumHttpMessageAdapter, IHttpRequest +{ + private readonly TitaniumRequest _request; + + /// The Titanium request to wrap. + /// + /// Optional body setter (typically the session's SetRequestBody) that + /// keeps Titanium's request state consistent on mutation. + /// + public TitaniumRequestAdapter(TitaniumRequest request, Action? setBody = null) + : base(request, setBody) + { + _request = request; + } + + /// + public Uri RequestUri => _request.RequestUri!; + + /// + public string Url => _request.Url; + + /// + public string Method => _request.Method!; + + /// + public Version HttpVersion => _request.HttpVersion; + + /// + public bool IsWebSocketRequest => _request.UpgradeToWebSocket; +} diff --git a/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs b/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs new file mode 100644 index 00000000..3c8bed18 --- /dev/null +++ b/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.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.Proxy.Http; +using TitaniumResponse = Titanium.Web.Proxy.Http.Response; + +namespace DevProxy.Proxy.Titanium; + +/// +/// Projects a Titanium onto the canonical +/// . Titanium stores the status as an ; +/// the canonical model exposes it as a strongly-typed . +/// +public sealed class TitaniumResponseAdapter : TitaniumHttpMessageAdapter, IHttpResponse +{ + private readonly TitaniumResponse _response; + + /// The Titanium response to wrap. + /// + /// Optional body setter (typically the session's SetResponseBody) that + /// keeps Titanium's response state consistent on mutation. + /// + public TitaniumResponseAdapter(TitaniumResponse response, Action? setBody = null) + : base(response, setBody) + { + _response = response; + } + + /// + public HttpStatusCode StatusCode + { + get => (HttpStatusCode)_response.StatusCode; + set => _response.StatusCode = (int)value; + } + + /// + public string? StatusDescription + { + get => _response.StatusDescription; + set => _response.StatusDescription = value ?? string.Empty; + } +} diff --git a/DevProxy.Proxy.Titanium/packages.lock.json b/DevProxy.Proxy.Titanium/packages.lock.json new file mode 100644 index 00000000..32f02e1a --- /dev/null +++ b/DevProxy.Proxy.Titanium/packages.lock.json @@ -0,0 +1,330 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "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" + } + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" + }, + "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.Extensions.Caching.Memory": "10.0.9", + "Microsoft.Extensions.Logging": "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.Extensions.Caching.Memory": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "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.Caching.Memory": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyModel": "10.0.9", + "Microsoft.Extensions.Logging": "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.Caching.Memory": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyModel": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "5fGxcw2vuYp8s0wio9H1ECiuk4iKSdTIlNuigdLIrkhg+5XAwgFVDB/5Ots3pfN/QhABLYXutA79JFtnUKDSHA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "G9mregdatGWMCQWeCw012LDeJVP7G/XIxH8Ddbjc8bD1//dA+8VVQdcRE9jI1moyoJxSSZhHITUnNQ8FUDl5+Q==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NgLB9cYnIb0/djSDcnqo4GIGGWooxGmr/gCUe3/CRXcKqLizOFui8MyW4EVkTB/KNJL+oXdMXnD6ZRm3Y+qkrQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "LiFKJgc9jZEW+7RhcSfsvCwoikt1lDdOqOn+whZC5zVHyg/gExftHl2QPtmfiHsEdDNg+Y+BDr6835tOfj8Y7A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "SCDTQ6HubnRvTUjR7dgMKHZvNoCb03t44ttHL8trlFTGgfDteWn/0nRdOxDhcI+lTWhKgd/flCVJEtAOPhSLNg==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "zm8WVod4swgprGrkxkuSILlbXqdDRqF+3y6U0I7jlmj4PMyKN6d8pzXZHUn5lr/gZVULzk/+FeTYlTupt6akpg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "mvRf9qOH/LslWIee/h+lsElnoUyKotEwoPL31soqScmO/eoxObaTCLCdx2DdqPdRi9LnB+7qKZ49jfyrLZuc+w==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" + }, + "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.Extensions.Configuration": "[10.0.9, )", + "Microsoft.Extensions.Configuration.Binder": "[10.0.9, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.9, )", + "Microsoft.Extensions.FileSystemGlobbing": "[10.0.9, )", + "Microsoft.Extensions.Logging.Abstractions": "[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, )", + "Unobtanium.Web.Proxy": "[0.1.5, )", + "YamlDotNet": "[18.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/DevProxy.sln b/DevProxy.sln index fda730b0..606851bb 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -18,6 +18,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevProxy.Abstractions", "De 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.Titanium", "DevProxy.Proxy.Titanium\DevProxy.Proxy.Titanium.csproj", "{E333351F-3772-488F-B78C-31D6AABF7A7A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Titanium.Tests", "DevProxy.Proxy.Titanium.Tests\DevProxy.Proxy.Titanium.Tests.csproj", "{A3784E2F-7CB4-4F1B-8A96-C17104D5C868}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -76,6 +80,30 @@ Global {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 + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|x64.ActiveCfg = Debug|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|x64.Build.0 = Debug|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|x86.ActiveCfg = Debug|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|x86.Build.0 = Debug|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|Any CPU.Build.0 = Release|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|x64.ActiveCfg = Release|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|x64.Build.0 = Release|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|x86.ActiveCfg = Release|Any CPU + {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|x86.Build.0 = Release|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|x64.ActiveCfg = Debug|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|x64.Build.0 = Debug|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|x86.ActiveCfg = Debug|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|x86.Build.0 = Debug|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|Any CPU.Build.0 = Release|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x64.ActiveCfg = Release|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x64.Build.0 = Release|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x86.ActiveCfg = Release|Any CPU + {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 52273f25aaa52c35911d4903c36e68927aec65b1 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 16:21:31 +0200 Subject: [PATCH 04/46] Wire canonical IProxySession through ProxyEvents; migrate first plugin (Phase 3 slice 1) Introduces the dual-field strangler that lets plugins migrate off Titanium types one at a time while staying green: - ProxyHttpEventArgsBase exposes IProxySession ProxySession alongside the existing Titanium SessionEventArgs Session. HasRequestUrlMatch now reads the canonical request. - ProxyEngine builds a TitaniumProxySession (keyed by the existing per-session GetHashCode key) and threads it through the request/response arg construction sites; DevProxy host references DevProxy.Proxy.Titanium. - CachingGuidancePlugin fully migrated to e.ProxySession.Request and is now Titanium-free. Verified live: the cache-window warning fires through the real proxy pipeline reading IHttpRequest.RequestUri via the adapter. Full solution builds 0/0; 71 tests green. The other 21 Titanium-touching plugins are untouched and keep compiling against Session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DevProxy.Abstractions/Proxy/ProxyEvents.cs | 21 +++++++++++++------ .../Guidance/CachingGuidancePlugin.cs | 8 +++---- DevProxy/DevProxy.csproj | 1 + DevProxy/Proxy/ProxyEngine.cs | 11 +++++++--- DevProxy/packages.lock.json | 7 +++++++ 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/DevProxy.Abstractions/Proxy/ProxyEvents.cs b/DevProxy.Abstractions/Proxy/ProxyEvents.cs index 249685cd..507a1202 100644 --- a/DevProxy.Abstractions/Proxy/ProxyEvents.cs +++ b/DevProxy.Abstractions/Proxy/ProxyEvents.cs @@ -2,6 +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 DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using System.CommandLine; using System.Text.Json.Serialization; @@ -15,17 +16,25 @@ public class ProxyEventArgsBase public Dictionary GlobalData { get; init; } = []; } -public class ProxyHttpEventArgsBase(SessionEventArgs session) : ProxyEventArgsBase +public class ProxyHttpEventArgsBase(SessionEventArgs session, IProxySession proxySession) : ProxyEventArgsBase { public SessionEventArgs Session { get; } = session ?? throw new ArgumentNullException(nameof(session)); + /// + /// Engine-agnostic view of the session. Plugins should prefer this over + /// , which exposes the underlying Titanium engine type + /// and will be removed once all plugins are migrated to the canonical model. + /// + 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(SessionEventArgs session, IProxySession proxySession, ResponseState responseState) : + ProxyHttpEventArgsBase(session, proxySession) { public ResponseState ResponseState { get; } = responseState ?? throw new ArgumentNullException(nameof(responseState)); @@ -35,8 +44,8 @@ public bool ShouldExecute(ISet watchedUrls) => && HasRequestUrlMatch(watchedUrls); } -public class ProxyResponseArgs(SessionEventArgs session, ResponseState responseState) : - ProxyHttpEventArgsBase(session) +public class ProxyResponseArgs(SessionEventArgs session, IProxySession proxySession, ResponseState responseState) : + ProxyHttpEventArgsBase(session, proxySession) { public ResponseState ResponseState { get; } = responseState ?? throw new ArgumentNullException(nameof(responseState)); diff --git a/DevProxy.Plugins/Guidance/CachingGuidancePlugin.cs b/DevProxy.Plugins/Guidance/CachingGuidancePlugin.cs index ab472735..d5808c12 100644 --- a/DevProxy.Plugins/Guidance/CachingGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/CachingGuidancePlugin.cs @@ -3,10 +3,10 @@ // See the LICENSE file in the project root for more information. using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Plugins; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Guidance; @@ -43,13 +43,13 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); 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)); return Task.CompletedTask; } - var request = e.Session.HttpClient.Request; + var request = e.ProxySession.Request; var url = request.RequestUri.AbsoluteUri; var now = DateTime.Now; @@ -78,6 +78,6 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca return Task.CompletedTask; } - private static string BuildCacheWarningMessage(Request r, int _warningSeconds, DateTime lastIntercepted) => + private static string BuildCacheWarningMessage(IHttpRequest r, int _warningSeconds, DateTime lastIntercepted) => $"Another request to {r.RequestUri.PathAndQuery} intercepted within {_warningSeconds} seconds. Last intercepted at {lastIntercepted}. Consider using cache to avoid calling the API too often."; } diff --git a/DevProxy/DevProxy.csproj b/DevProxy/DevProxy.csproj index 64e1f019..b85e48d9 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -47,6 +47,7 @@ + diff --git a/DevProxy/Proxy/ProxyEngine.cs b/DevProxy/Proxy/ProxyEngine.cs index c4c25751..5ee0ff0a 100755 --- a/DevProxy/Proxy/ProxyEngine.cs +++ b/DevProxy/Proxy/ProxyEngine.cs @@ -6,12 +6,14 @@ using DevProxy.Abstractions.Proxy; using DevProxy.Abstractions.Utils; using DevProxy.Commands; +using DevProxy.Proxy.Titanium; 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.Globalization; using System.Net; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; @@ -497,7 +499,7 @@ async Task OnRequestAsync(object sender, SessionEventArgs e) 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) + var proxyRequestArgs = new ProxyRequestArgs(e, CreateProxySession(e), responseState) { SessionData = _pluginData[e.GetHashCode()], GlobalData = _proxyController.ProxyState.GlobalData @@ -527,6 +529,9 @@ async Task OnRequestAsync(object sender, SessionEventArgs e) } } + private static TitaniumProxySession CreateProxySession(SessionEventArgs e) => + new(e.GetHashCode().ToString(CultureInfo.InvariantCulture), e); + private async Task HandleRequestAsync(SessionEventArgs e, ProxyRequestArgs proxyRequestArgs) { foreach (var plugin in _plugins.Where(p => p.Enabled)) @@ -604,7 +609,7 @@ async Task OnBeforeResponseAsync(object sender, SessionEventArgs e) // read response headers if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host)) { - var proxyResponseArgs = new ProxyResponseArgs(e, new()) + var proxyResponseArgs = new ProxyResponseArgs(e, CreateProxySession(e), new()) { SessionData = _pluginData[e.GetHashCode()], GlobalData = _proxyController.ProxyState.GlobalData @@ -643,7 +648,7 @@ async Task OnAfterResponseAsync(object sender, SessionEventArgs e) // read response headers if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host)) { - var proxyResponseArgs = new ProxyResponseArgs(e, new()) + var proxyResponseArgs = new ProxyResponseArgs(e, CreateProxySession(e), new()) { SessionData = _pluginData[e.GetHashCode()], GlobalData = _proxyController.ProxyState.GlobalData diff --git a/DevProxy/packages.lock.json b/DevProxy/packages.lock.json index a7b5a9ac..be74cb81 100644 --- a/DevProxy/packages.lock.json +++ b/DevProxy/packages.lock.json @@ -357,6 +357,13 @@ "Unobtanium.Web.Proxy": "[0.1.5, )", "YamlDotNet": "[18.0.0, )" } + }, + "devproxy.proxy.titanium": { + "type": "Project", + "dependencies": { + "DevProxy.Abstractions": "[3.1.0, )", + "Unobtanium.Web.Proxy": "[0.1.5, )" + } } } } From 5c8299d23e0030435c2de8634e2a9d3ad8d597dd Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 16:25:25 +0200 Subject: [PATCH 05/46] Phase 3 Wave 1: add IHttpRequest overloads to ProxyUtils Add canonical IHttpRequest overloads alongside the existing Titanium Request overloads for IsGraphRequest/IsSdkRequest/IsGraphBetaRequest/BuildGraphResponseHeaders. Extract a shared BuildGraphResponseHeadersCore to keep both overloads DRY. Additive only; Titanium overloads remain until the final wave. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DevProxy.Abstractions/Utils/ProxyUtils.cs | 51 ++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/DevProxy.Abstractions/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs index b5799481..a8559ec1 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; @@ -111,6 +112,13 @@ public static bool IsGraphRequest(Request request) return IsGraphUrl(request.RequestUri); } + public static bool IsGraphRequest(IHttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return IsGraphUrl(request.RequestUri); + } + public static bool IsGraphUrl(Uri uri) { ArgumentNullException.ThrowIfNull(uri); @@ -143,10 +151,25 @@ public static bool IsSdkRequest(Request request) return request.Headers.HeaderExists("SdkVersion"); } + public static bool IsSdkRequest(IHttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + 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) { ArgumentNullException.ThrowIfNull(uri); @@ -168,6 +191,32 @@ public static IList BuildGraphResponseHeaders(Request reques return []; } + var hasOrigin = request.Headers.FirstOrDefault((h) => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)) is not null; + return BuildGraphResponseHeadersCore(hasOrigin, requestId, requestDate); + } + + /// + /// Utility to build HTTP response headers consistent with Microsoft Graph + /// + /// The http request for which response headers are being constructed + /// 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(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 +227,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")); From a0b5e5ed9f01fc39427e2bf29e879c6969c51b53 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 16:29:23 +0200 Subject: [PATCH 06/46] Phase 3 guidance wave: migrate 4 guidance plugins off Titanium Migrate ODSPSearchGuidancePlugin, GraphSelectGuidancePlugin, GraphSdkGuidancePlugin, and GraphClientRequestIdGuidancePlugin to the canonical model: read request/response via e.ProxySession, helpers take IHttpRequest, header checks via Contains. Each plugin is now Titanium-free in source (LoggingContext still takes e.Session until the final wave). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Guidance/GraphClientRequestIdGuidancePlugin.cs | 10 +++++----- DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs | 10 +++++----- .../Guidance/GraphSelectGuidancePlugin.cs | 13 ++++++------- .../Guidance/ODSPSearchGuidancePlugin.cs | 13 ++++++------- 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/DevProxy.Plugins/Guidance/GraphClientRequestIdGuidancePlugin.cs b/DevProxy.Plugins/Guidance/GraphClientRequestIdGuidancePlugin.cs index 0aabcb6c..35faaaa1 100644 --- a/DevProxy.Plugins/Guidance/GraphClientRequestIdGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/GraphClientRequestIdGuidancePlugin.cs @@ -4,10 +4,10 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Utils; using Microsoft.Extensions.Logging; -using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Guidance; @@ -23,13 +23,13 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca ArgumentNullException.ThrowIfNull(e); - var request = e.Session.HttpClient.Request; + var request = e.ProxySession.Request; if (!e.HasRequestUrlMatch(UrlsToWatch)) { Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); 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)); return Task.CompletedTask; @@ -53,9 +53,9 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca return Task.CompletedTask; } - private static bool WarnNoClientRequestId(Request request) => + private static bool WarnNoClientRequestId(IHttpRequest request) => ProxyUtils.IsGraphRequest(request) && - !request.Headers.HeaderExists("client-request-id"); + !request.Headers.Contains("client-request-id"); private static string GetClientRequestIdGuidanceUrl() => "https://aka.ms/devproxy/guidance/client-request-id"; private static string BuildAddClientRequestIdMessage() => diff --git a/DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs b/DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs index de4e34cd..4e7fcdd5 100644 --- a/DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs @@ -4,10 +4,10 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Utils; using Microsoft.Extensions.Logging; -using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Guidance; @@ -23,20 +23,20 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c ArgumentNullException.ThrowIfNull(e); - var request = e.Session.HttpClient.Request; + var request = e.ProxySession.Request; if (!e.HasRequestUrlMatch(UrlsToWatch)) { Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); 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)); return Task.CompletedTask; } // only show the message if there is an error. - if (e.Session.HttpClient.Response.StatusCode >= 400) + if ((int)e.ProxySession.Response!.StatusCode >= 400) { if (WarnNoSdk(request)) { @@ -56,6 +56,6 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c return Task.CompletedTask; } - private static bool WarnNoSdk(Request request) => + private static bool WarnNoSdk(IHttpRequest request) => ProxyUtils.IsGraphRequest(request) && !ProxyUtils.IsSdkRequest(request); } diff --git a/DevProxy.Plugins/Guidance/GraphSelectGuidancePlugin.cs b/DevProxy.Plugins/Guidance/GraphSelectGuidancePlugin.cs index 2dc8997e..b465caf3 100644 --- a/DevProxy.Plugins/Guidance/GraphSelectGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/GraphSelectGuidancePlugin.cs @@ -8,7 +8,6 @@ using DevProxy.Abstractions.Utils; using Microsoft.Extensions.Logging; using System.Globalization; -using Titanium.Web.Proxy.EventArguments; namespace DevProxy.Plugins.Guidance; @@ -42,13 +41,13 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); 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)); return Task.CompletedTask; } - if (WarnNoSelect(e.Session)) + if (WarnNoSelect(e)) { Logger.LogRequest(BuildUseSelectMessage(), MessageType.Warning, new LoggingContext(e.Session)); } @@ -57,13 +56,13 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c return Task.CompletedTask; } - private bool WarnNoSelect(SessionEventArgs session) + private bool WarnNoSelect(ProxyResponseArgs e) { - var request = session.HttpClient.Request; + var request = e.ProxySession.Request; if (!ProxyUtils.IsGraphRequest(request) || request.Method != "GET") { - Logger.LogRequest("Not a Microsoft Graph GET request", MessageType.Skipped, new LoggingContext(session)); + Logger.LogRequest("Not a Microsoft Graph GET request", MessageType.Skipped, new LoggingContext(e.Session)); return false; } @@ -77,7 +76,7 @@ private bool WarnNoSelect(SessionEventArgs session) } else { - Logger.LogRequest("Endpoint does not support $select", MessageType.Skipped, new LoggingContext(session)); + Logger.LogRequest("Endpoint does not support $select", MessageType.Skipped, new LoggingContext(e.Session)); return false; } } diff --git a/DevProxy.Plugins/Guidance/ODSPSearchGuidancePlugin.cs b/DevProxy.Plugins/Guidance/ODSPSearchGuidancePlugin.cs index fe83bd27..34cd3345 100644 --- a/DevProxy.Plugins/Guidance/ODSPSearchGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/ODSPSearchGuidancePlugin.cs @@ -6,7 +6,6 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Utils; using Microsoft.Extensions.Logging; -using Titanium.Web.Proxy.EventArguments; namespace DevProxy.Plugins.Guidance; @@ -27,13 +26,13 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); 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)); return Task.CompletedTask; } - if (WarnDeprecatedSearch(e.Session)) + if (WarnDeprecatedSearch(e)) { Logger.LogRequest(BuildUseGraphSearchMessage(), MessageType.Warning, new LoggingContext(e.Session)); } @@ -42,13 +41,13 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca return Task.CompletedTask; } - private bool WarnDeprecatedSearch(SessionEventArgs session) + private bool WarnDeprecatedSearch(ProxyRequestArgs e) { - var request = session.HttpClient.Request; + var request = e.ProxySession.Request; if (!ProxyUtils.IsGraphRequest(request) || request.Method != "GET") { - Logger.LogRequest("Not a Microsoft Graph GET request", MessageType.Skipped, new LoggingContext(session)); + Logger.LogRequest("Not a Microsoft Graph GET request", MessageType.Skipped, new LoggingContext(e.Session)); return false; } @@ -66,7 +65,7 @@ private bool WarnDeprecatedSearch(SessionEventArgs session) } else { - Logger.LogRequest("Not a SharePoint search request", MessageType.Skipped, new LoggingContext(session)); + Logger.LogRequest("Not a SharePoint search request", MessageType.Skipped, new LoggingContext(e.Session)); return false; } } From facdb4ffb05a8d6164b3bb9c469e74e4d9595800 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 16:33:38 +0200 Subject: [PATCH 07/46] Phase 3 mocking wave: migrate 5 mocking plugins off Titanium Migrate AuthPlugin, CrudApiPlugin, GraphMockResponsePlugin, MockResponsePlugin, and OpenAIMockResponsePlugin to the canonical model: request/response via e.ProxySession, GenericResponse -> IProxySession.Respond, canonical HttpHeader, header methods via the canonical IHeaderCollection. Helper signatures and the CrudApi action-handler delegate now use IProxySession/IHttpRequest/ProxyRequestArgs. LoggingContext still takes e.Session until the final wave. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DevProxy.Plugins/Mocking/AuthPlugin.cs | 1156 +++++++------ DevProxy.Plugins/Mocking/CrudApiPlugin.cs | 1400 ++++++++------- .../Mocking/GraphMockResponsePlugin.cs | 420 ++--- .../Mocking/MockResponsePlugin.cs | 1509 ++++++++--------- .../Mocking/OpenAIMockResponsePlugin.cs | 374 ++-- 5 files changed, 2427 insertions(+), 2432 deletions(-) diff --git a/DevProxy.Plugins/Mocking/AuthPlugin.cs b/DevProxy.Plugins/Mocking/AuthPlugin.cs index 7fef066f..d55b8ff7 100644 --- a/DevProxy.Plugins/Mocking/AuthPlugin.cs +++ b/DevProxy.Plugins/Mocking/AuthPlugin.cs @@ -1,579 +1,577 @@ -// 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 Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; -using System.Diagnostics; -using System.IdentityModel.Tokens.Jwt; -using System.Net; -using System.Security.Claims; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Web; -using Titanium.Web.Proxy.EventArguments; -using Titanium.Web.Proxy.Http; -using Titanium.Web.Proxy.Models; - -namespace DevProxy.Plugins.Mocking; - -public enum AuthPluginAuthType -{ - ApiKey, - OAuth2 -} - -public enum AuthPluginApiKeyIn -{ - Header, - Query, - Cookie -} - -public sealed class AuthPluginApiKeyParameter -{ - [JsonConverter(typeof(JsonStringEnumConverter))] - public AuthPluginApiKeyIn? In { get; set; } - public string? Name { get; set; } -} - -public sealed class AuthPluginApiKeyConfiguration -{ - public IEnumerable? AllowedKeys { get; set; } - public IEnumerable? Parameters { get; set; } -} - -public sealed class AuthPluginOAuth2Configuration -{ - public IEnumerable? AllowedApplications { get; set; } - public IEnumerable? AllowedAudiences { get; set; } - public IEnumerable? AllowedPrincipals { get; set; } - public IEnumerable? AllowedTenants { get; set; } - public string? Issuer { get; set; } - public string? MetadataUrl { get; set; } - public IEnumerable? Roles { get; set; } - public IEnumerable? Scopes { get; set; } - public bool ValidateLifetime { get; set; } = true; - public bool ValidateSigningKey { get; set; } = true; -} - -public sealed class AuthPluginConfiguration -{ - public AuthPluginApiKeyConfiguration? ApiKey { get; set; } - public AuthPluginOAuth2Configuration? OAuth2 { get; set; } - [JsonConverter(typeof(JsonStringEnumConverter))] - public AuthPluginAuthType? Type { get; set; } -} - -public sealed class AuthPlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigurationSection) : - BasePlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigurationSection) -{ - private OpenIdConnectConfiguration? _openIdConnectConfiguration; - - public override string Name => nameof(AuthPlugin); - - public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) - { - await base.InitializeAsync(e, cancellationToken); - - // Disable by default to support early exits on configuration errors - Enabled = false; - - if (Configuration.Type == null) - { - Logger.LogError("Auth type is required"); - return; - } - - if (Configuration.Type == AuthPluginAuthType.ApiKey && - Configuration.ApiKey is null) - { - Logger.LogError("ApiKey configuration is required when using ApiKey auth type"); - return; - } - - if (Configuration.Type == AuthPluginAuthType.OAuth2 && - Configuration.OAuth2 is null) - { - Logger.LogError("OAuth2 configuration is required when using OAuth2 auth type"); - return; - } - - if (Configuration.Type == AuthPluginAuthType.ApiKey) - { - Debug.Assert(Configuration.ApiKey is not null); - - if (Configuration.ApiKey.Parameters == null || - !Configuration.ApiKey.Parameters.Any()) - { - Logger.LogError("ApiKey.Parameters is required when using ApiKey auth type"); - return; - } - - foreach (var parameter in Configuration.ApiKey.Parameters) - { - if (parameter.In is null || parameter.Name is null) - { - Logger.LogError("ApiKey.In and ApiKey.Name are required for each parameter"); - return; - } - } - - if (Configuration.ApiKey.AllowedKeys == null || - !Configuration.ApiKey.AllowedKeys.Any()) - { - Logger.LogError("ApiKey.AllowedKeys is required when using ApiKey auth type"); - return; - } - } - - if (Configuration.Type == AuthPluginAuthType.OAuth2) - { - Debug.Assert(Configuration.OAuth2 is not null); - - if (string.IsNullOrWhiteSpace(Configuration.OAuth2.MetadataUrl)) - { - Logger.LogError("OAuth2.MetadataUrl is required when using OAuth2 auth type"); - return; - } - - await SetupOpenIdConnectConfigurationAsync(Configuration.OAuth2.MetadataUrl); - } - - // Enable the plugin after successful initialization - Enabled = true; - } - - public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); - - ArgumentNullException.ThrowIfNull(e); - - if (!e.HasRequestUrlMatch(UrlsToWatch)) - { - Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); - return Task.CompletedTask; - } - if (e.ResponseState.HasBeenSet) - { - Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session)); - return Task.CompletedTask; - } - - if (!AuthorizeRequest(e.Session)) - { - SendUnauthorizedResponse(e.Session); - e.ResponseState.HasBeenSet = true; - } - else - { - Logger.LogRequest("Request authorized", MessageType.Normal, new LoggingContext(e.Session)); - } - - Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); - return Task.CompletedTask; - } - - private async Task SetupOpenIdConnectConfigurationAsync(string metadataUrl) - { - try - { - var retriever = new OpenIdConnectConfigurationRetriever(); - var configurationManager = new ConfigurationManager(metadataUrl, retriever); - _openIdConnectConfiguration = await configurationManager.GetConfigurationAsync(); - } - catch (Exception ex) - { - Logger.LogError(ex, "An error has occurred while loading OpenIdConnectConfiguration"); - } - } - - private bool AuthorizeRequest(SessionEventArgs session) - { - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.Type is not null); - - return Configuration.Type switch - { - AuthPluginAuthType.ApiKey => AuthorizeApiKeyRequest(session), - AuthPluginAuthType.OAuth2 => AuthorizeOAuth2Request(session), - _ => false, - }; - } - - private bool AuthorizeApiKeyRequest(SessionEventArgs session) - { - Logger.LogDebug("Authorizing request using API key"); - - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.ApiKey is not null); - Debug.Assert(Configuration.ApiKey.AllowedKeys is not null); - - var apiKey = GetApiKey(session); - if (apiKey is null) - { - Logger.LogRequest("401 Unauthorized. API key not found.", MessageType.Failed, new LoggingContext(session)); - return false; - } - - var isKeyValid = Configuration.ApiKey.AllowedKeys.Contains(apiKey); - if (!isKeyValid) - { - Logger.LogRequest($"401 Unauthorized. API key {apiKey} is not allowed.", MessageType.Failed, new LoggingContext(session)); - } - - return isKeyValid; - } - - private bool AuthorizeOAuth2Request(SessionEventArgs session) - { - Logger.LogDebug("Authorizing request using OAuth2"); - - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.OAuth2 is not null); - Debug.Assert(Configuration.OAuth2.MetadataUrl is not null); - Debug.Assert(_openIdConnectConfiguration is not null); - - var token = GetOAuth2Token(session); - if (token is null) - { - return false; - } - - var handler = new JwtSecurityTokenHandler(); - var validationParameters = new TokenValidationParameters - { - IssuerSigningKeys = _openIdConnectConfiguration?.SigningKeys, - ValidateIssuer = !string.IsNullOrEmpty(Configuration.OAuth2.Issuer), - ValidIssuer = Configuration.OAuth2.Issuer, - ValidateAudience = Configuration.OAuth2.AllowedAudiences is not null && Configuration.OAuth2.AllowedAudiences.Any(), - ValidAudiences = Configuration.OAuth2.AllowedAudiences, - ValidateLifetime = Configuration.OAuth2.ValidateLifetime, - ValidateIssuerSigningKey = Configuration.OAuth2.ValidateSigningKey - }; - if (!Configuration.OAuth2.ValidateSigningKey) - { - // suppress token validation - validationParameters.SignatureValidator = delegate (string token, TokenValidationParameters parameters) - { - return new JwtSecurityToken(token); - }; - } - - try - { - var claimsPrincipal = handler.ValidateToken(token, validationParameters, out _); - return ValidateTenants(claimsPrincipal, session) && - ValidateApplications(claimsPrincipal, session) && - ValidatePrincipals(claimsPrincipal, session) && - ValidateRoles(claimsPrincipal, session) && - ValidateScopes(claimsPrincipal, session); - } - catch (Exception ex) - { - Logger.LogRequest($"401 Unauthorized. The specified token is not valid: {ex.Message}", MessageType.Failed, new LoggingContext(session)); - return false; - } - } - - private bool ValidatePrincipals(ClaimsPrincipal claimsPrincipal, SessionEventArgs session) - { - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.OAuth2 is not null); - - if (Configuration.OAuth2.AllowedPrincipals is null || - Configuration.OAuth2.AllowedPrincipals.Any()) - { - return true; - } - - var principalId = claimsPrincipal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; - if (principalId is null) - { - Logger.LogRequest("401 Unauthorized. The specified token doesn't have the oid claim.", MessageType.Failed, new LoggingContext(session)); - return false; - } - - if (!Configuration.OAuth2.AllowedPrincipals.Contains(principalId)) - { - var principals = string.Join(", ", Configuration.OAuth2.AllowedPrincipals); - Logger.LogRequest($"401 Unauthorized. The specified token is not issued for an allowed principal. Allowed principals: {principals}, found: {principalId}", MessageType.Failed, new LoggingContext(session)); - return false; - } - - Logger.LogDebug("Principal ID {PrincipalId} is allowed", principalId); - - return true; - } - - private bool ValidateApplications(ClaimsPrincipal claimsPrincipal, SessionEventArgs session) - { - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.OAuth2 is not null); - - if (Configuration.OAuth2.AllowedApplications is null || - Configuration.OAuth2.AllowedApplications.Any()) - { - return true; - } - - var tokenVersion = claimsPrincipal.FindFirst("ver")?.Value; - if (tokenVersion is null) - { - Logger.LogRequest("401 Unauthorized. The specified token doesn't have the ver claim.", MessageType.Failed, new LoggingContext(session)); - return false; - } - - var appId = claimsPrincipal.FindFirst(tokenVersion == "1.0" ? "appid" : "azp")?.Value; - if (appId is null) - { - Logger.LogRequest($"401 Unauthorized. The specified token doesn't have the {(tokenVersion == "v1.0" ? "appid" : "azp")} claim.", MessageType.Failed, new LoggingContext(session)); - return false; - } - - if (!Configuration.OAuth2.AllowedApplications.Contains(appId)) - { - var applications = string.Join(", ", Configuration.OAuth2.AllowedApplications); - Logger.LogRequest($"401 Unauthorized. The specified token is not issued by an allowed application. Allowed applications: {applications}, found: {appId}", MessageType.Failed, new LoggingContext(session)); - return false; - } - - Logger.LogDebug("Application ID {AppId} is allowed", appId); - - return true; - } - - private bool ValidateTenants(ClaimsPrincipal claimsPrincipal, SessionEventArgs session) - { - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.OAuth2 is not null); - - if (Configuration.OAuth2.AllowedTenants is null || - Configuration.OAuth2.AllowedTenants.Any()) - { - return true; - } - - var tenantId = claimsPrincipal.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value; - if (tenantId is null) - { - Logger.LogRequest("401 Unauthorized. The specified token doesn't have the tid claim.", MessageType.Failed, new LoggingContext(session)); - return false; - } - - if (!Configuration.OAuth2.AllowedTenants.Contains(tenantId)) - { - var tenants = string.Join(", ", Configuration.OAuth2.AllowedTenants); - Logger.LogRequest($"401 Unauthorized. The specified token is not issued by an allowed tenant. Allowed tenants: {tenants}, found: {tenantId}", MessageType.Failed, new LoggingContext(session)); - return false; - } - - Logger.LogDebug("Token issued by an allowed tenant: {TenantId}", tenantId); - - return true; - } - - private bool ValidateRoles(ClaimsPrincipal claimsPrincipal, SessionEventArgs session) - { - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.OAuth2 is not null); - - if (Configuration.OAuth2.Roles is null || - Configuration.OAuth2.Roles.Any()) - { - return true; - } - - var rolesFromTheToken = string.Join(' ', claimsPrincipal.Claims - .Where(c => c.Type == ClaimTypes.Role) - .Select(c => c.Value)); - - var rolesRequired = string.Join(", ", Configuration.OAuth2.Roles); - if (!Configuration.OAuth2.Roles.Any(r => HasPermission(r, rolesFromTheToken))) - { - Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}", MessageType.Failed, new LoggingContext(session)); - return false; - } - - Logger.LogDebug("Token has the necessary role(s): {RolesRequired}", rolesRequired); - - return true; - } - - private bool ValidateScopes(ClaimsPrincipal claimsPrincipal, SessionEventArgs session) - { - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.OAuth2 is not null); - - if (Configuration.OAuth2.Scopes is null || - Configuration.OAuth2.Scopes.Any()) - { - return true; - } - - var scopesFromTheToken = string.Join(' ', claimsPrincipal.Claims - .Where(c => c.Type.Equals("http://schemas.microsoft.com/identity/claims/scope", StringComparison.OrdinalIgnoreCase)) - .Select(c => c.Value)); - - var scopesRequired = string.Join(", ", Configuration.OAuth2.Scopes); - if (!Configuration.OAuth2.Scopes.Any(s => HasPermission(s, scopesFromTheToken))) - { - Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}", MessageType.Failed, new LoggingContext(session)); - return false; - } - - Logger.LogDebug("Token has the necessary scope(s): {ScopesRequired}", scopesRequired); - - return true; - } - - private string? GetOAuth2Token(SessionEventArgs session) - { - var tokenParts = session.HttpClient.Request.Headers - .FirstOrDefault(h => h.Name.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) - ?.Value - ?.Split(' '); - - if (tokenParts is null) - { - Logger.LogRequest("401 Unauthorized. Authorization header not found.", MessageType.Failed, new LoggingContext(session)); - return null; - } - - if (tokenParts.Length != 2 || tokenParts[0] != "Bearer") - { - Logger.LogRequest("401 Unauthorized. The specified token is not a valid Bearer token.", MessageType.Failed, new LoggingContext(session)); - return null; - } - - return tokenParts[1]; - } - - private string? GetApiKey(SessionEventArgs session) - { - Debug.Assert(Configuration is not null); - Debug.Assert(Configuration.ApiKey is not null); - Debug.Assert(Configuration.ApiKey.Parameters is not null); - - string? apiKey = null; - - foreach (var parameter in Configuration.ApiKey.Parameters) - { - if (parameter.In is null || parameter.Name is null) - { - continue; - } - - Logger.LogDebug("Getting API key from parameter {Param} in {In}", parameter.Name, parameter.In); - apiKey = parameter.In switch - { - AuthPluginApiKeyIn.Header => GetApiKeyFromHeader(session.HttpClient.Request, parameter.Name), - AuthPluginApiKeyIn.Query => GetApiKeyFromQuery(session.HttpClient.Request, parameter.Name), - AuthPluginApiKeyIn.Cookie => GetApiKeyFromCookie(session.HttpClient.Request, parameter.Name), - _ => null - }; - Logger.LogDebug("API key from parameter {Param} in {In}: {ApiKey}", parameter.Name, parameter.In, apiKey ?? "(not found)"); - - if (apiKey is not null) - { - break; - } - } - - return apiKey; - } - - private static void SendUnauthorizedResponse(SessionEventArgs e) - { - var body = new - { - error = new - { - message = "Unauthorized" - } - }; - SendJsonResponse(JsonSerializer.Serialize(body, ProxyUtils.JsonSerializerOptions), HttpStatusCode.Unauthorized, e); - } - - private static void SendJsonResponse(string body, HttpStatusCode statusCode, SessionEventArgs e) - { - var headers = new List { - new("content-type", "application/json; charset=utf-8") - }; - if (e.HttpClient.Request.Headers.Any(h => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase))) - { - headers.Add(new("access-control-allow-origin", "*")); - } - e.GenericResponse(body, statusCode, headers); - } - - private static bool HasPermission(string permission, string permissionString) - { - if (string.IsNullOrEmpty(permissionString)) - { - return false; - } - - var permissions = permissionString.Split(' '); - return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase); - } - - private static string? GetApiKeyFromCookie(Request request, string cookieName) - { - var cookies = ParseCookies(request.Headers.FirstOrDefault(h => h.Name.Equals("Cookie", StringComparison.OrdinalIgnoreCase))?.Value); - if (cookies is null) - { - return null; - } - - _ = cookies.TryGetValue(cookieName, out var apiKey); - return apiKey; - } - - private static Dictionary? ParseCookies(string? cookieHeader) - { - if (cookieHeader is null) - { - return null; - } - - var cookies = new Dictionary(); - foreach (var cookie in cookieHeader.Split(';')) - { - var parts = cookie.Split('='); - if (parts.Length == 2) - { - cookies[parts[0].Trim()] = parts[1].Trim(); - } - } - return cookies; - } - - private static string? GetApiKeyFromQuery(Request request, string paramName) - { - var queryParameters = HttpUtility.ParseQueryString(request.RequestUri.Query); - return queryParameters[paramName]; - } - - private static string? GetApiKeyFromHeader(Request request, string headerName) - { - return request.Headers.FirstOrDefault(h => h.Name == headerName)?.Value; - } -} +// 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.Proxy.Http; +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Web; + +namespace DevProxy.Plugins.Mocking; + +public enum AuthPluginAuthType +{ + ApiKey, + OAuth2 +} + +public enum AuthPluginApiKeyIn +{ + Header, + Query, + Cookie +} + +public sealed class AuthPluginApiKeyParameter +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public AuthPluginApiKeyIn? In { get; set; } + public string? Name { get; set; } +} + +public sealed class AuthPluginApiKeyConfiguration +{ + public IEnumerable? AllowedKeys { get; set; } + public IEnumerable? Parameters { get; set; } +} + +public sealed class AuthPluginOAuth2Configuration +{ + public IEnumerable? AllowedApplications { get; set; } + public IEnumerable? AllowedAudiences { get; set; } + public IEnumerable? AllowedPrincipals { get; set; } + public IEnumerable? AllowedTenants { get; set; } + public string? Issuer { get; set; } + public string? MetadataUrl { get; set; } + public IEnumerable? Roles { get; set; } + public IEnumerable? Scopes { get; set; } + public bool ValidateLifetime { get; set; } = true; + public bool ValidateSigningKey { get; set; } = true; +} + +public sealed class AuthPluginConfiguration +{ + public AuthPluginApiKeyConfiguration? ApiKey { get; set; } + public AuthPluginOAuth2Configuration? OAuth2 { get; set; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public AuthPluginAuthType? Type { get; set; } +} + +public sealed class AuthPlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + BasePlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + private OpenIdConnectConfiguration? _openIdConnectConfiguration; + + public override string Name => nameof(AuthPlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + await base.InitializeAsync(e, cancellationToken); + + // Disable by default to support early exits on configuration errors + Enabled = false; + + if (Configuration.Type == null) + { + Logger.LogError("Auth type is required"); + return; + } + + if (Configuration.Type == AuthPluginAuthType.ApiKey && + Configuration.ApiKey is null) + { + Logger.LogError("ApiKey configuration is required when using ApiKey auth type"); + return; + } + + if (Configuration.Type == AuthPluginAuthType.OAuth2 && + Configuration.OAuth2 is null) + { + Logger.LogError("OAuth2 configuration is required when using OAuth2 auth type"); + return; + } + + if (Configuration.Type == AuthPluginAuthType.ApiKey) + { + Debug.Assert(Configuration.ApiKey is not null); + + if (Configuration.ApiKey.Parameters == null || + !Configuration.ApiKey.Parameters.Any()) + { + Logger.LogError("ApiKey.Parameters is required when using ApiKey auth type"); + return; + } + + foreach (var parameter in Configuration.ApiKey.Parameters) + { + if (parameter.In is null || parameter.Name is null) + { + Logger.LogError("ApiKey.In and ApiKey.Name are required for each parameter"); + return; + } + } + + if (Configuration.ApiKey.AllowedKeys == null || + !Configuration.ApiKey.AllowedKeys.Any()) + { + Logger.LogError("ApiKey.AllowedKeys is required when using ApiKey auth type"); + return; + } + } + + if (Configuration.Type == AuthPluginAuthType.OAuth2) + { + Debug.Assert(Configuration.OAuth2 is not null); + + if (string.IsNullOrWhiteSpace(Configuration.OAuth2.MetadataUrl)) + { + Logger.LogError("OAuth2.MetadataUrl is required when using OAuth2 auth type"); + return; + } + + await SetupOpenIdConnectConfigurationAsync(Configuration.OAuth2.MetadataUrl); + } + + // Enable the plugin after successful initialization + Enabled = true; + } + + public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); + + ArgumentNullException.ThrowIfNull(e); + + if (!e.HasRequestUrlMatch(UrlsToWatch)) + { + Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + if (e.ResponseState.HasBeenSet) + { + Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + + if (!AuthorizeRequest(e)) + { + SendUnauthorizedResponse(e.ProxySession); + e.ResponseState.HasBeenSet = true; + } + else + { + Logger.LogRequest("Request authorized", MessageType.Normal, new LoggingContext(e.Session)); + } + + Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); + return Task.CompletedTask; + } + + private async Task SetupOpenIdConnectConfigurationAsync(string metadataUrl) + { + try + { + var retriever = new OpenIdConnectConfigurationRetriever(); + var configurationManager = new ConfigurationManager(metadataUrl, retriever); + _openIdConnectConfiguration = await configurationManager.GetConfigurationAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "An error has occurred while loading OpenIdConnectConfiguration"); + } + } + + private bool AuthorizeRequest(ProxyRequestArgs e) + { + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.Type is not null); + + return Configuration.Type switch + { + AuthPluginAuthType.ApiKey => AuthorizeApiKeyRequest(e), + AuthPluginAuthType.OAuth2 => AuthorizeOAuth2Request(e), + _ => false, + }; + } + + private bool AuthorizeApiKeyRequest(ProxyRequestArgs e) + { + Logger.LogDebug("Authorizing request using API key"); + + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.ApiKey is not null); + Debug.Assert(Configuration.ApiKey.AllowedKeys is not null); + + var apiKey = GetApiKey(e.ProxySession.Request); + if (apiKey is null) + { + Logger.LogRequest("401 Unauthorized. API key not found.", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + var isKeyValid = Configuration.ApiKey.AllowedKeys.Contains(apiKey); + if (!isKeyValid) + { + Logger.LogRequest($"401 Unauthorized. API key {apiKey} is not allowed.", MessageType.Failed, new LoggingContext(e.Session)); + } + + return isKeyValid; + } + + private bool AuthorizeOAuth2Request(ProxyRequestArgs e) + { + Logger.LogDebug("Authorizing request using OAuth2"); + + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.OAuth2 is not null); + Debug.Assert(Configuration.OAuth2.MetadataUrl is not null); + Debug.Assert(_openIdConnectConfiguration is not null); + + var token = GetOAuth2Token(e); + if (token is null) + { + return false; + } + + var handler = new JwtSecurityTokenHandler(); + var validationParameters = new TokenValidationParameters + { + IssuerSigningKeys = _openIdConnectConfiguration?.SigningKeys, + ValidateIssuer = !string.IsNullOrEmpty(Configuration.OAuth2.Issuer), + ValidIssuer = Configuration.OAuth2.Issuer, + ValidateAudience = Configuration.OAuth2.AllowedAudiences is not null && Configuration.OAuth2.AllowedAudiences.Any(), + ValidAudiences = Configuration.OAuth2.AllowedAudiences, + ValidateLifetime = Configuration.OAuth2.ValidateLifetime, + ValidateIssuerSigningKey = Configuration.OAuth2.ValidateSigningKey + }; + if (!Configuration.OAuth2.ValidateSigningKey) + { + // suppress token validation + validationParameters.SignatureValidator = delegate (string token, TokenValidationParameters parameters) + { + return new JwtSecurityToken(token); + }; + } + + try + { + var claimsPrincipal = handler.ValidateToken(token, validationParameters, out _); + return ValidateTenants(claimsPrincipal, e) && + ValidateApplications(claimsPrincipal, e) && + ValidatePrincipals(claimsPrincipal, e) && + ValidateRoles(claimsPrincipal, e) && + ValidateScopes(claimsPrincipal, e); + } + catch (Exception ex) + { + Logger.LogRequest($"401 Unauthorized. The specified token is not valid: {ex.Message}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + } + + private bool ValidatePrincipals(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e) + { + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.OAuth2 is not null); + + if (Configuration.OAuth2.AllowedPrincipals is null || + Configuration.OAuth2.AllowedPrincipals.Any()) + { + return true; + } + + var principalId = claimsPrincipal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; + if (principalId is null) + { + Logger.LogRequest("401 Unauthorized. The specified token doesn't have the oid claim.", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + if (!Configuration.OAuth2.AllowedPrincipals.Contains(principalId)) + { + var principals = string.Join(", ", Configuration.OAuth2.AllowedPrincipals); + Logger.LogRequest($"401 Unauthorized. The specified token is not issued for an allowed principal. Allowed principals: {principals}, found: {principalId}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + Logger.LogDebug("Principal ID {PrincipalId} is allowed", principalId); + + return true; + } + + private bool ValidateApplications(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e) + { + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.OAuth2 is not null); + + if (Configuration.OAuth2.AllowedApplications is null || + Configuration.OAuth2.AllowedApplications.Any()) + { + return true; + } + + var tokenVersion = claimsPrincipal.FindFirst("ver")?.Value; + if (tokenVersion is null) + { + Logger.LogRequest("401 Unauthorized. The specified token doesn't have the ver claim.", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + var appId = claimsPrincipal.FindFirst(tokenVersion == "1.0" ? "appid" : "azp")?.Value; + if (appId is null) + { + Logger.LogRequest($"401 Unauthorized. The specified token doesn't have the {(tokenVersion == "v1.0" ? "appid" : "azp")} claim.", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + if (!Configuration.OAuth2.AllowedApplications.Contains(appId)) + { + var applications = string.Join(", ", Configuration.OAuth2.AllowedApplications); + Logger.LogRequest($"401 Unauthorized. The specified token is not issued by an allowed application. Allowed applications: {applications}, found: {appId}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + Logger.LogDebug("Application ID {AppId} is allowed", appId); + + return true; + } + + private bool ValidateTenants(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e) + { + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.OAuth2 is not null); + + if (Configuration.OAuth2.AllowedTenants is null || + Configuration.OAuth2.AllowedTenants.Any()) + { + return true; + } + + var tenantId = claimsPrincipal.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value; + if (tenantId is null) + { + Logger.LogRequest("401 Unauthorized. The specified token doesn't have the tid claim.", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + if (!Configuration.OAuth2.AllowedTenants.Contains(tenantId)) + { + var tenants = string.Join(", ", Configuration.OAuth2.AllowedTenants); + Logger.LogRequest($"401 Unauthorized. The specified token is not issued by an allowed tenant. Allowed tenants: {tenants}, found: {tenantId}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + Logger.LogDebug("Token issued by an allowed tenant: {TenantId}", tenantId); + + return true; + } + + private bool ValidateRoles(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e) + { + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.OAuth2 is not null); + + if (Configuration.OAuth2.Roles is null || + Configuration.OAuth2.Roles.Any()) + { + return true; + } + + var rolesFromTheToken = string.Join(' ', claimsPrincipal.Claims + .Where(c => c.Type == ClaimTypes.Role) + .Select(c => c.Value)); + + var rolesRequired = string.Join(", ", Configuration.OAuth2.Roles); + if (!Configuration.OAuth2.Roles.Any(r => HasPermission(r, rolesFromTheToken))) + { + Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + Logger.LogDebug("Token has the necessary role(s): {RolesRequired}", rolesRequired); + + return true; + } + + private bool ValidateScopes(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e) + { + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.OAuth2 is not null); + + if (Configuration.OAuth2.Scopes is null || + Configuration.OAuth2.Scopes.Any()) + { + return true; + } + + var scopesFromTheToken = string.Join(' ', claimsPrincipal.Claims + .Where(c => c.Type.Equals("http://schemas.microsoft.com/identity/claims/scope", StringComparison.OrdinalIgnoreCase)) + .Select(c => c.Value)); + + var scopesRequired = string.Join(", ", Configuration.OAuth2.Scopes); + if (!Configuration.OAuth2.Scopes.Any(s => HasPermission(s, scopesFromTheToken))) + { + Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + Logger.LogDebug("Token has the necessary scope(s): {ScopesRequired}", scopesRequired); + + return true; + } + + private string? GetOAuth2Token(ProxyRequestArgs e) + { + var tokenParts = e.ProxySession.Request.Headers + .FirstOrDefault(h => h.Name.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + ?.Value + ?.Split(' '); + + if (tokenParts is null) + { + Logger.LogRequest("401 Unauthorized. Authorization header not found.", MessageType.Failed, new LoggingContext(e.Session)); + return null; + } + + if (tokenParts.Length != 2 || tokenParts[0] != "Bearer") + { + Logger.LogRequest("401 Unauthorized. The specified token is not a valid Bearer token.", MessageType.Failed, new LoggingContext(e.Session)); + return null; + } + + return tokenParts[1]; + } + + private string? GetApiKey(IHttpRequest request) + { + Debug.Assert(Configuration is not null); + Debug.Assert(Configuration.ApiKey is not null); + Debug.Assert(Configuration.ApiKey.Parameters is not null); + + string? apiKey = null; + + foreach (var parameter in Configuration.ApiKey.Parameters) + { + if (parameter.In is null || parameter.Name is null) + { + continue; + } + + Logger.LogDebug("Getting API key from parameter {Param} in {In}", parameter.Name, parameter.In); + apiKey = parameter.In switch + { + AuthPluginApiKeyIn.Header => GetApiKeyFromHeader(request, parameter.Name), + AuthPluginApiKeyIn.Query => GetApiKeyFromQuery(request, parameter.Name), + AuthPluginApiKeyIn.Cookie => GetApiKeyFromCookie(request, parameter.Name), + _ => null + }; + Logger.LogDebug("API key from parameter {Param} in {In}: {ApiKey}", parameter.Name, parameter.In, apiKey ?? "(not found)"); + + if (apiKey is not null) + { + break; + } + } + + return apiKey; + } + + private static void SendUnauthorizedResponse(IProxySession session) + { + var body = new + { + error = new + { + message = "Unauthorized" + } + }; + SendJsonResponse(JsonSerializer.Serialize(body, ProxyUtils.JsonSerializerOptions), HttpStatusCode.Unauthorized, session); + } + + private static void SendJsonResponse(string body, HttpStatusCode statusCode, IProxySession session) + { + var headers = new List { + new("content-type", "application/json; charset=utf-8") + }; + if (session.Request.Headers.Any(h => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase))) + { + headers.Add(new("access-control-allow-origin", "*")); + } + session.Respond(body, statusCode, headers); + } + + private static bool HasPermission(string permission, string permissionString) + { + if (string.IsNullOrEmpty(permissionString)) + { + return false; + } + + var permissions = permissionString.Split(' '); + return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase); + } + + private static string? GetApiKeyFromCookie(IHttpRequest request, string cookieName) + { + var cookies = ParseCookies(request.Headers.FirstOrDefault(h => h.Name.Equals("Cookie", StringComparison.OrdinalIgnoreCase))?.Value); + if (cookies is null) + { + return null; + } + + _ = cookies.TryGetValue(cookieName, out var apiKey); + return apiKey; + } + + private static Dictionary? ParseCookies(string? cookieHeader) + { + if (cookieHeader is null) + { + return null; + } + + var cookies = new Dictionary(); + foreach (var cookie in cookieHeader.Split(';')) + { + var parts = cookie.Split('='); + if (parts.Length == 2) + { + cookies[parts[0].Trim()] = parts[1].Trim(); + } + } + return cookies; + } + + private static string? GetApiKeyFromQuery(IHttpRequest request, string paramName) + { + var queryParameters = HttpUtility.ParseQueryString(request.RequestUri.Query); + return queryParameters[paramName]; + } + + private static string? GetApiKeyFromHeader(IHttpRequest request, string headerName) + { + return request.Headers.FirstOrDefault(h => h.Name == headerName)?.Value; + } +} diff --git a/DevProxy.Plugins/Mocking/CrudApiPlugin.cs b/DevProxy.Plugins/Mocking/CrudApiPlugin.cs index 3ce73610..c1646abd 100644 --- a/DevProxy.Plugins/Mocking/CrudApiPlugin.cs +++ b/DevProxy.Plugins/Mocking/CrudApiPlugin.cs @@ -1,701 +1,699 @@ -// 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.Abstractions.Plugins; -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System.IdentityModel.Tokens.Jwt; -using System.Diagnostics; -using System.Net; -using System.Security.Claims; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Titanium.Web.Proxy.EventArguments; -using Titanium.Web.Proxy.Http; -using Titanium.Web.Proxy.Models; - -namespace DevProxy.Plugins.Mocking; - -public enum CrudApiActionType -{ - Create, - GetAll, - GetOne, - GetMany, - Merge, - Update, - Delete -} - -public enum CrudApiAuthType -{ - None, - Entra, - ApiKey -} - -public sealed class CrudApiEntraAuth -{ - public string Audience { get; set; } = string.Empty; - public string Issuer { get; set; } = string.Empty; - public IEnumerable Roles { get; set; } = []; - public IEnumerable Scopes { get; set; } = []; - public bool ValidateLifetime { get; set; } - public bool ValidateSigningKey { get; set; } -} - -public sealed class CrudApiApiKeyAuth -{ - public string ApiKey { get; set; } = string.Empty; - public string? HeaderName { get; set; } - public string? QueryParameterName { get; set; } -} - -public sealed class CrudApiAction -{ - [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] - public CrudApiActionType Action { get; set; } = CrudApiActionType.GetAll; - [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] - public CrudApiAuthType Auth { get; set; } = CrudApiAuthType.None; - public CrudApiEntraAuth? EntraAuthConfig { get; set; } - public string? Method { get; set; } - public string Query { get; set; } = string.Empty; - public string Url { get; set; } = string.Empty; -} - -public sealed class CrudApiConfiguration -{ - public IEnumerable Actions { get; set; } = []; - public CrudApiApiKeyAuth? ApiKeyAuthConfig { get; set; } - public string ApiFile { get; set; } = "api.json"; - [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] - public CrudApiAuthType Auth { get; set; } = CrudApiAuthType.None; - public string BaseUrl { get; set; } = string.Empty; - public string DataFile { get; set; } = string.Empty; - [JsonPropertyName("enableCors")] - public bool EnableCORS { get; set; } = true; - public CrudApiEntraAuth? EntraAuthConfig { get; set; } -} - -public sealed class CrudApiPlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigurationSection) : - BasePlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigurationSection) -{ - private CrudApiDefinitionLoader? _definitionLoader; - private CrudApiDataLoader? _dataLoader; - private JArray? _data; - private OpenIdConnectConfiguration? _openIdConnectConfiguration; - - public override string Name => nameof(CrudApiPlugin); - - public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(e); - - await base.InitializeAsync(e, cancellationToken); - - Configuration.ApiFile = ProxyUtils.GetFullPath(Configuration.ApiFile, ProxyConfiguration.ConfigFile); - - _definitionLoader = ActivatorUtilities.CreateInstance(e.ServiceProvider, Configuration); - await _definitionLoader.InitFileWatcherAsync(cancellationToken); - - if (Configuration.Auth == CrudApiAuthType.Entra && - Configuration.EntraAuthConfig is null) - { - Logger.LogError("Entra auth is enabled but no configuration is provided. API will work anonymously."); - Configuration.Auth = CrudApiAuthType.None; - } - - if (Configuration.Auth == CrudApiAuthType.ApiKey && - Configuration.ApiKeyAuthConfig is null) - { - Logger.LogError("API Key auth is enabled but no configuration is provided. API will work anonymously."); - Configuration.Auth = CrudApiAuthType.None; - } - - if (Configuration.Auth == CrudApiAuthType.ApiKey && - Configuration.ApiKeyAuthConfig is not null && - string.IsNullOrEmpty(Configuration.ApiKeyAuthConfig.ApiKey)) - { - Logger.LogError("API Key auth is enabled but no API key is configured. API will work anonymously."); - Configuration.Auth = CrudApiAuthType.None; - } - - if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, Configuration.BaseUrl, true)) - { - Logger.LogWarning( - "The base URL of the API {BaseUrl} does not match any URL to watch. The {Plugin} plugin will be disabled. To enable it, add {Url}* to the list of URLs to watch and restart Dev Proxy.", - Configuration.BaseUrl, - Name, - Configuration.BaseUrl - ); - Enabled = false; - return; - } - - _dataLoader = ActivatorUtilities.CreateInstance( - e.ServiceProvider, - Configuration, - (Action)(data => _data = data) - ); - await _dataLoader.InitFileWatcherAsync(cancellationToken); - - await SetupOpenIdConnectConfigurationAsync(); - } - - public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); - - ArgumentNullException.ThrowIfNull(e); - - var request = e.Session.HttpClient.Request; - var state = e.ResponseState; - - if (!e.HasRequestUrlMatch(UrlsToWatch)) - { - Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); - return Task.CompletedTask; - } - if (e.ResponseState.HasBeenSet) - { - Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session)); - return Task.CompletedTask; - } - - if (IsCORSPreflightRequest(request) && Configuration.EnableCORS) - { - SendEmptyResponse(HttpStatusCode.NoContent, e.Session); - Logger.LogRequest("CORS preflight request", MessageType.Mocked, new LoggingContext(e.Session)); - return Task.CompletedTask; - } - - if (!AuthorizeRequest(e)) - { - SendUnauthorizedResponse(e.Session); - state.HasBeenSet = true; - return Task.CompletedTask; - } - - var actionAndParams = GetMatchingActionHandler(request); - if (actionAndParams is not null) - { - if (!AuthorizeRequest(e, actionAndParams.Value.action)) - { - SendUnauthorizedResponse(e.Session); - state.HasBeenSet = true; - return Task.CompletedTask; - } - - actionAndParams.Value.handler(e.Session, actionAndParams.Value.action, actionAndParams.Value.parameters); - state.HasBeenSet = true; - } - else - { - Logger.LogRequest("Did not match any action", MessageType.Skipped, new LoggingContext(e.Session)); - } - - Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); - return Task.CompletedTask; - } - - private async Task SetupOpenIdConnectConfigurationAsync() - { - try - { - var retriever = new OpenIdConnectConfigurationRetriever(); - var configurationManager = new ConfigurationManager("https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration", retriever); - _openIdConnectConfiguration = await configurationManager.GetConfigurationAsync(); - } - catch (Exception ex) - { - Logger.LogError(ex, "An error has occurred while loading OpenIdConnectConfiguration"); - } - } - - private (Action> handler, CrudApiAction action, IDictionary parameters)? GetMatchingActionHandler(Request request) - { - if (Configuration.Actions is null || - !Configuration.Actions.Any()) - { - return null; - } - - var parameterMatchEvaluator = new MatchEvaluator(m => - { - var paramName = m.Value.Trim('{', '}').Replace('-', '_'); - return $"(?<{paramName}>[^/&]+)"; - }); - - var requestUrlWithoutQuery = request.RequestUri.GetLeftPart(UriPartial.Path); - var parameters = new Dictionary(); - var action = Configuration.Actions.FirstOrDefault(action => - { - if (action.Method != request.Method) - { - return false; - } - - var absoluteActionUrl = (Configuration.BaseUrl + action.Url).Replace("//", "/", 8); - - if (absoluteActionUrl == requestUrlWithoutQuery) - { - return true; - } - - // check if the action contains parameters - // if it doesn't, it's not a match for the current request for sure - if (!absoluteActionUrl.Contains('{', StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // convert parameters into named regex groups - var urlRegex = Regex.Replace(Regex.Escape(absoluteActionUrl).Replace("\\{", "{", StringComparison.OrdinalIgnoreCase), "({[^}]+})", parameterMatchEvaluator); - var match = Regex.Match(requestUrlWithoutQuery, urlRegex); - if (!match.Success) - { - return false; - } - - foreach (var groupName in match.Groups.Keys) - { - if (groupName == "0") - { - continue; - } - parameters.Add(groupName, Uri.UnescapeDataString(match.Groups[groupName].Value)); - } - return true; - }); - - if (action is null) - { - return null; - } - - return (handler: action.Action switch - { - CrudApiActionType.Create => Create, - CrudApiActionType.GetAll => GetAll, - CrudApiActionType.GetOne => GetOne, - CrudApiActionType.GetMany => GetMany, - CrudApiActionType.Merge => Merge, - CrudApiActionType.Update => Update, - CrudApiActionType.Delete => Delete, - _ => throw new NotImplementedException() - }, action, parameters); - } - - private void AddCORSHeaders(Request request, List headers) - { - var origin = request.Headers.FirstOrDefault(h => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase))?.Value; - if (string.IsNullOrEmpty(origin)) - { - return; - } - - headers.Add(new HttpHeader("access-control-allow-origin", origin)); - - var allowHeaders = new List { "content-type" }; - - if (Configuration.EntraAuthConfig is not null || - Configuration.Actions.Any(a => a.Auth == CrudApiAuthType.Entra)) - { - allowHeaders.Add("authorization"); - } - - if (Configuration.ApiKeyAuthConfig is not null && - !string.IsNullOrEmpty(Configuration.ApiKeyAuthConfig.HeaderName)) - { - if (!allowHeaders.Contains(Configuration.ApiKeyAuthConfig.HeaderName, StringComparer.OrdinalIgnoreCase)) - { - allowHeaders.Add(Configuration.ApiKeyAuthConfig.HeaderName); - } - } - - headers.Add(new HttpHeader("access-control-allow-headers", string.Join(", ", allowHeaders))); - - var methods = string.Join(", ", Configuration.Actions - .Where(a => a.Method is not null) - .Select(a => a.Method) - .Distinct()); - - headers.Add(new HttpHeader("access-control-allow-methods", methods)); - } - - private bool AuthorizeRequest(ProxyRequestArgs e, CrudApiAction? action = null) - { - var authType = action is null ? Configuration.Auth : action.Auth; - - if (authType == CrudApiAuthType.None) - { - if (action is null) - { - Logger.LogDebug("No auth is required for this API."); - } - return true; - } - - if (authType == CrudApiAuthType.ApiKey) - { - return AuthorizeApiKeyRequest(e); - } - - return AuthorizeEntraRequest(e, action); - } - - private bool AuthorizeApiKeyRequest(ProxyRequestArgs e) - { - var apiKeyAuthConfig = Configuration.ApiKeyAuthConfig; - - Debug.Assert(apiKeyAuthConfig is not null, "ApiKeyAuthConfig is null when API key auth is required."); - - // Check header - if (!string.IsNullOrEmpty(apiKeyAuthConfig.HeaderName)) - { - var headerValue = e.Session.HttpClient.Request.Headers - .FirstOrDefault(h => h.Name.Equals(apiKeyAuthConfig.HeaderName, StringComparison.OrdinalIgnoreCase))?.Value; - - if (!string.IsNullOrEmpty(headerValue) && headerValue == apiKeyAuthConfig.ApiKey) - { - return true; - } - } - - // Check query parameter - if (!string.IsNullOrEmpty(apiKeyAuthConfig.QueryParameterName)) - { - var requestUrl = e.Session.HttpClient.Request.RequestUri; - var queryString = requestUrl.Query; - if (!string.IsNullOrEmpty(queryString)) - { - var queryParams = System.Web.HttpUtility.ParseQueryString(queryString); - var queryValue = queryParams[apiKeyAuthConfig.QueryParameterName]; - if (!string.IsNullOrEmpty(queryValue) && queryValue == apiKeyAuthConfig.ApiKey) - { - return true; - } - } - } - - Logger.LogRequest("401 Unauthorized. The specified API key is not valid.", MessageType.Failed, new LoggingContext(e.Session)); - return false; - } - - private bool AuthorizeEntraRequest(ProxyRequestArgs e, CrudApiAction? action = null) - { - var authConfig = action is null ? Configuration.EntraAuthConfig : action.EntraAuthConfig; - - Debug.Assert(authConfig is not null, "EntraAuthConfig is null when auth is required."); - - var token = e.Session.HttpClient.Request.Headers.FirstOrDefault(h => h.Name.Equals("Authorization", StringComparison.OrdinalIgnoreCase))?.Value; - // is there a token - if (string.IsNullOrEmpty(token)) - { - Logger.LogRequest("401 Unauthorized. No token found on the request.", MessageType.Failed, new LoggingContext(e.Session)); - return false; - } - - // does the token has a valid format - var tokenHeaderParts = token.Split(' '); - if (tokenHeaderParts.Length != 2 || tokenHeaderParts[0] != "Bearer") - { - Logger.LogRequest("401 Unauthorized. The specified token is not a valid Bearer token.", MessageType.Failed, new LoggingContext(e.Session)); - return false; - } - - var handler = new JwtSecurityTokenHandler(); - var validationParameters = new TokenValidationParameters - { - IssuerSigningKeys = _openIdConnectConfiguration?.SigningKeys, - ValidateIssuer = !string.IsNullOrEmpty(authConfig.Issuer), - ValidIssuer = authConfig.Issuer, - ValidateAudience = !string.IsNullOrEmpty(authConfig.Audience), - ValidAudience = authConfig.Audience, - ValidateLifetime = authConfig.ValidateLifetime, - ValidateIssuerSigningKey = authConfig.ValidateSigningKey - }; - if (!authConfig.ValidateSigningKey) - { - // suppress token validation - validationParameters.SignatureValidator = delegate (string token, TokenValidationParameters parameters) - { - var jwt = new JwtSecurityToken(token); - return jwt; - }; - } - - try - { - var claimsPrincipal = handler.ValidateToken(tokenHeaderParts[1], validationParameters, out _); - - // does the token has valid roles/scopes - if (authConfig.Roles.Any()) - { - var rolesFromTheToken = string.Join(' ', claimsPrincipal.Claims - .Where(c => c.Type == ClaimTypes.Role) - .Select(c => c.Value)); - - if (!authConfig.Roles.Any(r => HasPermission(r, rolesFromTheToken))) - { - var rolesRequired = string.Join(", ", authConfig.Roles); - - Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); - return false; - } - - return true; - } - if (authConfig.Scopes.Any()) - { - var scopesFromTheToken = string.Join(' ', claimsPrincipal.Claims - .Where(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope") - .Select(c => c.Value)); - - if (!authConfig.Scopes.Any(s => HasPermission(s, scopesFromTheToken))) - { - var scopesRequired = string.Join(", ", authConfig.Scopes); - - Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); - return false; - } - - return true; - } - } - catch (Exception ex) - { - Logger.LogRequest($"401 Unauthorized. The specified token is not valid: {ex.Message}", MessageType.Failed, new LoggingContext(e.Session)); - return false; - } - - return true; - } - - private void SendUnauthorizedResponse(SessionEventArgs e) - { - var body = new - { - error = new - { - message = "Unauthorized" - } - }; - SendJsonResponse(System.Text.Json.JsonSerializer.Serialize(body, ProxyUtils.JsonSerializerOptions), HttpStatusCode.Unauthorized, e); - } - - private void SendNotFoundResponse(SessionEventArgs e) - { - var body = new - { - error = new - { - message = "Not found" - } - }; - SendJsonResponse(System.Text.Json.JsonSerializer.Serialize(body, ProxyUtils.JsonSerializerOptions), HttpStatusCode.NotFound, e); - } - - private void SendEmptyResponse(HttpStatusCode statusCode, SessionEventArgs e) - { - var headers = new List(); - AddCORSHeaders(e.HttpClient.Request, headers); - e.GenericResponse("", statusCode, headers); - } - - private void SendJsonResponse(string body, HttpStatusCode statusCode, SessionEventArgs e) - { - var headers = new List { - new("content-type", "application/json; charset=utf-8") - }; - AddCORSHeaders(e.HttpClient.Request, headers); - e.GenericResponse(body, statusCode, headers); - } - - private void GetAll(SessionEventArgs e, CrudApiAction action, IDictionary parameters) - { - SendJsonResponse(JsonConvert.SerializeObject(_data, Formatting.Indented), HttpStatusCode.OK, e); - Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - } - - private void GetOne(SessionEventArgs e, CrudApiAction action, IDictionary parameters) - { - try - { - var item = _data?.SelectToken(ReplaceParams(action.Query, parameters)); - if (item is null) - { - SendNotFoundResponse(e); - Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - return; - } - - SendJsonResponse(JsonConvert.SerializeObject(item, Formatting.Indented), HttpStatusCode.OK, e); - Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - } - catch (Exception ex) - { - SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e); - Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e)); - } - } - - private void GetMany(SessionEventArgs e, CrudApiAction action, IDictionary parameters) - { - try - { - var items = (_data?.SelectTokens(ReplaceParams(action.Query, parameters))) ?? []; - SendJsonResponse(JsonConvert.SerializeObject(items, Formatting.Indented), HttpStatusCode.OK, e); - Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - } - catch (Exception ex) - { - SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e); - Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e)); - } - } - - private void Create(SessionEventArgs e, CrudApiAction action, IDictionary parameters) - { - try - { - var data = JObject.Parse(e.HttpClient.Request.BodyString); - _data?.Add(data); - SendJsonResponse(JsonConvert.SerializeObject(data, Formatting.Indented), HttpStatusCode.Created, e); - Logger.LogRequest($"201 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - } - catch (Exception ex) - { - SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e); - Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e)); - } - } - - private void Merge(SessionEventArgs e, CrudApiAction action, IDictionary parameters) - { - try - { - var item = _data?.SelectToken(ReplaceParams(action.Query, parameters)); - if (item is null) - { - SendNotFoundResponse(e); - Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - return; - } - var update = JObject.Parse(e.HttpClient.Request.BodyString); - ((JContainer)item).Merge(update); - SendEmptyResponse(HttpStatusCode.NoContent, e); - Logger.LogRequest($"204 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - } - catch (Exception ex) - { - SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e); - Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e)); - } - } - - private void Update(SessionEventArgs e, CrudApiAction action, IDictionary parameters) - { - try - { - var item = _data?.SelectToken(ReplaceParams(action.Query, parameters)); - if (item is null) - { - SendNotFoundResponse(e); - Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - return; - } - var update = JObject.Parse(e.HttpClient.Request.BodyString); - ((JContainer)item).Replace(update); - SendEmptyResponse(HttpStatusCode.NoContent, e); - Logger.LogRequest($"204 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - } - catch (Exception ex) - { - SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e); - Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e)); - } - } - - private void Delete(SessionEventArgs e, CrudApiAction action, IDictionary parameters) - { - try - { - var item = _data?.SelectToken(ReplaceParams(action.Query, parameters)); - if (item is null) - { - SendNotFoundResponse(e); - Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - return; - } - - item.Remove(); - SendEmptyResponse(HttpStatusCode.NoContent, e); - Logger.LogRequest($"204 {action.Url}", MessageType.Mocked, new LoggingContext(e)); - } - catch (Exception ex) - { - SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e); - Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e)); - } - } - - private static bool IsCORSPreflightRequest(Request request) - { - return request.Method == "OPTIONS" && - request.Headers.Any(h => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)); - } - - private static bool HasPermission(string permission, string permissionString) - { - if (string.IsNullOrEmpty(permissionString)) - { - return false; - } - - var permissions = permissionString.Split(' '); - return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase); - } - - private static string ReplaceParams(string query, IDictionary parameters) - { - var result = Regex.Replace(query, "{([^}]+)}", new MatchEvaluator(m => - { - return $"{{{m.Groups[1].Value.Replace('-', '_')}}}"; - })); - foreach (var param in parameters) - { - result = result.Replace($"{{{param.Key}}}", param.Value, StringComparison.OrdinalIgnoreCase); - } - return result; - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _dataLoader?.Dispose(); - _definitionLoader?.Dispose(); - } - base.Dispose(disposing); - } -} +// 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.Abstractions.Proxy.Http; +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.IdentityModel.Tokens.Jwt; +using System.Diagnostics; +using System.Net; +using System.Security.Claims; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace DevProxy.Plugins.Mocking; + +public enum CrudApiActionType +{ + Create, + GetAll, + GetOne, + GetMany, + Merge, + Update, + Delete +} + +public enum CrudApiAuthType +{ + None, + Entra, + ApiKey +} + +public sealed class CrudApiEntraAuth +{ + public string Audience { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public IEnumerable Roles { get; set; } = []; + public IEnumerable Scopes { get; set; } = []; + public bool ValidateLifetime { get; set; } + public bool ValidateSigningKey { get; set; } +} + +public sealed class CrudApiApiKeyAuth +{ + public string ApiKey { get; set; } = string.Empty; + public string? HeaderName { get; set; } + public string? QueryParameterName { get; set; } +} + +public sealed class CrudApiAction +{ + [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] + public CrudApiActionType Action { get; set; } = CrudApiActionType.GetAll; + [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] + public CrudApiAuthType Auth { get; set; } = CrudApiAuthType.None; + public CrudApiEntraAuth? EntraAuthConfig { get; set; } + public string? Method { get; set; } + public string Query { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; +} + +public sealed class CrudApiConfiguration +{ + public IEnumerable Actions { get; set; } = []; + public CrudApiApiKeyAuth? ApiKeyAuthConfig { get; set; } + public string ApiFile { get; set; } = "api.json"; + [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] + public CrudApiAuthType Auth { get; set; } = CrudApiAuthType.None; + public string BaseUrl { get; set; } = string.Empty; + public string DataFile { get; set; } = string.Empty; + [JsonPropertyName("enableCors")] + public bool EnableCORS { get; set; } = true; + public CrudApiEntraAuth? EntraAuthConfig { get; set; } +} + +public sealed class CrudApiPlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + BasePlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + private CrudApiDefinitionLoader? _definitionLoader; + private CrudApiDataLoader? _dataLoader; + private JArray? _data; + private OpenIdConnectConfiguration? _openIdConnectConfiguration; + + public override string Name => nameof(CrudApiPlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(e); + + await base.InitializeAsync(e, cancellationToken); + + Configuration.ApiFile = ProxyUtils.GetFullPath(Configuration.ApiFile, ProxyConfiguration.ConfigFile); + + _definitionLoader = ActivatorUtilities.CreateInstance(e.ServiceProvider, Configuration); + await _definitionLoader.InitFileWatcherAsync(cancellationToken); + + if (Configuration.Auth == CrudApiAuthType.Entra && + Configuration.EntraAuthConfig is null) + { + Logger.LogError("Entra auth is enabled but no configuration is provided. API will work anonymously."); + Configuration.Auth = CrudApiAuthType.None; + } + + if (Configuration.Auth == CrudApiAuthType.ApiKey && + Configuration.ApiKeyAuthConfig is null) + { + Logger.LogError("API Key auth is enabled but no configuration is provided. API will work anonymously."); + Configuration.Auth = CrudApiAuthType.None; + } + + if (Configuration.Auth == CrudApiAuthType.ApiKey && + Configuration.ApiKeyAuthConfig is not null && + string.IsNullOrEmpty(Configuration.ApiKeyAuthConfig.ApiKey)) + { + Logger.LogError("API Key auth is enabled but no API key is configured. API will work anonymously."); + Configuration.Auth = CrudApiAuthType.None; + } + + if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, Configuration.BaseUrl, true)) + { + Logger.LogWarning( + "The base URL of the API {BaseUrl} does not match any URL to watch. The {Plugin} plugin will be disabled. To enable it, add {Url}* to the list of URLs to watch and restart Dev Proxy.", + Configuration.BaseUrl, + Name, + Configuration.BaseUrl + ); + Enabled = false; + return; + } + + _dataLoader = ActivatorUtilities.CreateInstance( + e.ServiceProvider, + Configuration, + (Action)(data => _data = data) + ); + await _dataLoader.InitFileWatcherAsync(cancellationToken); + + await SetupOpenIdConnectConfigurationAsync(); + } + + public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); + + ArgumentNullException.ThrowIfNull(e); + + var request = e.ProxySession.Request; + var state = e.ResponseState; + + if (!e.HasRequestUrlMatch(UrlsToWatch)) + { + Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + if (e.ResponseState.HasBeenSet) + { + Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + + if (IsCORSPreflightRequest(request) && Configuration.EnableCORS) + { + SendEmptyResponse(HttpStatusCode.NoContent, e.ProxySession); + Logger.LogRequest("CORS preflight request", MessageType.Mocked, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + + if (!AuthorizeRequest(e)) + { + SendUnauthorizedResponse(e.ProxySession); + state.HasBeenSet = true; + return Task.CompletedTask; + } + + var actionAndParams = GetMatchingActionHandler(request); + if (actionAndParams is not null) + { + if (!AuthorizeRequest(e, actionAndParams.Value.action)) + { + SendUnauthorizedResponse(e.ProxySession); + state.HasBeenSet = true; + return Task.CompletedTask; + } + + actionAndParams.Value.handler(e, actionAndParams.Value.action, actionAndParams.Value.parameters); + state.HasBeenSet = true; + } + else + { + Logger.LogRequest("Did not match any action", MessageType.Skipped, new LoggingContext(e.Session)); + } + + Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); + return Task.CompletedTask; + } + + private async Task SetupOpenIdConnectConfigurationAsync() + { + try + { + var retriever = new OpenIdConnectConfigurationRetriever(); + var configurationManager = new ConfigurationManager("https://login.microsoftonline.com/organizations/v2.0/.well-known/openid-configuration", retriever); + _openIdConnectConfiguration = await configurationManager.GetConfigurationAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "An error has occurred while loading OpenIdConnectConfiguration"); + } + } + + private (Action> handler, CrudApiAction action, IDictionary parameters)? GetMatchingActionHandler(IHttpRequest request) + { + if (Configuration.Actions is null || + !Configuration.Actions.Any()) + { + return null; + } + + var parameterMatchEvaluator = new MatchEvaluator(m => + { + var paramName = m.Value.Trim('{', '}').Replace('-', '_'); + return $"(?<{paramName}>[^/&]+)"; + }); + + var requestUrlWithoutQuery = request.RequestUri.GetLeftPart(UriPartial.Path); + var parameters = new Dictionary(); + var action = Configuration.Actions.FirstOrDefault(action => + { + if (action.Method != request.Method) + { + return false; + } + + var absoluteActionUrl = (Configuration.BaseUrl + action.Url).Replace("//", "/", 8); + + if (absoluteActionUrl == requestUrlWithoutQuery) + { + return true; + } + + // check if the action contains parameters + // if it doesn't, it's not a match for the current request for sure + if (!absoluteActionUrl.Contains('{', StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // convert parameters into named regex groups + var urlRegex = Regex.Replace(Regex.Escape(absoluteActionUrl).Replace("\\{", "{", StringComparison.OrdinalIgnoreCase), "({[^}]+})", parameterMatchEvaluator); + var match = Regex.Match(requestUrlWithoutQuery, urlRegex); + if (!match.Success) + { + return false; + } + + foreach (var groupName in match.Groups.Keys) + { + if (groupName == "0") + { + continue; + } + parameters.Add(groupName, Uri.UnescapeDataString(match.Groups[groupName].Value)); + } + return true; + }); + + if (action is null) + { + return null; + } + + return (handler: action.Action switch + { + CrudApiActionType.Create => Create, + CrudApiActionType.GetAll => GetAll, + CrudApiActionType.GetOne => GetOne, + CrudApiActionType.GetMany => GetMany, + CrudApiActionType.Merge => Merge, + CrudApiActionType.Update => Update, + CrudApiActionType.Delete => Delete, + _ => throw new NotImplementedException() + }, action, parameters); + } + + private void AddCORSHeaders(IHttpRequest request, List headers) + { + var origin = request.Headers.FirstOrDefault(h => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase))?.Value; + if (string.IsNullOrEmpty(origin)) + { + return; + } + + headers.Add(new HttpHeader("access-control-allow-origin", origin)); + + var allowHeaders = new List { "content-type" }; + + if (Configuration.EntraAuthConfig is not null || + Configuration.Actions.Any(a => a.Auth == CrudApiAuthType.Entra)) + { + allowHeaders.Add("authorization"); + } + + if (Configuration.ApiKeyAuthConfig is not null && + !string.IsNullOrEmpty(Configuration.ApiKeyAuthConfig.HeaderName)) + { + if (!allowHeaders.Contains(Configuration.ApiKeyAuthConfig.HeaderName, StringComparer.OrdinalIgnoreCase)) + { + allowHeaders.Add(Configuration.ApiKeyAuthConfig.HeaderName); + } + } + + headers.Add(new HttpHeader("access-control-allow-headers", string.Join(", ", allowHeaders))); + + var methods = string.Join(", ", Configuration.Actions + .Where(a => a.Method is not null) + .Select(a => a.Method) + .Distinct()); + + headers.Add(new HttpHeader("access-control-allow-methods", methods)); + } + + private bool AuthorizeRequest(ProxyRequestArgs e, CrudApiAction? action = null) + { + var authType = action is null ? Configuration.Auth : action.Auth; + + if (authType == CrudApiAuthType.None) + { + if (action is null) + { + Logger.LogDebug("No auth is required for this API."); + } + return true; + } + + if (authType == CrudApiAuthType.ApiKey) + { + return AuthorizeApiKeyRequest(e); + } + + return AuthorizeEntraRequest(e, action); + } + + private bool AuthorizeApiKeyRequest(ProxyRequestArgs e) + { + var apiKeyAuthConfig = Configuration.ApiKeyAuthConfig; + + Debug.Assert(apiKeyAuthConfig is not null, "ApiKeyAuthConfig is null when API key auth is required."); + + // Check header + if (!string.IsNullOrEmpty(apiKeyAuthConfig.HeaderName)) + { + var headerValue = e.ProxySession.Request.Headers + .FirstOrDefault(h => h.Name.Equals(apiKeyAuthConfig.HeaderName, StringComparison.OrdinalIgnoreCase))?.Value; + + if (!string.IsNullOrEmpty(headerValue) && headerValue == apiKeyAuthConfig.ApiKey) + { + return true; + } + } + + // Check query parameter + if (!string.IsNullOrEmpty(apiKeyAuthConfig.QueryParameterName)) + { + var requestUrl = e.ProxySession.Request.RequestUri; + var queryString = requestUrl.Query; + if (!string.IsNullOrEmpty(queryString)) + { + var queryParams = System.Web.HttpUtility.ParseQueryString(queryString); + var queryValue = queryParams[apiKeyAuthConfig.QueryParameterName]; + if (!string.IsNullOrEmpty(queryValue) && queryValue == apiKeyAuthConfig.ApiKey) + { + return true; + } + } + } + + Logger.LogRequest("401 Unauthorized. The specified API key is not valid.", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + private bool AuthorizeEntraRequest(ProxyRequestArgs e, CrudApiAction? action = null) + { + var authConfig = action is null ? Configuration.EntraAuthConfig : action.EntraAuthConfig; + + Debug.Assert(authConfig is not null, "EntraAuthConfig is null when auth is required."); + + var token = e.ProxySession.Request.Headers.FirstOrDefault(h => h.Name.Equals("Authorization", StringComparison.OrdinalIgnoreCase))?.Value; + // is there a token + if (string.IsNullOrEmpty(token)) + { + Logger.LogRequest("401 Unauthorized. No token found on the request.", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + // does the token has a valid format + var tokenHeaderParts = token.Split(' '); + if (tokenHeaderParts.Length != 2 || tokenHeaderParts[0] != "Bearer") + { + Logger.LogRequest("401 Unauthorized. The specified token is not a valid Bearer token.", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + var handler = new JwtSecurityTokenHandler(); + var validationParameters = new TokenValidationParameters + { + IssuerSigningKeys = _openIdConnectConfiguration?.SigningKeys, + ValidateIssuer = !string.IsNullOrEmpty(authConfig.Issuer), + ValidIssuer = authConfig.Issuer, + ValidateAudience = !string.IsNullOrEmpty(authConfig.Audience), + ValidAudience = authConfig.Audience, + ValidateLifetime = authConfig.ValidateLifetime, + ValidateIssuerSigningKey = authConfig.ValidateSigningKey + }; + if (!authConfig.ValidateSigningKey) + { + // suppress token validation + validationParameters.SignatureValidator = delegate (string token, TokenValidationParameters parameters) + { + var jwt = new JwtSecurityToken(token); + return jwt; + }; + } + + try + { + var claimsPrincipal = handler.ValidateToken(tokenHeaderParts[1], validationParameters, out _); + + // does the token has valid roles/scopes + if (authConfig.Roles.Any()) + { + var rolesFromTheToken = string.Join(' ', claimsPrincipal.Claims + .Where(c => c.Type == ClaimTypes.Role) + .Select(c => c.Value)); + + if (!authConfig.Roles.Any(r => HasPermission(r, rolesFromTheToken))) + { + var rolesRequired = string.Join(", ", authConfig.Roles); + + Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + return true; + } + if (authConfig.Scopes.Any()) + { + var scopesFromTheToken = string.Join(' ', claimsPrincipal.Claims + .Where(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope") + .Select(c => c.Value)); + + if (!authConfig.Scopes.Any(s => HasPermission(s, scopesFromTheToken))) + { + var scopesRequired = string.Join(", ", authConfig.Scopes); + + Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + return true; + } + } + catch (Exception ex) + { + Logger.LogRequest($"401 Unauthorized. The specified token is not valid: {ex.Message}", MessageType.Failed, new LoggingContext(e.Session)); + return false; + } + + return true; + } + + private void SendUnauthorizedResponse(IProxySession session) + { + var body = new + { + error = new + { + message = "Unauthorized" + } + }; + SendJsonResponse(System.Text.Json.JsonSerializer.Serialize(body, ProxyUtils.JsonSerializerOptions), HttpStatusCode.Unauthorized, session); + } + + private void SendNotFoundResponse(IProxySession session) + { + var body = new + { + error = new + { + message = "Not found" + } + }; + SendJsonResponse(System.Text.Json.JsonSerializer.Serialize(body, ProxyUtils.JsonSerializerOptions), HttpStatusCode.NotFound, session); + } + + private void SendEmptyResponse(HttpStatusCode statusCode, IProxySession session) + { + var headers = new List(); + AddCORSHeaders(session.Request, headers); + session.Respond("", statusCode, headers); + } + + private void SendJsonResponse(string body, HttpStatusCode statusCode, IProxySession session) + { + var headers = new List { + new("content-type", "application/json; charset=utf-8") + }; + AddCORSHeaders(session.Request, headers); + session.Respond(body, statusCode, headers); + } + + private void GetAll(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) + { + SendJsonResponse(JsonConvert.SerializeObject(_data, Formatting.Indented), HttpStatusCode.OK, e.ProxySession); + Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + + private void GetOne(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) + { + try + { + var item = _data?.SelectToken(ReplaceParams(action.Query, parameters)); + if (item is null) + { + SendNotFoundResponse(e.ProxySession); + Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + return; + } + + SendJsonResponse(JsonConvert.SerializeObject(item, Formatting.Indented), HttpStatusCode.OK, e.ProxySession); + Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + catch (Exception ex) + { + SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e.ProxySession); + Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e.Session)); + } + } + + private void GetMany(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) + { + try + { + var items = (_data?.SelectTokens(ReplaceParams(action.Query, parameters))) ?? []; + SendJsonResponse(JsonConvert.SerializeObject(items, Formatting.Indented), HttpStatusCode.OK, e.ProxySession); + Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + catch (Exception ex) + { + SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e.ProxySession); + Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e.Session)); + } + } + + private void Create(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) + { + try + { + var data = JObject.Parse(e.ProxySession.Request.BodyString); + _data?.Add(data); + SendJsonResponse(JsonConvert.SerializeObject(data, Formatting.Indented), HttpStatusCode.Created, e.ProxySession); + Logger.LogRequest($"201 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + catch (Exception ex) + { + SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e.ProxySession); + Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e.Session)); + } + } + + private void Merge(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) + { + try + { + var item = _data?.SelectToken(ReplaceParams(action.Query, parameters)); + if (item is null) + { + SendNotFoundResponse(e.ProxySession); + Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + return; + } + var update = JObject.Parse(e.ProxySession.Request.BodyString); + ((JContainer)item).Merge(update); + SendEmptyResponse(HttpStatusCode.NoContent, e.ProxySession); + Logger.LogRequest($"204 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + catch (Exception ex) + { + SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e.ProxySession); + Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e.Session)); + } + } + + private void Update(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) + { + try + { + var item = _data?.SelectToken(ReplaceParams(action.Query, parameters)); + if (item is null) + { + SendNotFoundResponse(e.ProxySession); + Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + return; + } + var update = JObject.Parse(e.ProxySession.Request.BodyString); + ((JContainer)item).Replace(update); + SendEmptyResponse(HttpStatusCode.NoContent, e.ProxySession); + Logger.LogRequest($"204 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + catch (Exception ex) + { + SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e.ProxySession); + Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e.Session)); + } + } + + private void Delete(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) + { + try + { + var item = _data?.SelectToken(ReplaceParams(action.Query, parameters)); + if (item is null) + { + SendNotFoundResponse(e.ProxySession); + Logger.LogRequest($"404 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + return; + } + + item.Remove(); + SendEmptyResponse(HttpStatusCode.NoContent, e.ProxySession); + Logger.LogRequest($"204 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + catch (Exception ex) + { + SendJsonResponse(JsonConvert.SerializeObject(ex, Formatting.Indented), HttpStatusCode.InternalServerError, e.ProxySession); + Logger.LogRequest($"500 {action.Url}", MessageType.Failed, new LoggingContext(e.Session)); + } + } + + private static bool IsCORSPreflightRequest(IHttpRequest request) + { + return request.Method == "OPTIONS" && + request.Headers.Any(h => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasPermission(string permission, string permissionString) + { + if (string.IsNullOrEmpty(permissionString)) + { + return false; + } + + var permissions = permissionString.Split(' '); + return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase); + } + + private static string ReplaceParams(string query, IDictionary parameters) + { + var result = Regex.Replace(query, "{([^}]+)}", new MatchEvaluator(m => + { + return $"{{{m.Groups[1].Value.Replace('-', '_')}}}"; + })); + foreach (var param in parameters) + { + result = result.Replace($"{{{param.Key}}}", param.Value, StringComparison.OrdinalIgnoreCase); + } + return result; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _dataLoader?.Dispose(); + _definitionLoader?.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/DevProxy.Plugins/Mocking/GraphMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/GraphMockResponsePlugin.cs index ffe92fd8..b03ec392 100644 --- a/DevProxy.Plugins/Mocking/GraphMockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/GraphMockResponsePlugin.cs @@ -1,211 +1,211 @@ -// 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 DevProxy.Abstractions.Utils; -using DevProxy.Plugins.Behavior; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using System.Globalization; -using System.Net; -using System.Text.Json; -using System.Text.RegularExpressions; -using Titanium.Web.Proxy.Models; - -namespace DevProxy.Plugins.Mocking; - -public class GraphMockResponsePlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigurationSection) : - MockResponsePlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigurationSection) -{ - public override string Name => nameof(GraphMockResponsePlugin); - - public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); - - ArgumentNullException.ThrowIfNull(e); - - if (Configuration.NoMocks) - { - Logger.LogRequest("Mocks are disabled", MessageType.Skipped, new LoggingContext(e.Session)); - return; - } - - if (!ProxyUtils.IsGraphBatchUrl(e.Session.HttpClient.Request.RequestUri)) - { - // not a batch request, use the basic mock functionality - await base.BeforeRequestAsync(e, cancellationToken); - return; - } - - var batch = JsonSerializer.Deserialize(e.Session.HttpClient.Request.BodyString, ProxyUtils.JsonSerializerOptions); - if (batch is null) - { - await base.BeforeRequestAsync(e, cancellationToken); - return; - } - - var responses = new List(); - foreach (var request in batch.Requests) - { - GraphBatchResponsePayloadResponse? response = null; - var requestId = Guid.NewGuid().ToString(); - var requestDate = DateTime.Now.ToString("r", CultureInfo.InvariantCulture); - var headers = ProxyUtils - .BuildGraphResponseHeaders(e.Session.HttpClient.Request, requestId, requestDate); - - if (e.SessionData.TryGetValue(nameof(RateLimitingPlugin), out var pluginData) && - pluginData is List rateLimitingHeaders) - { - ProxyUtils.MergeHeaders(headers, rateLimitingHeaders); - } - - var mockResponse = GetMatchingMockResponse(request, e.Session.HttpClient.Request.RequestUri); - if (mockResponse == null) - { - response = new() - { - Id = request.Id, - Status = (int)HttpStatusCode.BadGateway, - Headers = headers.ToDictionary(h => h.Name, h => h.Value), - Body = new GraphBatchResponsePayloadResponseBody - { - Error = new() - { - Code = "BadGateway", - Message = "No mock response found for this request" - } - } - }; - - Logger.LogRequest($"502 {request.Url}", MessageType.Mocked, new LoggingContext(e.Session)); - } - else - { - dynamic? body = null; - var statusCode = HttpStatusCode.OK; - if (mockResponse.Response?.StatusCode is not null) - { - statusCode = (HttpStatusCode)mockResponse.Response.StatusCode; - } - - if (mockResponse.Response?.Headers is not null) - { - ProxyUtils.MergeHeaders(headers, [.. mockResponse.Response.Headers]); - } - - // default the content type to application/json unless set in the mock response - if (!headers.Any(h => h.Name.Equals("content-type", StringComparison.OrdinalIgnoreCase))) - { - headers.Add(new("content-type", "application/json")); - } - - if (mockResponse.Response?.Body is not null) - { - var bodyString = JsonSerializer.Serialize(mockResponse.Response.Body, ProxyUtils.JsonSerializerOptions) as string; - // we get a JSON string so need to start with the opening quote - if (bodyString?.StartsWith("\"@", StringComparison.OrdinalIgnoreCase) ?? false) - { - // we've got a mock body starting with @-token which means we're sending - // a response from a file on disk - // if we can read the file, we can immediately send the response and - // skip the rest of the logic in this method - // remove the surrounding quotes and the @-token - var filePath = Path.Combine(Path.GetDirectoryName(Configuration.MocksFile) ?? "", ProxyUtils.ReplacePathTokens(bodyString.Trim('"')[1..])); - if (!File.Exists(filePath)) - { - Logger.LogError("File {FilePath} not found. Serving file path in the mock response", filePath); - body = bodyString; - } - else - { - var bodyBytes = await File.ReadAllBytesAsync(filePath, cancellationToken); - body = Convert.ToBase64String(bodyBytes); - } - } - else - { - body = mockResponse.Response.Body; - } - } - response = new() - { - Id = request.Id, - Status = (int)statusCode, - Headers = headers.ToDictionary(h => h.Name, h => h.Value), - Body = body - }; - - Logger.LogRequest($"{mockResponse.Response?.StatusCode ?? 200} {mockResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); - } - - responses.Add(response); - } - - var batchRequestId = Guid.NewGuid().ToString(); - var batchRequestDate = DateTime.Now.ToString("r", CultureInfo.InvariantCulture); - var batchHeaders = ProxyUtils.BuildGraphResponseHeaders(e.Session.HttpClient.Request, batchRequestId, batchRequestDate); - var batchResponse = new GraphBatchResponsePayload - { - Responses = [.. responses] - }; - var batchResponseString = JsonSerializer.Serialize(batchResponse, ProxyUtils.JsonSerializerOptions); - ProcessMockResponse(ref batchResponseString, batchHeaders, e, null); - e.Session.GenericResponse(batchResponseString ?? string.Empty, HttpStatusCode.OK, batchHeaders.Select(h => new HttpHeader(h.Name, h.Value))); - Logger.LogRequest($"200 {e.Session.HttpClient.Request.RequestUri}", MessageType.Mocked, new LoggingContext(e.Session)); - e.ResponseState.HasBeenSet = true; - - Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); - } - - protected MockResponse? GetMatchingMockResponse(GraphBatchRequestPayloadRequest request, Uri batchRequestUri) - { - if (Configuration.NoMocks || - Configuration.Mocks is null || - !Configuration.Mocks.Any()) - { - return null; - } - - var mockResponse = Configuration.Mocks.FirstOrDefault(mockResponse => - { - if (mockResponse.Request?.Method != request.Method) - { - return false; - } - // URLs in batch are relative to Graph version number so we need - // to make them absolute using the batch request URL - var absoluteRequestFromBatchUrl = ProxyUtils - .GetAbsoluteRequestUrlFromBatch(batchRequestUri, request.Url) - .ToString(); - if (mockResponse.Request.Url == absoluteRequestFromBatchUrl) - { - return true; - } - - // check if the URL contains a wildcard - // if it doesn't, it's not a match for the current request for sure - if (!mockResponse.Request.Url.Contains('*', StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - //turn mock URL with wildcard into a regex and match against the request URL - var mockResponseUrlRegex = Regex.Escape(mockResponse.Request.Url).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase); - return Regex.IsMatch(absoluteRequestFromBatchUrl, $"^{mockResponseUrlRegex}$"); - }); - return mockResponse; - } +// 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 DevProxy.Abstractions.Proxy.Http; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Behavior; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace DevProxy.Plugins.Mocking; + +public class GraphMockResponsePlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + MockResponsePlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + public override string Name => nameof(GraphMockResponsePlugin); + + public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); + + ArgumentNullException.ThrowIfNull(e); + + if (Configuration.NoMocks) + { + Logger.LogRequest("Mocks are disabled", MessageType.Skipped, new LoggingContext(e.Session)); + return; + } + + if (!ProxyUtils.IsGraphBatchUrl(e.ProxySession.Request.RequestUri)) + { + // not a batch request, use the basic mock functionality + await base.BeforeRequestAsync(e, cancellationToken); + return; + } + + var batch = JsonSerializer.Deserialize(e.ProxySession.Request.BodyString, ProxyUtils.JsonSerializerOptions); + if (batch is null) + { + await base.BeforeRequestAsync(e, cancellationToken); + return; + } + + var responses = new List(); + foreach (var request in batch.Requests) + { + GraphBatchResponsePayloadResponse? response = null; + var requestId = Guid.NewGuid().ToString(); + var requestDate = DateTime.Now.ToString("r", CultureInfo.InvariantCulture); + var headers = ProxyUtils + .BuildGraphResponseHeaders(e.ProxySession.Request, requestId, requestDate); + + if (e.SessionData.TryGetValue(nameof(RateLimitingPlugin), out var pluginData) && + pluginData is List rateLimitingHeaders) + { + ProxyUtils.MergeHeaders(headers, rateLimitingHeaders); + } + + var mockResponse = GetMatchingMockResponse(request, e.ProxySession.Request.RequestUri); + if (mockResponse == null) + { + response = new() + { + Id = request.Id, + Status = (int)HttpStatusCode.BadGateway, + Headers = headers.ToDictionary(h => h.Name, h => h.Value), + Body = new GraphBatchResponsePayloadResponseBody + { + Error = new() + { + Code = "BadGateway", + Message = "No mock response found for this request" + } + } + }; + + Logger.LogRequest($"502 {request.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + else + { + dynamic? body = null; + var statusCode = HttpStatusCode.OK; + if (mockResponse.Response?.StatusCode is not null) + { + statusCode = (HttpStatusCode)mockResponse.Response.StatusCode; + } + + if (mockResponse.Response?.Headers is not null) + { + ProxyUtils.MergeHeaders(headers, [.. mockResponse.Response.Headers]); + } + + // default the content type to application/json unless set in the mock response + if (!headers.Any(h => h.Name.Equals("content-type", StringComparison.OrdinalIgnoreCase))) + { + headers.Add(new("content-type", "application/json")); + } + + if (mockResponse.Response?.Body is not null) + { + var bodyString = JsonSerializer.Serialize(mockResponse.Response.Body, ProxyUtils.JsonSerializerOptions) as string; + // we get a JSON string so need to start with the opening quote + if (bodyString?.StartsWith("\"@", StringComparison.OrdinalIgnoreCase) ?? false) + { + // we've got a mock body starting with @-token which means we're sending + // a response from a file on disk + // if we can read the file, we can immediately send the response and + // skip the rest of the logic in this method + // remove the surrounding quotes and the @-token + var filePath = Path.Combine(Path.GetDirectoryName(Configuration.MocksFile) ?? "", ProxyUtils.ReplacePathTokens(bodyString.Trim('"')[1..])); + if (!File.Exists(filePath)) + { + Logger.LogError("File {FilePath} not found. Serving file path in the mock response", filePath); + body = bodyString; + } + else + { + var bodyBytes = await File.ReadAllBytesAsync(filePath, cancellationToken); + body = Convert.ToBase64String(bodyBytes); + } + } + else + { + body = mockResponse.Response.Body; + } + } + response = new() + { + Id = request.Id, + Status = (int)statusCode, + Headers = headers.ToDictionary(h => h.Name, h => h.Value), + Body = body + }; + + Logger.LogRequest($"{mockResponse.Response?.StatusCode ?? 200} {mockResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + + responses.Add(response); + } + + var batchRequestId = Guid.NewGuid().ToString(); + var batchRequestDate = DateTime.Now.ToString("r", CultureInfo.InvariantCulture); + var batchHeaders = ProxyUtils.BuildGraphResponseHeaders(e.ProxySession.Request, batchRequestId, batchRequestDate); + var batchResponse = new GraphBatchResponsePayload + { + Responses = [.. responses] + }; + var batchResponseString = JsonSerializer.Serialize(batchResponse, ProxyUtils.JsonSerializerOptions); + ProcessMockResponse(ref batchResponseString, batchHeaders, e, null); + e.ProxySession.Respond(batchResponseString ?? string.Empty, HttpStatusCode.OK, batchHeaders.Select(h => new HttpHeader(h.Name, h.Value))); + Logger.LogRequest($"200 {e.ProxySession.Request.RequestUri}", MessageType.Mocked, new LoggingContext(e.Session)); + e.ResponseState.HasBeenSet = true; + + Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); + } + + protected MockResponse? GetMatchingMockResponse(GraphBatchRequestPayloadRequest request, Uri batchRequestUri) + { + if (Configuration.NoMocks || + Configuration.Mocks is null || + !Configuration.Mocks.Any()) + { + return null; + } + + var mockResponse = Configuration.Mocks.FirstOrDefault(mockResponse => + { + if (mockResponse.Request?.Method != request.Method) + { + return false; + } + // URLs in batch are relative to Graph version number so we need + // to make them absolute using the batch request URL + var absoluteRequestFromBatchUrl = ProxyUtils + .GetAbsoluteRequestUrlFromBatch(batchRequestUri, request.Url) + .ToString(); + if (mockResponse.Request.Url == absoluteRequestFromBatchUrl) + { + return true; + } + + // check if the URL contains a wildcard + // if it doesn't, it's not a match for the current request for sure + if (!mockResponse.Request.Url.Contains('*', StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + //turn mock URL with wildcard into a regex and match against the request URL + var mockResponseUrlRegex = Regex.Escape(mockResponse.Request.Url).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase); + return Regex.IsMatch(absoluteRequestFromBatchUrl, $"^{mockResponseUrlRegex}$"); + }); + return mockResponse; + } } \ No newline at end of file diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index 7f569c7a..a34959e6 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -1,755 +1,754 @@ -// 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.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using DevProxy.Plugins.Behavior; -using DevProxy.Plugins.Models; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileSystemGlobbing; -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using System.CommandLine; -using System.CommandLine.Parsing; -using System.Globalization; -using System.Net; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Titanium.Web.Proxy.Http; -using Titanium.Web.Proxy.Models; - -namespace DevProxy.Plugins.Mocking; - -public sealed class MockResponseConfiguration -{ - [JsonIgnore] - public bool BlockUnmockedRequests { get; set; } - public IEnumerable Mocks { get; set; } = []; - [JsonIgnore] - public string MocksFile { get; set; } = "mocks.json"; - [JsonIgnore] - public bool NoMocks { get; set; } - [JsonPropertyName("$schema")] - public string Schema { get; set; } = "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/mockresponseplugin.mocksfile.schema.json"; -} - -public class MockResponsePlugin( - HttpClient httpClient, - ILogger logger, - ISet urlsToWatch, - IProxyConfiguration proxyConfiguration, - IConfigurationSection pluginConfigurationSection) : - BasePlugin( - httpClient, - logger, - urlsToWatch, - proxyConfiguration, - pluginConfigurationSection) -{ - private const string _noMocksOptionName = "--no-mocks"; - private const string _mocksFileOptionName = "--mocks-file"; - - private readonly IProxyConfiguration _proxyConfiguration = proxyConfiguration; - // tracks the number of times a mock has been applied - // used in combination with mocks that have an Nth property - private readonly ConcurrentDictionary _appliedMocks = []; - - private MockResponsesLoader? _loader; - private Argument>? _httpResponseFilesArgument; - private Option? _httpResponseMocksFileNameOption; - - public override string Name => nameof(MockResponsePlugin); - - public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(e); - - await base.InitializeAsync(e, cancellationToken); - - _loader = ActivatorUtilities.CreateInstance(e.ServiceProvider, Configuration); - } - - public override Option[] GetOptions() - { - var _noMocks = new Option(_noMocksOptionName, "-n") - { - Description = "Disable loading mock requests", - HelpName = "no-mocks" - }; - - var _mocksFile = new Option(_mocksFileOptionName) - { - Description = "Provide a file populated with mock responses", - HelpName = "mocks-file" - }; - - return [_noMocks, _mocksFile]; - } - - public override Command[] GetCommands() - { - var mocksCommand = new Command("mocks", "Manage mock responses"); - var mocksFromHttpResponseCommand = new Command("from-http-responses", "Create a mock response from HTTP responses"); - _httpResponseFilesArgument = new Argument>("http-response-files") - { - Arity = ArgumentArity.OneOrMore, - Description = "Glob pattern to the file(s) containing HTTP responses to create mock responses from", - }; - mocksFromHttpResponseCommand.Add(_httpResponseFilesArgument); - _httpResponseMocksFileNameOption = new Option("--mocks-file") - { - HelpName = "mocks file", - Arity = ArgumentArity.ExactlyOne, - Description = "File to save the generated mock responses to", - Required = true - }; - mocksFromHttpResponseCommand.Add(_httpResponseMocksFileNameOption); - mocksFromHttpResponseCommand.SetAction(GenerateMocksFromHttpResponsesAsync); - - mocksCommand.AddCommands(new[] - { - mocksFromHttpResponseCommand - }.OrderByName()); - return [mocksCommand]; - } - - public override void OptionsLoaded(OptionsLoadedArgs e) - { - ArgumentNullException.ThrowIfNull(e); - - base.OptionsLoaded(e); - - var parseResult = e.ParseResult; - - // allow disabling of mocks as a command line option - var noMocks = parseResult.GetValueOrDefault(_noMocksOptionName); - if (noMocks.HasValue) - { - Configuration.NoMocks = noMocks.Value; - } - if (Configuration.NoMocks) - { - // mocks have been disabled. No need to continue - return; - } - - // update the name of the mocks file to load from if supplied - var mocksFile = parseResult.GetValueOrDefault(_mocksFileOptionName); - if (mocksFile is not null) - { - Configuration.MocksFile = mocksFile; - } - - Configuration.MocksFile = ProxyUtils.GetFullPath(Configuration.MocksFile, _proxyConfiguration.ConfigFile); - - // load the responses from the configured mocks file - _loader!.InitFileWatcherAsync(CancellationToken.None).GetAwaiter().GetResult(); - - ValidateMocks(); - } - - public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); - - ArgumentNullException.ThrowIfNull(e); - - var request = e.Session.HttpClient.Request; - var state = e.ResponseState; - if (Configuration.NoMocks) - { - Logger.LogRequest("Mocks disabled", MessageType.Skipped, new LoggingContext(e.Session)); - return Task.CompletedTask; - } - if (!e.ShouldExecute(UrlsToWatch)) - { - Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); - return Task.CompletedTask; - } - - var matchingResponse = GetMatchingMockResponse(request); - if (matchingResponse is not null) - { - // we need to clone the response so that we're not modifying - // the original that might be used in other requests - var clonedResponse = (MockResponse)matchingResponse.Clone(); - ProcessMockResponseInternal(e, clonedResponse); - state.HasBeenSet = true; - return Task.CompletedTask; - } - else if (Configuration.BlockUnmockedRequests) - { - ProcessMockResponseInternal(e, new() - { - Request = new() - { - Url = request.Url, - Method = request.Method ?? "" - }, - Response = new() - { - StatusCode = 502, - Body = new GraphErrorResponseBody(new() - { - Code = "Bad Gateway", - Message = $"No mock response found for {request.Method} {request.Url}" - }) - } - }); - state.HasBeenSet = true; - return Task.CompletedTask; - } - - Logger.LogRequest("No matching mock response found", MessageType.Skipped, new LoggingContext(e.Session)); - - Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); - return Task.CompletedTask; - } - - protected virtual void ProcessMockResponse(ref byte[] body, IList headers, ProxyRequestArgs e, MockResponse? matchingResponse) - { - } - - protected virtual void ProcessMockResponse(ref string? body, IList headers, ProxyRequestArgs e, MockResponse? matchingResponse) - { - if (string.IsNullOrEmpty(body)) - { - return; - } - - var bytes = Encoding.UTF8.GetBytes(body); - ProcessMockResponse(ref bytes, headers, e, matchingResponse); - body = Encoding.UTF8.GetString(bytes); - } - - private void ValidateMocks() - { - Logger.LogDebug("Validating mock responses"); - - if (Configuration.NoMocks) - { - Logger.LogDebug("Mocks are disabled"); - return; - } - - if (Configuration.Mocks is null || - !Configuration.Mocks.Any()) - { - Logger.LogDebug("No mock responses defined"); - return; - } - - var unmatchedMockUrls = new List(); - - foreach (var mock in Configuration.Mocks) - { - if (mock.Request is null) - { - Logger.LogDebug("Mock response is missing a request"); - continue; - } - - if (string.IsNullOrEmpty(mock.Request.Url)) - { - Logger.LogDebug("Mock response is missing a URL"); - continue; - } - - if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, mock.Request.Url, true)) - { - unmatchedMockUrls.Add(mock.Request.Url); - } - } - - if (unmatchedMockUrls.Count == 0) - { - return; - } - - var suggestedWildcards = ProxyUtils.GetWildcardPatterns(unmatchedMockUrls.AsReadOnly()); - if (Logger.IsEnabled(LogLevel.Warning)) - { - Logger.LogWarning( - "The following URLs in {MocksFile} don't match any URL to watch: {UnmatchedMocks}. Add the following URLs to URLs to watch: {UrlsToWatch}", - Configuration.MocksFile, - string.Join(", ", unmatchedMockUrls), - string.Join(", ", suggestedWildcards) - ); - } - } - - private MockResponse? GetMatchingMockResponse(Request request) - { - if (Configuration.NoMocks || - Configuration.Mocks is null || - !Configuration.Mocks.Any()) - { - return null; - } - - var mockResponse = Configuration.Mocks.FirstOrDefault(mockResponse => - { - if (mockResponse.Request is null) - { - return false; - } - - if (mockResponse.Request.Method != request.Method) - { - return false; - } - - if (mockResponse.Request.Url == request.Url && - HasMatchingBody(mockResponse, request) && - IsNthRequest(mockResponse)) - { - return true; - } - - // check if the URL contains a wildcard - // if it doesn't, it's not a match for the current request for sure - if (!mockResponse.Request.Url.Contains('*', StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // turn mock URL with wildcard into a regex and match against the request URL - return Regex.IsMatch(request.Url, ProxyUtils.PatternToRegex(mockResponse.Request.Url)) && - HasMatchingBody(mockResponse, request) && - IsNthRequest(mockResponse); - }); - - if (mockResponse is not null && mockResponse.Request is not null) - { - _ = _appliedMocks.AddOrUpdate(mockResponse.Request.Url, 1, (_, value) => ++value); - } - - return mockResponse; - } - - private bool IsNthRequest(MockResponse mockResponse) - { - if (mockResponse.Request?.Nth is null) - { - // mock doesn't define an Nth property so it always qualifies - return true; - } - - _ = _appliedMocks.TryGetValue(mockResponse.Request.Url, out var nth); - nth++; - - return mockResponse.Request.Nth == nth; - } - - private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchingResponse) - { - string? body = null; - var requestId = Guid.NewGuid().ToString(); - var requestDate = DateTime.Now.ToString("r", CultureInfo.InvariantCulture); - var headers = ProxyUtils.BuildGraphResponseHeaders(e.Session.HttpClient.Request, requestId, requestDate); - var statusCode = HttpStatusCode.OK; - if (matchingResponse.Response?.StatusCode is not null) - { - statusCode = (HttpStatusCode)matchingResponse.Response.StatusCode; - } - - if (matchingResponse.Response?.Headers is not null) - { - ProxyUtils.MergeHeaders(headers, [.. matchingResponse.Response.Headers]); - } - - // default the content type to application/json unless set in the mock response - if (!headers.Any(h => h.Name.Equals("content-type", StringComparison.OrdinalIgnoreCase)) && - matchingResponse.Response?.Body is not null) - { - headers.Add(new("content-type", "application/json")); - } - - if (e.SessionData.TryGetValue(nameof(RateLimitingPlugin), out var pluginData) && - pluginData is List rateLimitingHeaders) - { - ProxyUtils.MergeHeaders(headers, rateLimitingHeaders); - } - - ReplacePlaceholders(matchingResponse.Response, e.Session.HttpClient.Request, Logger); - - if (matchingResponse.Response?.Body is not null) - { - var bodyString = JsonSerializer.Serialize(matchingResponse.Response.Body, ProxyUtils.JsonSerializerOptions) as string; - // we get a JSON string so need to start with the opening quote - if (bodyString?.StartsWith("\"@", StringComparison.OrdinalIgnoreCase) ?? false) - { - // we've got a mock body starting with @-token which means we're sending - // a response from a file on disk - // if we can read the file, we can immediately send the response and - // skip the rest of the logic in this method - // remove the surrounding quotes and the @-token - var filePath = Path.Combine(Path.GetDirectoryName(Configuration.MocksFile) ?? "", ProxyUtils.ReplacePathTokens(bodyString.Trim('"')[1..])); - if (!File.Exists(filePath)) - { - - Logger.LogError("File {FilePath} not found. Serving file path in the mock response", filePath); - body = bodyString; - } - else - { - var bodyBytes = File.ReadAllBytes(filePath); - ProcessMockResponse(ref bodyBytes, headers, e, matchingResponse); - e.Session.GenericResponse(bodyBytes, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value))); - Logger.LogRequest($"{matchingResponse.Response.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); - return; - } - } - else - { - body = bodyString; - } - } - else - { - // we need to remove the content-type header if the body is empty - // some clients fail on empty body + content-type - var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("content-type", StringComparison.OrdinalIgnoreCase)); - if (contentTypeHeader is not null) - { - _ = headers.Remove(contentTypeHeader); - } - } - ProcessMockResponse(ref body, headers, e, matchingResponse); - e.Session.GenericResponse(body ?? string.Empty, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value))); - - Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); - } - - private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) - { - Logger.LogTrace("{Method} called", nameof(GenerateMocksFromHttpResponsesAsync)); - - if (_httpResponseFilesArgument is null) - { - throw new InvalidOperationException("HTTP response files argument is not initialized."); - } - if (_httpResponseMocksFileNameOption is null) - { - throw new InvalidOperationException("HTTP response mocks file name option is not initialized."); - } - - var outputFilePath = parseResult.GetValue(_httpResponseMocksFileNameOption); - if (string.IsNullOrEmpty(outputFilePath)) - { - Logger.LogError("No output file path provided for mock responses."); - return; - } - - var httpResponseFiles = parseResult.GetValue(_httpResponseFilesArgument); - if (httpResponseFiles is null || !httpResponseFiles.Any()) - { - Logger.LogError("No HTTP response files provided."); - return; - } - - var matcher = new Matcher(); - matcher.AddIncludePatterns(httpResponseFiles); - - var matchingFiles = matcher.GetResultsInFullPath("."); - if (!matchingFiles.Any()) - { - Logger.LogError("No matching HTTP response files found."); - return; - } - - if (Logger.IsEnabled(LogLevel.Information)) - { - Logger.LogInformation("Found {FileCount} matching HTTP response files", matchingFiles.Count()); - } - if (Logger.IsEnabled(LogLevel.Debug)) - { - Logger.LogDebug("Matching files: {Files}", string.Join(", ", matchingFiles)); - } - - var mockResponses = new List(); - foreach (var file in matchingFiles) - { - Logger.LogInformation("Processing file: {File}", Path.GetRelativePath(".", file)); - try - { - mockResponses.Add(MockResponse.FromHttpResponse(await File.ReadAllTextAsync(file), Logger)); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error processing file {File}", file); - continue; - } - } - - var mocksFile = new MockResponseConfiguration - { - Mocks = mockResponses - }; - await File.WriteAllTextAsync( - outputFilePath, - JsonSerializer.Serialize(mocksFile, ProxyUtils.JsonSerializerOptions) - ); - - Logger.LogInformation("Generated mock responses saved to {OutputFile}", outputFilePath); - - Logger.LogTrace("Left {Method}", nameof(GenerateMocksFromHttpResponsesAsync)); - } - - private static bool HasMatchingBody(MockResponse mockResponse, Request request) - { - if (request.Method == "GET") - { - // GET requests don't have a body so we can't match on it - return true; - } - - if (mockResponse.Request?.BodyFragment is null) - { - // no body fragment to match on - return true; - } - - if (!request.HasBody || string.IsNullOrEmpty(request.BodyString)) - { - // mock defines a body fragment but the request has no body - // so it can't match - return false; - } - - return request.BodyString.Contains(mockResponse.Request.BodyFragment, StringComparison.OrdinalIgnoreCase); - } - - private static void ReplacePlaceholders(MockResponseResponse? response, Request request, ILogger logger) - { - logger.LogTrace("{Method} called", nameof(ReplacePlaceholders)); - - if (response is null || - response.Body is null || !request.HasBody) - { - logger.LogTrace("Body is empty. Skipping replacing placeholders"); - return; - } - - var contentType = request.ContentType; - // Only attempt to parse JSON if content-type is: - // - null or empty (for backward compatibility) - // - a JSON type (application/json, application/vnd.api+json, etc.) - var isJsonContent = string.IsNullOrEmpty(contentType) || - contentType.Contains("json", StringComparison.OrdinalIgnoreCase); - - if (!isJsonContent) - { - logger.LogDebug("Content-Type '{ContentType}' is not JSON. Skipping placeholder replacement", contentType); - return; - } - - try - { - var requestBody = JsonSerializer.Deserialize(request.BodyString, ProxyUtils.JsonSerializerOptions); - - response.Body = ReplacePlaceholdersInObject(response.Body, requestBody, logger); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to parse request body as JSON"); - logger.LogWarning("Failed to parse request body as JSON. Placeholders in the mock response won't be replaced."); - } - - logger.LogTrace("Left {Method}", nameof(ReplacePlaceholders)); - } - - private static object? ReplacePlaceholdersInObject(object? obj, JsonElement requestBody, ILogger logger) - { - logger.LogTrace("{Method} called", nameof(ReplacePlaceholdersInObject)); - - if (obj is null) - { - return null; - } - - // Handle JsonElement (which is what we get from System.Text.Json) - if (obj is JsonElement element) - { - return ReplacePlaceholdersInJsonElement(element, requestBody, logger); - } - - // Handle string values - check for placeholders - if (obj is string strValue) - { - return ReplacePlaceholderInString(strValue, requestBody, logger); - } - - // For other types, convert to JsonElement and process - var json = JsonSerializer.Serialize(obj); - var jsonElement = JsonSerializer.Deserialize(json); - return ReplacePlaceholdersInJsonElement(jsonElement, requestBody, logger); - } - - private static object? ReplacePlaceholdersInJsonElement(JsonElement element, JsonElement requestBody, ILogger logger) - { - logger.LogTrace("{Method} called", nameof(ReplacePlaceholdersInJsonElement)); - - switch (element.ValueKind) - { - case JsonValueKind.Object: - var resultObj = new Dictionary(); - foreach (var property in element.EnumerateObject()) - { - resultObj[property.Name] = ReplacePlaceholdersInJsonElement(property.Value, requestBody, logger); - } - return resultObj; - - case JsonValueKind.Array: - var resultArray = new List(); - foreach (var item in element.EnumerateArray()) - { - resultArray.Add(ReplacePlaceholdersInJsonElement(item, requestBody, logger)); - } - return resultArray; - case JsonValueKind.String: - return ReplacePlaceholderInString(element.GetString() ?? "", requestBody, logger); - case JsonValueKind.Number: - return GetSafeNumber(element, logger); - case JsonValueKind.True: - return true; - case JsonValueKind.False: - return false; - case JsonValueKind.Null: - case JsonValueKind.Undefined: - return null; - default: - return element.ToString(); - } - } - -#pragma warning disable CA1859 - // CA1859: This method must return object? because it may return different concrete types (string, int, bool, etc.) based on the JSON content. - private static object? ReplacePlaceholderInString(string value, JsonElement requestBody, ILogger logger) -#pragma warning restore CA1859 - { - logger.LogTrace("{Method} called", nameof(ReplacePlaceholderInString)); - - logger.LogDebug("Processing value: {Value}", value); - - // Check if the value starts with @request.body. - if (!value.StartsWith("@request.body.", StringComparison.OrdinalIgnoreCase)) - { - logger.LogDebug("Value {Value} does not start with @request.body. Skipping", value); - return value; - } - - // Extract the property path after @request.body. - var propertyPath = value["@request.body.".Length..]; - - logger.LogDebug("Extracted property path: {PropertyPath}", propertyPath); - - return GetValueFromRequestBody(requestBody, propertyPath, logger); - } - - private static object? GetValueFromRequestBody(JsonElement requestBody, string propertyPath, ILogger logger) - { - logger.LogTrace("{Method} called", nameof(GetValueFromRequestBody)); - - logger.LogDebug("Getting value for {PropertyPath}", propertyPath); - - try - { - // Split the property path by dots to handle nested properties - var propertyNames = propertyPath.Split('.'); - return GetNestedValueFromJsonElement(requestBody, propertyNames, logger); - } - catch (Exception ex) - { - // If we can't get the property, return null - logger.LogDebug(ex, "Failed to get value for {PropertyPath}. Returning null", propertyPath); - } - - return null; - } - - private static object? GetNestedValueFromJsonElement(JsonElement element, string[] propertyNames, ILogger logger) - { - logger.LogTrace("{Method} called", nameof(GetNestedValueFromJsonElement)); - - var current = element; - - // Navigate through the nested properties - foreach (var propertyName in propertyNames) - { - if (current.ValueKind != JsonValueKind.Object) - { - logger.LogDebug("Current JSON element is not an object. Cannot navigate to property {PropertyName}", propertyName); - return null; // Can't navigate further if current element is not an object - } - - if (!current.TryGetProperty(propertyName, out current)) - { - logger.LogDebug("Property {PropertyName} not found in JSON. Returning null", propertyName); - return null; // Property not found - } - } - - return ConvertJsonElementToObject(current, logger); - } - - private static object? ConvertJsonElementToObject(JsonElement element, ILogger logger) - { - logger.LogTrace("{Method} called", nameof(ConvertJsonElementToObject)); - - return element.ValueKind switch - { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => GetSafeNumber(element, logger), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null or JsonValueKind.Undefined => null, - // For complex objects/arrays, return the JsonElement itself - // which can be serialized later - JsonValueKind.Object or JsonValueKind.Array => element, - _ => element.ToString(), - }; - } - - // Attempts to safely extract a number from a JsonElement, falling back to double or string if necessary - private static object? GetSafeNumber(JsonElement element, ILogger logger) - { - logger.LogTrace("{Method} called", nameof(GetSafeNumber)); - - // Try to get as int - if (element.TryGetInt32(out var intValue)) - { - return intValue; - } - if (element.TryGetInt64(out var longValue)) - { - return longValue; - } - if (element.TryGetDecimal(out var decimalValue)) - { - return decimalValue; - } - if (element.TryGetDouble(out var doubleValue)) - { - return doubleValue; - } - - // Fallback: return as string to avoid exceptions - return element.GetRawText(); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _loader?.Dispose(); - } - base.Dispose(disposing); - } -} +// 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.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Behavior; +using DevProxy.Plugins.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace DevProxy.Plugins.Mocking; + +public sealed class MockResponseConfiguration +{ + [JsonIgnore] + public bool BlockUnmockedRequests { get; set; } + public IEnumerable Mocks { get; set; } = []; + [JsonIgnore] + public string MocksFile { get; set; } = "mocks.json"; + [JsonIgnore] + public bool NoMocks { get; set; } + [JsonPropertyName("$schema")] + public string Schema { get; set; } = "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/mockresponseplugin.mocksfile.schema.json"; +} + +public class MockResponsePlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + BasePlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + private const string _noMocksOptionName = "--no-mocks"; + private const string _mocksFileOptionName = "--mocks-file"; + + private readonly IProxyConfiguration _proxyConfiguration = proxyConfiguration; + // tracks the number of times a mock has been applied + // used in combination with mocks that have an Nth property + private readonly ConcurrentDictionary _appliedMocks = []; + + private MockResponsesLoader? _loader; + private Argument>? _httpResponseFilesArgument; + private Option? _httpResponseMocksFileNameOption; + + public override string Name => nameof(MockResponsePlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(e); + + await base.InitializeAsync(e, cancellationToken); + + _loader = ActivatorUtilities.CreateInstance(e.ServiceProvider, Configuration); + } + + public override Option[] GetOptions() + { + var _noMocks = new Option(_noMocksOptionName, "-n") + { + Description = "Disable loading mock requests", + HelpName = "no-mocks" + }; + + var _mocksFile = new Option(_mocksFileOptionName) + { + Description = "Provide a file populated with mock responses", + HelpName = "mocks-file" + }; + + return [_noMocks, _mocksFile]; + } + + public override Command[] GetCommands() + { + var mocksCommand = new Command("mocks", "Manage mock responses"); + var mocksFromHttpResponseCommand = new Command("from-http-responses", "Create a mock response from HTTP responses"); + _httpResponseFilesArgument = new Argument>("http-response-files") + { + Arity = ArgumentArity.OneOrMore, + Description = "Glob pattern to the file(s) containing HTTP responses to create mock responses from", + }; + mocksFromHttpResponseCommand.Add(_httpResponseFilesArgument); + _httpResponseMocksFileNameOption = new Option("--mocks-file") + { + HelpName = "mocks file", + Arity = ArgumentArity.ExactlyOne, + Description = "File to save the generated mock responses to", + Required = true + }; + mocksFromHttpResponseCommand.Add(_httpResponseMocksFileNameOption); + mocksFromHttpResponseCommand.SetAction(GenerateMocksFromHttpResponsesAsync); + + mocksCommand.AddCommands(new[] + { + mocksFromHttpResponseCommand + }.OrderByName()); + return [mocksCommand]; + } + + public override void OptionsLoaded(OptionsLoadedArgs e) + { + ArgumentNullException.ThrowIfNull(e); + + base.OptionsLoaded(e); + + var parseResult = e.ParseResult; + + // allow disabling of mocks as a command line option + var noMocks = parseResult.GetValueOrDefault(_noMocksOptionName); + if (noMocks.HasValue) + { + Configuration.NoMocks = noMocks.Value; + } + if (Configuration.NoMocks) + { + // mocks have been disabled. No need to continue + return; + } + + // update the name of the mocks file to load from if supplied + var mocksFile = parseResult.GetValueOrDefault(_mocksFileOptionName); + if (mocksFile is not null) + { + Configuration.MocksFile = mocksFile; + } + + Configuration.MocksFile = ProxyUtils.GetFullPath(Configuration.MocksFile, _proxyConfiguration.ConfigFile); + + // load the responses from the configured mocks file + _loader!.InitFileWatcherAsync(CancellationToken.None).GetAwaiter().GetResult(); + + ValidateMocks(); + } + + public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); + + ArgumentNullException.ThrowIfNull(e); + + var request = e.ProxySession.Request; + var state = e.ResponseState; + if (Configuration.NoMocks) + { + Logger.LogRequest("Mocks disabled", MessageType.Skipped, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + if (!e.ShouldExecute(UrlsToWatch)) + { + Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); + return Task.CompletedTask; + } + + var matchingResponse = GetMatchingMockResponse(request); + if (matchingResponse is not null) + { + // we need to clone the response so that we're not modifying + // the original that might be used in other requests + var clonedResponse = (MockResponse)matchingResponse.Clone(); + ProcessMockResponseInternal(e, clonedResponse); + state.HasBeenSet = true; + return Task.CompletedTask; + } + else if (Configuration.BlockUnmockedRequests) + { + ProcessMockResponseInternal(e, new() + { + Request = new() + { + Url = request.Url, + Method = request.Method ?? "" + }, + Response = new() + { + StatusCode = 502, + Body = new GraphErrorResponseBody(new() + { + Code = "Bad Gateway", + Message = $"No mock response found for {request.Method} {request.Url}" + }) + } + }); + state.HasBeenSet = true; + return Task.CompletedTask; + } + + Logger.LogRequest("No matching mock response found", MessageType.Skipped, new LoggingContext(e.Session)); + + Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); + return Task.CompletedTask; + } + + protected virtual void ProcessMockResponse(ref byte[] body, IList headers, ProxyRequestArgs e, MockResponse? matchingResponse) + { + } + + protected virtual void ProcessMockResponse(ref string? body, IList headers, ProxyRequestArgs e, MockResponse? matchingResponse) + { + if (string.IsNullOrEmpty(body)) + { + return; + } + + var bytes = Encoding.UTF8.GetBytes(body); + ProcessMockResponse(ref bytes, headers, e, matchingResponse); + body = Encoding.UTF8.GetString(bytes); + } + + private void ValidateMocks() + { + Logger.LogDebug("Validating mock responses"); + + if (Configuration.NoMocks) + { + Logger.LogDebug("Mocks are disabled"); + return; + } + + if (Configuration.Mocks is null || + !Configuration.Mocks.Any()) + { + Logger.LogDebug("No mock responses defined"); + return; + } + + var unmatchedMockUrls = new List(); + + foreach (var mock in Configuration.Mocks) + { + if (mock.Request is null) + { + Logger.LogDebug("Mock response is missing a request"); + continue; + } + + if (string.IsNullOrEmpty(mock.Request.Url)) + { + Logger.LogDebug("Mock response is missing a URL"); + continue; + } + + if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, mock.Request.Url, true)) + { + unmatchedMockUrls.Add(mock.Request.Url); + } + } + + if (unmatchedMockUrls.Count == 0) + { + return; + } + + var suggestedWildcards = ProxyUtils.GetWildcardPatterns(unmatchedMockUrls.AsReadOnly()); + if (Logger.IsEnabled(LogLevel.Warning)) + { + Logger.LogWarning( + "The following URLs in {MocksFile} don't match any URL to watch: {UnmatchedMocks}. Add the following URLs to URLs to watch: {UrlsToWatch}", + Configuration.MocksFile, + string.Join(", ", unmatchedMockUrls), + string.Join(", ", suggestedWildcards) + ); + } + } + + private MockResponse? GetMatchingMockResponse(IHttpRequest request) + { + if (Configuration.NoMocks || + Configuration.Mocks is null || + !Configuration.Mocks.Any()) + { + return null; + } + + var mockResponse = Configuration.Mocks.FirstOrDefault(mockResponse => + { + if (mockResponse.Request is null) + { + return false; + } + + if (mockResponse.Request.Method != request.Method) + { + return false; + } + + if (mockResponse.Request.Url == request.Url && + HasMatchingBody(mockResponse, request) && + IsNthRequest(mockResponse)) + { + return true; + } + + // check if the URL contains a wildcard + // if it doesn't, it's not a match for the current request for sure + if (!mockResponse.Request.Url.Contains('*', StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // turn mock URL with wildcard into a regex and match against the request URL + return Regex.IsMatch(request.Url, ProxyUtils.PatternToRegex(mockResponse.Request.Url)) && + HasMatchingBody(mockResponse, request) && + IsNthRequest(mockResponse); + }); + + if (mockResponse is not null && mockResponse.Request is not null) + { + _ = _appliedMocks.AddOrUpdate(mockResponse.Request.Url, 1, (_, value) => ++value); + } + + return mockResponse; + } + + private bool IsNthRequest(MockResponse mockResponse) + { + if (mockResponse.Request?.Nth is null) + { + // mock doesn't define an Nth property so it always qualifies + return true; + } + + _ = _appliedMocks.TryGetValue(mockResponse.Request.Url, out var nth); + nth++; + + return mockResponse.Request.Nth == nth; + } + + private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchingResponse) + { + string? body = null; + var requestId = Guid.NewGuid().ToString(); + var requestDate = DateTime.Now.ToString("r", CultureInfo.InvariantCulture); + var headers = ProxyUtils.BuildGraphResponseHeaders(e.ProxySession.Request, requestId, requestDate); + var statusCode = HttpStatusCode.OK; + if (matchingResponse.Response?.StatusCode is not null) + { + statusCode = (HttpStatusCode)matchingResponse.Response.StatusCode; + } + + if (matchingResponse.Response?.Headers is not null) + { + ProxyUtils.MergeHeaders(headers, [.. matchingResponse.Response.Headers]); + } + + // default the content type to application/json unless set in the mock response + if (!headers.Any(h => h.Name.Equals("content-type", StringComparison.OrdinalIgnoreCase)) && + matchingResponse.Response?.Body is not null) + { + headers.Add(new("content-type", "application/json")); + } + + if (e.SessionData.TryGetValue(nameof(RateLimitingPlugin), out var pluginData) && + pluginData is List rateLimitingHeaders) + { + ProxyUtils.MergeHeaders(headers, rateLimitingHeaders); + } + + ReplacePlaceholders(matchingResponse.Response, e.ProxySession.Request, Logger); + + if (matchingResponse.Response?.Body is not null) + { + var bodyString = JsonSerializer.Serialize(matchingResponse.Response.Body, ProxyUtils.JsonSerializerOptions) as string; + // we get a JSON string so need to start with the opening quote + if (bodyString?.StartsWith("\"@", StringComparison.OrdinalIgnoreCase) ?? false) + { + // we've got a mock body starting with @-token which means we're sending + // a response from a file on disk + // if we can read the file, we can immediately send the response and + // skip the rest of the logic in this method + // remove the surrounding quotes and the @-token + var filePath = Path.Combine(Path.GetDirectoryName(Configuration.MocksFile) ?? "", ProxyUtils.ReplacePathTokens(bodyString.Trim('"')[1..])); + if (!File.Exists(filePath)) + { + + Logger.LogError("File {FilePath} not found. Serving file path in the mock response", filePath); + body = bodyString; + } + else + { + var bodyBytes = File.ReadAllBytes(filePath); + ProcessMockResponse(ref bodyBytes, headers, e, matchingResponse); + e.ProxySession.Respond(bodyBytes, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value))); + Logger.LogRequest($"{matchingResponse.Response.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + return; + } + } + else + { + body = bodyString; + } + } + else + { + // we need to remove the content-type header if the body is empty + // some clients fail on empty body + content-type + var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("content-type", StringComparison.OrdinalIgnoreCase)); + if (contentTypeHeader is not null) + { + _ = headers.Remove(contentTypeHeader); + } + } + ProcessMockResponse(ref body, headers, e, matchingResponse); + e.ProxySession.Respond(body ?? string.Empty, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value))); + + Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + } + + private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) + { + Logger.LogTrace("{Method} called", nameof(GenerateMocksFromHttpResponsesAsync)); + + if (_httpResponseFilesArgument is null) + { + throw new InvalidOperationException("HTTP response files argument is not initialized."); + } + if (_httpResponseMocksFileNameOption is null) + { + throw new InvalidOperationException("HTTP response mocks file name option is not initialized."); + } + + var outputFilePath = parseResult.GetValue(_httpResponseMocksFileNameOption); + if (string.IsNullOrEmpty(outputFilePath)) + { + Logger.LogError("No output file path provided for mock responses."); + return; + } + + var httpResponseFiles = parseResult.GetValue(_httpResponseFilesArgument); + if (httpResponseFiles is null || !httpResponseFiles.Any()) + { + Logger.LogError("No HTTP response files provided."); + return; + } + + var matcher = new Matcher(); + matcher.AddIncludePatterns(httpResponseFiles); + + var matchingFiles = matcher.GetResultsInFullPath("."); + if (!matchingFiles.Any()) + { + Logger.LogError("No matching HTTP response files found."); + return; + } + + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation("Found {FileCount} matching HTTP response files", matchingFiles.Count()); + } + if (Logger.IsEnabled(LogLevel.Debug)) + { + Logger.LogDebug("Matching files: {Files}", string.Join(", ", matchingFiles)); + } + + var mockResponses = new List(); + foreach (var file in matchingFiles) + { + Logger.LogInformation("Processing file: {File}", Path.GetRelativePath(".", file)); + try + { + mockResponses.Add(MockResponse.FromHttpResponse(await File.ReadAllTextAsync(file), Logger)); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error processing file {File}", file); + continue; + } + } + + var mocksFile = new MockResponseConfiguration + { + Mocks = mockResponses + }; + await File.WriteAllTextAsync( + outputFilePath, + JsonSerializer.Serialize(mocksFile, ProxyUtils.JsonSerializerOptions) + ); + + Logger.LogInformation("Generated mock responses saved to {OutputFile}", outputFilePath); + + Logger.LogTrace("Left {Method}", nameof(GenerateMocksFromHttpResponsesAsync)); + } + + private static bool HasMatchingBody(MockResponse mockResponse, IHttpRequest request) + { + if (request.Method == "GET") + { + // GET requests don't have a body so we can't match on it + return true; + } + + if (mockResponse.Request?.BodyFragment is null) + { + // no body fragment to match on + return true; + } + + if (!request.HasBody || string.IsNullOrEmpty(request.BodyString)) + { + // mock defines a body fragment but the request has no body + // so it can't match + return false; + } + + return request.BodyString.Contains(mockResponse.Request.BodyFragment, StringComparison.OrdinalIgnoreCase); + } + + private static void ReplacePlaceholders(MockResponseResponse? response, IHttpRequest request, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(ReplacePlaceholders)); + + if (response is null || + response.Body is null || !request.HasBody) + { + logger.LogTrace("Body is empty. Skipping replacing placeholders"); + return; + } + + var contentType = request.ContentType; + // Only attempt to parse JSON if content-type is: + // - null or empty (for backward compatibility) + // - a JSON type (application/json, application/vnd.api+json, etc.) + var isJsonContent = string.IsNullOrEmpty(contentType) || + contentType.Contains("json", StringComparison.OrdinalIgnoreCase); + + if (!isJsonContent) + { + logger.LogDebug("Content-Type '{ContentType}' is not JSON. Skipping placeholder replacement", contentType); + return; + } + + try + { + var requestBody = JsonSerializer.Deserialize(request.BodyString, ProxyUtils.JsonSerializerOptions); + + response.Body = ReplacePlaceholdersInObject(response.Body, requestBody, logger); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to parse request body as JSON"); + logger.LogWarning("Failed to parse request body as JSON. Placeholders in the mock response won't be replaced."); + } + + logger.LogTrace("Left {Method}", nameof(ReplacePlaceholders)); + } + + private static object? ReplacePlaceholdersInObject(object? obj, JsonElement requestBody, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(ReplacePlaceholdersInObject)); + + if (obj is null) + { + return null; + } + + // Handle JsonElement (which is what we get from System.Text.Json) + if (obj is JsonElement element) + { + return ReplacePlaceholdersInJsonElement(element, requestBody, logger); + } + + // Handle string values - check for placeholders + if (obj is string strValue) + { + return ReplacePlaceholderInString(strValue, requestBody, logger); + } + + // For other types, convert to JsonElement and process + var json = JsonSerializer.Serialize(obj); + var jsonElement = JsonSerializer.Deserialize(json); + return ReplacePlaceholdersInJsonElement(jsonElement, requestBody, logger); + } + + private static object? ReplacePlaceholdersInJsonElement(JsonElement element, JsonElement requestBody, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(ReplacePlaceholdersInJsonElement)); + + switch (element.ValueKind) + { + case JsonValueKind.Object: + var resultObj = new Dictionary(); + foreach (var property in element.EnumerateObject()) + { + resultObj[property.Name] = ReplacePlaceholdersInJsonElement(property.Value, requestBody, logger); + } + return resultObj; + + case JsonValueKind.Array: + var resultArray = new List(); + foreach (var item in element.EnumerateArray()) + { + resultArray.Add(ReplacePlaceholdersInJsonElement(item, requestBody, logger)); + } + return resultArray; + case JsonValueKind.String: + return ReplacePlaceholderInString(element.GetString() ?? "", requestBody, logger); + case JsonValueKind.Number: + return GetSafeNumber(element, logger); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return null; + default: + return element.ToString(); + } + } + +#pragma warning disable CA1859 + // CA1859: This method must return object? because it may return different concrete types (string, int, bool, etc.) based on the JSON content. + private static object? ReplacePlaceholderInString(string value, JsonElement requestBody, ILogger logger) +#pragma warning restore CA1859 + { + logger.LogTrace("{Method} called", nameof(ReplacePlaceholderInString)); + + logger.LogDebug("Processing value: {Value}", value); + + // Check if the value starts with @request.body. + if (!value.StartsWith("@request.body.", StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Value {Value} does not start with @request.body. Skipping", value); + return value; + } + + // Extract the property path after @request.body. + var propertyPath = value["@request.body.".Length..]; + + logger.LogDebug("Extracted property path: {PropertyPath}", propertyPath); + + return GetValueFromRequestBody(requestBody, propertyPath, logger); + } + + private static object? GetValueFromRequestBody(JsonElement requestBody, string propertyPath, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(GetValueFromRequestBody)); + + logger.LogDebug("Getting value for {PropertyPath}", propertyPath); + + try + { + // Split the property path by dots to handle nested properties + var propertyNames = propertyPath.Split('.'); + return GetNestedValueFromJsonElement(requestBody, propertyNames, logger); + } + catch (Exception ex) + { + // If we can't get the property, return null + logger.LogDebug(ex, "Failed to get value for {PropertyPath}. Returning null", propertyPath); + } + + return null; + } + + private static object? GetNestedValueFromJsonElement(JsonElement element, string[] propertyNames, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(GetNestedValueFromJsonElement)); + + var current = element; + + // Navigate through the nested properties + foreach (var propertyName in propertyNames) + { + if (current.ValueKind != JsonValueKind.Object) + { + logger.LogDebug("Current JSON element is not an object. Cannot navigate to property {PropertyName}", propertyName); + return null; // Can't navigate further if current element is not an object + } + + if (!current.TryGetProperty(propertyName, out current)) + { + logger.LogDebug("Property {PropertyName} not found in JSON. Returning null", propertyName); + return null; // Property not found + } + } + + return ConvertJsonElementToObject(current, logger); + } + + private static object? ConvertJsonElementToObject(JsonElement element, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(ConvertJsonElementToObject)); + + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => GetSafeNumber(element, logger), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null or JsonValueKind.Undefined => null, + // For complex objects/arrays, return the JsonElement itself + // which can be serialized later + JsonValueKind.Object or JsonValueKind.Array => element, + _ => element.ToString(), + }; + } + + // Attempts to safely extract a number from a JsonElement, falling back to double or string if necessary + private static object? GetSafeNumber(JsonElement element, ILogger logger) + { + logger.LogTrace("{Method} called", nameof(GetSafeNumber)); + + // Try to get as int + if (element.TryGetInt32(out var intValue)) + { + return intValue; + } + if (element.TryGetInt64(out var longValue)) + { + return longValue; + } + if (element.TryGetDecimal(out var decimalValue)) + { + return decimalValue; + } + if (element.TryGetDouble(out var doubleValue)) + { + return doubleValue; + } + + // Fallback: return as string to avoid exceptions + return element.GetRawText(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _loader?.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/DevProxy.Plugins/Mocking/OpenAIMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/OpenAIMockResponsePlugin.cs index 9d20c953..620003d0 100644 --- a/DevProxy.Plugins/Mocking/OpenAIMockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/OpenAIMockResponsePlugin.cs @@ -1,187 +1,187 @@ -// 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; -using DevProxy.Abstractions.Plugins; -using DevProxy.Abstractions.Proxy; -using DevProxy.Abstractions.Utils; -using Microsoft.Extensions.Logging; -using System.Net; -using System.Text.Json; -using Titanium.Web.Proxy.Models; - -namespace DevProxy.Plugins.Mocking; - -public sealed class OpenAIMockResponsePlugin( - ILogger logger, - ISet urlsToWatch, - ILanguageModelClient languageModelClient) : BasePlugin(logger, urlsToWatch) -{ - private const string ResponsesMessageIdPrefix = "msg_"; - - public override string Name => nameof(OpenAIMockResponsePlugin); - - public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) - { - await base.InitializeAsync(e, cancellationToken); - - Logger.LogInformation("Checking language model availability..."); - if (!await languageModelClient.IsEnabledAsync(cancellationToken)) - { - Logger.LogError("Local language model is not enabled. The {Plugin} will not be used.", Name); - Enabled = false; - } - } - - public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) - { - Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); - - ArgumentNullException.ThrowIfNull(e); - - if (!e.HasRequestUrlMatch(UrlsToWatch)) - { - Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); - return; - } - if (e.ResponseState.HasBeenSet) - { - Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session)); - return; - } - - var request = e.Session.HttpClient.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)); - return; - } - - if (!OpenAIRequest.TryGetCompletionLikeRequest(request.BodyString, Logger, out var openAiRequest)) - { - Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new LoggingContext(e.Session)); - return; - } - - if (openAiRequest is OpenAICompletionRequest completionRequest) - { - if ((await languageModelClient.GenerateCompletionAsync(completionRequest.Prompt, null, cancellationToken)) is not ILanguageModelCompletionResponse lmResponse) - { - return; - } - if (lmResponse.ErrorMessage is not null) - { - Logger.LogError("Error from local language model: {Error}", lmResponse.ErrorMessage); - return; - } - - var openAiResponse = lmResponse.ConvertToOpenAIResponse(); - SendMockResponse(openAiResponse, lmResponse.RequestUrl ?? string.Empty, e); - } - else if (openAiRequest is OpenAIChatCompletionRequest chatRequest) - { - if ((await languageModelClient - .GenerateChatCompletionAsync(chatRequest.Messages, null, cancellationToken)) is not ILanguageModelCompletionResponse lmResponse) - { - return; - } - if (lmResponse.ErrorMessage is not null) - { - Logger.LogError("Error from local language model: {Error}", lmResponse.ErrorMessage); - return; - } - - var openAiResponse = lmResponse.ConvertToOpenAIResponse(); - SendMockResponse(openAiResponse, lmResponse.RequestUrl ?? string.Empty, e); - } - else if (openAiRequest is OpenAIResponsesRequest responsesRequest) - { - // Convert Responses API input to chat completion messages - var messages = ConvertResponsesInputToChatMessages(responsesRequest); - if ((await languageModelClient - .GenerateChatCompletionAsync(messages, null, cancellationToken)) is not ILanguageModelCompletionResponse lmResponse) - { - return; - } - if (lmResponse.ErrorMessage is not null) - { - Logger.LogError("Error from local language model: {Error}", lmResponse.ErrorMessage); - return; - } - - // Convert the chat completion response to Responses API format - var responsesResponse = ConvertToResponsesResponse(lmResponse.ConvertToOpenAIResponse()); - SendMockResponse(responsesResponse, lmResponse.RequestUrl ?? string.Empty, e); - } - else - { - Logger.LogError("Unknown OpenAI request type."); - } - - Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); - } - - private static IEnumerable ConvertResponsesInputToChatMessages(OpenAIResponsesRequest responsesRequest) - { - if (responsesRequest.Input is null) - { - return []; - } - - return responsesRequest.Input.Select(item => new OpenAIChatCompletionMessage - { - Role = item.Role, - Content = item.Content - }); - } - - private static OpenAIResponsesResponse ConvertToResponsesResponse(OpenAIResponse chatResponse) - { - return new OpenAIResponsesResponse - { - Id = chatResponse.Id, - Model = chatResponse.Model, - Object = "response", - Created = chatResponse.Created, - CreatedAt = chatResponse.Created, - Status = "completed", - Usage = chatResponse.Usage, - Output = - [ - new OpenAIResponsesOutputItem - { - Type = "message", - Id = $"{ResponsesMessageIdPrefix}{Guid.NewGuid():N}", - Role = "assistant", - Status = "completed", - Content = - [ - new OpenAIResponsesOutputContent - { - Type = "output_text", - Text = chatResponse.Response - } - ] - } - ] - }; - } - - private void SendMockResponse(OpenAIResponse response, string localLmUrl, ProxyRequestArgs e) where TResponse : OpenAIResponse - { - e.Session.GenericResponse( - // we need this cast or else the JsonSerializer drops derived properties - JsonSerializer.Serialize((TResponse)response, ProxyUtils.JsonSerializerOptions), - HttpStatusCode.OK, - [ - new HttpHeader("content-type", "application/json"), - new HttpHeader("access-control-allow-origin", "*") - ] - ); - e.ResponseState.HasBeenSet = true; - Logger.LogRequest($"200 {localLmUrl}", MessageType.Mocked, new LoggingContext(e.Session)); - } -} +// 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; +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; +using DevProxy.Abstractions.Utils; +using Microsoft.Extensions.Logging; +using System.Net; +using System.Text.Json; + +namespace DevProxy.Plugins.Mocking; + +public sealed class OpenAIMockResponsePlugin( + ILogger logger, + ISet urlsToWatch, + ILanguageModelClient languageModelClient) : BasePlugin(logger, urlsToWatch) +{ + private const string ResponsesMessageIdPrefix = "msg_"; + + public override string Name => nameof(OpenAIMockResponsePlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + await base.InitializeAsync(e, cancellationToken); + + Logger.LogInformation("Checking language model availability..."); + if (!await languageModelClient.IsEnabledAsync(cancellationToken)) + { + Logger.LogError("Local language model is not enabled. The {Plugin} will not be used.", Name); + Enabled = false; + } + } + + public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); + + ArgumentNullException.ThrowIfNull(e); + + if (!e.HasRequestUrlMatch(UrlsToWatch)) + { + Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.Session)); + return; + } + if (e.ResponseState.HasBeenSet) + { + Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session)); + return; + } + + 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)); + return; + } + + if (!OpenAIRequest.TryGetCompletionLikeRequest(request.BodyString, Logger, out var openAiRequest)) + { + Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new LoggingContext(e.Session)); + return; + } + + if (openAiRequest is OpenAICompletionRequest completionRequest) + { + if ((await languageModelClient.GenerateCompletionAsync(completionRequest.Prompt, null, cancellationToken)) is not ILanguageModelCompletionResponse lmResponse) + { + return; + } + if (lmResponse.ErrorMessage is not null) + { + Logger.LogError("Error from local language model: {Error}", lmResponse.ErrorMessage); + return; + } + + var openAiResponse = lmResponse.ConvertToOpenAIResponse(); + SendMockResponse(openAiResponse, lmResponse.RequestUrl ?? string.Empty, e); + } + else if (openAiRequest is OpenAIChatCompletionRequest chatRequest) + { + if ((await languageModelClient + .GenerateChatCompletionAsync(chatRequest.Messages, null, cancellationToken)) is not ILanguageModelCompletionResponse lmResponse) + { + return; + } + if (lmResponse.ErrorMessage is not null) + { + Logger.LogError("Error from local language model: {Error}", lmResponse.ErrorMessage); + return; + } + + var openAiResponse = lmResponse.ConvertToOpenAIResponse(); + SendMockResponse(openAiResponse, lmResponse.RequestUrl ?? string.Empty, e); + } + else if (openAiRequest is OpenAIResponsesRequest responsesRequest) + { + // Convert Responses API input to chat completion messages + var messages = ConvertResponsesInputToChatMessages(responsesRequest); + if ((await languageModelClient + .GenerateChatCompletionAsync(messages, null, cancellationToken)) is not ILanguageModelCompletionResponse lmResponse) + { + return; + } + if (lmResponse.ErrorMessage is not null) + { + Logger.LogError("Error from local language model: {Error}", lmResponse.ErrorMessage); + return; + } + + // Convert the chat completion response to Responses API format + var responsesResponse = ConvertToResponsesResponse(lmResponse.ConvertToOpenAIResponse()); + SendMockResponse(responsesResponse, lmResponse.RequestUrl ?? string.Empty, e); + } + else + { + Logger.LogError("Unknown OpenAI request type."); + } + + Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); + } + + private static IEnumerable ConvertResponsesInputToChatMessages(OpenAIResponsesRequest responsesRequest) + { + if (responsesRequest.Input is null) + { + return []; + } + + return responsesRequest.Input.Select(item => new OpenAIChatCompletionMessage + { + Role = item.Role, + Content = item.Content + }); + } + + private static OpenAIResponsesResponse ConvertToResponsesResponse(OpenAIResponse chatResponse) + { + return new OpenAIResponsesResponse + { + Id = chatResponse.Id, + Model = chatResponse.Model, + Object = "response", + Created = chatResponse.Created, + CreatedAt = chatResponse.Created, + Status = "completed", + Usage = chatResponse.Usage, + Output = + [ + new OpenAIResponsesOutputItem + { + Type = "message", + Id = $"{ResponsesMessageIdPrefix}{Guid.NewGuid():N}", + Role = "assistant", + Status = "completed", + Content = + [ + new OpenAIResponsesOutputContent + { + Type = "output_text", + Text = chatResponse.Response + } + ] + } + ] + }; + } + + private void SendMockResponse(OpenAIResponse response, string localLmUrl, ProxyRequestArgs e) where TResponse : OpenAIResponse + { + e.ProxySession.Respond( + // we need this cast or else the JsonSerializer drops derived properties + JsonSerializer.Serialize((TResponse)response, ProxyUtils.JsonSerializerOptions), + HttpStatusCode.OK, + [ + new HttpHeader("content-type", "application/json"), + new HttpHeader("access-control-allow-origin", "*") + ] + ); + e.ResponseState.HasBeenSet = true; + Logger.LogRequest($"200 {localLmUrl}", MessageType.Mocked, new LoggingContext(e.Session)); + } +} From 3a3962eef62b942999c38dc85061bcaeb743608f Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 16:36:36 +0200 Subject: [PATCH 08/46] Phase 3 inspection-lite: migrate HttpUtils + OpenAI usage/telemetry streaming off Titanium Change HttpUtils.IsStreamingResponse/GetBodyFromStreamingResponse to take the canonical IHttpResponse. Migrate OpenAIUsageDebuggingPlugin fully to e.ProxySession (status comparisons cast HttpStatusCode to int). Point OpenAITelemetryPlugin's streaming call site at e.ProxySession.Response. DevToolsPlugin and the OpenAITelemetry RequestLog.Context.Session readers remain for the final LoggingContext wave. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Inspection/OpenAITelemetryPlugin.cs | 2 +- .../Inspection/OpenAIUsageDebuggingPlugin.cs | 18 +++++++++--------- DevProxy.Plugins/Utils/HttpUtils.cs | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs b/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs index 8e9ff527..aa82a29e 100644 --- a/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs +++ b/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs @@ -321,7 +321,7 @@ private void ProcessSuccessResponse(Activity activity, ProxyResponseArgs e) { Logger.LogTrace("ProcessSuccessResponse() called"); - var response = e.Session.HttpClient.Response; + var response = e.ProxySession.Response!; if (!response.HasBody || string.IsNullOrEmpty(response.BodyString)) { diff --git a/DevProxy.Plugins/Inspection/OpenAIUsageDebuggingPlugin.cs b/DevProxy.Plugins/Inspection/OpenAIUsageDebuggingPlugin.cs index 8fa2cd64..fe7799be 100644 --- a/DevProxy.Plugins/Inspection/OpenAIUsageDebuggingPlugin.cs +++ b/DevProxy.Plugins/Inspection/OpenAIUsageDebuggingPlugin.cs @@ -5,12 +5,12 @@ using DevProxy.Abstractions.LanguageModel; using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Utils; using Microsoft.Extensions.Logging; using System.Globalization; using System.Text.Json; -using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Inspection; @@ -61,7 +61,7 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT 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) @@ -76,7 +76,7 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT return; } - var response = e.Session.HttpClient.Response; + var response = e.ProxySession.Response!; var bodyString = response.BodyString; if (HttpUtils.IsStreamingResponse(response, Logger)) { @@ -86,17 +86,17 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT var usage = new UsageRecord { Time = DateTime.TryParse( - e.Session.HttpClient.Response.Headers.FirstOrDefault(h => h.Name.Equals("date", StringComparison.OrdinalIgnoreCase))?.Value, + response.Headers.FirstOrDefault(h => h.Name.Equals("date", StringComparison.OrdinalIgnoreCase))?.Value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate) ? parsedDate : DateTime.Now, - Status = e.Session.HttpClient.Response.StatusCode + Status = (int)response.StatusCode }; #pragma warning disable IDE0010 - switch (response.StatusCode) + switch ((int)response.StatusCode) #pragma warning restore IDE0010 { case int code when code is >= 200 and < 300: @@ -129,7 +129,7 @@ private void ProcessSuccessResponse(string responseBody, UsageRecord usage, Prox return; } - var response = e.Session.HttpClient.Response; + var response = e.ProxySession.Response!; usage.PromptTokens = oaiResponse.Usage?.PromptTokens; usage.CompletionTokens = oaiResponse.Usage?.CompletionTokens; @@ -144,7 +144,7 @@ private void ProcessErrorResponse(UsageRecord usage, ProxyResponseArgs e) { Logger.LogTrace("{Method} called", nameof(ProcessErrorResponse)); - var response = e.Session.HttpClient.Response; + var response = e.ProxySession.Response!; usage.RetryAfter = response.Headers.FirstOrDefault(h => h.Name.Equals("retry-after", StringComparison.OrdinalIgnoreCase))?.Value; usage.Policy = response.Headers.FirstOrDefault(h => h.Name.Equals("policy-id", StringComparison.OrdinalIgnoreCase))?.Value; @@ -152,7 +152,7 @@ private void ProcessErrorResponse(UsageRecord usage, ProxyResponseArgs e) Logger.LogTrace("Left {Name}", nameof(ProcessErrorResponse)); } - private static bool TryParseHeaderAsLong(Response response, string headerName, out long? value) + private static bool TryParseHeaderAsLong(IHttpResponse response, string headerName, out long? value) { value = null; var header = response.Headers.FirstOrDefault(h => h.Name.Equals(headerName, StringComparison.OrdinalIgnoreCase))?.Value; diff --git a/DevProxy.Plugins/Utils/HttpUtils.cs b/DevProxy.Plugins/Utils/HttpUtils.cs index 500290ec..81b0b733 100644 --- a/DevProxy.Plugins/Utils/HttpUtils.cs +++ b/DevProxy.Plugins/Utils/HttpUtils.cs @@ -2,9 +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.Abstractions.Proxy.Http; using Microsoft.Extensions.Logging; using System.Text; -using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Utils; @@ -47,7 +47,7 @@ public static string GetBodyString(string? contentType, byte[] body) } } - public static string GetBodyFromStreamingResponse(Response response, ILogger logger) + public static string GetBodyFromStreamingResponse(IHttpResponse response, ILogger logger) { logger.LogTrace("{Method} called", nameof(GetBodyFromStreamingResponse)); @@ -89,7 +89,7 @@ public static string GetBodyFromStreamingResponse(Response response, ILogger log return bodyString; } - public static bool IsStreamingResponse(Response response, ILogger logger) + public static bool IsStreamingResponse(IHttpResponse response, ILogger logger) { logger.LogTrace("{Method} called", nameof(IsStreamingResponse)); var contentType = response.Headers.FirstOrDefault(h => h.Name.Equals("content-type", StringComparison.OrdinalIgnoreCase))?.Value; From 49db8c58fdac318807f44f85fd853dbab95f2dc0 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 16:39:43 +0200 Subject: [PATCH 09/46] Phase 3 throttling group: migrate ThrottlerInfo + 5 throttling plugins off Titanium Flip ThrottlerInfo.ShouldThrottle delegate from Func to Func (PluginEvents) and migrate the coordinated consumers: GraphUtils.BuildThrottleKey, RateLimitingPlugin, GraphRandomErrorPlugin, GenericRandomErrorPlugin, LanguageModelRateLimitingPlugin, RetryAfterPlugin. RetryAfter's GenericResponse becomes IProxySession.Respond with canonical HttpHeader. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DevProxy.Abstractions/Plugins/PluginEvents.cs | 6 ++-- .../Behavior/GenericRandomErrorPlugin.cs | 22 +++++++-------- .../Behavior/GraphRandomErrorPlugin.cs | 27 ++++++++---------- .../LanguageModelRateLimitingPlugin.cs | 28 ++++++++----------- .../Behavior/RateLimitingPlugin.cs | 26 ++++++++--------- DevProxy.Plugins/Behavior/RetryAfterPlugin.cs | 13 ++++----- DevProxy.Plugins/Utils/GraphUtils.cs | 4 +-- 7 files changed, 57 insertions(+), 69 deletions(-) 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.Plugins/Behavior/GenericRandomErrorPlugin.cs b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs index 8a437734..51973af0 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; @@ -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) { @@ -157,13 +156,13 @@ private void FailResponse(ProxyRequestArgs e) } } - 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,17 +277,17 @@ 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)); @@ -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..6b9d2ed8 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; @@ -192,7 +191,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca Logger.LogRequest("Pass through", MessageType.Skipped, new LoggingContext(e.Session)); 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) { @@ -322,7 +320,7 @@ 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))); + 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))); + 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/LanguageModelRateLimitingPlugin.cs b/DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs index 9858f672..40ea64a5 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,7 +76,6 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca ArgumentNullException.ThrowIfNull(e); - var session = e.Session; var state = e.ResponseState; if (state.HasBeenSet) { @@ -90,9 +88,8 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca 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)); @@ -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, []); + e.ProxySession.Respond("Custom response file not found.", HttpStatusCode.InternalServerError, []); state.HasBeenSet = true; } } @@ -215,9 +212,8 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken 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; @@ -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/RateLimitingPlugin.cs b/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs index 9af07719..489c6340 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,7 +88,6 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca ArgumentNullException.ThrowIfNull(e); - var session = e.Session; var state = e.ResponseState; if (state.HasBeenSet) { @@ -124,7 +122,7 @@ 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)); if (Configuration.WhenLimitExceeded == RateLimitResponseWhenLimitExceeded.Throttle) @@ -182,7 +180,7 @@ 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 @@ -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..7457bb38 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; @@ -42,7 +41,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca Logger.LogRequest("Response already set", MessageType.Skipped, new LoggingContext(e.Session)); 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)); return Task.CompletedTask; @@ -56,7 +55,7 @@ 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)); @@ -102,7 +101,7 @@ private static void UpdateProxyResponse(ProxyRequestArgs e, ThrottlingInfo throt { 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/Utils/GraphUtils.cs b/DevProxy.Plugins/Utils/GraphUtils.cs index 761059bb..bccd68e8 100644 --- a/DevProxy.Plugins/Utils/GraphUtils.cs +++ b/DevProxy.Plugins/Utils/GraphUtils.cs @@ -3,12 +3,12 @@ // See the LICENSE file in the project root for more information. using DevProxy.Abstractions.Models; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Models; using Microsoft.Extensions.Logging; using System.Net.Http.Json; using System.Text.Json; -using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Utils; @@ -20,7 +20,7 @@ sealed class GraphUtils( private readonly ILogger _logger = logger; // throttle requests per workload - public static string BuildThrottleKey(Request r) => BuildThrottleKey(r.RequestUri); + public static string BuildThrottleKey(IHttpRequest r) => BuildThrottleKey(r.RequestUri); public static string BuildThrottleKey(Uri uri) { From 39b8bef04393f3d9fb92ccc8e495a2a1a129fdec Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 16:53:04 +0200 Subject: [PATCH 10/46] Migrate plugins off Titanium onto canonical HTTP model (Phase 3 final wave) Complete the strangler migration: flip LoggingContext and the proxy event args to the engine-agnostic IProxySession model, migrate all remaining plugins off Titanium.Web.Proxy types, and drop the Unobtanium.Web.Proxy package reference from DevProxy.Abstractions and DevProxy.Plugins. - LoggingContext/RequestLog now carry IProxySession (was SessionEventArgs); ProxyHttpEventArgsBase no longer exposes the Titanium Session. - All Behavior/Generation/Guidance/Inspection/Manipulation/Mocking/Reporting plugins read e.ProxySession.Request/Response and the canonical header API. - DevToolsPlugin re-keys _responseBody/CDP requestId on the phase-stable ProxySession.SessionId instead of the Titanium Request hash code. - Add Url setter and response HttpVersion to the canonical model (+ Titanium adapters) to preserve Rewrite and HAR behavior. - Remove dead Titanium FuncExtensions helper. DevProxy.Abstractions and DevProxy.Plugins are now Titanium-free; only the host engine and the Titanium adapter retain the dependency. Build 0/0, 33+38 tests green, live smoke test verified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Abstractions.csproj | 1 - .../Extensions/FuncExtensions.cs | 34 ------------ .../Proxy/Http/IHttpRequest.cs | 2 +- .../Proxy/Http/IHttpResponse.cs | 3 ++ DevProxy.Abstractions/Proxy/IProxyLogger.cs | 6 +-- DevProxy.Abstractions/Proxy/ProxyEvents.cs | 21 +++----- DevProxy.Abstractions/Utils/ProxyUtils.cs | 37 ------------- DevProxy.Abstractions/packages.lock.json | 15 ------ .../Behavior/GenericRandomErrorPlugin.cs | 10 ++-- .../Behavior/GraphRandomErrorPlugin.cs | 10 ++-- .../Behavior/LanguageModelFailurePlugin.cs | 25 +++++---- .../LanguageModelRateLimitingPlugin.cs | 16 +++--- DevProxy.Plugins/Behavior/LatencyPlugin.cs | 4 +- .../Behavior/RateLimitingPlugin.cs | 14 ++--- DevProxy.Plugins/Behavior/RetryAfterPlugin.cs | 16 +++--- DevProxy.Plugins/DevProxy.Plugins.csproj | 4 -- .../Extensions/ApiCenterExtensions.cs | 2 +- .../Extensions/OpenApiDocumentExtensions.cs | 2 +- .../Generation/HarGeneratorPlugin.cs | 18 +++---- .../Generation/HttpFileGeneratorPlugin.cs | 10 ++-- .../Generation/MockGeneratorPlugin.cs | 16 +++--- .../Generation/OpenApiSpecGeneratorPlugin.cs | 35 ++++++------ .../Generation/TypeSpecGeneratorPlugin.cs | 24 ++++----- .../Guidance/CachingGuidancePlugin.cs | 10 ++-- .../GraphBetaSupportGuidancePlugin.cs | 12 ++--- .../GraphClientRequestIdGuidancePlugin.cs | 10 ++-- .../Guidance/GraphConnectorGuidancePlugin.cs | 16 +++--- .../Guidance/GraphSdkGuidancePlugin.cs | 10 ++-- .../Guidance/GraphSelectGuidancePlugin.cs | 10 ++-- .../Guidance/ODSPSearchGuidancePlugin.cs | 10 ++-- .../Guidance/ODataPagingGuidancePlugin.cs | 46 ++++++++-------- DevProxy.Plugins/Inspection/DevToolsPlugin.cs | 53 +++++++++--------- .../Inspection/OpenAITelemetryPlugin.cs | 35 ++++++------ .../Inspection/OpenAIUsageDebuggingPlugin.cs | 8 +-- .../Manipulation/RewritePlugin.cs | 10 ++-- DevProxy.Plugins/Mocking/AuthPlugin.cs | 34 ++++++------ DevProxy.Plugins/Mocking/CrudApiPlugin.cs | 54 +++++++++---------- .../Mocking/EntraMockResponsePlugin.cs | 12 ++--- .../Mocking/GraphMockResponsePlugin.cs | 8 +-- .../Mocking/MockResponsePlugin.cs | 10 ++-- .../Mocking/OpenAIMockResponsePlugin.cs | 10 ++-- .../ApiCenterMinimalPermissionsPlugin.cs | 4 +- .../ApiCenterProductionVersionPlugin.cs | 2 +- .../Reporting/ExecutionSummaryPlugin.cs | 4 +- .../GraphMinimalPermissionsGuidancePlugin.cs | 4 +- .../GraphMinimalPermissionsPlugin.cs | 2 +- .../Reporting/MinimalCsomPermissionsPlugin.cs | 6 +-- .../MinimalPermissionsGuidancePlugin.cs | 4 +- .../Reporting/MinimalPermissionsPlugin.cs | 2 +- .../Reporting/UrlDiscoveryPlugin.cs | 4 +- DevProxy.Plugins/packages.lock.json | 16 ------ .../TitaniumRequestAdapter.cs | 6 ++- .../TitaniumResponseAdapter.cs | 3 ++ DevProxy/Proxy/ProxyEngine.cs | 12 ++--- 54 files changed, 322 insertions(+), 430 deletions(-) delete mode 100644 DevProxy.Abstractions/Extensions/FuncExtensions.cs diff --git a/DevProxy.Abstractions/DevProxy.Abstractions.csproj b/DevProxy.Abstractions/DevProxy.Abstractions.csproj index 434b45d3..5d534a4e 100644 --- a/DevProxy.Abstractions/DevProxy.Abstractions.csproj +++ b/DevProxy.Abstractions/DevProxy.Abstractions.csproj @@ -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/Proxy/Http/IHttpRequest.cs b/DevProxy.Abstractions/Proxy/Http/IHttpRequest.cs index 314f1ec8..79389339 100644 --- a/DevProxy.Abstractions/Proxy/Http/IHttpRequest.cs +++ b/DevProxy.Abstractions/Proxy/Http/IHttpRequest.cs @@ -15,7 +15,7 @@ public interface IHttpRequest : IHttpMessage /// /// Convenience accessor equal to .AbsoluteUri. /// - string Url { get; } + string Url { get; set; } /// HTTP method (e.g. GET, POST), upper-cased. string Method { get; } diff --git a/DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs b/DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs index d0505a57..171a3b48 100644 --- a/DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs +++ b/DevProxy.Abstractions/Proxy/Http/IHttpResponse.cs @@ -19,4 +19,7 @@ public interface IHttpResponse : IHttpMessage /// . /// string? StatusDescription { get; set; } + + /// Negotiated HTTP version for this response. + Version HttpVersion { get; } } diff --git a/DevProxy.Abstractions/Proxy/IProxyLogger.cs b/DevProxy.Abstractions/Proxy/IProxyLogger.cs index 4330268e..bff280dd 100644 --- a/DevProxy.Abstractions/Proxy/IProxyLogger.cs +++ b/DevProxy.Abstractions/Proxy/IProxyLogger.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 507a1202..edc4cb24 100644 --- a/DevProxy.Abstractions/Proxy/ProxyEvents.cs +++ b/DevProxy.Abstractions/Proxy/ProxyEvents.cs @@ -6,7 +6,6 @@ using DevProxy.Abstractions.Utils; using System.CommandLine; using System.Text.Json.Serialization; -using Titanium.Web.Proxy.EventArguments; namespace DevProxy.Abstractions.Proxy; @@ -16,16 +15,8 @@ public class ProxyEventArgsBase public Dictionary GlobalData { get; init; } = []; } -public class ProxyHttpEventArgsBase(SessionEventArgs session, IProxySession proxySession) : ProxyEventArgsBase +public class ProxyHttpEventArgsBase(IProxySession proxySession) : ProxyEventArgsBase { - public SessionEventArgs Session { get; } = session ?? - throw new ArgumentNullException(nameof(session)); - - /// - /// Engine-agnostic view of the session. Plugins should prefer this over - /// , which exposes the underlying Titanium engine type - /// and will be removed once all plugins are migrated to the canonical model. - /// public IProxySession ProxySession { get; } = proxySession ?? throw new ArgumentNullException(nameof(proxySession)); @@ -33,8 +24,8 @@ public bool HasRequestUrlMatch(ISet watchedUrls) => ProxyUtils.MatchesUrlToWatch(watchedUrls, ProxySession.Request.RequestUri.AbsoluteUri); } -public class ProxyRequestArgs(SessionEventArgs session, IProxySession proxySession, ResponseState responseState) : - ProxyHttpEventArgsBase(session, proxySession) +public class ProxyRequestArgs(IProxySession proxySession, ResponseState responseState) : + ProxyHttpEventArgsBase(proxySession) { public ResponseState ResponseState { get; } = responseState ?? throw new ArgumentNullException(nameof(responseState)); @@ -44,8 +35,8 @@ public bool ShouldExecute(ISet watchedUrls) => && HasRequestUrlMatch(watchedUrls); } -public class ProxyResponseArgs(SessionEventArgs session, IProxySession proxySession, ResponseState responseState) : - ProxyHttpEventArgsBase(session, proxySession) +public class ProxyResponseArgs(IProxySession proxySession, ResponseState responseState) : + ProxyHttpEventArgsBase(proxySession) { public ResponseState ResponseState { get; } = responseState ?? throw new ArgumentNullException(nameof(responseState)); @@ -81,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/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs index a8559ec1..e398bd27 100644 --- a/DevProxy.Abstractions/Utils/ProxyUtils.cs +++ b/DevProxy.Abstractions/Utils/ProxyUtils.cs @@ -15,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; @@ -105,13 +104,6 @@ static ProxyUtils() JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); } - public static bool IsGraphRequest(Request request) - { - ArgumentNullException.ThrowIfNull(request); - - return IsGraphUrl(request.RequestUri); - } - public static bool IsGraphRequest(IHttpRequest request) { ArgumentNullException.ThrowIfNull(request); @@ -144,13 +136,6 @@ public static Uri GetAbsoluteRequestUrlFromBatch(Uri batchRequestUri, string rel return absoluteRequestUrl; } - public static bool IsSdkRequest(Request request) - { - ArgumentNullException.ThrowIfNull(request); - - return request.Headers.HeaderExists("SdkVersion"); - } - public static bool IsSdkRequest(IHttpRequest request) { ArgumentNullException.ThrowIfNull(request); @@ -158,10 +143,6 @@ public static bool IsSdkRequest(IHttpRequest request) 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); @@ -177,24 +158,6 @@ public static bool IsGraphBetaUrl(Uri uri) return uri.AbsolutePath.Contains("/beta/", StringComparison.OrdinalIgnoreCase); } - /// - /// Utility to build HTTP response headers consistent with Microsoft Graph - /// - /// The http request for which response headers are being constructed - /// 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) - { - if (!IsGraphRequest(request)) - { - return []; - } - - var hasOrigin = request.Headers.FirstOrDefault((h) => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)) is not null; - return BuildGraphResponseHeadersCore(hasOrigin, requestId, requestDate); - } - /// /// Utility to build HTTP response headers consistent with Microsoft Graph /// 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.Plugins/Behavior/GenericRandomErrorPlugin.cs b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs index 51973af0..8c6f8d7c 100644 --- a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs +++ b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs @@ -115,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; } @@ -128,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); @@ -152,7 +152,7 @@ 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)); } } @@ -290,7 +290,7 @@ private void UpdateProxyResponse(ProxyRequestArgs e, GenericErrorResponseRespons 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() diff --git a/DevProxy.Plugins/Behavior/GraphRandomErrorPlugin.cs b/DevProxy.Plugins/Behavior/GraphRandomErrorPlugin.cs index 6b9d2ed8..1e69d29e 100644 --- a/DevProxy.Plugins/Behavior/GraphRandomErrorPlugin.cs +++ b/DevProxy.Plugins/Behavior/GraphRandomErrorPlugin.cs @@ -176,19 +176,19 @@ 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.ProxySession.Request.RequestUri)) @@ -319,7 +319,7 @@ private void UpdateProxyResponse(ProxyRequestArgs e, HttpStatusCode errorStatus) }), ProxyUtils.JsonSerializerOptions ); - Logger.LogRequest($"{(int)errorStatus} {errorStatus}", MessageType.Chaos, new LoggingContext(e.Session)); + 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))); } @@ -334,7 +334,7 @@ private void UpdateProxyBatchResponse(ProxyRequestArgs ev, GraphBatchResponsePay 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)); + 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))); } 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 40ea64a5..a5238b1c 100644 --- a/DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs +++ b/DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs @@ -79,12 +79,12 @@ 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; } @@ -92,13 +92,13 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca 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; } @@ -124,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) { @@ -186,7 +186,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca } 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)); e.ProxySession.Respond("Custom response file not found.", HttpStatusCode.InternalServerError, []); state.HasBeenSet = true; } @@ -208,7 +208,7 @@ 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; } @@ -253,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) 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 489c6340..46cd78ac 100644 --- a/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs +++ b/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs @@ -91,12 +91,12 @@ 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; } @@ -124,7 +124,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca _resourcesRemaining = 0; 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)) @@ -185,13 +185,13 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca } 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); @@ -206,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; } diff --git a/DevProxy.Plugins/Behavior/RetryAfterPlugin.cs b/DevProxy.Plugins/Behavior/RetryAfterPlugin.cs index 7457bb38..4eb4a72a 100644 --- a/DevProxy.Plugins/Behavior/RetryAfterPlugin.cs +++ b/DevProxy.Plugins/Behavior/RetryAfterPlugin.cs @@ -33,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.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; } @@ -58,13 +58,13 @@ private void ThrottleIfNecessary(ProxyRequestArgs e) 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; } @@ -76,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; } @@ -86,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)); @@ -94,7 +94,7 @@ 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) diff --git a/DevProxy.Plugins/DevProxy.Plugins.csproj b/DevProxy.Plugins/DevProxy.Plugins.csproj index 5edf8179..1c7d0709 100644 --- a/DevProxy.Plugins/DevProxy.Plugins.csproj +++ b/DevProxy.Plugins/DevProxy.Plugins.csproj @@ -65,10 +65,6 @@ false runtime - - false - runtime - diff --git a/DevProxy.Plugins/Extensions/ApiCenterExtensions.cs b/DevProxy.Plugins/Extensions/ApiCenterExtensions.cs index d16603c7..7235b235 100644 --- a/DevProxy.Plugins/Extensions/ApiCenterExtensions.cs +++ b/DevProxy.Plugins/Extensions/ApiCenterExtensions.cs @@ -201,7 +201,7 @@ internal static IEnumerable GetUrls(this Api api) // check headers Debug.Assert(request.Context is not null); - var header = request.Context.Session.HttpClient.Request.Headers.FirstOrDefault( + var header = request.Context.Session.Request.Headers.FirstOrDefault( h => (!string.IsNullOrEmpty(apiVersion.Name) && h.Value.Contains(apiVersion.Name, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrEmpty(apiVersion.Properties?.Title) && h.Value.Contains(apiVersion.Properties.Title, StringComparison.OrdinalIgnoreCase)) diff --git a/DevProxy.Plugins/Extensions/OpenApiDocumentExtensions.cs b/DevProxy.Plugins/Extensions/OpenApiDocumentExtensions.cs index 0677769c..069eea0b 100644 --- a/DevProxy.Plugins/Extensions/OpenApiDocumentExtensions.cs +++ b/DevProxy.Plugins/Extensions/OpenApiDocumentExtensions.cs @@ -36,7 +36,7 @@ public static ApiPermissionsInfo CheckMinimalPermissions(this OpenApiDocument op logger.LogDebug("Checking request {Request}...", methodAndUrl); var (method, url) = (methodAndUrlChunks[0].ToUpperInvariant(), methodAndUrlChunks[1]); - var authorizationHeaderValue = request.Context?.Session.HttpClient.Request.Headers.FirstOrDefault(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase))?.Value; + var authorizationHeaderValue = request.Context?.Session.Request.Headers.FirstOrDefault(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase))?.Value; if (authorizationHeaderValue is null) { errors.Add(new() diff --git a/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs b/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs index 217f9a23..b0e3ed8c 100644 --- a/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/HarGeneratorPlugin.cs @@ -68,7 +68,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation r is not null && r.Context is not null && r.Context.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, r.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)).Select(CreateHarEntry)] + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, r.Context.Session.Request.RequestUri.AbsoluteUri)).Select(CreateHarEntry)] } }; @@ -102,8 +102,8 @@ private HarEntry CreateHarEntry(RequestLog log) Debug.Assert(log is not null); Debug.Assert(log.Context is not null); - var request = log.Context.Session.HttpClient.Request; - var response = log.Context.Session.HttpClient.Response; + var request = log.Context.Session.Request; + var response = log.Context.Session.Response!; var entry = new HarEntry { @@ -129,17 +129,17 @@ private HarEntry CreateHarEntry(RequestLog log) return new HarCookie { Name = parts[0].Trim(), Value = parts.Length > 1 ? parts[1].Trim() : "" }; })], HeadersSize = request.Headers?.ToString()?.Length ?? 0, - BodySize = request.HasBody ? (request.Body?.Length ?? 0) : 0, + BodySize = request.HasBody ? request.Body.Length : 0, PostData = request.HasBody ? new HarPostData { MimeType = request.ContentType, - Text = request.Body is not null ? HttpUtils.GetBodyString(request.ContentType, request.Body) : "" + Text = HttpUtils.GetBodyString(request.ContentType, request.Body.ToArray()) } : null }, Response = response is not null ? new HarResponse { - Status = response.StatusCode, + Status = (int)response.StatusCode, StatusText = response.StatusDescription, HttpVersion = $"HTTP/{response.HttpVersion}", Headers = [.. response.Headers.Select(h => new HarHeader { Name = h.Name, Value = GetHeaderValue(h.Name, string.Join(", ", h.Value)) })], @@ -153,12 +153,12 @@ private HarEntry CreateHarEntry(RequestLog log) })], Content = new HarContent { - Size = response.HasBody ? (response.Body?.Length ?? 0) : 0, + Size = response.HasBody ? response.Body.Length : 0, MimeType = response.ContentType ?? "", - Text = Configuration.IncludeResponse && response.HasBody && response.Body is not null ? HttpUtils.GetBodyString(response.ContentType, response.Body) : null + Text = Configuration.IncludeResponse && response.HasBody ? HttpUtils.GetBodyString(response.ContentType, response.Body.ToArray()) : null }, HeadersSize = response.Headers?.ToString()?.Length ?? 0, - BodySize = response.HasBody ? (response.Body?.Length ?? 0) : 0 + BodySize = response.HasBody ? response.Body.Length : 0 } : null }; diff --git a/DevProxy.Plugins/Generation/HttpFileGeneratorPlugin.cs b/DevProxy.Plugins/Generation/HttpFileGeneratorPlugin.cs index 302b435b..bfbb4ffe 100644 --- a/DevProxy.Plugins/Generation/HttpFileGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/HttpFileGeneratorPlugin.cs @@ -142,15 +142,15 @@ private async Task GetHttpRequestsAsync(IEnumerable reques if (request.MessageType != MessageType.InterceptedResponse || request.Context is null || request.Context.Session is null || - !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) + !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.Request.RequestUri.AbsoluteUri)) { continue; } if (!Configuration.IncludeOptionsRequests && - string.Equals(request.Context.Session.HttpClient.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + string.Equals(request.Context.Session.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) { - Logger.LogDebug("Skipping OPTIONS request {Url}...", request.Context.Session.HttpClient.Request.RequestUri); + Logger.LogDebug("Skipping OPTIONS request {Url}...", request.Context.Session.Request.RequestUri); continue; } @@ -162,8 +162,8 @@ request.Context.Session is null || { Method = methodAndUrl[0], Url = methodAndUrl[1], - Body = request.Context.Session.HttpClient.Request.HasBody ? await request.Context.Session.GetRequestBodyAsString(cancellationToken) : null, - Headers = [.. request.Context.Session.HttpClient.Request.Headers.Select(h => new HttpFileRequestHeader { Name = h.Name, Value = h.Value })] + Body = request.Context.Session.Request.HasBody ? request.Context.Session.Request.BodyString : null, + Headers = [.. request.Context.Session.Request.Headers.Select(h => new HttpFileRequestHeader { Name = h.Name, Value = h.Value })] }); } diff --git a/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs b/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs index 7ed8b64c..3f354b55 100644 --- a/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs @@ -5,11 +5,11 @@ using DevProxy.Abstractions.Models; using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Mocking; using Microsoft.Extensions.Logging; using System.Text.Json; -using Titanium.Web.Proxy.EventArguments; namespace DevProxy.Plugins.Generation; @@ -42,7 +42,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation if (request.MessageType != MessageType.InterceptedResponse || request.Context is null || request.Context.Session is null || - !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) + !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.Request.RequestUri.AbsoluteUri)) { continue; } @@ -51,7 +51,7 @@ request.Context.Session is null || Logger.LogDebug("Processing request {MethodAndUrlString}...", methodAndUrlString); var (method, url) = GetMethodAndUrl(methodAndUrlString); - var response = request.Context.Session.HttpClient.Response; + var response = request.Context.Session.Response!; var newHeaders = new List(); newHeaders.AddRange(response.Headers.Select(h => new MockResponseHeader(h.Name, h.Value))); @@ -64,7 +64,7 @@ request.Context.Session is null || }, Response = new() { - StatusCode = response.StatusCode, + StatusCode = (int)response.StatusCode, Headers = newHeaders, Body = await GetResponseBodyAsync(request.Context.Session, cancellationToken) } @@ -109,11 +109,11 @@ request.Context.Session is null || /// /// Request session /// Response body or @filename for binary responses - private async Task GetResponseBodyAsync(SessionEventArgs session, CancellationToken cancellationToken) + private async Task GetResponseBodyAsync(IProxySession session, CancellationToken cancellationToken) { Logger.LogDebug("Getting response body..."); - var response = session.HttpClient.Response; + var response = session.Response!; if (response.ContentType is null || !response.HasBody) { Logger.LogDebug("Response has no content-type set or has no body. Skipping"); @@ -127,7 +127,7 @@ request.Context.Session is null || try { Logger.LogDebug("Reading response body as string..."); - var body = response.IsBodyRead ? response.BodyString : await session.GetResponseBodyAsString(cancellationToken); + var body = response.BodyString; Logger.LogDebug("Body: {Body}", body); Logger.LogDebug("Deserializing response body..."); return JsonSerializer.Deserialize(body, ProxyUtils.JsonSerializerOptions); @@ -145,7 +145,7 @@ request.Context.Session is null || { var filename = $"response-{Guid.NewGuid()}.bin"; Logger.LogDebug("Reading response body as bytes..."); - var body = await session.GetResponseBody(cancellationToken); + var body = response.Body.ToArray(); Logger.LogDebug("Writing response body to {Filename}...", filename); await File.WriteAllBytesAsync(filename, body, cancellationToken); return $"@{filename}"; diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index e64df782..131ba71a 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -5,6 +5,7 @@ using DevProxy.Abstractions.LanguageModel; using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -15,8 +16,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Web; -using Titanium.Web.Proxy.EventArguments; -using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Generation; @@ -99,15 +98,15 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation if (request.MessageType != MessageType.InterceptedResponse || request.Context is null || request.Context.Session is null || - !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) + !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.Request.RequestUri.AbsoluteUri)) { continue; } if (!Configuration.IncludeOptionsRequests && - string.Equals(request.Context.Session.HttpClient.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + string.Equals(request.Context.Session.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) { - Logger.LogDebug("Skipping OPTIONS request {Url}...", request.Context.Session.HttpClient.Request.RequestUri); + Logger.LogDebug("Skipping OPTIONS request {Url}...", request.Context.Session.Request.RequestUri); continue; } @@ -117,7 +116,7 @@ request.Context.Session is null || try { var pathItem = GetOpenApiPathItem(request.Context.Session); - var parametrizedPath = ParametrizePath(pathItem, request.Context.Session.HttpClient.Request.RequestUri); + var parametrizedPath = ParametrizePath(pathItem, request.Context.Session.Request.RequestUri); if (pathItem.Operations is null) { Logger.LogDebug("No operations found for request {MethodAndUrlString}. Skipping...", methodAndUrlString); @@ -126,17 +125,17 @@ request.Context.Session is null || var operationInfo = pathItem.Operations.First(); operationInfo.Value.OperationId = await GetOperationIdAsync( operationInfo.Key.ToString(), - request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), + request.Context.Session.Request.RequestUri.GetLeftPart(UriPartial.Authority), parametrizedPath, cancellationToken ); operationInfo.Value.Description = await GetOperationDescriptionAsync( operationInfo.Key.ToString(), - request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), + request.Context.Session.Request.RequestUri.GetLeftPart(UriPartial.Authority), parametrizedPath, cancellationToken ); - AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); + AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.Request.RequestUri, parametrizedPath); } catch (Exception ex) { @@ -223,10 +222,10 @@ private async Task GetOperationDescriptionAsync(string method, string se * Creates an OpenAPI PathItem from an intercepted request and response pair. * @param session The intercepted session. */ - private OpenApiPathItem GetOpenApiPathItem(SessionEventArgs session) + private OpenApiPathItem GetOpenApiPathItem(IProxySession session) { - var request = session.HttpClient.Request; - var response = session.HttpClient.Response; + var request = session.Request; + var response = session.Response!; var resource = GetLastNonTokenSegment(request.RequestUri.Segments); var path = new OpenApiPathItem @@ -263,7 +262,7 @@ private OpenApiPathItem GetOpenApiPathItem(SessionEventArgs session) return path; } - private void SetRequestBody(OpenApiOperation operation, Request request) + private void SetRequestBody(OpenApiOperation operation, IHttpRequest request) { if (!request.HasBody) { @@ -293,10 +292,10 @@ private void SetRequestBody(OpenApiOperation operation, Request request) }; } - private void SetParametersFromRequestHeaders(OpenApiOperation operation, HeaderCollection headers) + private void SetParametersFromRequestHeaders(OpenApiOperation operation, IHeaderCollection headers) { if (headers is null || - !headers.Any()) + headers.Count == 0) { Logger.LogDebug(" Request has no headers"); return; @@ -373,7 +372,7 @@ private static void SetParameterDefault(OpenApiParameter parameter, object? valu } } - private void SetResponseFromSession(OpenApiOperation operation, Response response) + private void SetResponseFromSession(OpenApiOperation operation, IHttpResponse response) { if (response is null) { @@ -387,7 +386,7 @@ private void SetResponseFromSession(OpenApiOperation operation, Response respons { Description = response.StatusDescription }; - var responseCode = response.StatusCode.ToString(CultureInfo.InvariantCulture); + var responseCode = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture); if (response.HasBody) { Logger.LogDebug(" Response has body"); @@ -402,7 +401,7 @@ private void SetResponseFromSession(OpenApiOperation operation, Response respons Logger.LogDebug(" Response doesn't have body"); } - if (response.Headers is not null && response.Headers.Any()) + if (response.Headers is not null && response.Headers.Count > 0) { Logger.LogDebug(" Response has headers"); diff --git a/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs index b85d8c0d..84b6baf3 100644 --- a/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/TypeSpecGeneratorPlugin.cs @@ -5,6 +5,7 @@ using DevProxy.Abstractions.LanguageModel; using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Models.TypeSpec; using Microsoft.Extensions.Configuration; @@ -14,7 +15,6 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Web; -using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Generation; @@ -80,8 +80,8 @@ request.Context.Session is null || request.Url is null || request.Method is null || // TypeSpec does not support OPTIONS requests - string.Equals(request.Context.Session.HttpClient.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase) || - !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) + string.Equals(request.Context.Session.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase) || + !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.Request.RequestUri.AbsoluteUri)) { continue; } @@ -145,8 +145,8 @@ private async Task GetOperationAsync(RequestLog request, TypeSpecFile Debug.Assert(request.Url is not null, "request.Url is null"); var url = new Uri(request.Url); - var httpRequest = request.Context.Session.HttpClient.Request; - var httpResponse = request.Context.Session.HttpClient.Response; + var httpRequest = request.Context.Session.Request; + var httpResponse = request.Context.Session.Response!; var (route, parameters) = await GetRouteAndParametersAsync(url); var op = new Operation @@ -170,7 +170,7 @@ private async Task GetOperationAsync(RequestLog request, TypeSpecFile return op; } - private void ProcessAuth(Request httpRequest, TypeSpecFile doc, Operation op) + private void ProcessAuth(IHttpRequest httpRequest, TypeSpecFile doc, Operation op) { Logger.LogTrace("Entered {Name}", nameof(ProcessAuth)); @@ -327,7 +327,7 @@ private bool IsJwtToken(string bearerToken, out JwtSecurityToken jwtToken) return false; } - private async Task ProcessRequestBodyAsync(Request httpRequest, TypeSpecFile doc, Operation op, string lastSegment) + private async Task ProcessRequestBodyAsync(IHttpRequest httpRequest, TypeSpecFile doc, Operation op, string lastSegment) { Logger.LogTrace("Entered {Name}", nameof(ProcessRequestBodyAsync)); @@ -357,7 +357,7 @@ private async Task ProcessRequestBodyAsync(Request httpRequest, TypeSpecFile doc Logger.LogTrace("Left {Name}", nameof(ProcessRequestBodyAsync)); } - private void ProcessRequestHeaders(Request httpRequest, Operation op) + private void ProcessRequestHeaders(IHttpRequest httpRequest, Operation op) { Logger.LogTrace("Entered {Name}", nameof(ProcessRequestHeaders)); @@ -380,7 +380,7 @@ private void ProcessRequestHeaders(Request httpRequest, Operation op) Logger.LogTrace("Left {Name}", nameof(ProcessRequestHeaders)); } - private async Task ProcessResponseAsync(Response? httpResponse, TypeSpecFile doc, Operation op, string lastSegment, Uri url) + private async Task ProcessResponseAsync(IHttpResponse? httpResponse, TypeSpecFile doc, Operation op, string lastSegment, Uri url) { Logger.LogTrace("Entered {Name}", nameof(ProcessResponseAsync)); @@ -396,7 +396,7 @@ private async Task ProcessResponseAsync(Response? httpResponse, TypeSpecFile doc { res = new() { - StatusCode = httpResponse.StatusCode, + StatusCode = (int)httpResponse.StatusCode, BodyType = "string" }; } @@ -404,7 +404,7 @@ private async Task ProcessResponseAsync(Response? httpResponse, TypeSpecFile doc { res = new() { - StatusCode = httpResponse.StatusCode, + StatusCode = (int)httpResponse.StatusCode, Headers = httpResponse.Headers .Where(h => !Models.Http.StandardHeaders.Contains(h.Name.ToLowerInvariant()) && !Models.Http.AuthHeaders.Contains(h.Name.ToLowerInvariant())) @@ -413,7 +413,7 @@ private async Task ProcessResponseAsync(Response? httpResponse, TypeSpecFile doc if (httpResponse.HasBody) { - var models = await GetModelsFromStringAsync(httpResponse.BodyString, lastSegment.ToPascalCase(), httpResponse.StatusCode >= 400); + var models = await GetModelsFromStringAsync(httpResponse.BodyString, lastSegment.ToPascalCase(), (int)httpResponse.StatusCode >= 400); if (models.Length > 0) { foreach (var model in models) diff --git a/DevProxy.Plugins/Guidance/CachingGuidancePlugin.cs b/DevProxy.Plugins/Guidance/CachingGuidancePlugin.cs index d5808c12..9506ba86 100644 --- a/DevProxy.Plugins/Guidance/CachingGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/CachingGuidancePlugin.cs @@ -40,12 +40,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 (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; } @@ -57,7 +57,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca { value = now; _interceptedRequests.Add(url, value); - Logger.LogRequest("First request", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("First request", MessageType.Skipped, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } @@ -65,11 +65,11 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca var secondsSinceLastIntercepted = (now - lastIntercepted).TotalSeconds; if (secondsSinceLastIntercepted <= Configuration.CacheThresholdSeconds) { - Logger.LogRequest(BuildCacheWarningMessage(request, Configuration.CacheThresholdSeconds, lastIntercepted), MessageType.Warning, new LoggingContext(e.Session)); + Logger.LogRequest(BuildCacheWarningMessage(request, Configuration.CacheThresholdSeconds, lastIntercepted), MessageType.Warning, new LoggingContext(e.ProxySession)); } else { - Logger.LogRequest("Request outside of cache window", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Request outside of cache window", MessageType.Skipped, new LoggingContext(e.ProxySession)); } _interceptedRequests[url] = now; diff --git a/DevProxy.Plugins/Guidance/GraphBetaSupportGuidancePlugin.cs b/DevProxy.Plugins/Guidance/GraphBetaSupportGuidancePlugin.cs index 20bf0c93..c5921c01 100644 --- a/DevProxy.Plugins/Guidance/GraphBetaSupportGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/GraphBetaSupportGuidancePlugin.cs @@ -21,24 +21,24 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c ArgumentNullException.ThrowIfNull(e); - var request = e.Session.HttpClient.Request; + var request = e.ProxySession.Request; 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 (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; } if (!ProxyUtils.IsGraphBetaRequest(request)) { - Logger.LogRequest("Not a Microsoft Graph beta request", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Not a Microsoft Graph beta request", MessageType.Skipped, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } - Logger.LogRequest(BuildBetaSupportMessage(), MessageType.Warning, new LoggingContext(e.Session)); + Logger.LogRequest(BuildBetaSupportMessage(), MessageType.Warning, new LoggingContext(e.ProxySession)); Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); return Task.CompletedTask; } diff --git a/DevProxy.Plugins/Guidance/GraphClientRequestIdGuidancePlugin.cs b/DevProxy.Plugins/Guidance/GraphClientRequestIdGuidancePlugin.cs index 35faaaa1..d1bf75d0 100644 --- a/DevProxy.Plugins/Guidance/GraphClientRequestIdGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/GraphClientRequestIdGuidancePlugin.cs @@ -26,27 +26,27 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca var request = e.ProxySession.Request; 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 (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; } if (WarnNoClientRequestId(request)) { - Logger.LogRequest(BuildAddClientRequestIdMessage(), MessageType.Warning, new LoggingContext(e.Session)); + Logger.LogRequest(BuildAddClientRequestIdMessage(), MessageType.Warning, new LoggingContext(e.ProxySession)); if (!ProxyUtils.IsSdkRequest(request)) { - Logger.LogRequest(MessageUtils.BuildUseSdkMessage(), MessageType.Tip, new LoggingContext(e.Session)); + Logger.LogRequest(MessageUtils.BuildUseSdkMessage(), MessageType.Tip, new LoggingContext(e.ProxySession)); } } else { - Logger.LogRequest("client-request-id header present", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("client-request-id header present", MessageType.Skipped, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); diff --git a/DevProxy.Plugins/Guidance/GraphConnectorGuidancePlugin.cs b/DevProxy.Plugins/Guidance/GraphConnectorGuidancePlugin.cs index 2d33a921..190b6e46 100644 --- a/DevProxy.Plugins/Guidance/GraphConnectorGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/GraphConnectorGuidancePlugin.cs @@ -42,28 +42,28 @@ 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 (!string.Equals(e.Session.HttpClient.Request.Method, "PATCH", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(e.ProxySession.Request.Method, "PATCH", StringComparison.OrdinalIgnoreCase)) { - Logger.LogRequest("Skipping non-PATCH request", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Skipping non-PATCH request", MessageType.Skipped, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } try { - var schemaString = e.Session.HttpClient.Request.BodyString; + var schemaString = e.ProxySession.Request.BodyString; if (string.IsNullOrEmpty(schemaString)) { - Logger.LogRequest("No schema found in the request body.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("No schema found in the request body.", MessageType.Failed, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } var schema = JsonSerializer.Deserialize(schemaString, ProxyUtils.JsonSerializerOptions); if (schema is null || schema.Properties is null) { - Logger.LogRequest("Invalid schema found in the request body.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("Invalid schema found in the request body.", MessageType.Failed, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } @@ -99,12 +99,12 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca Logger.LogRequest( $"The schema is missing the following semantic labels: {string.Join(", ", missingLabels.Where(s => !string.IsNullOrEmpty(s)))}. Ingested content might not show up in Microsoft Copilot for Microsoft 365. More information: https://aka.ms/devproxy/guidance/gc/ux", - MessageType.Failed, new LoggingContext(e.Session) + MessageType.Failed, new LoggingContext(e.ProxySession) ); } else { - Logger.LogRequest("The schema contains all the required semantic labels.", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("The schema contains all the required semantic labels.", MessageType.Skipped, new LoggingContext(e.ProxySession)); } } catch (Exception ex) diff --git a/DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs b/DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs index 4e7fcdd5..43309a88 100644 --- a/DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/GraphSdkGuidancePlugin.cs @@ -26,12 +26,12 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c var request = e.ProxySession.Request; 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 (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; } @@ -40,16 +40,16 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c { if (WarnNoSdk(request)) { - Logger.LogRequest(MessageUtils.BuildUseSdkForErrorsMessage(), MessageType.Tip, new LoggingContext(e.Session)); + Logger.LogRequest(MessageUtils.BuildUseSdkForErrorsMessage(), MessageType.Tip, new LoggingContext(e.ProxySession)); } else { - Logger.LogRequest("Request issued using SDK", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Request issued using SDK", MessageType.Skipped, new LoggingContext(e.ProxySession)); } } else { - Logger.LogRequest("Skipping non-error response", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Skipping non-error response", MessageType.Skipped, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(AfterResponseAsync)); diff --git a/DevProxy.Plugins/Guidance/GraphSelectGuidancePlugin.cs b/DevProxy.Plugins/Guidance/GraphSelectGuidancePlugin.cs index b465caf3..c10bca46 100644 --- a/DevProxy.Plugins/Guidance/GraphSelectGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/GraphSelectGuidancePlugin.cs @@ -38,18 +38,18 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c 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 (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; } if (WarnNoSelect(e)) { - Logger.LogRequest(BuildUseSelectMessage(), MessageType.Warning, new LoggingContext(e.Session)); + Logger.LogRequest(BuildUseSelectMessage(), MessageType.Warning, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(AfterResponseAsync)); @@ -62,7 +62,7 @@ private bool WarnNoSelect(ProxyResponseArgs e) if (!ProxyUtils.IsGraphRequest(request) || request.Method != "GET") { - Logger.LogRequest("Not a Microsoft Graph GET request", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Not a Microsoft Graph GET request", MessageType.Skipped, new LoggingContext(e.ProxySession)); return false; } @@ -76,7 +76,7 @@ private bool WarnNoSelect(ProxyResponseArgs e) } else { - Logger.LogRequest("Endpoint does not support $select", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Endpoint does not support $select", MessageType.Skipped, new LoggingContext(e.ProxySession)); return false; } } diff --git a/DevProxy.Plugins/Guidance/ODSPSearchGuidancePlugin.cs b/DevProxy.Plugins/Guidance/ODSPSearchGuidancePlugin.cs index 34cd3345..983d9faf 100644 --- a/DevProxy.Plugins/Guidance/ODSPSearchGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/ODSPSearchGuidancePlugin.cs @@ -23,18 +23,18 @@ 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 (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; } if (WarnDeprecatedSearch(e)) { - Logger.LogRequest(BuildUseGraphSearchMessage(), MessageType.Warning, new LoggingContext(e.Session)); + Logger.LogRequest(BuildUseGraphSearchMessage(), MessageType.Warning, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); @@ -47,7 +47,7 @@ private bool WarnDeprecatedSearch(ProxyRequestArgs e) if (!ProxyUtils.IsGraphRequest(request) || request.Method != "GET") { - Logger.LogRequest("Not a Microsoft Graph GET request", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Not a Microsoft Graph GET request", MessageType.Skipped, new LoggingContext(e.ProxySession)); return false; } @@ -65,7 +65,7 @@ private bool WarnDeprecatedSearch(ProxyRequestArgs e) } else { - Logger.LogRequest("Not a SharePoint search request", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Not a SharePoint search request", MessageType.Skipped, new LoggingContext(e.ProxySession)); return false; } } diff --git a/DevProxy.Plugins/Guidance/ODataPagingGuidancePlugin.cs b/DevProxy.Plugins/Guidance/ODataPagingGuidancePlugin.cs index 973f95af..6fe079fd 100644 --- a/DevProxy.Plugins/Guidance/ODataPagingGuidancePlugin.cs +++ b/DevProxy.Plugins/Guidance/ODataPagingGuidancePlugin.cs @@ -27,29 +27,29 @@ 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 (!string.Equals(e.Session.HttpClient.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(e.ProxySession.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) { - Logger.LogRequest("Skipping non-GET request", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Skipping non-GET request", MessageType.Skipped, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } - if (IsODataPagingUrl(e.Session.HttpClient.Request.RequestUri)) + if (IsODataPagingUrl(e.ProxySession.Request.RequestUri)) { - if (!pagingUrls.Contains(e.Session.HttpClient.Request.Url)) + if (!pagingUrls.Contains(e.ProxySession.Request.Url)) { - Logger.LogRequest(BuildIncorrectPagingUrlMessage(), MessageType.Warning, new LoggingContext(e.Session)); + Logger.LogRequest(BuildIncorrectPagingUrlMessage(), MessageType.Warning, new LoggingContext(e.ProxySession)); } else { - Logger.LogRequest("Paging URL is correct", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Paging URL is correct", MessageType.Skipped, new LoggingContext(e.ProxySession)); } } else { - Logger.LogRequest("Not an OData paging URL", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Not an OData paging URL", MessageType.Skipped, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); @@ -64,39 +64,37 @@ public override async Task BeforeResponseAsync(ProxyResponseArgs e, Cancellation 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 (!string.Equals(e.Session.HttpClient.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(e.ProxySession.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) { - Logger.LogRequest("Skipping non-GET request", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Skipping non-GET request", MessageType.Skipped, new LoggingContext(e.ProxySession)); return; } - if (e.Session.HttpClient.Response.StatusCode >= 300) + if ((int)e.ProxySession.Response!.StatusCode >= 300) { - Logger.LogRequest("Skipping non-success response", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Skipping non-success response", MessageType.Skipped, new LoggingContext(e.ProxySession)); return; } - if (e.Session.HttpClient.Response.ContentType is null || - (!e.Session.HttpClient.Response.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase) && - !e.Session.HttpClient.Response.ContentType.Contains("application/atom+xml", StringComparison.OrdinalIgnoreCase)) || - !e.Session.HttpClient.Response.HasBody) + if (e.ProxySession.Response!.ContentType is null || + (!e.ProxySession.Response!.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase) && + !e.ProxySession.Response!.ContentType.Contains("application/atom+xml", StringComparison.OrdinalIgnoreCase)) || + !e.ProxySession.Response!.HasBody) { - Logger.LogRequest("Skipping response with unsupported body type", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Skipping response with unsupported body type", MessageType.Skipped, new LoggingContext(e.ProxySession)); return; } - e.Session.HttpClient.Response.KeepBody = true; - var nextLink = string.Empty; - var bodyString = await e.Session.GetResponseBodyAsString(cancellationToken); + var bodyString = e.ProxySession.Response!.BodyString; if (string.IsNullOrEmpty(bodyString)) { - Logger.LogRequest("Skipping empty response body", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Skipping empty response body", MessageType.Skipped, new LoggingContext(e.ProxySession)); return; } - var contentType = e.Session.HttpClient.Response.ContentType; + var contentType = e.ProxySession.Response!.ContentType; if (contentType.Contains("json", StringComparison.OrdinalIgnoreCase)) { nextLink = GetNextLinkFromJson(bodyString); @@ -112,7 +110,7 @@ public override async Task BeforeResponseAsync(ProxyResponseArgs e, Cancellation } else { - Logger.LogRequest("No next link found in the response", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("No next link found in the response", MessageType.Skipped, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(BeforeResponseAsync)); diff --git a/DevProxy.Plugins/Inspection/DevToolsPlugin.cs b/DevProxy.Plugins/Inspection/DevToolsPlugin.cs index 769077dd..2cc5f7fb 100644 --- a/DevProxy.Plugins/Inspection/DevToolsPlugin.cs +++ b/DevProxy.Plugins/Inspection/DevToolsPlugin.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Inspection.CDP; @@ -106,12 +107,12 @@ 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; } - var requestId = GetRequestId(e.Session.HttpClient.Request); - var headers = e.Session.HttpClient.Request.Headers + var requestId = GetRequestId(e.ProxySession); + var headers = e.ProxySession.Request.Headers .GroupBy(h => h.Name) .ToDictionary(g => g.Key, g => string.Join(", ", g.Select(h => h.Value))); @@ -121,13 +122,13 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo { RequestId = requestId, LoaderId = "1", - DocumentUrl = e.Session.HttpClient.Request.Url, + DocumentUrl = e.ProxySession.Request.Url, Request = new() { - Url = e.Session.HttpClient.Request.Url, - Method = e.Session.HttpClient.Request.Method, + Url = e.ProxySession.Request.Url, + Method = e.ProxySession.Request.Method, Headers = headers, - PostData = e.Session.HttpClient.Request.HasBody ? HttpUtils.GetBodyString(e.Session.HttpClient.Request.ContentType, e.Session.HttpClient.Request.Body) : null + PostData = e.ProxySession.Request.HasBody ? HttpUtils.GetBodyString(e.ProxySession.Request.ContentType, e.ProxySession.Request.Body.ToArray()) : null }, Timestamp = (double)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000, WallTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @@ -168,7 +169,7 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT 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; } @@ -177,22 +178,22 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT Body = string.Empty, Base64Encoded = false }; - if (e.Session.HttpClient.Response.HasBody) + if (e.ProxySession.Response!.HasBody) { - if (IsTextResponse(e.Session.HttpClient.Response.ContentType)) + if (IsTextResponse(e.ProxySession.Response!.ContentType)) { - body.Body = HttpUtils.GetBodyString(e.Session.HttpClient.Response.ContentType, e.Session.HttpClient.Response.Body); + body.Body = HttpUtils.GetBodyString(e.ProxySession.Response!.ContentType, e.ProxySession.Response!.Body.ToArray()); body.Base64Encoded = false; } else { - body.Body = Convert.ToBase64String(e.Session.HttpClient.Response.Body); + body.Body = Convert.ToBase64String(e.ProxySession.Response!.Body.ToArray()); body.Base64Encoded = true; } } - _responseBody[e.Session.HttpClient.Request.GetHashCode().ToString(CultureInfo.InvariantCulture)] = body; + _responseBody[e.ProxySession.SessionId] = body; - var requestId = GetRequestId(e.Session.HttpClient.Request); + var requestId = GetRequestId(e.ProxySession); var responseReceivedMessage = new ResponseReceivedMessage { @@ -204,13 +205,13 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT Type = "XHR", Response = new() { - Url = e.Session.HttpClient.Request.Url, - Status = e.Session.HttpClient.Response.StatusCode, - StatusText = e.Session.HttpClient.Response.StatusDescription, - Headers = e.Session.HttpClient.Response.Headers + Url = e.ProxySession.Request.Url, + Status = (int)e.ProxySession.Response!.StatusCode, + StatusText = e.ProxySession.Response!.StatusDescription, + Headers = e.ProxySession.Response!.Headers .GroupBy(h => h.Name) .ToDictionary(g => g.Key, g => string.Join(", ", g.Select(h => h.Value))), - MimeType = e.Session.HttpClient.Response.ContentType + MimeType = e.ProxySession.Response!.ContentType }, HasExtraInfo = true } @@ -218,7 +219,7 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT await _webSocket.SendAsync(responseReceivedMessage, cancellationToken); - if (e.Session.HttpClient.Response.ContentType == "text/event-stream") + if (e.ProxySession.Response!.ContentType == "text/event-stream") { await SendBodyAsDataReceivedAsync(requestId, body.Body, cancellationToken); } @@ -229,7 +230,7 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT { RequestId = requestId, Timestamp = (double)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000, - EncodedDataLength = e.Session.HttpClient.Response.HasBody ? e.Session.HttpClient.Response.Body.Length : 0 + EncodedDataLength = e.ProxySession.Response!.HasBody ? e.ProxySession.Response!.Body.Length : 0 } }; await _webSocket.SendAsync(loadingFinishedMessage, cancellationToken); @@ -258,8 +259,8 @@ public override async Task AfterRequestLogAsync(RequestLogArgs e, CancellationTo Text = string.Join(" ", e.RequestLog.Message), Level = Entry.GetLevel(e.RequestLog.MessageType), Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - Url = e.RequestLog.Context?.Session.HttpClient.Request.Url, - NetworkRequestId = GetRequestId(e.RequestLog.Context?.Session.HttpClient.Request) + Url = e.RequestLog.Context?.Session.Request.Url, + NetworkRequestId = GetRequestId(e.RequestLog.Context?.Session) } } }; @@ -1046,14 +1047,14 @@ private static int GetFreePort() return port; } - private static string GetRequestId(Titanium.Web.Proxy.Http.Request? request) + private static string GetRequestId(IProxySession? session) { - if (request is null) + if (session is null) { return string.Empty; } - return request.GetHashCode().ToString(CultureInfo.InvariantCulture); + return session.SessionId; } private static bool IsTextResponse(string? contentType) diff --git a/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs b/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs index aa82a29e..69aae586 100644 --- a/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs +++ b/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs @@ -99,22 +99,22 @@ 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; } - 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 Task.CompletedTask; } if (!OpenAIRequest.TryGetOpenAIRequest(request.BodyString, Logger, out var openAiRequest) || openAiRequest is null) { - 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; } @@ -165,12 +165,13 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c try { - var response = e.Session.HttpClient.Response; + var response = e.ProxySession.Response!; - _ = activity.SetTag("http.status_code", response.StatusCode); + var statusCode = (int)response.StatusCode; + _ = activity.SetTag("http.status_code", statusCode); #pragma warning disable IDE0010 - switch (response.StatusCode) + switch (statusCode) #pragma warning restore IDE0010 { case int code when code is >= 200 and < 300: @@ -189,7 +190,7 @@ public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken c _ = e.SessionData.Remove("OpenAIActivity"); _ = e.SessionData.Remove("OpenAIRequest"); - Logger.LogRequest("OpenTelemetry information emitted", MessageType.Processed, new LoggingContext(e.Session)); + Logger.LogRequest("OpenTelemetry information emitted", MessageType.Processed, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(AfterResponseAsync)); @@ -289,7 +290,7 @@ private void ProcessErrorResponse(Activity activity, ProxyResponseArgs e) { Logger.LogTrace("ProcessErrorResponse() called"); - var response = e.Session.HttpClient.Response; + var response = e.ProxySession.Response!; _ = activity.SetTag("error", true) .SetTag("error.type", "http") @@ -988,13 +989,13 @@ r is not null && r.Context is not null && r.Context.Session is not null && r.MessageType == MessageType.InterceptedResponse && - string.Equals("POST", r.Context.Session.HttpClient.Request.Method, StringComparison.OrdinalIgnoreCase) && - r.Context.Session.HttpClient.Response.StatusCode >= 200 && - r.Context.Session.HttpClient.Response.StatusCode < 300 && - r.Context.Session.HttpClient.Response.HasBody && - !string.IsNullOrEmpty(r.Context.Session.HttpClient.Response.BodyString) && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, r.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri) && - OpenAIRequest.TryGetOpenAIRequest(r.Context.Session.HttpClient.Request.BodyString, NullLogger.Instance, out var openAiRequest) && + string.Equals("POST", r.Context.Session.Request.Method, StringComparison.OrdinalIgnoreCase) && + (int)r.Context.Session.Response!.StatusCode >= 200 && + (int)r.Context.Session.Response!.StatusCode < 300 && + r.Context.Session.Response!.HasBody && + !string.IsNullOrEmpty(r.Context.Session.Response!.BodyString) && + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, r.Context.Session.Request.RequestUri.AbsoluteUri) && + OpenAIRequest.TryGetOpenAIRequest(r.Context.Session.Request.BodyString, NullLogger.Instance, out var openAiRequest) && openAiRequest is not null ); @@ -1002,7 +1003,7 @@ openAiRequest is not null { try { - var response = JsonSerializer.Deserialize(requestLog.Context!.Session.HttpClient.Response.BodyString, ProxyUtils.JsonSerializerOptions); + var response = JsonSerializer.Deserialize(requestLog.Context!.Session.Response!.BodyString, ProxyUtils.JsonSerializerOptions); if (response is null) { continue; diff --git a/DevProxy.Plugins/Inspection/OpenAIUsageDebuggingPlugin.cs b/DevProxy.Plugins/Inspection/OpenAIUsageDebuggingPlugin.cs index fe7799be..5a3c7aa1 100644 --- a/DevProxy.Plugins/Inspection/OpenAIUsageDebuggingPlugin.cs +++ b/DevProxy.Plugins/Inspection/OpenAIUsageDebuggingPlugin.cs @@ -57,7 +57,7 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT 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; } @@ -66,13 +66,13 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT !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; } if (!OpenAIRequest.TryGetOpenAIRequest(request.BodyString, Logger, out var openAiRequest) || openAiRequest is null) { - 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; } @@ -114,7 +114,7 @@ public override async Task AfterResponseAsync(ProxyResponseArgs e, CancellationT } await File.AppendAllLinesAsync(outputFileName, [usage.ToString()], cancellationToken); - Logger.LogRequest("Processed OpenAI request", MessageType.Processed, new LoggingContext(e.Session)); + Logger.LogRequest("Processed OpenAI request", MessageType.Processed, new LoggingContext(e.ProxySession)); Logger.LogTrace("Left {Name}", nameof(AfterResponseAsync)); } diff --git a/DevProxy.Plugins/Manipulation/RewritePlugin.cs b/DevProxy.Plugins/Manipulation/RewritePlugin.cs index dc298f4f..93780520 100644 --- a/DevProxy.Plugins/Manipulation/RewritePlugin.cs +++ b/DevProxy.Plugins/Manipulation/RewritePlugin.cs @@ -67,18 +67,18 @@ 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 (Configuration.Rewrites is null || !Configuration.Rewrites.Any()) { - Logger.LogRequest("No rewrites configured", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("No rewrites configured", MessageType.Skipped, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } - var request = e.Session.HttpClient.Request; + var request = e.ProxySession.Request; foreach (var rewrite in Configuration.Rewrites) { @@ -92,11 +92,11 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca if (request.Url.Equals(newUrl, StringComparison.OrdinalIgnoreCase)) { - Logger.LogRequest($"{rewrite.In?.Url}", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest($"{rewrite.In?.Url}", MessageType.Skipped, new LoggingContext(e.ProxySession)); } else { - Logger.LogRequest($"{rewrite.In?.Url} > {newUrl}", MessageType.Processed, new LoggingContext(e.Session)); + Logger.LogRequest($"{rewrite.In?.Url} > {newUrl}", MessageType.Processed, new LoggingContext(e.ProxySession)); request.Url = newUrl; } } diff --git a/DevProxy.Plugins/Mocking/AuthPlugin.cs b/DevProxy.Plugins/Mocking/AuthPlugin.cs index d55b8ff7..6c6c0a6d 100644 --- a/DevProxy.Plugins/Mocking/AuthPlugin.cs +++ b/DevProxy.Plugins/Mocking/AuthPlugin.cs @@ -166,12 +166,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; } @@ -182,7 +182,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca } else { - Logger.LogRequest("Request authorized", MessageType.Normal, new LoggingContext(e.Session)); + Logger.LogRequest("Request authorized", MessageType.Normal, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); @@ -227,14 +227,14 @@ private bool AuthorizeApiKeyRequest(ProxyRequestArgs e) var apiKey = GetApiKey(e.ProxySession.Request); if (apiKey is null) { - Logger.LogRequest("401 Unauthorized. API key not found.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. API key not found.", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } var isKeyValid = Configuration.ApiKey.AllowedKeys.Contains(apiKey); if (!isKeyValid) { - Logger.LogRequest($"401 Unauthorized. API key {apiKey} is not allowed.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. API key {apiKey} is not allowed.", MessageType.Failed, new LoggingContext(e.ProxySession)); } return isKeyValid; @@ -286,7 +286,7 @@ private bool AuthorizeOAuth2Request(ProxyRequestArgs e) } catch (Exception ex) { - Logger.LogRequest($"401 Unauthorized. The specified token is not valid: {ex.Message}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token is not valid: {ex.Message}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } } @@ -305,14 +305,14 @@ private bool ValidatePrincipals(ClaimsPrincipal claimsPrincipal, ProxyRequestArg var principalId = claimsPrincipal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value; if (principalId is null) { - Logger.LogRequest("401 Unauthorized. The specified token doesn't have the oid claim.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. The specified token doesn't have the oid claim.", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } if (!Configuration.OAuth2.AllowedPrincipals.Contains(principalId)) { var principals = string.Join(", ", Configuration.OAuth2.AllowedPrincipals); - Logger.LogRequest($"401 Unauthorized. The specified token is not issued for an allowed principal. Allowed principals: {principals}, found: {principalId}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token is not issued for an allowed principal. Allowed principals: {principals}, found: {principalId}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -335,21 +335,21 @@ private bool ValidateApplications(ClaimsPrincipal claimsPrincipal, ProxyRequestA var tokenVersion = claimsPrincipal.FindFirst("ver")?.Value; if (tokenVersion is null) { - Logger.LogRequest("401 Unauthorized. The specified token doesn't have the ver claim.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. The specified token doesn't have the ver claim.", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } var appId = claimsPrincipal.FindFirst(tokenVersion == "1.0" ? "appid" : "azp")?.Value; if (appId is null) { - Logger.LogRequest($"401 Unauthorized. The specified token doesn't have the {(tokenVersion == "v1.0" ? "appid" : "azp")} claim.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token doesn't have the {(tokenVersion == "v1.0" ? "appid" : "azp")} claim.", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } if (!Configuration.OAuth2.AllowedApplications.Contains(appId)) { var applications = string.Join(", ", Configuration.OAuth2.AllowedApplications); - Logger.LogRequest($"401 Unauthorized. The specified token is not issued by an allowed application. Allowed applications: {applications}, found: {appId}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token is not issued by an allowed application. Allowed applications: {applications}, found: {appId}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -372,14 +372,14 @@ private bool ValidateTenants(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e var tenantId = claimsPrincipal.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value; if (tenantId is null) { - Logger.LogRequest("401 Unauthorized. The specified token doesn't have the tid claim.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. The specified token doesn't have the tid claim.", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } if (!Configuration.OAuth2.AllowedTenants.Contains(tenantId)) { var tenants = string.Join(", ", Configuration.OAuth2.AllowedTenants); - Logger.LogRequest($"401 Unauthorized. The specified token is not issued by an allowed tenant. Allowed tenants: {tenants}, found: {tenantId}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token is not issued by an allowed tenant. Allowed tenants: {tenants}, found: {tenantId}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -406,7 +406,7 @@ private bool ValidateRoles(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e) var rolesRequired = string.Join(", ", Configuration.OAuth2.Roles); if (!Configuration.OAuth2.Roles.Any(r => HasPermission(r, rolesFromTheToken))) { - Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -433,7 +433,7 @@ private bool ValidateScopes(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e) var scopesRequired = string.Join(", ", Configuration.OAuth2.Scopes); if (!Configuration.OAuth2.Scopes.Any(s => HasPermission(s, scopesFromTheToken))) { - Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -451,13 +451,13 @@ private bool ValidateScopes(ClaimsPrincipal claimsPrincipal, ProxyRequestArgs e) if (tokenParts is null) { - Logger.LogRequest("401 Unauthorized. Authorization header not found.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. Authorization header not found.", MessageType.Failed, new LoggingContext(e.ProxySession)); return null; } if (tokenParts.Length != 2 || tokenParts[0] != "Bearer") { - Logger.LogRequest("401 Unauthorized. The specified token is not a valid Bearer token.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. The specified token is not a valid Bearer token.", MessageType.Failed, new LoggingContext(e.ProxySession)); return null; } diff --git a/DevProxy.Plugins/Mocking/CrudApiPlugin.cs b/DevProxy.Plugins/Mocking/CrudApiPlugin.cs index c1646abd..49625899 100644 --- a/DevProxy.Plugins/Mocking/CrudApiPlugin.cs +++ b/DevProxy.Plugins/Mocking/CrudApiPlugin.cs @@ -170,19 +170,19 @@ 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 (IsCORSPreflightRequest(request) && Configuration.EnableCORS) { SendEmptyResponse(HttpStatusCode.NoContent, e.ProxySession); - Logger.LogRequest("CORS preflight request", MessageType.Mocked, new LoggingContext(e.Session)); + Logger.LogRequest("CORS preflight request", MessageType.Mocked, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } @@ -208,7 +208,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca } else { - Logger.LogRequest("Did not match any action", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Did not match any action", MessageType.Skipped, new LoggingContext(e.ProxySession)); } Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); @@ -395,7 +395,7 @@ private bool AuthorizeApiKeyRequest(ProxyRequestArgs e) } } - Logger.LogRequest("401 Unauthorized. The specified API key is not valid.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. The specified API key is not valid.", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -409,7 +409,7 @@ private bool AuthorizeEntraRequest(ProxyRequestArgs e, CrudApiAction? action = n // is there a token if (string.IsNullOrEmpty(token)) { - Logger.LogRequest("401 Unauthorized. No token found on the request.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. No token found on the request.", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -417,7 +417,7 @@ private bool AuthorizeEntraRequest(ProxyRequestArgs e, CrudApiAction? action = n var tokenHeaderParts = token.Split(' '); if (tokenHeaderParts.Length != 2 || tokenHeaderParts[0] != "Bearer") { - Logger.LogRequest("401 Unauthorized. The specified token is not a valid Bearer token.", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest("401 Unauthorized. The specified token is not a valid Bearer token.", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -457,7 +457,7 @@ private bool AuthorizeEntraRequest(ProxyRequestArgs e, CrudApiAction? action = n { var rolesRequired = string.Join(", ", authConfig.Roles); - Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary role(s). Required one of: {rolesRequired}, found: {rolesFromTheToken}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -473,7 +473,7 @@ private bool AuthorizeEntraRequest(ProxyRequestArgs e, CrudApiAction? action = n { var scopesRequired = string.Join(", ", authConfig.Scopes); - Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token does not have the necessary scope(s). Required one of: {scopesRequired}, found: {scopesFromTheToken}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -482,7 +482,7 @@ private bool AuthorizeEntraRequest(ProxyRequestArgs e, CrudApiAction? action = n } catch (Exception ex) { - Logger.LogRequest($"401 Unauthorized. The specified token is not valid: {ex.Message}", MessageType.Failed, new LoggingContext(e.Session)); + Logger.LogRequest($"401 Unauthorized. The specified token is not valid: {ex.Message}", MessageType.Failed, new LoggingContext(e.ProxySession)); return false; } @@ -532,7 +532,7 @@ private void SendJsonResponse(string body, HttpStatusCode statusCode, IProxySess private void GetAll(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) { SendJsonResponse(JsonConvert.SerializeObject(_data, Formatting.Indented), HttpStatusCode.OK, e.ProxySession); - Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + Logger.LogRequest($"200 {action.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession)); } private void GetOne(ProxyRequestArgs e, CrudApiAction action, IDictionary parameters) @@ -543,17 +543,17 @@ private void GetOne(ProxyRequestArgs e, CrudApiAction action, IDictionary headers, var locationHeader = headers.FirstOrDefault(h => h.Name.Equals("Location", StringComparison.OrdinalIgnoreCase)); if (locationHeader is null || - !e.Session.HttpClient.Request.RequestUri.Query.Contains("state=", StringComparison.OrdinalIgnoreCase)) + !e.ProxySession.Request.RequestUri.Query.Contains("state=", StringComparison.OrdinalIgnoreCase)) { return; } - var queryString = HttpUtility.ParseQueryString(e.Session.HttpClient.Request.RequestUri.Query); + var queryString = HttpUtility.ParseQueryString(e.ProxySession.Request.RequestUri.Query); var msalState = queryString["state"]; locationHeader.Value = locationHeader.Value.Replace("state=@dynamic", $"state={msalState}", StringComparison.OrdinalIgnoreCase); } @@ -192,12 +192,12 @@ private static void UpdateMsalStateInHeaders(IList headers, private static void UpdateMsalStateInBody(ref string body, ProxyRequestArgs e, ref bool changed) { if (!body.Contains("state=@dynamic", StringComparison.OrdinalIgnoreCase) || - !e.Session.HttpClient.Request.RequestUri.Query.Contains("state=", StringComparison.OrdinalIgnoreCase)) + !e.ProxySession.Request.RequestUri.Query.Contains("state=", StringComparison.OrdinalIgnoreCase)) { return; } - var queryString = HttpUtility.ParseQueryString(e.Session.HttpClient.Request.RequestUri.Query); + var queryString = HttpUtility.ParseQueryString(e.ProxySession.Request.RequestUri.Query); var msalState = queryString["state"]; body = body.Replace("state=@dynamic", $"state={msalState}", StringComparison.OrdinalIgnoreCase); changed = true; diff --git a/DevProxy.Plugins/Mocking/GraphMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/GraphMockResponsePlugin.cs index b03ec392..172ed290 100644 --- a/DevProxy.Plugins/Mocking/GraphMockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/GraphMockResponsePlugin.cs @@ -39,7 +39,7 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo if (Configuration.NoMocks) { - Logger.LogRequest("Mocks are disabled", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Mocks are disabled", MessageType.Skipped, new LoggingContext(e.ProxySession)); return; } @@ -90,7 +90,7 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo } }; - Logger.LogRequest($"502 {request.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + Logger.LogRequest($"502 {request.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession)); } else { @@ -148,7 +148,7 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo Body = body }; - Logger.LogRequest($"{mockResponse.Response?.StatusCode ?? 200} {mockResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + Logger.LogRequest($"{mockResponse.Response?.StatusCode ?? 200} {mockResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession)); } responses.Add(response); @@ -164,7 +164,7 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo var batchResponseString = JsonSerializer.Serialize(batchResponse, ProxyUtils.JsonSerializerOptions); ProcessMockResponse(ref batchResponseString, batchHeaders, e, null); e.ProxySession.Respond(batchResponseString ?? string.Empty, HttpStatusCode.OK, batchHeaders.Select(h => new HttpHeader(h.Name, h.Value))); - Logger.LogRequest($"200 {e.ProxySession.Request.RequestUri}", MessageType.Mocked, new LoggingContext(e.Session)); + Logger.LogRequest($"200 {e.ProxySession.Request.RequestUri}", MessageType.Mocked, new LoggingContext(e.ProxySession)); e.ResponseState.HasBeenSet = true; Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index a34959e6..1a1f7d2f 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -163,12 +163,12 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca var state = e.ResponseState; if (Configuration.NoMocks) { - Logger.LogRequest("Mocks disabled", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("Mocks disabled", MessageType.Skipped, new LoggingContext(e.ProxySession)); return Task.CompletedTask; } if (!e.ShouldExecute(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; } @@ -205,7 +205,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca return Task.CompletedTask; } - Logger.LogRequest("No matching mock response found", MessageType.Skipped, new LoggingContext(e.Session)); + Logger.LogRequest("No matching mock response found", MessageType.Skipped, new LoggingContext(e.ProxySession)); Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); return Task.CompletedTask; @@ -401,7 +401,7 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi var bodyBytes = File.ReadAllBytes(filePath); ProcessMockResponse(ref bodyBytes, headers, e, matchingResponse); e.ProxySession.Respond(bodyBytes, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value))); - Logger.LogRequest($"{matchingResponse.Response.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + Logger.LogRequest($"{matchingResponse.Response.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession)); return; } } @@ -423,7 +423,7 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi ProcessMockResponse(ref body, headers, e, matchingResponse); e.ProxySession.Respond(body ?? string.Empty, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value))); - Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.Session)); + Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession)); } private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult) diff --git a/DevProxy.Plugins/Mocking/OpenAIMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/OpenAIMockResponsePlugin.cs index 620003d0..7f59368e 100644 --- a/DevProxy.Plugins/Mocking/OpenAIMockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/OpenAIMockResponsePlugin.cs @@ -42,12 +42,12 @@ 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; } @@ -56,13 +56,13 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo !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; } 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; } @@ -182,6 +182,6 @@ private void SendMockResponse(OpenAIResponse response, string localLm ] ); e.ResponseState.HasBeenSet = true; - Logger.LogRequest($"200 {localLmUrl}", MessageType.Mocked, new LoggingContext(e.Session)); + Logger.LogRequest($"200 {localLmUrl}", MessageType.Mocked, new LoggingContext(e.ProxySession)); } } diff --git a/DevProxy.Plugins/Reporting/ApiCenterMinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/ApiCenterMinimalPermissionsPlugin.cs index a0ddb148..539182ac 100644 --- a/DevProxy.Plugins/Reporting/ApiCenterMinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/ApiCenterMinimalPermissionsPlugin.cs @@ -94,8 +94,8 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation l.MessageType == MessageType.InterceptedRequest && !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri) && - l.Context.Session.HttpClient.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) && + l.Context.Session.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) ); if (!interceptedRequests.Any()) { diff --git a/DevProxy.Plugins/Reporting/ApiCenterProductionVersionPlugin.cs b/DevProxy.Plugins/Reporting/ApiCenterProductionVersionPlugin.cs index 58368e80..eb49e33c 100644 --- a/DevProxy.Plugins/Reporting/ApiCenterProductionVersionPlugin.cs +++ b/DevProxy.Plugins/Reporting/ApiCenterProductionVersionPlugin.cs @@ -90,7 +90,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation .Where( l => l.MessageType == MessageType.InterceptedRequest && l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri) + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) ); if (!interceptedRequests.Any()) { diff --git a/DevProxy.Plugins/Reporting/ExecutionSummaryPlugin.cs b/DevProxy.Plugins/Reporting/ExecutionSummaryPlugin.cs index 45e42da4..90e09244 100644 --- a/DevProxy.Plugins/Reporting/ExecutionSummaryPlugin.cs +++ b/DevProxy.Plugins/Reporting/ExecutionSummaryPlugin.cs @@ -91,7 +91,7 @@ public override Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken .Where( l => l.MessageType == MessageType.InterceptedRequest && l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri) + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) ); ExecutionSummaryPluginReportBase report = Configuration.GroupBy switch @@ -231,7 +231,7 @@ private static string GetRequestMessage(RequestLog requestLog) => private static string GetMethodAndUrl(RequestLog requestLog) { return requestLog.Context is not null - ? $"{requestLog.Context.Session.HttpClient.Request.Method} {requestLog.Context.Session.HttpClient.Request.RequestUri}" + ? $"{requestLog.Context.Session.Request.Method} {requestLog.Context.Session.Request.RequestUri}" : "Undefined"; } diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs index 08b83d06..9851272b 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs @@ -117,7 +117,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation if (ProxyUtils.IsGraphBatchUrl(uri)) { var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; - requestsFromBatch = GraphUtils.GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); + requestsFromBatch = GraphUtils.GetRequestsFromBatch(request.Context?.Session.Request.BodyString!, graphVersion, uri.Host); } else { @@ -311,7 +311,7 @@ private async Task EvaluateMinimalScopesAsync( /// private static (GraphPermissionsType type, IEnumerable permissions) GetPermissionsAndType(RequestLog request) { - var authHeader = request.Context?.Session.HttpClient.Request.Headers.GetFirstHeader("Authorization"); + var authHeader = request.Context?.Session.Request.Headers.GetFirst("Authorization"); if (authHeader == null) { return (GraphPermissionsType.Application, []); diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs index 94173b2f..b7da897e 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs @@ -92,7 +92,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation if (ProxyUtils.IsGraphBatchUrl(uri)) { var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; - var requestsFromBatch = GraphUtils.GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); + var requestsFromBatch = GraphUtils.GetRequestsFromBatch(request.Context?.Session.Request.BodyString!, graphVersion, uri.Host); endpoints.AddRange(requestsFromBatch); } else diff --git a/DevProxy.Plugins/Reporting/MinimalCsomPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/MinimalCsomPermissionsPlugin.cs index 750b54c1..cdaf893e 100644 --- a/DevProxy.Plugins/Reporting/MinimalCsomPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalCsomPermissionsPlugin.cs @@ -95,13 +95,13 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation continue; } - if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) + if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.Request.RequestUri.AbsoluteUri)) { - Logger.LogDebug("URL not matched: {Url}", request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri); + Logger.LogDebug("URL not matched: {Url}", request.Context.Session.Request.RequestUri.AbsoluteUri); continue; } - var requestBody = await request.Context.Session.GetRequestBodyAsString(cancellationToken); + var requestBody = request.Context.Session.Request.BodyString; if (string.IsNullOrEmpty(requestBody)) { continue; diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index 986881f7..f83fa5ff 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -56,8 +56,8 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation l.MessageType == MessageType.InterceptedRequest && !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri) && - l.Context.Session.HttpClient.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) && + l.Context.Session.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) ); if (!interceptedRequests.Any()) { diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs index f53efee4..943b4b2e 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsPlugin.cs @@ -66,7 +66,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation l.MessageType == MessageType.InterceptedRequest && !l.Message.StartsWith("OPTIONS", StringComparison.OrdinalIgnoreCase) && l.Context?.Session is not null && - ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri) + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) ); if (!interceptedRequests.Any()) { diff --git a/DevProxy.Plugins/Reporting/UrlDiscoveryPlugin.cs b/DevProxy.Plugins/Reporting/UrlDiscoveryPlugin.cs index 7d70e1f5..25ab3365 100644 --- a/DevProxy.Plugins/Reporting/UrlDiscoveryPlugin.cs +++ b/DevProxy.Plugins/Reporting/UrlDiscoveryPlugin.cs @@ -28,7 +28,7 @@ public override Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken } var requestLogs = e.RequestLogs - .Where(l => ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context?.Session.HttpClient.Request.RequestUri.AbsoluteUri ?? "")); + .Where(l => ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context?.Session.Request.RequestUri.AbsoluteUri ?? "")); UrlDiscoveryPluginReport report = new() { @@ -36,7 +36,7 @@ public override Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken [ .. requestLogs .Where(log => log.Context is not null) - .Select(log => log.Context!.Session.HttpClient.Request.RequestUri.ToString()).Distinct().Order() + .Select(log => log.Context!.Session.Request.RequestUri.ToString()).Distinct().Order() ] }; diff --git a/DevProxy.Plugins/packages.lock.json b/DevProxy.Plugins/packages.lock.json index 917b1dab..f9953a91 100644 --- a/DevProxy.Plugins/packages.lock.json +++ b/DevProxy.Plugins/packages.lock.json @@ -110,16 +110,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", - "Microsoft.Extensions.Logging.Abstractions": "8.0.1" - } - }, "Azure.Core": { "type": "Transitive", "resolved": "1.53.0", @@ -134,11 +124,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", @@ -540,7 +525,6 @@ "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, )" } } diff --git a/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs b/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs index e5e00338..b87e77da 100644 --- a/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs +++ b/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs @@ -30,7 +30,11 @@ public TitaniumRequestAdapter(TitaniumRequest request, Action? setBody = public Uri RequestUri => _request.RequestUri!; /// - public string Url => _request.Url; + public string Url + { + get => _request.Url; + set => _request.Url = value; + } /// public string Method => _request.Method!; diff --git a/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs b/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs index 3c8bed18..4db992e7 100644 --- a/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs +++ b/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs @@ -41,4 +41,7 @@ public string? StatusDescription get => _response.StatusDescription; set => _response.StatusDescription = value ?? string.Empty; } + + /// + public Version HttpVersion => _response.HttpVersion; } diff --git a/DevProxy/Proxy/ProxyEngine.cs b/DevProxy/Proxy/ProxyEngine.cs index 5ee0ff0a..f114b7df 100755 --- a/DevProxy/Proxy/ProxyEngine.cs +++ b/DevProxy/Proxy/ProxyEngine.cs @@ -499,7 +499,7 @@ async Task OnRequestAsync(object sender, SessionEventArgs e) throw new InvalidOperationException($"Unable to initialize the plugin data storage for hash key {e.GetHashCode()}"); } var responseState = new ResponseState(); - var proxyRequestArgs = new ProxyRequestArgs(e, CreateProxySession(e), responseState) + var proxyRequestArgs = new ProxyRequestArgs(CreateProxySession(e), responseState) { SessionData = _pluginData[e.GetHashCode()], GlobalData = _proxyController.ProxyState.GlobalData @@ -521,7 +521,7 @@ async Task OnRequestAsync(object sender, SessionEventArgs e) e.UserData = e.HttpClient.Request; - var loggingContext = new LoggingContext(e); + var loggingContext = new LoggingContext(proxyRequestArgs.ProxySession); _logger.LogRequest($"{e.HttpClient.Request.Method} {e.HttpClient.Request.Url}", MessageType.InterceptedRequest, loggingContext); _logger.LogRequest($"{DateTimeOffset.UtcNow}", MessageType.Timestamp, loggingContext); @@ -551,7 +551,7 @@ private async Task HandleRequestAsync(SessionEventArgs e, ProxyRequestArgs proxy // 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)); + _logger?.LogRequest("Passed through", MessageType.PassedThrough, new LoggingContext(proxyRequestArgs.ProxySession)); AddProxyHeader(e.HttpClient.Request); } } @@ -609,7 +609,7 @@ async Task OnBeforeResponseAsync(object sender, SessionEventArgs e) // read response headers if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host)) { - var proxyResponseArgs = new ProxyResponseArgs(e, CreateProxySession(e), new()) + var proxyResponseArgs = new ProxyResponseArgs(CreateProxySession(e), new()) { SessionData = _pluginData[e.GetHashCode()], GlobalData = _proxyController.ProxyState.GlobalData @@ -648,7 +648,7 @@ async Task OnAfterResponseAsync(object sender, SessionEventArgs e) // read response headers if (IsProxiedHost(e.HttpClient.Request.RequestUri.Host)) { - var proxyResponseArgs = new ProxyResponseArgs(e, CreateProxySession(e), new()) + var proxyResponseArgs = new ProxyResponseArgs(CreateProxySession(e), new()) { SessionData = _pluginData[e.GetHashCode()], GlobalData = _proxyController.ProxyState.GlobalData @@ -667,7 +667,7 @@ async Task OnAfterResponseAsync(object sender, SessionEventArgs e) 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); + var loggingContext = new LoggingContext(proxyResponseArgs.ProxySession); _logger.LogRequest(message, MessageType.InterceptedResponse, loggingContext); foreach (var plugin in _plugins.Where(p => p.Enabled)) From 8862ff5a8edd8d941520df0eec8730852e8e2ea7 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 17:35:01 +0200 Subject: [PATCH 11/46] Add Kestrel proxy engine vertical slice (Phase 4) Introduce DevProxy.Proxy.Kestrel: a forward-proxy engine built on ASP.NET Core Kestrel that runs the Dev Proxy plugin pipeline against the canonical HTTP model, as the eventual replacement for the Titanium engine. Slice-1 scope (dev-toggle via DEV_PROXY_ENGINE=kestrel, for golden-output comparison against Titanium; not a shipped fallback): - KestrelProxyEngine hosts a raw TCP endpoint via UseConnectionHandler. - ProxyConnectionHandler: HTTP/1.1 parse, CONNECT->SslStream MITM, forward, write-back; catches client disconnect/cancel as normal teardown. - PluginPipeline mirrors ProxyEngine semantics (watched/mocked/notwatched, Before/After request+response, per-exchange SessionData) and emits request logs inside the requestId scope so the console formatter groups plugin and engine lines identically to Titanium. - UpstreamForwarder/ResponseWriter apply ForwardingInvariants (hop-by-hop stripping, decompressed bodies, Content-Length/Encoding fix-up). - In-memory CertificateAuthority + DuplexPipeStream ported from the POC. Engine selection wired via DEV_PROXY_ENGINE in DI; ProxyConsoleFormatter suppresses the engine-name prefix for KestrelProxyEngine (as for ProxyEngine). Deferred hardening (tracked): keep-alive, blind-tunnel for non-watched and h2-only/gRPC (ClientHello peek), WebSocket relay, chunked/trailers/100-continue, body-mode streaming, process filter, persistent CA, host-watch DRY consolidation. Full solution builds 0/0; 71 existing tests green; live-verified plain HTTP passthrough, HTTPS MITM, GenericRandomError/RetryAfter mocking with correct console output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Proxy.Kestrel.csproj | 24 ++ .../Http/CanonicalProxySession.cs | 102 ++++++++ .../Http/MutableHttpMessage.cs | 88 +++++++ .../Http/MutableHttpRequest.cs | 63 +++++ .../Http/MutableHttpResponse.cs | 39 +++ .../Internal/CertificateAuthority.cs | 104 ++++++++ .../Internal/DuplexPipeStream.cs | 77 ++++++ .../Internal/HostWatchList.cs | 75 ++++++ .../Internal/Http1RequestReader.cs | 157 ++++++++++++ .../Internal/PluginPipeline.cs | 232 ++++++++++++++++++ .../Internal/ProxyConnectionHandler.cs | 222 +++++++++++++++++ .../Internal/ResponseWriter.cs | 55 +++++ .../Internal/UpstreamForwarder.cs | 100 ++++++++ DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 91 +++++++ DevProxy.Proxy.Kestrel/packages.lock.json | 161 ++++++++++++ DevProxy.sln | 14 ++ DevProxy/DevProxy.csproj | 1 + .../IServiceCollectionExtensions.cs | 29 ++- DevProxy/Logging/ProxyConsoleFormatter.cs | 3 +- DevProxy/packages.lock.json | 7 +- 20 files changed, 1641 insertions(+), 3 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj create mode 100644 DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs create mode 100644 DevProxy.Proxy.Kestrel/Http/MutableHttpMessage.cs create mode 100644 DevProxy.Proxy.Kestrel/Http/MutableHttpRequest.cs create mode 100644 DevProxy.Proxy.Kestrel/Http/MutableHttpResponse.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/DuplexPipeStream.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/HostWatchList.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs create mode 100644 DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs create mode 100644 DevProxy.Proxy.Kestrel/packages.lock.json diff --git a/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj new file mode 100644 index 00000000..d8a5d964 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + DevProxy.Proxy.Kestrel + enable + enable + 3.1.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..321db5ae --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs @@ -0,0 +1,102 @@ +// 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; + + 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; + + /// + /// 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; + } +} 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/Internal/CertificateAuthority.cs b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs new file mode 100644 index 00000000..10ef5de1 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.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.Collections.Concurrent; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace DevProxy.Proxy.Kestrel.Internal; + +/// +/// An in-memory certificate authority. It creates a self-signed root CA once and +/// mints (and caches) a leaf certificate per host on demand so the proxy can +/// terminate TLS and inspect the decrypted traffic. +/// +/// +/// Slice-1 scope: ephemeral root + leaves, no disk persistence and no OS trust +/// integration. Phase 5 replaces this with the persistent cache + keychain/root +/// store trust that the existing Titanium engine already provides, regenerating +/// on upgrade (no cross-version PFX compatibility contract required). +/// +/// +internal sealed class CertificateAuthority : IDisposable +{ + private readonly X509Certificate2 _ca; + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + + public CertificateAuthority() => _ca = CreateRootCertificate(); + + /// The root CA certificate (public part) clients must trust to allow interception. + public X509Certificate2 RootCertificate => _ca; + + public X509Certificate2 GetCertificateForHost(string host) => _cache.GetOrAdd(host, CreateLeafCertificate); + + private static X509Certificate2 CreateRootCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Dev Proxy CA, O=Dev Proxy", + 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 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); + + // 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), null); + } + + public void Dispose() + { + _ca.Dispose(); + foreach (var leaf in _cache.Values) + { + leaf.Dispose(); + } + _cache.Clear(); + } +} 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..b4118282 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/HostWatchList.cs @@ -0,0 +1,75 @@ +// 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; + +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). +/// +/// +/// TODO (DRY, cut-over): this host-extraction logic is duplicated from +/// ProxyEngine.LoadHostNamesFromUrls. Consolidate into a single shared +/// helper in DevProxy.Abstractions when the Titanium engine is removed, +/// so there is one implementation with one test suite. +/// +/// +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 pattern = Regex.Unescape(urlToWatch.Url.ToString()) + .Trim('^', '$') + .Replace(".*", "*", StringComparison.OrdinalIgnoreCase); + + string host; + if (pattern.Contains("://", StringComparison.OrdinalIgnoreCase)) + { + var chunks = pattern.Split("://"); + var slash = chunks[1].IndexOf('/', StringComparison.OrdinalIgnoreCase); + host = slash < 0 ? chunks[1] : chunks[1][..slash]; + } + else + { + host = pattern; + } + + var portPos = host.IndexOf(':', StringComparison.OrdinalIgnoreCase); + if (portPos > 0) + { + host = host[..portPos]; + } + + var regexString = Regex.Escape(host).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase); + var regex = new Regex($"^{regexString}$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + 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/Http1RequestReader.cs b/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs new file mode 100644 index 00000000..3d3c4237 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs @@ -0,0 +1,157 @@ +// 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; + +/// +/// A single HTTP/1.x message head parsed off the wire: request line plus headers, +/// and any bytes already read past the header terminator. +/// +/// 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). +/// Bytes read past the header block (start of the body). +internal sealed record ParsedRequestHead( + string Method, + string Target, + string Version, + IReadOnlyList<(string Name, string Value)> Headers, + byte[] Leftover); + +/// +/// Minimal, allocation-conscious HTTP/1.x reader for the proxy's decrypted/plain +/// side. Slice-1 scope: request line + headers + Content-Length bodies. +/// +/// +/// Deferred (tracked hardening): chunked transfer-decoding + trailers, +/// Expect: 100-continue, request-smuggling guards (duplicate/ambiguous +/// framing), and keep-alive leftover reuse. Until then the connection handler +/// reads one request and closes (Connection: close). +/// +/// +internal static class Http1RequestReader +{ + private const int MaxHeaderBlockBytes = 1024 * 1024; + + /// Reads one request head, or null on a clean EOF before any bytes. + public static async Task ReadHeadAsync(Stream stream, CancellationToken ct) + { + var headerBlock = await ReadHeaderBlockAsync(stream, ct).ConfigureAwait(false); + if (headerBlock is null) + { + return null; + } + + var (headerText, leftover) = headerBlock.Value; + 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, leftover); + } + + /// + /// Reads a fixed-length body given the Content-Length and any bytes the + /// header read already consumed past the terminator. + /// + public static async Task ReadBodyAsync(Stream stream, byte[] leftover, int contentLength, CancellationToken ct) + { + if (contentLength <= 0) + { + return []; + } + + var body = new byte[contentLength]; + var copied = Math.Min(leftover.Length, contentLength); + Array.Copy(leftover, body, copied); + + var offset = copied; + while (offset < contentLength) + { + var read = await stream.ReadAsync(body.AsMemory(offset, contentLength - offset), ct).ConfigureAwait(false); + if (read == 0) + { + break; + } + offset += read; + } + + return body; + } + + /// Reads the Content-Length header value, defaulting to 0. + public static int GetContentLength(IReadOnlyList<(string Name, string Value)> 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; + } + + private static async Task<(string Header, byte[] Leftover)?> ReadHeaderBlockAsync(Stream stream, CancellationToken ct) + { + var buffer = new byte[4096]; + var accumulator = new List(4096); + + while (true) + { + var read = await stream.ReadAsync(buffer, ct).ConfigureAwait(false); + if (read == 0) + { + return null; + } + + accumulator.AddRange(buffer.AsSpan(0, read)); + + var terminator = IndexOfDoubleCrlf(accumulator); + if (terminator >= 0) + { + var header = Encoding.ASCII.GetString(accumulator.ToArray(), 0, terminator); + var leftover = accumulator.GetRange(terminator + 4, accumulator.Count - (terminator + 4)).ToArray(); + return (header, leftover); + } + + if (accumulator.Count > MaxHeaderBlockBytes) + { + throw new InvalidOperationException("Request header block too large."); + } + } + } + + private static int IndexOfDoubleCrlf(List 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..497d2ae4 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs @@ -0,0 +1,232 @@ +// 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 async Task RunResponseAsync(CanonicalProxySession session, 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); + + 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/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs new file mode 100644 index 00000000..ce3604ec --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -0,0 +1,222 @@ +// 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.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, terminates TLS for +/// watched CONNECT targets, runs the plugin pipeline against the canonical +/// model, forwards to the origin, and writes the response back. +/// +/// +/// Slice-1 scope: one request per connection (Connection: close), plain +/// HTTP + HTTPS-via-CONNECT, mocking short-circuit. Deferred hardening (tracked): +/// keep-alive, blind-tunnel for non-watched / h2-only (ClientHello peek), WebSocket +/// relay + inspection, chunked/trailers, body-mode streaming. +/// +/// +internal sealed class ProxyConnectionHandler( + CertificateAuthority ca, + UpstreamForwarder forwarder, + PluginPipeline pipeline, + ILogger logger) : ConnectionHandler +{ + private static int _requestCounter; + + public override async Task OnConnectedAsync(ConnectionContext connection) + { + var ct = connection.ConnectionClosed; + await using var clientStream = new DuplexPipeStream(connection.Transport); + + try + { + var head = await Http1RequestReader.ReadHeadAsync(clientStream, ct).ConfigureAwait(false); + if (head is null) + { + return; + } + + if (string.Equals(head.Method, "CONNECT", StringComparison.OrdinalIgnoreCase)) + { + await HandleConnectAsync(clientStream, head, ct).ConfigureAwait(false); + } + else + { + // Plain HTTP proxy request: the target is an absolute URL. + await ExchangeAsync(clientStream, head, head.Target, ct).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or ConnectionResetException) + { + // Client disconnect / cancellation — normal teardown, not an error. + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling proxy connection"); + } + } + + private async Task HandleConnectAsync(Stream clientStream, ParsedRequestHead connect, CancellationToken ct) + { + var (host, portPart) = SplitHostPort(connect.Target); + var port = portPart ?? 443; + + // Slice-1: always MITM. Deferred: blind-tunnel non-watched hosts and + // h2-only (gRPC) clients via a ClientHello ALPN peek. + await WriteAsciiAsync(clientStream, "HTTP/1.1 200 Connection Established\r\n\r\n", ct).ConfigureAwait(false); + + var certificate = ca.GetCertificateForHost(host); + await using var tls = new SslStream(clientStream, leaveInnerStreamOpen: false); + await tls.AuthenticateAsServerAsync( + new SslServerAuthenticationOptions { ServerCertificate = certificate }, ct).ConfigureAwait(false); + + var head = await Http1RequestReader.ReadHeadAsync(tls, ct).ConfigureAwait(false); + if (head is null) + { + return; + } + + var url = port == 443 + ? $"https://{host}{head.Target}" + : $"https://{host}:{port.ToString(CultureInfo.InvariantCulture)}{head.Target}"; + + await ExchangeAsync(tls, head, url, ct).ConfigureAwait(false); + } + + private async Task ExchangeAsync(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; + } + + var contentLength = Http1RequestReader.GetContentLength(head.Headers); + var body = await Http1RequestReader.ReadBodyAsync(clientStream, head.Leftover, contentLength, ct).ConfigureAwait(false); + + 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; + } + + // 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!, ct).ConfigureAwait(false); + return; + } + + MutableHttpResponse response; + try + { + response = await forwarder.ForwardAsync(request, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + pipeline.Forget(session.SessionId); + logger.LogError(ex, "Error forwarding to origin {Url}", absoluteUrl); + await WriteErrorAsync(clientStream, HttpStatusCode.BadGateway, "Upstream request failed", ct).ConfigureAwait(false); + return; + } + + if (phase == RequestPhase.Watched) + { + session.SetOriginResponse(response); + await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); + await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, ct).ConfigureAwait(false); + } + else + { + // NotWatched: pure passthrough, no response-phase plugins. + await ResponseWriter.WriteAsync(clientStream, response, ct).ConfigureAwait(false); + } + } + + 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 (ex is IOException or OperationCanceledException) + { + // Client already gone. + } + } + + private static string ReasonPhrase(HttpStatusCode status) => status switch + { + HttpStatusCode.BadRequest => "Bad Request", + HttpStatusCode.BadGateway => "Bad Gateway", + _ => status.ToString(), + }; + + private static (string Host, int? Port) SplitHostPort(string authority) + { + var separator = authority.LastIndexOf(':'); + if (separator > 0 && int.TryParse(authority[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)) + { + return (authority[..separator], port); + } + return (authority, null); + } + + 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..fc69e21b --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs @@ -0,0 +1,55 @@ +// 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 +/// (). +/// +/// Slice-1 scope: writes Connection: close and one body buffer. +/// Keep-alive + chunked write-back are tracked hardening. +/// +internal static class ResponseWriter +{ + public static async Task WriteAsync(Stream clientStream, IHttpResponse response, 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)) + { + continue; + } + _ = head.Append(CultureInfo.InvariantCulture, $"{header.Name}: {header.Value}\r\n"); + } + + var body = response.Body; + _ = head.Append(CultureInfo.InvariantCulture, $"Content-Length: {body.Length}\r\n"); + _ = head.Append("Connection: close\r\n\r\n"); + + await clientStream.WriteAsync(Encoding.ASCII.GetBytes(head.ToString()), ct).ConfigureAwait(false); + if (!body.IsEmpty) + { + await clientStream.WriteAsync(body, ct).ConfigureAwait(false); + } + await clientStream.FlushAsync(ct).ConfigureAwait(false); + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs new file mode 100644 index 00000000..19677180 --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.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 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, the body +/// is delivered to plugins decompressed, and framing headers are recomputed on +/// write-back. +/// +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, "Content-Length", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!outgoing.Headers.TryAddWithoutValidation(header.Name, header.Value)) + { + _ = content?.Headers.TryAddWithoutValidation(header.Name, header.Value); + } + } + + var originResponse = await _httpClient + .SendAsync(outgoing, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + try + { + // 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 headers = new HeaderCollection(); + CopyHeaders(originResponse.Headers, headers); + CopyHeaders(originResponse.Content.Headers, headers); + + var response = new MutableHttpResponse( + originResponse.StatusCode, + originResponse.Version, + headers, + body, + originResponse.ReasonPhrase); + + // Body is already decompressed; advertise its real length and drop any + // stale framing/encoding the origin declared. + _ = headers.Remove("Content-Encoding"); + _ = headers.Remove("Content-Length"); + _ = headers.Remove("Transfer-Encoding"); + + return response; + } + finally + { + originResponse.Dispose(); + } + } + + 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); +} diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs new file mode 100644 index 00000000..248876bf --- /dev/null +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.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.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 — the replacement for the +/// Titanium-based 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. +/// +/// +/// Selected via the engine dev-toggle so it can run side-by-side with the Titanium +/// engine during development for golden-output comparison. Not a shipped fallback — +/// it becomes the only engine at cut-over. +/// +/// +public sealed class KestrelProxyEngine( + IEnumerable plugins, + ISet urlsToWatch, + IProxyConfiguration configuration, + Dictionary globalData, + ILoggerFactory loggerFactory) : 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; + + using var ca = new CertificateAuthority(); + 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 pipeline = new PluginPipeline( + plugins, + urlsToWatch, + configuration, + globalData, + loggerFactory.CreateLogger()); + var handler = new ProxyConnectionHandler(ca, forwarder, pipeline, _logger); + + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + _ = builder.WebHost.UseKestrelCore(); + _ = builder.Services.AddSingleton(handler); + builder.WebHost.ConfigureKestrel(options => + options.Listen(ipAddress, port, listen => listen.UseConnectionHandler())); + + await using var app = builder.Build(); + + await app.StartAsync(stoppingToken).ConfigureAwait(false); + _logger.LogInformation( + "Dev Proxy (Kestrel engine) listening on {Address}:{Port}", + ipAddress.ToString(), + port.ToString(CultureInfo.InvariantCulture)); + + try + { + await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Normal shutdown. + } + finally + { + await app.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + } +} 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.sln b/DevProxy.sln index 606851bb..da2a54b7 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -22,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Titanium", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Titanium.Tests", "DevProxy.Proxy.Titanium.Tests\DevProxy.Proxy.Titanium.Tests.csproj", "{A3784E2F-7CB4-4F1B-8A96-C17104D5C868}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Kestrel", "DevProxy.Proxy.Kestrel\DevProxy.Proxy.Kestrel.csproj", "{E357B2FB-0A62-4DCF-AFA0-D258647EA664}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -104,6 +106,18 @@ Global {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x64.Build.0 = Release|Any CPU {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x86.ActiveCfg = Release|Any CPU {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DevProxy/DevProxy.csproj b/DevProxy/DevProxy.csproj index b85e48d9..ecfce66a 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -48,6 +48,7 @@ + diff --git a/DevProxy/Extensions/IServiceCollectionExtensions.cs b/DevProxy/Extensions/IServiceCollectionExtensions.cs index 13d10cfb..9ea5035e 100644 --- a/DevProxy/Extensions/IServiceCollectionExtensions.cs +++ b/DevProxy/Extensions/IServiceCollectionExtensions.cs @@ -5,9 +5,12 @@ 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 Microsoft.Extensions.Logging; #pragma warning disable IDE0130 namespace Microsoft.Extensions.DependencyInjection; @@ -33,12 +36,36 @@ public static IServiceCollection ConfigureDevProxyServices( }); _ = services .AddApplicationServices(configuration, options) - .AddHostedService() + .AddProxyEngine() .Configure(options => options.LowercaseUrls = true); return services; } + // Engine selection (dev-toggle). The Titanium engine is the default; setting + // DEV_PROXY_ENGINE=kestrel selects the new Kestrel engine so the two can run + // side-by-side during development for golden-output comparison. This toggle is + // NOT a shipped fallback — it is removed at the hard cut-over (decision #3). + static IServiceCollection AddProxyEngine(this IServiceCollection services) + { + var engine = Environment.GetEnvironmentVariable("DEV_PROXY_ENGINE"); + if (string.Equals(engine, "kestrel", StringComparison.OrdinalIgnoreCase)) + { + _ = services.AddHostedService(sp => new KestrelProxyEngine( + sp.GetServices(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService().GlobalData, + sp.GetRequiredService())); + } + else + { + _ = services.AddHostedService(); + } + + return services; + } + static IServiceCollection AddApplicationServices( this IServiceCollection services, ConfigurationManager configuration, diff --git a/DevProxy/Logging/ProxyConsoleFormatter.cs b/DevProxy/Logging/ProxyConsoleFormatter.cs index f1e3b3ad..1fcca3d1 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(ProxyEngine) && pluginName != nameof(KestrelProxyEngine)) { textWriter.Write($"{pluginName}: "); } diff --git a/DevProxy/packages.lock.json b/DevProxy/packages.lock.json index be74cb81..0ba1840c 100644 --- a/DevProxy/packages.lock.json +++ b/DevProxy/packages.lock.json @@ -354,10 +354,15 @@ "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": "[3.1.0, )" + } + }, "devproxy.proxy.titanium": { "type": "Project", "dependencies": { From 911c38f99da6d2996c5d60e58be0c15f01af90fc Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 17:39:50 +0200 Subject: [PATCH 12/46] Add Kestrel proxy engine test project (Phase 4) Add DevProxy.Proxy.Kestrel.Tests with 28 unit tests covering the HTTP/1.1 parser, host watch list (including order-dependent exclusion semantics), response writer, and the mutable HTTP model / CanonicalProxySession. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Proxy.Kestrel.Tests.csproj | 29 +++++ .../HostWatchListTests.cs | 98 +++++++++++++++ .../Http1RequestReaderTests.cs | 113 ++++++++++++++++++ .../MutableHttpModelTests.cs | 104 ++++++++++++++++ .../ResponseWriterTests.cs | 98 +++++++++++++++ .../DevProxy.Proxy.Kestrel.csproj | 4 + DevProxy.sln | 14 +++ 7 files changed, 460 insertions(+) create mode 100644 DevProxy.Proxy.Kestrel.Tests/DevProxy.Proxy.Kestrel.Tests.csproj create mode 100644 DevProxy.Proxy.Kestrel.Tests/HostWatchListTests.cs create mode 100644 DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs create mode 100644 DevProxy.Proxy.Kestrel.Tests/MutableHttpModelTests.cs create mode 100644 DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs diff --git a/DevProxy.Proxy.Kestrel.Tests/DevProxy.Proxy.Kestrel.Tests.csproj b/DevProxy.Proxy.Kestrel.Tests/DevProxy.Proxy.Kestrel.Tests.csproj new file mode 100644 index 00000000..197fb541 --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/DevProxy.Proxy.Kestrel.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + $(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/Http1RequestReaderTests.cs b/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs new file mode 100644 index 00000000..de54a6e7 --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs @@ -0,0 +1,113 @@ +// 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 async Task ReadHeadAsync_ParsesRequestLineAndHeaders() + { + const string raw = + "GET /posts/1 HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "Accept: application/json\r\n" + + "\r\n"; + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + var head = await Http1RequestReader.ReadHeadAsync(stream, CancellationToken.None); + + Assert.NotNull(head); + 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 async Task ReadHeadAsync_ReturnsNull_OnCleanEofBeforeAnyBytes() + { + using var stream = new MemoryStream([]); + + var head = await Http1RequestReader.ReadHeadAsync(stream, CancellationToken.None); + + Assert.Null(head); + } + + [Fact] + public async Task ReadHeadAsync_Throws_OnMalformedRequestLine() + { + using var stream = new MemoryStream(Encoding.ASCII.GetBytes("GARBAGE\r\n\r\n")); + + _ = await Assert.ThrowsAsync( + async () => await Http1RequestReader.ReadHeadAsync(stream, CancellationToken.None)); + } + + [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 async Task ReadBodyAsync_UsesLeftoverThenStream() + { + // The header read consumed "AB" past the terminator; the rest comes from the stream. + var leftover = Encoding.ASCII.GetBytes("AB"); + using var stream = new MemoryStream(Encoding.ASCII.GetBytes("CDE")); + + var body = await Http1RequestReader.ReadBodyAsync(stream, leftover, contentLength: 5, CancellationToken.None); + + Assert.Equal("ABCDE", Encoding.ASCII.GetString(body)); + } + + [Fact] + public async Task ReadBodyAsync_ReturnsEmpty_WhenContentLengthZero() + { + using var stream = new MemoryStream(Encoding.ASCII.GetBytes("ignored")); + + var body = await Http1RequestReader.ReadBodyAsync(stream, [], contentLength: 0, CancellationToken.None); + + Assert.Empty(body); + } + + [Fact] + public async Task ReadHeadAsync_LeftoverContainsBodyBytesReadWithHeaderBlock() + { + const string raw = + "POST /posts HTTP/1.1\r\n" + + "Content-Length: 3\r\n" + + "\r\n" + + "abc"; + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + + var head = await Http1RequestReader.ReadHeadAsync(stream, CancellationToken.None); + Assert.NotNull(head); + + var length = Http1RequestReader.GetContentLength(head!.Headers); + var body = await Http1RequestReader.ReadBodyAsync(stream, head.Leftover, length, CancellationToken.None); + + Assert.Equal("abc", Encoding.ASCII.GetString(body)); + } +} 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/ResponseWriterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs new file mode 100644 index 00000000..6a4ed565 --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.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.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) + { + using var stream = new MemoryStream(); + await ResponseWriter.WriteAsync(stream, response, 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 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 engine always closes the connection in slice 1. + Assert.Contains("Connection: close\r\n", 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); + } +} diff --git a/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj index d8a5d964..54dad4af 100644 --- a/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj +++ b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj @@ -17,6 +17,10 @@ + + + + diff --git a/DevProxy.sln b/DevProxy.sln index da2a54b7..107678c0 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Titanium.Tes 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -118,6 +120,18 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 7d68af759a088382b0ec879f09df5e35b5eb3811 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 17:49:37 +0200 Subject: [PATCH 13/46] Add selective decrypt + ALPN blind-tunnel to Kestrel engine (Phase 4) At CONNECT time the engine now non-destructively peeks the TLS ClientHello (SNI + ALPN) and decides whether to intercept: - non-watched hosts are blind-tunnelled (encrypted bytes relayed, never MITM'd) - watched hosts whose client is h2-only (gRPC, no http/1.1 fallback) are blind-tunnelled so they stop failing - otherwise the engine terminates TLS advertising http/1.1 only, so h2-capable clients downgrade and are intercepted as HTTP/1.1 Adds a minimal tolerant TlsClientHello parser, wires HostWatchList into the connection handler, and relays blind tunnels bidirectionally with a linked cancellation token so both copy tasks finish before the streams are disposed. Covered by 9 new TlsClientHello unit tests (37 Kestrel tests total). Verified live: watched host downgrades h2->http/1.1 and is intercepted; example.com (non-watched) validates its real cert without -k, proving it is never MITM'd. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TlsClientHelloTests.cs | 226 ++++++++++++++++++ .../Internal/ProxyConnectionHandler.cs | 151 +++++++++++- .../Internal/TlsClientHello.cs | 199 +++++++++++++++ DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 3 +- 4 files changed, 566 insertions(+), 13 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/TlsClientHelloTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/TlsClientHello.cs 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/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index ce3604ec..4db1bd67 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -2,9 +2,12 @@ // 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; @@ -14,21 +17,38 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// -/// Handles one raw TCP connection: parses the proxy request, terminates TLS for -/// watched CONNECT targets, runs the plugin pipeline against the canonical -/// model, forwards to the origin, and writes the response back. +/// 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. /// /// -/// Slice-1 scope: one request per connection (Connection: close), plain -/// HTTP + HTTPS-via-CONNECT, mocking short-circuit. Deferred hardening (tracked): -/// keep-alive, blind-tunnel for non-watched / h2-only (ClientHello peek), WebSocket -/// relay + inspection, chunked/trailers, body-mode streaming. +/// 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) +/// └─ otherwise ..................→ MITM, advertise http/1.1 so h2 clients downgrade +/// +/// +/// +/// +/// Slice scope: one request per intercepted connection (Connection: close), +/// plain HTTP + HTTPS-via-CONNECT, mocking short-circuit, selective decrypt + ALPN +/// blind-tunnel. Deferred hardening (tracked): keep-alive, WebSocket relay + inspection, +/// chunked/trailers, body-mode streaming. /// /// internal sealed class ProxyConnectionHandler( CertificateAuthority ca, UpstreamForwarder forwarder, PluginPipeline pipeline, + HostWatchList watchList, ILogger logger) : ConnectionHandler { private static int _requestCounter; @@ -48,7 +68,7 @@ public override async Task OnConnectedAsync(ConnectionContext connection) if (string.Equals(head.Method, "CONNECT", StringComparison.OrdinalIgnoreCase)) { - await HandleConnectAsync(clientStream, head, ct).ConfigureAwait(false); + await HandleConnectAsync(connection, clientStream, head, ct).ConfigureAwait(false); } else { @@ -66,19 +86,46 @@ public override async Task OnConnectedAsync(ConnectionContext connection) } } - private async Task HandleConnectAsync(Stream clientStream, ParsedRequestHead connect, CancellationToken ct) + private async Task HandleConnectAsync( + ConnectionContext connection, Stream clientStream, ParsedRequestHead connect, CancellationToken ct) { var (host, portPart) = SplitHostPort(connect.Target); var port = portPart ?? 443; - // Slice-1: always MITM. Deferred: blind-tunnel non-watched hosts and - // h2-only (gRPC) clients via a ClientHello ALPN peek. + // 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; + } + + 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 }, ct).ConfigureAwait(false); + 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 head = await Http1RequestReader.ReadHeadAsync(tls, ct).ConfigureAwait(false); if (head is null) @@ -93,6 +140,86 @@ await tls.AuthenticateAsServerAsync( await ExchangeAsync(tls, head, url, 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 until either side closes. When the first direction + // finishes, cancel the other so it stops touching the streams. The finally + // awaits both tasks before `origin`/`tcp` are disposed (CA2025: no task may + // outlive the IDisposable it uses). + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); +#pragma warning disable CA2025 // The finally below awaits both copies before origin/clientStream are disposed. + var clientToOrigin = clientStream.CopyToAsync(origin, linked.Token); + var originToClient = origin.CopyToAsync(clientStream, linked.Token); + + try + { + _ = await Task.WhenAny(clientToOrigin, originToClient).ConfigureAwait(false); + await linked.CancelAsync().ConfigureAwait(false); + } + finally + { + try + { + await Task.WhenAll(clientToOrigin, originToClient).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or OperationCanceledException or SocketException) + { + // Either peer closed the connection — normal tunnel teardown. + } + } +#pragma warning restore CA2025 + } + + /// + /// 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, []); + } + } + } + private async Task ExchangeAsync(Stream clientStream, ParsedRequestHead head, string absoluteUrl, CancellationToken ct) { if (!Uri.TryCreate(absoluteUrl, UriKind.Absolute, out var requestUri)) 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/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index 248876bf..fc9ba37d 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -53,13 +53,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) }; using var httpClient = new HttpClient(httpHandler, disposeHandler: false); var forwarder = new UpstreamForwarder(httpClient); + var watchList = HostWatchList.FromUrls(urlsToWatch); var pipeline = new PluginPipeline( plugins, urlsToWatch, configuration, globalData, loggerFactory.CreateLogger()); - var handler = new ProxyConnectionHandler(ca, forwarder, pipeline, _logger); + var handler = new ProxyConnectionHandler(ca, forwarder, pipeline, watchList, _logger); var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); _ = builder.WebHost.UseKestrelCore(); From e269fc7dc7583c3130901c5b849550efbb131d6d Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 18:02:05 +0200 Subject: [PATCH 14/46] Add keep-alive + per-request state isolation to Kestrel engine (Phase 4 slice 3) Replace the static one-shot HTTP/1.1 reader with a stateful per-connection Http1ConnectionReader that owns the inter-request buffer, so bytes read past one request's body (a pipelined next request) are retained instead of dropped. This makes client-side keep-alive correct. - Http1ConnectionReader: stateful ReadHeadAsync/ReadBodyAsync owning _pending. - Http1RequestReader: reduced to stateless parse helpers (ParseHead, GetContentLength, IndexOfDoubleCrlf). - ProxyConnectionHandler.ServeConnectionAsync: keep-alive loop over both plain HTTP and decrypted CONNECT streams; each exchange builds a fresh CanonicalProxySession (new GUID + Interlocked requestId) so plugin state never leaks across requests on a reused connection. - ShouldKeepAlive (RFC 9112 9.3): persistent by default on HTTP/1.1, opt-in on HTTP/1.0; forces Connection: close for Connection: close, Transfer-Encoding (chunked, not yet reframable) and Expect: 100-continue (unsupported), so an unframable body can never corrupt the next request. - ResponseWriter.WriteAsync: keepAlive flag drives the Connection header. Tests: +15 Kestrel (stateful reader incl. pipelining/surplus-retention, ShouldKeepAlive matrix, keep-alive/close response-writer). 123 green total. Verified live: two URLs in one curl invocation reuse one TCP connection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Http1ConnectionReaderTests.cs | 161 ++++++++++++++++++ .../Http1RequestReaderTests.cs | 67 ++------ .../ResponseWriterTests.cs | 31 +++- .../ShouldKeepAliveTests.cs | 65 +++++++ .../Internal/Http1ConnectionReader.cs | 110 ++++++++++++ .../Internal/Http1RequestReader.cs | 100 ++--------- .../Internal/ProxyConnectionHandler.cs | 130 +++++++++++--- .../Internal/ResponseWriter.cs | 10 +- 8 files changed, 509 insertions(+), 165 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs create mode 100644 DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs diff --git a/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs b/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs new file mode 100644 index 00000000..27eee5de --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs @@ -0,0 +1,161 @@ +// 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); + } + + 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 index de54a6e7..bd1fdc3d 100644 --- a/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs @@ -12,19 +12,16 @@ namespace DevProxy.Proxy.Kestrel.Tests; public class Http1RequestReaderTests { [Fact] - public async Task ReadHeadAsync_ParsesRequestLineAndHeaders() + public void ParseHead_ParsesRequestLineAndHeaders() { - const string raw = + const string headerText = "GET /posts/1 HTTP/1.1\r\n" + "Host: example.com\r\n" + - "Accept: application/json\r\n" + - "\r\n"; - using var stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); + "Accept: application/json"; - var head = await Http1RequestReader.ReadHeadAsync(stream, CancellationToken.None); + var head = Http1RequestReader.ParseHead(headerText); - Assert.NotNull(head); - Assert.Equal("GET", head!.Method); + 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"); @@ -32,22 +29,9 @@ public async Task ReadHeadAsync_ParsesRequestLineAndHeaders() } [Fact] - public async Task ReadHeadAsync_ReturnsNull_OnCleanEofBeforeAnyBytes() + public void ParseHead_Throws_OnMalformedRequestLine() { - using var stream = new MemoryStream([]); - - var head = await Http1RequestReader.ReadHeadAsync(stream, CancellationToken.None); - - Assert.Null(head); - } - - [Fact] - public async Task ReadHeadAsync_Throws_OnMalformedRequestLine() - { - using var stream = new MemoryStream(Encoding.ASCII.GetBytes("GARBAGE\r\n\r\n")); - - _ = await Assert.ThrowsAsync( - async () => await Http1RequestReader.ReadHeadAsync(stream, CancellationToken.None)); + _ = Assert.Throws(() => Http1RequestReader.ParseHead("GARBAGE")); } [Fact] @@ -71,43 +55,18 @@ public void GetContentLength_DefaultsToZero_WhenAbsent() } [Fact] - public async Task ReadBodyAsync_UsesLeftoverThenStream() - { - // The header read consumed "AB" past the terminator; the rest comes from the stream. - var leftover = Encoding.ASCII.GetBytes("AB"); - using var stream = new MemoryStream(Encoding.ASCII.GetBytes("CDE")); - - var body = await Http1RequestReader.ReadBodyAsync(stream, leftover, contentLength: 5, CancellationToken.None); - - Assert.Equal("ABCDE", Encoding.ASCII.GetString(body)); - } - - [Fact] - public async Task ReadBodyAsync_ReturnsEmpty_WhenContentLengthZero() + public void IndexOfDoubleCrlf_FindsTerminator() { - using var stream = new MemoryStream(Encoding.ASCII.GetBytes("ignored")); - - var body = await Http1RequestReader.ReadBodyAsync(stream, [], contentLength: 0, CancellationToken.None); + var data = Encoding.ASCII.GetBytes("ab\r\n\r\ncd"); - Assert.Empty(body); + Assert.Equal(2, Http1RequestReader.IndexOfDoubleCrlf(data)); } [Fact] - public async Task ReadHeadAsync_LeftoverContainsBodyBytesReadWithHeaderBlock() + public void IndexOfDoubleCrlf_ReturnsMinusOne_WhenAbsent() { - const string raw = - "POST /posts HTTP/1.1\r\n" + - "Content-Length: 3\r\n" + - "\r\n" + - "abc"; - using var stream = new MemoryStream(Encoding.ASCII.GetBytes(raw)); - - var head = await Http1RequestReader.ReadHeadAsync(stream, CancellationToken.None); - Assert.NotNull(head); - - var length = Http1RequestReader.GetContentLength(head!.Headers); - var body = await Http1RequestReader.ReadBodyAsync(stream, head.Leftover, length, CancellationToken.None); + var data = Encoding.ASCII.GetBytes("no terminator here"); - Assert.Equal("abc", Encoding.ASCII.GetString(body)); + Assert.Equal(-1, Http1RequestReader.IndexOfDoubleCrlf(data)); } } diff --git a/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs index 6a4ed565..dc4ea73f 100644 --- a/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs @@ -14,10 +14,10 @@ namespace DevProxy.Proxy.Kestrel.Tests; public class ResponseWriterTests { - private static async Task WriteAsync(MutableHttpResponse response) + private static async Task WriteAsync(MutableHttpResponse response, bool keepAlive = false) { using var stream = new MemoryStream(); - await ResponseWriter.WriteAsync(stream, response, CancellationToken.None); + await ResponseWriter.WriteAsync(stream, response, keepAlive, CancellationToken.None); return Encoding.ASCII.GetString(stream.ToArray()); } @@ -68,10 +68,35 @@ public async Task WriteAsync_StripsHopByHopAndContentEncodingHeaders() 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 engine always closes the connection in slice 1. + // 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() { diff --git a/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs b/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs new file mode 100644 index 00000000..b9b55e32 --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs @@ -0,0 +1,65 @@ +// 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_ForcesClose() + { + // We cannot reframe a chunked body yet, so refuse keep-alive to avoid + // corrupting the next request on the connection. + Assert.False(ProxyConnectionHandler.ShouldKeepAlive( + Head("HTTP/1.1", ("Transfer-Encoding", "chunked")))); + } + + [Fact] + public void ExpectHeader_ForcesClose() + { + // 100-continue is unsupported; close rather than mishandle the body. + Assert.False(ProxyConnectionHandler.ShouldKeepAlive( + Head("HTTP/1.1", ("Expect", "100-continue")))); + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs b/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs new file mode 100644 index 00000000..abd6b30c --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs @@ -0,0 +1,110 @@ +// 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; + +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) consumes leftover first, +/// then the stream; any surplus stays in _pending for the +/// next request (HTTP pipelining). +/// +/// +/// +/// One instance per connection (or per decrypted TLS session). Not thread-safe: +/// the connection handler drives it sequentially (read head → read body → repeat). +/// +/// +/// +/// Deferred (tracked hardening): chunked transfer-decoding + trailers and +/// Expect: 100-continue. Until then the connection handler refuses keep-alive +/// for any request that uses those (see ProxyConnectionHandler.ShouldKeepAlive), +/// so an unframable body never corrupts a subsequent request. +/// +/// +internal sealed class Http1ConnectionReader(Stream stream) +{ + private const int ReadChunkBytes = 4096; + 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 . + /// + public async Task ReadBodyAsync(int contentLength, CancellationToken ct) + { + if (contentLength <= 0) + { + return []; + } + + var body = new byte[contentLength]; + var copied = Math.Min(_pending.Length, contentLength); + Array.Copy(_pending, body, copied); + _pending = _pending.Length > copied ? _pending[copied..] : []; + + var offset = copied; + while (offset < contentLength) + { + var read = await stream.ReadAsync(body.AsMemory(offset, contentLength - offset), ct).ConfigureAwait(false); + if (read == 0) + { + break; + } + offset += read; + } + + return body; + } + + 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 index 3d3c4237..b4b00958 100644 --- a/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs +++ b/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs @@ -3,51 +3,37 @@ // See the LICENSE file in the project root for more information. using System.Globalization; -using System.Text; namespace DevProxy.Proxy.Kestrel.Internal; /// -/// A single HTTP/1.x message head parsed off the wire: request line plus headers, -/// and any bytes already read past the header terminator. +/// 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). -/// Bytes read past the header block (start of the body). internal sealed record ParsedRequestHead( string Method, string Target, string Version, - IReadOnlyList<(string Name, string Value)> Headers, - byte[] Leftover); + IReadOnlyList<(string Name, string Value)> Headers); /// -/// Minimal, allocation-conscious HTTP/1.x reader for the proxy's decrypted/plain -/// side. Slice-1 scope: request line + headers + Content-Length bodies. -/// -/// -/// Deferred (tracked hardening): chunked transfer-decoding + trailers, -/// Expect: 100-continue, request-smuggling guards (duplicate/ambiguous -/// framing), and keep-alive leftover reuse. Until then the connection handler -/// reads one request and closes (Connection: close). -/// +/// 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 { - private const int MaxHeaderBlockBytes = 1024 * 1024; + public const int MaxHeaderBlockBytes = 1024 * 1024; - /// Reads one request head, or null on a clean EOF before any bytes. - public static async Task ReadHeadAsync(Stream stream, CancellationToken ct) + /// Parses a CRLF-delimited header block (request line + header lines). + /// The request line is malformed. + public static ParsedRequestHead ParseHead(string headerText) { - var headerBlock = await ReadHeaderBlockAsync(stream, ct).ConfigureAwait(false); - if (headerBlock is null) - { - return null; - } + ArgumentNullException.ThrowIfNull(headerText); - var (headerText, leftover) = headerBlock.Value; var lines = headerText.Split("\r\n"); var startLine = lines[0].Split(' ', 3); if (startLine.Length < 3) @@ -66,41 +52,14 @@ internal static class Http1RequestReader headers.Add((lines[i][..separator].Trim(), lines[i][(separator + 1)..].Trim())); } - return new ParsedRequestHead(startLine[0], startLine[1], startLine[2], headers, leftover); - } - - /// - /// Reads a fixed-length body given the Content-Length and any bytes the - /// header read already consumed past the terminator. - /// - public static async Task ReadBodyAsync(Stream stream, byte[] leftover, int contentLength, CancellationToken ct) - { - if (contentLength <= 0) - { - return []; - } - - var body = new byte[contentLength]; - var copied = Math.Min(leftover.Length, contentLength); - Array.Copy(leftover, body, copied); - - var offset = copied; - while (offset < contentLength) - { - var read = await stream.ReadAsync(body.AsMemory(offset, contentLength - offset), ct).ConfigureAwait(false); - if (read == 0) - { - break; - } - offset += read; - } - - return body; + 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) @@ -112,38 +71,11 @@ public static int GetContentLength(IReadOnlyList<(string Name, string Value)> he return 0; } - private static async Task<(string Header, byte[] Leftover)?> ReadHeaderBlockAsync(Stream stream, CancellationToken ct) + /// Index of the first CRLFCRLF in , or -1. + public static int IndexOfDoubleCrlf(IReadOnlyList data) { - var buffer = new byte[4096]; - var accumulator = new List(4096); + ArgumentNullException.ThrowIfNull(data); - while (true) - { - var read = await stream.ReadAsync(buffer, ct).ConfigureAwait(false); - if (read == 0) - { - return null; - } - - accumulator.AddRange(buffer.AsSpan(0, read)); - - var terminator = IndexOfDoubleCrlf(accumulator); - if (terminator >= 0) - { - var header = Encoding.ASCII.GetString(accumulator.ToArray(), 0, terminator); - var leftover = accumulator.GetRange(terminator + 4, accumulator.Count - (terminator + 4)).ToArray(); - return (header, leftover); - } - - if (accumulator.Count > MaxHeaderBlockBytes) - { - throw new InvalidOperationException("Request header block too large."); - } - } - } - - private static int IndexOfDoubleCrlf(List data) - { for (var i = 0; i + 3 < data.Count; i++) { if (data[i] == (byte)'\r' && data[i + 1] == (byte)'\n' diff --git a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index 4db1bd67..6d3e2aa9 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -38,10 +38,11 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// /// /// -/// Slice scope: one request per intercepted connection (Connection: close), +/// 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. Deferred hardening (tracked): keep-alive, WebSocket relay + inspection, -/// chunked/trailers, body-mode streaming. +/// blind-tunnel. Deferred hardening (tracked): WebSocket relay + inspection, +/// chunked/trailers + Expect: 100-continue (such requests fall back to +/// Connection: close), body-mode streaming. /// /// internal sealed class ProxyConnectionHandler( @@ -57,10 +58,11 @@ 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 Http1RequestReader.ReadHeadAsync(clientStream, ct).ConfigureAwait(false); + var head = await reader.ReadHeadAsync(ct).ConfigureAwait(false); if (head is null) { return; @@ -72,8 +74,10 @@ public override async Task OnConnectedAsync(ConnectionContext connection) } else { - // Plain HTTP proxy request: the target is an absolute URL. - await ExchangeAsync(clientStream, head, head.Target, ct).ConfigureAwait(false); + // 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 (ex is OperationCanceledException or IOException or ConnectionResetException) @@ -127,17 +131,48 @@ await tls.AuthenticateAsServerAsync( ApplicationProtocols = [SslApplicationProtocol.Http11], }, ct).ConfigureAwait(false); - var head = await Http1RequestReader.ReadHeadAsync(tls, ct).ConfigureAwait(false); + var tlsReader = new Http1ConnectionReader(tls); + var head = await tlsReader.ReadHeadAsync(ct).ConfigureAwait(false); if (head is null) { return; } - var url = port == 443 - ? $"https://{host}{head.Target}" - : $"https://{host}:{port.ToString(CultureInfo.InvariantCulture)}{head.Target}"; + await ServeConnectionAsync(tlsReader, tls, head, host, 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; + } - await ExchangeAsync(tls, head, url, ct).ConfigureAwait(false); + head = await reader.ReadHeadAsync(ct).ConfigureAwait(false); + } } /// @@ -220,16 +255,24 @@ private async Task BlindTunnelAsync(Stream clientStream, string host, int port, } } - private async Task ExchangeAsync(Stream clientStream, ParsedRequestHead head, string absoluteUrl, CancellationToken ct) + /// + /// 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; + return false; } var contentLength = Http1RequestReader.GetContentLength(head.Headers); - var body = await Http1RequestReader.ReadBodyAsync(clientStream, head.Leftover, contentLength, ct).ConfigureAwait(false); + var body = await reader.ReadBodyAsync(contentLength, ct).ConfigureAwait(false); + var keepAlive = ShouldKeepAlive(head); var headers = new HeaderCollection(); foreach (var (name, value) in head.Headers) @@ -255,7 +298,7 @@ private async Task ExchangeAsync(Stream clientStream, ParsedRequestHead head, st pipeline.Forget(session.SessionId); logger.LogError(ex, "Error running request pipeline"); await WriteErrorAsync(clientStream, HttpStatusCode.BadGateway, "Plugin pipeline error", ct).ConfigureAwait(false); - return; + return false; } // Mocked: a plugin produced the response during the request phase. Skip the @@ -264,8 +307,8 @@ private async Task ExchangeAsync(Stream clientStream, ParsedRequestHead head, st if (phase == RequestPhase.Mocked) { await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); - await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, ct).ConfigureAwait(false); - return; + await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, keepAlive, ct).ConfigureAwait(false); + return keepAlive; } MutableHttpResponse response; @@ -278,20 +321,67 @@ private async Task ExchangeAsync(Stream clientStream, ParsedRequestHead head, st pipeline.Forget(session.SessionId); logger.LogError(ex, "Error forwarding to origin {Url}", absoluteUrl); await WriteErrorAsync(clientStream, HttpStatusCode.BadGateway, "Upstream request failed", ct).ConfigureAwait(false); - return; + return false; } if (phase == RequestPhase.Watched) { session.SetOriginResponse(response); await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); - await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, ct).ConfigureAwait(false); + await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, keepAlive, ct).ConfigureAwait(false); } else { // NotWatched: pure passthrough, no response-phase plugins. - await ResponseWriter.WriteAsync(clientStream, response, ct).ConfigureAwait(false); + await ResponseWriter.WriteAsync(clientStream, response, keepAlive, 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. We + /// refuse keep-alive for requests whose body we cannot yet frame for the next + /// message — a chunked (Transfer-Encoding) body or Expect: 100-continue + /// — so an unread/misframed body never corrupts a subsequent request. + /// + internal static bool ShouldKeepAlive(ParsedRequestHead head) + { + string? connection = null; + var hasUnframableBody = false; + foreach (var (name, value) in head.Headers) + { + if (string.Equals(name, "Connection", StringComparison.OrdinalIgnoreCase)) + { + connection = value; + } + else if (string.Equals(name, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Expect", StringComparison.OrdinalIgnoreCase)) + { + hasUnframableBody = true; + } + } + + if (hasUnframableBody) + { + return false; + } + + 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) diff --git a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs index fc69e21b..5c5f644b 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs @@ -15,12 +15,14 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// framing / encoding headers so the client always receives a valid message /// (). /// -/// Slice-1 scope: writes Connection: close and one body buffer. -/// Keep-alive + chunked write-back are tracked hardening. +/// Because the engine always buffers the full body and recomputes +/// Content-Length, the client can frame the response unambiguously, so the +/// connection may be kept alive when the request allows it. Chunked write-back is +/// tracked hardening. /// internal static class ResponseWriter { - public static async Task WriteAsync(Stream clientStream, IHttpResponse response, CancellationToken ct) + public static async Task WriteAsync(Stream clientStream, IHttpResponse response, bool keepAlive, CancellationToken ct) { var head = new StringBuilder(); var statusCode = (int)response.StatusCode; @@ -43,7 +45,7 @@ public static async Task WriteAsync(Stream clientStream, IHttpResponse response, var body = response.Body; _ = head.Append(CultureInfo.InvariantCulture, $"Content-Length: {body.Length}\r\n"); - _ = head.Append("Connection: close\r\n\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 (!body.IsEmpty) From 1bc6b3bf0f8addf0f4a9045c29d474e32b4eb0ac Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 18:46:14 +0200 Subject: [PATCH 15/46] Add WebSocket transparent relay to Kestrel engine (Phase 4 slice 4) Watched-host WebSocket upgrades (ws:// plain + wss:// after MITM) now relay through the Kestrel engine for parity with Titanium, which relays WS opaquely (no plugin sees frames). HttpClient can't carry a 101 upgrade, so WebSocketRelay opens its own raw socket to the origin (TLS w/ http/1.1 ALPN for wss), replays the handshake in origin-form (preserving Upgrade/Connection/Sec-WebSocket-*, stripping Proxy-*), writes the origin's 101 back to the client verbatim, then splices frames raw via a shared StreamRelay (also now used by BlindTunnelAsync, DRY). Critical ordering: the response pipeline (which flushes the buffered req-log) runs in the onHandshakeResponse callback, right after the 101 and before the splice blocks, else a watched WS never logs until the socket closes. A handshakeObserved fallback flush covers origin-connect failure. Tests: ParseResponseHead unit tests, StreamRelay bidirectional copy, and an end-to-end RelayAsync test against a loopback fake origin (asserts origin-form handshake replay, header preservation, 101 verbatim write-back, frame splicing, and onHandshakeResponse invocation). 128 tests green (+5 Kestrel). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../StreamRelayTests.cs | 57 ++++ DevProxy.Proxy.Kestrel.Tests/TestSockets.cs | 40 +++ .../WebSocketRelayTests.cs | 173 +++++++++++ .../Internal/ProxyConnectionHandler.cs | 84 ++++-- .../Internal/StreamRelay.cs | 52 ++++ .../Internal/WebSocketRelay.cs | 269 ++++++++++++++++++ 6 files changed, 646 insertions(+), 29 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/StreamRelayTests.cs create mode 100644 DevProxy.Proxy.Kestrel.Tests/TestSockets.cs create mode 100644 DevProxy.Proxy.Kestrel.Tests/WebSocketRelayTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/WebSocketRelay.cs 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/TestSockets.cs b/DevProxy.Proxy.Kestrel.Tests/TestSockets.cs new file mode 100644 index 00000000..4e8b1ee2 --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/TestSockets.cs @@ -0,0 +1,40 @@ +// 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; + } + } +} 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/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index 6d3e2aa9..82155768 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -40,9 +40,11 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// /// 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. Deferred hardening (tracked): WebSocket relay + inspection, -/// chunked/trailers + Expect: 100-continue (such requests fall back to -/// Connection: close), body-mode streaming. +/// blind-tunnel, and transparent WebSocket relay (handshake replayed, frames spliced +/// opaque — see ). Deferred hardening (tracked): +/// WebSocket frame inspection/mocking (plan §7), chunked/trailers + +/// Expect: 100-continue (such requests fall back to Connection: close), +/// body-mode streaming. /// /// internal sealed class ProxyConnectionHandler( @@ -53,6 +55,7 @@ internal sealed class ProxyConnectionHandler( ILogger logger) : ConnectionHandler { private static int _requestCounter; + private readonly WebSocketRelay _webSocketRelay = new(logger); public override async Task OnConnectedAsync(ConnectionContext connection) { @@ -196,32 +199,8 @@ private async Task BlindTunnelAsync(Stream clientStream, string host, int port, await using var origin = tcp.GetStream(); - // Relay both directions until either side closes. When the first direction - // finishes, cancel the other so it stops touching the streams. The finally - // awaits both tasks before `origin`/`tcp` are disposed (CA2025: no task may - // outlive the IDisposable it uses). - using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); -#pragma warning disable CA2025 // The finally below awaits both copies before origin/clientStream are disposed. - var clientToOrigin = clientStream.CopyToAsync(origin, linked.Token); - var originToClient = origin.CopyToAsync(clientStream, linked.Token); - - try - { - _ = await Task.WhenAny(clientToOrigin, originToClient).ConfigureAwait(false); - await linked.CancelAsync().ConfigureAwait(false); - } - finally - { - try - { - await Task.WhenAll(clientToOrigin, originToClient).ConfigureAwait(false); - } - catch (Exception ex) when (ex is IOException or OperationCanceledException or SocketException) - { - // Either peer closed the connection — normal tunnel teardown. - } - } -#pragma warning restore CA2025 + // Relay both directions (still-encrypted bytes) until either side closes. + await StreamRelay.RelayBidirectionalAsync(clientStream, origin, ct).ConfigureAwait(false); } /// @@ -311,6 +290,53 @@ private async Task ExchangeAsync( 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 (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 + { + await _webSocketRelay.RelayAsync(clientStream, request, requestUri, OnHandshakeAsync, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + 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; + } + MutableHttpResponse response; try { diff --git a/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs b/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs new file mode 100644 index 00000000..d5d0c3ae --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs @@ -0,0 +1,52 @@ +// 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.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 (ex is IOException or OperationCanceledException or SocketException) + { + // Either peer closed the connection — normal relay teardown. + } + } +#pragma warning restore CA2025 + } +} 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); + } +} From 481abc1127a97435a175aa15f89717efce910d0b Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 19:03:32 +0200 Subject: [PATCH 16/46] Add chunked request bodies + 100-continue to Kestrel engine (Phase 4 slice 5) Closes the biggest remaining HTTP/1.1 correctness gap on the request read path. Previously a chunked request body was silently dropped (Content-Length defaulted to 0, so an empty body was forwarded) and Expect: 100-continue hung (the proxy never sent the interim 100). Response-side chunked already worked via HttpClient. - Http1ConnectionReader.ReadChunkedBodyAsync decodes chunked framing (size lines, chunk extensions, CRLF delimiters) into one buffer, consumes and drops trailers, and retains surplus for the next pipelined request. Shared _pending fill primitives (PullAsync/ReadExactlyAsync/ReadLineAsync/TakeFromPending), reused by ReadBodyAsync. - Http1RequestReader.DetectBodyFraming (None/ContentLength/Chunked/Conflicting) and HasExpectContinue. A request declaring both Content-Length and chunked Transfer-Encoding is Conflicting and refused with 400 (RFC 9112 6.3.3 smuggling guard). - ResponseWriter.WriteContinueAsync sends 100 Continue; ExchangeAsync answers Expect: 100-continue itself before reading the body (the buffering model can't relay the origin's interim 100). Expect is stripped on forward. - ShouldKeepAlive no longer forces close for chunked/Expect; malformed chunked framing replies 400 and closes. 146 tests green (+18). Verified live through the MITM proxy: a chunked upload reached httpbin with the body intact and reframed to Content-Length (no Transfer-Encoding), the client got 100 Continue, two chunked POSTs reused one TCP connection, and a raw Content-Length+Transfer-Encoding request got 400. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Http1ConnectionReaderTests.cs | 100 ++++++++++ .../Http1RequestReaderTests.cs | 55 ++++++ .../ResponseWriterTests.cs | 10 + .../ShouldKeepAliveTests.cs | 22 ++- .../Internal/Http1ConnectionReader.cs | 179 ++++++++++++++++-- .../Internal/Http1RequestReader.cs | 82 ++++++++ .../Internal/ProxyConnectionHandler.cs | 55 ++++-- .../Internal/ResponseWriter.cs | 14 ++ .../Internal/UpstreamForwarder.cs | 7 +- 9 files changed, 483 insertions(+), 41 deletions(-) diff --git a/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs b/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs index 27eee5de..8fcbcda3 100644 --- a/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/Http1ConnectionReaderTests.cs @@ -122,6 +122,106 @@ public async Task ReadBodyAsync_RetainsSurplusBytesForNextRequest() 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)); diff --git a/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs b/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs index bd1fdc3d..caaf3007 100644 --- a/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/Http1RequestReaderTests.cs @@ -69,4 +69,59 @@ public void IndexOfDoubleCrlf_ReturnsMinusOne_WhenAbsent() 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/ResponseWriterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs index dc4ea73f..3c27a2e3 100644 --- a/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs @@ -36,6 +36,16 @@ public async Task WriteAsync_WritesStatusLineAndBody() 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() { diff --git a/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs b/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs index b9b55e32..a8ee1576 100644 --- a/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/ShouldKeepAliveTests.cs @@ -47,19 +47,27 @@ public void ConnectionHeaderIsCaseInsensitive() } [Fact] - public void TransferEncoding_ForcesClose() + public void TransferEncoding_KeepsAlive_NowThatChunkedIsReframed() { - // We cannot reframe a chunked body yet, so refuse keep-alive to avoid - // corrupting the next request on the connection. - Assert.False(ProxyConnectionHandler.ShouldKeepAlive( + // 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_ForcesClose() + public void ExpectHeader_KeepsAlive_NowThat100ContinueIsHandled() { - // 100-continue is unsupported; close rather than mishandle the body. - Assert.False(ProxyConnectionHandler.ShouldKeepAlive( + // 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/Internal/Http1ConnectionReader.cs b/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs index abd6b30c..bc971ac5 100644 --- a/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs +++ b/DevProxy.Proxy.Kestrel/Internal/Http1ConnectionReader.cs @@ -2,6 +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 System.Globalization; using System.Text; namespace DevProxy.Proxy.Kestrel.Internal; @@ -17,26 +18,27 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// stream ──read 4096──► _pending ──► [find CRLFCRLF] ──► head /// │ leftover /// ▼ -/// ReadBodyAsync(Content-Length) consumes leftover first, -/// then the stream; any surplus stays in _pending for the -/// next request (HTTP pipelining). +/// ReadBodyAsync(Content-Length) ─┐ consume leftover first, +/// ReadChunkedBodyAsync() ─┘ then the stream; any +/// surplus stays in _pending for the next request (pipelining). /// /// /// -/// One instance per connection (or per decrypted TLS session). Not thread-safe: -/// the connection handler drives it sequentially (read head → read body → repeat). +/// 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. /// /// /// -/// Deferred (tracked hardening): chunked transfer-decoding + trailers and -/// Expect: 100-continue. Until then the connection handler refuses keep-alive -/// for any request that uses those (see ProxyConnectionHandler.ShouldKeepAlive), -/// so an unframable body never corrupts a subsequent 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 = []; /// @@ -77,7 +79,8 @@ internal sealed class Http1ConnectionReader(Stream stream) /// /// 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 . + /// 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) { @@ -87,11 +90,10 @@ public async Task ReadBodyAsync(int contentLength, CancellationToken ct) } var body = new byte[contentLength]; - var copied = Math.Min(_pending.Length, contentLength); - Array.Copy(_pending, body, copied); - _pending = _pending.Length > copied ? _pending[copied..] : []; + var fromPending = TakeFromPending(Math.Min(_pending.Length, contentLength)); + fromPending.CopyTo(body.AsSpan()); - var offset = copied; + var offset = fromPending.Length; while (offset < contentLength) { var read = await stream.ReadAsync(body.AsMemory(offset, contentLength - offset), ct).ConfigureAwait(false); @@ -105,6 +107,155 @@ public async Task ReadBodyAsync(int contentLength, CancellationToken ct) 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 index b4b00958..cb41bde5 100644 --- a/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs +++ b/DevProxy.Proxy.Kestrel/Internal/Http1RequestReader.cs @@ -19,6 +19,35 @@ internal sealed record ParsedRequestHead( 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 @@ -71,6 +100,59 @@ public static int GetContentLength(IReadOnlyList<(string Name, string Value)> he 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) { diff --git a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index 82155768..dde9682e 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -249,8 +249,39 @@ private async Task ExchangeAsync( return false; } - var contentLength = Http1RequestReader.GetContentLength(head.Headers); - var body = await reader.ReadBodyAsync(contentLength, ct).ConfigureAwait(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(); @@ -367,31 +398,21 @@ async Task OnHandshakeAsync(MutableHttpResponse handshakeResponse) /// /// 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. We - /// refuse keep-alive for requests whose body we cannot yet frame for the next - /// message — a chunked (Transfer-Encoding) body or Expect: 100-continue - /// — so an unread/misframed body never corrupts a subsequent request. + /// 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; - var hasUnframableBody = false; foreach (var (name, value) in head.Headers) { if (string.Equals(name, "Connection", StringComparison.OrdinalIgnoreCase)) { connection = value; + break; } - else if (string.Equals(name, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Expect", StringComparison.OrdinalIgnoreCase)) - { - hasUnframableBody = true; - } - } - - if (hasUnframableBody) - { - return false; } var isHttp10 = head.Version.EndsWith("1.0", StringComparison.Ordinal); diff --git a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs index 5c5f644b..1f2301a5 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs @@ -22,6 +22,20 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// 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, CancellationToken ct) { var head = new StringBuilder(); diff --git a/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs index 19677180..83a44bf2 100644 --- a/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs +++ b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs @@ -11,9 +11,9 @@ 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, the body -/// is delivered to plugins decompressed, and framing headers are recomputed on -/// write-back. +/// : 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. /// internal sealed class UpstreamForwarder(HttpClient httpClient) { @@ -34,6 +34,7 @@ public async Task ForwardAsync(IHttpRequest request, Cancel { 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; From 650ff7d3e21071eb36f70179e3a5d9cc9f670770 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 19:23:43 +0200 Subject: [PATCH 17/46] Kestrel engine: incremental SSE streaming with capped tee (Slice 6) Forward text/event-stream responses to the client piece-by-piece (re-framed as HTTP/1.1 chunked) instead of buffering the whole body. Buffering withheld every event until the stream ended and hung the engine on unbounded streams. - UpstreamForwarder.ForwardAsync returns an OriginResponse: buffered for normal responses, or a live body stream for text/event-stream (message stays open). - StreamingResponseWriter pumps origin->client one chunk per read (flushed so events arrive live), accumulating a capped copy (4 MiB, reusing BodyModeResolver.DefaultInMemoryLimitBytes); beyond the cap accumulation is dropped but relaying continues, so the engine never hangs or OOMs. - PluginPipeline.RunStreamingResponseAsync runs the body pump between BeforeResponse and AfterResponse, exposing the accumulated body to read-only AfterResponse inspectors (OpenAITelemetry/OpenAIUsageDebugging) for parity. - Keep-alive preserved (chunked is self-delimiting). Tests: +12 (StreamingResponseWriter chunk framing/accumulation/cap, forwarder streaming detection). 158 green. Live-verified: local finite SSE streamed ~0.5s per event (not buffered), Transfer-Encoding: chunked, Connection: keep-alive, connection reused across two streamed responses, non-watched passthrough intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../StreamingResponseWriterTests.cs | 179 ++++++++++++++++++ DevProxy.Proxy.Kestrel.Tests/TestSockets.cs | 41 ++++ .../UpstreamForwarderTests.cs | 75 ++++++++ .../Internal/PluginPipeline.cs | 28 ++- .../Internal/ProxyConnectionHandler.cs | 80 ++++++-- .../Internal/ResponseWriter.cs | 8 +- .../Internal/StreamingResponseWriter.cs | 143 ++++++++++++++ .../Internal/UpstreamForwarder.cs | 97 ++++++++-- 8 files changed, 621 insertions(+), 30 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/StreamingResponseWriterTests.cs create mode 100644 DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/StreamingResponseWriter.cs 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 index 4e8b1ee2..5d34996f 100644 --- a/DevProxy.Proxy.Kestrel.Tests/TestSockets.cs +++ b/DevProxy.Proxy.Kestrel.Tests/TestSockets.cs @@ -38,3 +38,44 @@ static async Task ConnectAsync(int port) } } } + +/// +/// 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/UpstreamForwarderTests.cs b/DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs new file mode 100644 index 00000000..f4ae16a0 --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs @@ -0,0 +1,75 @@ +// 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() => + new("GET", 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")); + } + + private sealed class StubHandler(HttpResponseMessage response) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(response); + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs b/DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs index 497d2ae4..0e610179 100644 --- a/DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs +++ b/DevProxy.Proxy.Kestrel/Internal/PluginPipeline.cs @@ -129,7 +129,27 @@ public async Task RunRequestAsync(CanonicalProxySession session, C return RequestPhase.Watched; } - public async Task RunResponseAsync(CanonicalProxySession session, CancellationToken ct) + 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)) { @@ -167,6 +187,12 @@ public async Task RunResponseAsync(CanonicalProxySession session, CancellationTo _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(); diff --git a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index dde9682e..868c8d7f 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -41,10 +41,10 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// 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 ). Deferred hardening (tracked): -/// WebSocket frame inspection/mocking (plan §7), chunked/trailers + -/// Expect: 100-continue (such requests fall back to Connection: close), -/// body-mode streaming. +/// 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( @@ -368,10 +368,10 @@ async Task OnHandshakeAsync(MutableHttpResponse handshakeResponse) return false; } - MutableHttpResponse response; + OriginResponse origin; try { - response = await forwarder.ForwardAsync(request, ct).ConfigureAwait(false); + origin = await forwarder.ForwardAsync(request, ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -381,16 +381,74 @@ async Task OnHandshakeAsync(MutableHttpResponse handshakeResponse) 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, ct).ConfigureAwait(false); + } + else + { + // NotWatched: pure passthrough, no response-phase plugins. + await ResponseWriter.WriteAsync(clientStream, origin.Response, keepAlive, 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 = (int)BodyModeResolver.DefaultInMemoryLimitBytes; + if (phase == RequestPhase.Watched) { - session.SetOriginResponse(response); - await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); - await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, keepAlive, ct).ConfigureAwait(false); + 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: pure passthrough, no response-phase plugins. - await ResponseWriter.WriteAsync(clientStream, response, keepAlive, ct).ConfigureAwait(false); + // 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; diff --git a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs index 1f2301a5..a6090aae 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs @@ -15,10 +15,10 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// framing / encoding headers so the client always receives a valid message /// (). /// -/// Because the engine always buffers the full body and recomputes -/// Content-Length, the client can frame the response unambiguously, so the -/// connection may be kept alive when the request allows it. Chunked write-back is -/// tracked hardening. +/// 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 { 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/UpstreamForwarder.cs b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs index 83a44bf2..926f1294 100644 --- a/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs +++ b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs @@ -14,12 +14,20 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// : 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) + public async Task ForwardAsync(IHttpRequest request, CancellationToken ct) { using var outgoing = new HttpRequestMessage(new HttpMethod(request.Method), request.RequestUri); @@ -46,20 +54,46 @@ public async Task ForwardAsync(IHttpRequest request, Cancel } } - var originResponse = await _httpClient + HttpResponseMessage? originResponse = await _httpClient .SendAsync(outgoing, HttpCompletionOption.ResponseHeadersRead, ct) .ConfigureAwait(false); try { - // 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 headers = new HeaderCollection(); CopyHeaders(originResponse.Headers, headers); CopyHeaders(originResponse.Content.Headers, headers); + var isStreaming = 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. + _ = headers.Remove("Content-Encoding"); + _ = headers.Remove("Content-Length"); + _ = headers.Remove("Transfer-Encoding"); + + 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, @@ -67,20 +101,18 @@ public async Task ForwardAsync(IHttpRequest request, Cancel body, originResponse.ReasonPhrase); - // Body is already decompressed; advertise its real length and drop any - // stale framing/encoding the origin declared. - _ = headers.Remove("Content-Encoding"); - _ = headers.Remove("Content-Length"); - _ = headers.Remove("Transfer-Encoding"); - - return response; + return new OriginResponse(response, bodyStream: null, message: null); } finally { - originResponse.Dispose(); + 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) @@ -99,3 +131,40 @@ private static void CopyHeaders(System.Net.Http.Headers.HttpHeaders source, Head 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(); + } +} From 2b5e01df20a7ee4efea20b7becad0f779cf8bb86 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 19:35:01 +0200 Subject: [PATCH 18/46] Phase 4 Slice 7: process filtering at CONNECT (--watch-pids/--watch-process-names) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the last functional parity gap with the Titanium engine: restricting MITM to specific processes via --watch-pids / --watch-process-names. Matches Titanium semantics exactly — filtering applies ONLY at the CONNECT decision (IsProxiedProcess in ProxyEngine.cs), so plain HTTP stays unfiltered (documented parity quirk). - ProcessFilter resolves the client's PID from its source port and matches against WatchPids then WatchProcessNames (ordinal/case-sensitive HashSet to mirror Titanium's default Contains comparer). No filter configured -> watch all (IsEmpty short-circuit, zero overhead). - ConnectionProcessResolver shells out per-platform: lsof -i on Unix, netstat -ano on Windows. LsofParser/NetstatParser are pure, fully unit-tested partial classes (GeneratedRegex). Chosen over Windows P/Invoke (GetExtendedTcpTable) for testability and boring-tech symmetry. NOTE: the Windows netstat path is parser-tested but not live-verifiable on macOS. - Wired into ProxyConnectionHandler.HandleConnectAsync (after the h2-only check): unresolved/non-matching process -> blind-tunnel (never MITM'd). KestrelProxyEngine constructs the filter from configuration.WatchPids/WatchProcessNames. - Updated the CONNECT-flow doc diagram. Tests: +21 Kestrel (12 ProcessFilter + 4 Lsof + 5 Netstat) -> 179 green (33 Abstractions + 38 Titanium + 108 Kestrel). Live-verified on macOS: matching process name -> MITM intercept + req-log; non-matching -> blind tunnel (real cert, no -k, zero req-log lines). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProcessFilterTests.cs | 189 +++++++++++++++ .../Internal/ProcessFilter.cs | 215 ++++++++++++++++++ .../Internal/ProxyConnectionHandler.cs | 17 ++ DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 3 +- 4 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/ProcessFilter.cs 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/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 index 868c8d7f..869b6dae 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -33,6 +33,7 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// │ /// ├─ 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 /// /// @@ -52,6 +53,7 @@ internal sealed class ProxyConnectionHandler( UpstreamForwarder forwarder, PluginPipeline pipeline, HostWatchList watchList, + ProcessFilter processFilter, ILogger logger) : ConnectionHandler { private static int _requestCounter; @@ -122,6 +124,16 @@ private async Task HandleConnectAsync( 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); @@ -539,6 +551,11 @@ private static (string Host, int? Port) SplitHostPort(string authority) return (authority, null); } + // 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/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index fc9ba37d..c01c5f1e 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -54,13 +54,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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, _logger); + var handler = new ProxyConnectionHandler(ca, forwarder, pipeline, watchList, processFilter, _logger); var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); _ = builder.WebHost.UseKestrelCore(); From f09916dc92db0cb4c1a1cb71806a0bc01a0f0d19 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 19:49:17 +0200 Subject: [PATCH 19/46] Phase 5 Slice 5a: persistent certificate authority (disk root + leaf cache) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kestrel engine previously minted a brand-new in-memory root CA on every start, so the OS would distrust it after each restart. This makes the root (and per-host leaves) persistent on disk, mirroring the Titanium engine's CertificateDiskCache layout exactly: root at /rootCert.pfx (overridable via DEV_PROXY_CERT_PATH), leaves at /crts/.pfx, both PKCS#12 with an empty password. configDir matches Titanium's resolution (ApplicationData/dev-proxy on mac/Linux, the executable dir on Windows). EUREKA: because the format + path match, the persistent CA loads a root that the Titanium engine ALREADY created and the OS ALREADY trusts — existing users get interception with zero new trust code (verified live: curl WITHOUT -k against a watched HTTPS host returned 200 + full MITM req-log). - LoadOrCreateRoot: load + validate (isCA, has private key, not expired) the on-disk root; regenerate + re-save (and purge the now-stale leaf cache) if absent/invalid/ expired. Per decision #5 there is no cross-version compatibility contract. - Leaves are minted on demand, cached in memory + on disk, and reused across restarts. Leaf validity is clamped to the issuer's window so reusing a near-expiry root can't produce a leaf that outlives it (CertificateRequest.Create rejects that). Filename- unsafe hosts (IPv6 ':', wildcard '*', '/') are sanitized for the disk cache key. - Disk writes are best-effort: an unwritable location degrades to in-memory-only for the run instead of failing interception. - KestrelProxyEngine now builds the CA via CertificateAuthority.CreateDefault(logger). OS-trust install (mac keychain / Windows root store) + the first-run prompt remain Slice 5b (host-side, reusing the existing trust helpers). This slice only makes the root persistent. Tests: +12 Kestrel (CertificateAuthorityTests: first-run create+persist, reuse across instances, invalid/expired/non-CA regeneration, stale-leaf purge, leaf mint+sign+ persist+reuse, leaf-not-outliving-root clamp, IP-literal SAN, filename sanitization) -> 191 green (33 + 38 + 120). Live-verified on macOS: existing root reused (fingerprint unchanged, "Loaded persisted root certificate"), leaf cached + reused across restarts, MITM works without -k. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CertificateAuthorityTests.cs | 224 +++++++++++++ .../Internal/CertificateAuthority.cs | 299 +++++++++++++++++- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 2 +- 3 files changed, 509 insertions(+), 16 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs diff --git a/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs b/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs new file mode 100644 index 00000000..ebbfcd11 --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs @@ -0,0 +1,224 @@ +// 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; +using System.Security.Cryptography.X509Certificates; +using DevProxy.Proxy.Kestrel.Internal; +using Xunit; + +namespace DevProxy.Proxy.Kestrel.Tests; + +/// +/// Verifies the persistent certificate authority: a root saved to disk is reused +/// across instances (so existing OS trust is inherited), invalid/expired roots are +/// regenerated, leaves are minted + cached on disk, and filename-unsafe hosts are +/// handled. Each test runs in its own temp directory so nothing touches the real +/// Dev Proxy config folder. +/// +public sealed class CertificateAuthorityTests : IDisposable +{ + private readonly string _dir; + private readonly string _rootPath; + private readonly string _leafDir; + + public CertificateAuthorityTests() + { + _dir = Path.Combine(Path.GetTempPath(), "devproxy-ca-tests-" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(_dir); + _rootPath = Path.Combine(_dir, "rootCert.pfx"); + _leafDir = Path.Combine(_dir, "crts"); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, recursive: true); + } + } + catch (IOException) + { + // best-effort cleanup + } + } + + [Fact] + public void FirstRun_CreatesValidRoot_AndPersistsToDisk() + { + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + var root = ca.RootCertificate; + Assert.Equal("CN=Dev Proxy CA", root.Subject); + Assert.True(root.HasPrivateKey); + Assert.True(IsCertificateAuthority(root)); + Assert.True(root.NotAfter > DateTime.Now); + Assert.True(File.Exists(_rootPath)); + } + + [Fact] + public void SecondInstance_ReusesPersistedRoot() + { + string thumbprint; + using (var ca1 = new CertificateAuthority(_rootPath, _leafDir)) + { + thumbprint = ca1.RootCertificate.Thumbprint; + } + + using var ca2 = new CertificateAuthority(_rootPath, _leafDir); + Assert.Equal(thumbprint, ca2.RootCertificate.Thumbprint); + } + + [Fact] + public void InvalidRootFile_Regenerates() + { + File.WriteAllBytes(_rootPath, [0x00, 0x01, 0x02, 0x03]); + + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + Assert.True(IsCertificateAuthority(ca.RootCertificate)); + Assert.True(ca.RootCertificate.NotAfter > DateTime.Now); + // The garbage file was overwritten with a real PKCS#12. + using var reloaded = new CertificateAuthority(_rootPath, _leafDir); + Assert.Equal(ca.RootCertificate.Thumbprint, reloaded.RootCertificate.Thumbprint); + } + + [Fact] + public void ExpiredRoot_Regenerates() + { + File.WriteAllBytes(_rootPath, CreateRootPfx(NotAfter: DateTimeOffset.UtcNow.AddDays(-1))); + + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + Assert.True(ca.RootCertificate.NotAfter > DateTime.Now); + } + + [Fact] + public void NonCaRoot_Regenerates() + { + File.WriteAllBytes(_rootPath, CreateNonCaPfx()); + + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + Assert.True(IsCertificateAuthority(ca.RootCertificate)); + } + + [Fact] + public void GetCertificateForHost_MintsLeafSignedByRoot_AndPersists() + { + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + var leaf = ca.GetCertificateForHost("example.com"); + + Assert.True(leaf.HasPrivateKey); + Assert.Equal("CN=example.com", leaf.Subject); + Assert.Equal(ca.RootCertificate.Subject, leaf.Issuer); + Assert.Contains("example.com", GetSanText(leaf), StringComparison.Ordinal); + Assert.True(File.Exists(Path.Combine(_leafDir, "example.com.pfx"))); + } + + [Fact] + public void GetCertificateForHost_SameHost_ReturnsCachedInstance() + { + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + var first = ca.GetCertificateForHost("example.com"); + var second = ca.GetCertificateForHost("example.com"); + + Assert.Same(first, second); + } + + [Fact] + public void SecondInstance_ReusesPersistedLeaf() + { + string thumbprint; + using (var ca1 = new CertificateAuthority(_rootPath, _leafDir)) + { + thumbprint = ca1.GetCertificateForHost("example.com").Thumbprint; + } + + using var ca2 = new CertificateAuthority(_rootPath, _leafDir); + Assert.Equal(thumbprint, ca2.GetCertificateForHost("example.com").Thumbprint); + } + + [Fact] + public void FreshRoot_PurgesStaleLeafCache() + { + _ = Directory.CreateDirectory(_leafDir); + var stale = Path.Combine(_leafDir, "stale.pfx"); + File.WriteAllBytes(stale, [0x00]); + + // No root file exists -> a fresh root is created -> the old leaf cache is dropped. + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + Assert.False(File.Exists(stale)); + } + + [Fact] + public void GetCertificateForHost_IpLiteral_AddsIpSan() + { + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + var leaf = ca.GetCertificateForHost("127.0.0.1"); + + Assert.True(leaf.HasPrivateKey); + Assert.Contains("127.0.0.1", GetSanText(leaf)); + } + + [Fact] + public void GetCertificateForHost_FilenameUnsafeHost_IsSanitized() + { + using var ca = new CertificateAuthority(_rootPath, _leafDir); + + // '/' is filename-invalid on every platform; the leaf must still be minted and + // the on-disk cache key sanitized rather than throwing. + var leaf = ca.GetCertificateForHost("a/b"); + + Assert.True(leaf.HasPrivateKey); + Assert.True(File.Exists(Path.Combine(_leafDir, "a_b.pfx"))); + } + + [Fact] + public void GetCertificateForHost_LeafDoesNotOutliveRoot() + { + // A near-expiry root: a fresh 365-day leaf would otherwise outlive it, which + // CertificateRequest.Create rejects. The leaf validity must be clamped. + var rootNotAfter = DateTimeOffset.UtcNow.AddDays(10); + File.WriteAllBytes(_rootPath, CreateRootPfx(rootNotAfter)); + + using var ca = new CertificateAuthority(_rootPath, _leafDir); + var leaf = ca.GetCertificateForHost("example.com"); + + Assert.True(leaf.HasPrivateKey); + Assert.True(leaf.NotAfter <= ca.RootCertificate.NotAfter); + } + + private static bool IsCertificateAuthority(X509Certificate2 cert) => + cert.Extensions.OfType().FirstOrDefault()?.CertificateAuthority == true; + + private static string GetSanText(X509Certificate2 cert) + { + var san = cert.Extensions.FirstOrDefault(e => e.Oid?.Value == "2.5.29.17"); + return san?.Format(false) ?? string.Empty; + } + + private static byte[] CreateRootPfx(DateTimeOffset NotAfter) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=Old Root", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + using var cert = req.CreateSelfSigned(NotAfter.AddDays(-10), NotAfter); + return cert.Export(X509ContentType.Pkcs12, string.Empty); + } + + private static byte[] CreateNonCaPfx() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=Not A CA", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + return cert.Export(X509ContentType.Pkcs12, string.Empty); + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs index 10ef5de1..c594399e 100644 --- a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs +++ b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs @@ -5,38 +5,280 @@ 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; /// -/// An in-memory certificate authority. It creates a self-signed root CA once and -/// mints (and caches) a leaf certificate per host on demand so the proxy can -/// terminate TLS and inspect the decrypted traffic. +/// 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. /// /// -/// Slice-1 scope: ephemeral root + leaves, no disk persistence and no OS trust -/// integration. Phase 5 replaces this with the persistent cache + keychain/root -/// store trust that the existing Titanium engine already provides, regenerating -/// on upgrade (no cross-version PFX compatibility contract required). +/// 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 +/// /// internal 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() => _ca = CreateRootCertificate(); + 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); - /// The root CA certificate (public part) clients must trust to allow interception. + 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, CreateLeafCertificate); + 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=Dev Proxy CA, O=Dev Proxy", + $"CN={RootCertCommonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); @@ -46,7 +288,15 @@ private static X509Certificate2 CreateRootCertificate() X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); - return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); + // 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) @@ -79,17 +329,36 @@ private X509Certificate2 CreateLeafCertificate(string host) 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, - DateTimeOffset.UtcNow.AddDays(-1), - DateTimeOffset.UtcNow.AddYears(1), + 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), null); + return X509CertificateLoader.LoadPkcs12( + leafWithKey.Export(X509ContentType.Pkcs12, string.Empty), + string.Empty, + X509KeyStorageFlags.Exportable); } public void Dispose() diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index c01c5f1e..fcb5c992 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -44,7 +44,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) : IPAddress.Parse(configuration.IPAddress); var port = configuration.Port; - using var ca = new CertificateAuthority(); + using var ca = CertificateAuthority.CreateDefault(_logger); using var httpHandler = new SocketsHttpHandler { UseProxy = false, From 1c5b330d6bd256e773352f0d6ba1f3d3c12d8ad5 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 19:58:43 +0200 Subject: [PATCH 20/46] Add OS-trust install + first-run wiring for the Kestrel engine (Slice 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire root-certificate OS trust for the Kestrel engine via a new IRootCertificateTrust abstraction (DevProxy.Abstractions/Proxy), implemented host-side by RootCertificateTrust (DevProxy/Proxy). The engine mints/persists its root (Slice 5a) then calls EnsureTrusted; the host performs the platform install: - macOS: trust in the login keychain via MacCertificateHelper, gated by the first-run flow (InstallCert + NoFirstRun + HasRunFlag + Y/n prompt, auto-"y" under CI/redirected stdin) — mirrors Titanium's FirstRunSetup. - Windows: install the public-only root into CurrentUser/Root, idempotent by identity (CA5380 suppressed — installing the Dev Proxy root is the proxy's explicit, user-consented purpose). NOT live-verifiable on macOS. - Linux: warn to trust manually. The trust decision is factored into a pure RootTrustPolicy.Decide table in Abstractions so it's exhaustively unit-testable without platform/console I/O; the host impl is just the thin I/O around it (option X — new interface, leave Titanium's FirstRunSetup untouched for low blast radius). CertCommand (ensure/remove) already works for Kestrel mode unchanged thanks to Slice 5a's on-disk path parity. The Kestrel engine ctor takes an optional IRootCertificateTrust (null = no trust, e.g. tests); the DEV_PROXY_ENGINE=kestrel DI toggle registers and injects RootCertificateTrust. 207 tests green (49 Abstractions incl. +16 RootTrustPolicy, 38 Titanium, 120 Kestrel). Live-verified on macOS: with an already-trusted persisted root and --no-first-run, a watched HTTPS request returned 200 WITHOUT -k plus a full MITM req-log — trust wiring doesn't regress the existing-root happy path. Fresh-machine keychain trust is covered by the unit-tested decision table (not live-exercised to avoid keychain GUI pollution); Windows store install is parser/logic-only on macOS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Proxy/RootTrustPolicyTests.cs | 99 +++++++++++++++ .../Proxy/IRootCertificateTrust.cs | 28 +++++ .../Proxy/RootTrustPolicy.cs | 77 ++++++++++++ DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 4 +- .../IServiceCollectionExtensions.cs | 4 +- DevProxy/Proxy/RootCertificateTrust.cs | 116 ++++++++++++++++++ 6 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 DevProxy.Abstractions.Tests/Proxy/RootTrustPolicyTests.cs create mode 100644 DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs create mode 100644 DevProxy.Abstractions/Proxy/RootTrustPolicy.cs create mode 100644 DevProxy/Proxy/RootCertificateTrust.cs 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/Proxy/IRootCertificateTrust.cs b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs new file mode 100644 index 00000000..78e0e09f --- /dev/null +++ b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs @@ -0,0 +1,28 @@ +// 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); +} 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.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index fcb5c992..e470f789 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -33,7 +33,8 @@ public sealed class KestrelProxyEngine( ISet urlsToWatch, IProxyConfiguration configuration, Dictionary globalData, - ILoggerFactory loggerFactory) : BackgroundService + ILoggerFactory loggerFactory, + IRootCertificateTrust? rootCertificateTrust = null) : BackgroundService { private readonly ILogger _logger = loggerFactory.CreateLogger(); @@ -45,6 +46,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var port = configuration.Port; using var ca = CertificateAuthority.CreateDefault(_logger); + rootCertificateTrust?.EnsureTrusted(ca.RootCertificate); using var httpHandler = new SocketsHttpHandler { UseProxy = false, diff --git a/DevProxy/Extensions/IServiceCollectionExtensions.cs b/DevProxy/Extensions/IServiceCollectionExtensions.cs index 9ea5035e..7beb6e95 100644 --- a/DevProxy/Extensions/IServiceCollectionExtensions.cs +++ b/DevProxy/Extensions/IServiceCollectionExtensions.cs @@ -51,12 +51,14 @@ static IServiceCollection AddProxyEngine(this IServiceCollection services) var engine = Environment.GetEnvironmentVariable("DEV_PROXY_ENGINE"); if (string.Equals(engine, "kestrel", StringComparison.OrdinalIgnoreCase)) { + _ = services.AddSingleton(); _ = services.AddHostedService(sp => new KestrelProxyEngine( sp.GetServices(), sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService().GlobalData, - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); } else { diff --git a/DevProxy/Proxy/RootCertificateTrust.cs b/DevProxy/Proxy/RootCertificateTrust.cs new file mode 100644 index 00000000..ad2e9e9e --- /dev/null +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -0,0 +1,116 @@ +// 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; + } + } + + 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."); + } + } +} From eed5b6d94ac6356d416ad3764b66ddc2799835a1 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 20:06:43 +0200 Subject: [PATCH 21/46] Graceful-teardown audit: DRY connection-close classifier (Slice 5c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a single ConnectionTeardown.IsExpected(exception) classifier and use it at every client/origin read/copy/write boundary, replacing the per-site hardcoded `ex is IOException or OperationCanceledException or ...` lists. An exception is "expected" (treat as EOF/close, don't log as error) when it's a ConnectionResetException, ConnectionAbortedException, OperationCanceledException, IOException, or SocketException — the normal result of a peer disconnecting, resetting, or the connection being cancelled mid-exchange. Boundaries updated to the shared classifier: - ProxyConnectionHandler.OnConnectedAsync outer catch (keep-alive read, body read, response write, streaming pump, blind tunnel) — now also covers SocketException/ConnectionAbortedException. - StreamRelay bidirectional-copy teardown. - WriteErrorAsync best-effort write to a possibly-gone client. Fixes the one genuine noise gap: the WebSocket relay catch logged a client disconnect mid-handshake / verbatim-101-write as an ERROR. It now logs at Debug when IsExpected and reserves Error for genuine faults. 216 tests green (49 Abstractions, 38 Titanium, 129 Kestrel; +9 ConnectionTeardown covering OCE/TaskCanceled/IOException/SocketException/ConnectionReset/ ConnectionAborted true and InvalidOperation/NullReference/unrelated false). Live-verified on macOS: aborting curl mid-stream (httpbin /drip, --max-time 1) produced ZERO error/exception log lines and the engine kept serving (a following request returned 200). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConnectionTeardownTests.cs | 51 +++++++++++++++++++ .../Internal/ConnectionTeardown.cs | 43 ++++++++++++++++ .../Internal/ProxyConnectionHandler.cs | 11 ++-- .../Internal/StreamRelay.cs | 4 +- 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/ConnectionTeardown.cs diff --git a/DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs b/DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs new file mode 100644 index 00000000..df28e24b --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs @@ -0,0 +1,51 @@ +// 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 DevProxy.Proxy.Kestrel.Internal; +using Microsoft.AspNetCore.Connections; +using Xunit; + +namespace DevProxy.Proxy.Kestrel.Tests; + +public class ConnectionTeardownTests +{ + // ── Expected (normal close/cancel) ────────────────────────────────── + [Fact] + public void IsExpected_OperationCanceled_True() => + Assert.True(ConnectionTeardown.IsExpected(new OperationCanceledException())); + + [Fact] + public void IsExpected_TaskCanceled_True() => + Assert.True(ConnectionTeardown.IsExpected(new TaskCanceledException())); + + [Fact] + public void IsExpected_IOException_True() => + Assert.True(ConnectionTeardown.IsExpected(new IOException("broken pipe"))); + + [Fact] + public void IsExpected_SocketException_True() => + Assert.True(ConnectionTeardown.IsExpected(new SocketException((int)SocketError.ConnectionReset))); + + [Fact] + public void IsExpected_ConnectionResetException_True() => + Assert.True(ConnectionTeardown.IsExpected(new ConnectionResetException("reset"))); + + [Fact] + public void IsExpected_ConnectionAbortedException_True() => + Assert.True(ConnectionTeardown.IsExpected(new ConnectionAbortedException())); + + // ── Not expected (real faults) ────────────────────────────────────── + [Fact] + public void IsExpected_InvalidOperationException_False() => + Assert.False(ConnectionTeardown.IsExpected(new InvalidOperationException())); + + [Fact] + public void IsExpected_NullReferenceException_False() => + Assert.False(ConnectionTeardown.IsExpected(new NullReferenceException())); + + [Fact] + public void IsExpected_UnrelatedException_False() => + Assert.False(ConnectionTeardown.IsExpected(new FormatException("boom"))); +} 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/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index 869b6dae..a008ee3c 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -85,7 +85,7 @@ await ServeConnectionAsync(reader, clientStream, head, httpsHost: null, httpsPor .ConfigureAwait(false); } } - catch (Exception ex) when (ex is OperationCanceledException or IOException or ConnectionResetException) + catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) { // Client disconnect / cancellation — normal teardown, not an error. } @@ -356,7 +356,12 @@ async Task OnHandshakeAsync(MutableHttpResponse handshakeResponse) { await _webSocketRelay.RelayAsync(clientStream, request, requestUri, OnHandshakeAsync, ct).ConfigureAwait(false); } - catch (Exception ex) when (ex is not OperationCanceledException) + 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); } @@ -528,7 +533,7 @@ private static async Task WriteErrorAsync(Stream clientStream, HttpStatusCode st await clientStream.WriteAsync(body, ct).ConfigureAwait(false); await clientStream.FlushAsync(ct).ConfigureAwait(false); } - catch (Exception ex) when (ex is IOException or OperationCanceledException) + catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) { // Client already gone. } diff --git a/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs b/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs index d5d0c3ae..d1cfec9a 100644 --- a/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs +++ b/DevProxy.Proxy.Kestrel/Internal/StreamRelay.cs @@ -2,8 +2,6 @@ // 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.Proxy.Kestrel.Internal; /// @@ -42,7 +40,7 @@ public static async Task RelayBidirectionalAsync(Stream a, Stream b, Cancellatio { await Task.WhenAll(aToB, bToA).ConfigureAwait(false); } - catch (Exception ex) when (ex is IOException or OperationCanceledException or SocketException) + catch (Exception ex) when (ConnectionTeardown.IsExpected(ex)) { // Either peer closed the connection — normal relay teardown. } From 1bdf5cf7bdca9759aa52555ebe743bb2e78a1cca Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 20:12:13 +0200 Subject: [PATCH 22/46] Harden CONNECT authority parsing: IPv6, ports, malformed targets (Slice 5d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the naive "split on the last colon" SplitHostPort with a pure, explicit ConnectAuthorityParser that correctly handles: - IPv6 literals: [::1] and [2001:db8::1]:8443 — the bare host (no brackets) is used for socket connect / cert minting, and a UrlHost property re-adds brackets for composing the absolute URL (https://[::1]:8443/path). - Explicit ports, including validation (reject port 0 / >65535 / non-numeric / empty). - Malformed targets: empty host, unterminated bracket, junk after ], bare (unbracketed) IPv6, invalid host names — all rejected. The authority is now parsed + validated BEFORE the proxy writes "200 Connection Established", so a malformed CONNECT is refused with a 400 instead of establishing an unusable tunnel. The decrypted-tunnel URL is composed from UrlHost so IPv6 targets produce valid absolute URLs. 237 tests green (49 Abstractions, 38 Titanium, 150 Kestrel; +21 parser tests covering reg-name/IPv4/punycode/IPv6 with and without ports plus 15 malformed cases). Live-verified on macOS: normal + explicit-:443 watched MITM return 200 (no -k); malformed CONNECT targets (port 0, bare IPv6 ::1:443, unterminated [::1) each return 400 Bad Request while a valid target returns 200 Connection Established; zero error-log noise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConnectAuthorityParserTests.cs | 90 ++++++++++++ .../Internal/ConnectAuthorityParser.cs | 128 ++++++++++++++++++ .../Internal/ProxyConnectionHandler.cs | 25 ++-- 3 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/ConnectAuthorityParserTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/ConnectAuthorityParser.cs diff --git a/DevProxy.Proxy.Kestrel.Tests/ConnectAuthorityParserTests.cs b/DevProxy.Proxy.Kestrel.Tests/ConnectAuthorityParserTests.cs new file mode 100644 index 00000000..f4aaacd5 --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/ConnectAuthorityParserTests.cs @@ -0,0 +1,90 @@ +// 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 ConnectAuthorityParserTests +{ + // ── reg-name / IPv4 hosts ─────────────────────────────────────────── + [Fact] + public void Parse_HostWithPort() + { + Assert.True(ConnectAuthorityParser.TryParse("example.com:8443", 443, out var a)); + Assert.Equal("example.com", a.Host); + Assert.Equal("example.com", a.UrlHost); + Assert.Equal(8443, a.Port); + Assert.False(a.IsIPv6); + } + + [Fact] + public void Parse_HostWithoutPort_UsesDefault() + { + Assert.True(ConnectAuthorityParser.TryParse("example.com", 443, out var a)); + Assert.Equal("example.com", a.Host); + Assert.Equal(443, a.Port); + } + + [Fact] + public void Parse_IPv4WithPort() + { + Assert.True(ConnectAuthorityParser.TryParse("10.0.0.1:9000", 443, out var a)); + Assert.Equal("10.0.0.1", a.Host); + Assert.Equal(9000, a.Port); + Assert.False(a.IsIPv6); + } + + [Fact] + public void Parse_PunycodeHost() + { + Assert.True(ConnectAuthorityParser.TryParse("xn--n3h.com:443", 443, out var a)); + Assert.Equal("xn--n3h.com", a.Host); + Assert.Equal(443, a.Port); + } + + // ── IPv6 literals ─────────────────────────────────────────────────── + [Fact] + public void Parse_IPv6WithPort() + { + Assert.True(ConnectAuthorityParser.TryParse("[2001:db8::1]:8443", 443, out var a)); + Assert.Equal("2001:db8::1", a.Host); + Assert.Equal("[2001:db8::1]", a.UrlHost); + Assert.Equal(8443, a.Port); + Assert.True(a.IsIPv6); + } + + [Fact] + public void Parse_IPv6WithoutPort_UsesDefault() + { + Assert.True(ConnectAuthorityParser.TryParse("[::1]", 443, out var a)); + Assert.Equal("::1", a.Host); + Assert.Equal("[::1]", a.UrlHost); + Assert.Equal(443, a.Port); + Assert.True(a.IsIPv6); + } + + // ── malformed → rejected ──────────────────────────────────────────── + [Theory] + [InlineData("")] // empty + [InlineData(" ")] // whitespace + [InlineData(":443")] // empty host + [InlineData("example.com:")] // empty port + [InlineData("example.com:0")] // port out of range (low) + [InlineData("example.com:70000")] // port out of range (high) + [InlineData("example.com:-1")] // negative port + [InlineData("example.com:abc")] // non-numeric port + [InlineData("::1")] // bare IPv6 (must be bracketed) + [InlineData("2001:db8::1:443")] // bare IPv6 + port, ambiguous + [InlineData("[::1")] // unterminated bracket + [InlineData("[::1]extra")] // junk after ] + [InlineData("[notipv6]:443")] // bracketed but not an IPv6 literal + [InlineData("[1.2.3.4]:443")] // IPv4 in brackets is not IPv6 + [InlineData("bad host:443")] // invalid host name (space) + public void Parse_Malformed_ReturnsFalse(string authority) + { + Assert.False(ConnectAuthorityParser.TryParse(authority, 443, out _)); + } +} 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/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index a008ee3c..44c2e35e 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -98,8 +98,17 @@ await ServeConnectionAsync(reader, clientStream, head, httpsHost: null, httpsPor private async Task HandleConnectAsync( ConnectionContext connection, Stream clientStream, ParsedRequestHead connect, CancellationToken ct) { - var (host, portPart) = SplitHostPort(connect.Target); - var port = portPart ?? 443; + // 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); @@ -153,7 +162,7 @@ await tls.AuthenticateAsServerAsync( return; } - await ServeConnectionAsync(tlsReader, tls, head, host, port, ct).ConfigureAwait(false); + await ServeConnectionAsync(tlsReader, tls, head, authority.UrlHost, port, ct).ConfigureAwait(false); } /// @@ -546,16 +555,6 @@ private static async Task WriteErrorAsync(Stream clientStream, HttpStatusCode st _ => status.ToString(), }; - private static (string Host, int? Port) SplitHostPort(string authority) - { - var separator = authority.LastIndexOf(':'); - if (separator > 0 && int.TryParse(authority[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)) - { - return (authority[..separator], port); - } - return (authority, null); - } - // 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) => From 499a16a4cc2f8be1ba04747a0ab02b7342dc9be4 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 20:23:49 +0200 Subject: [PATCH 23/46] Slice 5e: --port 0 actual-port logging + DRY host extraction Log the OS-assigned port (not the configured 0) for the Kestrel engine by reading the bound ListenOptions endpoint after StartAsync, so users running with --port 0 can connect. Extract the duplicated urlsToWatch host-derivation logic into a shared WatchedHostExtractor.ToHostRegex in DevProxy.Abstractions, used by both the Titanium ProxyEngine.LoadHostNamesFromUrls and the Kestrel HostWatchList. One implementation, one test suite (11 new tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Proxy/WatchedHostExtractorTests.cs | 61 ++++++++++++++++ .../Proxy/WatchedHostExtractor.cs | 69 +++++++++++++++++++ .../Internal/HostWatchList.cs | 35 +--------- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 13 +++- DevProxy/Proxy/ProxyEngine.cs | 29 +------- 5 files changed, 145 insertions(+), 62 deletions(-) create mode 100644 DevProxy.Abstractions.Tests/Proxy/WatchedHostExtractorTests.cs create mode 100644 DevProxy.Abstractions/Proxy/WatchedHostExtractor.cs 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/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.Proxy.Kestrel/Internal/HostWatchList.cs b/DevProxy.Proxy.Kestrel/Internal/HostWatchList.cs index b4118282..01d1c90c 100644 --- a/DevProxy.Proxy.Kestrel/Internal/HostWatchList.cs +++ b/DevProxy.Proxy.Kestrel/Internal/HostWatchList.cs @@ -2,7 +2,6 @@ // 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; namespace DevProxy.Proxy.Kestrel.Internal; @@ -10,13 +9,8 @@ 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). -/// -/// -/// TODO (DRY, cut-over): this host-extraction logic is duplicated from -/// ProxyEngine.LoadHostNamesFromUrls. Consolidate into a single shared -/// helper in DevProxy.Abstractions when the Titanium engine is removed, -/// so there is one implementation with one test suite. -/// +/// Host derivation is shared with the Titanium engine via +/// so the two engines match identically. /// internal sealed class HostWatchList { @@ -31,30 +25,7 @@ public static HostWatchList FromUrls(IEnumerable urlsToWatch) var hosts = new List(); foreach (var urlToWatch in urlsToWatch) { - var pattern = Regex.Unescape(urlToWatch.Url.ToString()) - .Trim('^', '$') - .Replace(".*", "*", StringComparison.OrdinalIgnoreCase); - - string host; - if (pattern.Contains("://", StringComparison.OrdinalIgnoreCase)) - { - var chunks = pattern.Split("://"); - var slash = chunks[1].IndexOf('/', StringComparison.OrdinalIgnoreCase); - host = slash < 0 ? chunks[1] : chunks[1][..slash]; - } - else - { - host = pattern; - } - - var portPos = host.IndexOf(':', StringComparison.OrdinalIgnoreCase); - if (portPos > 0) - { - host = host[..portPos]; - } - - var regexString = Regex.Escape(host).Replace("\\*", ".*", StringComparison.OrdinalIgnoreCase); - var regex = new Regex($"^{regexString}$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + var regex = WatchedHostExtractor.ToHostRegex(urlToWatch.Url); if (!hosts.Exists(h => h.Url.ToString() == regex.ToString())) { diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index e470f789..7fd604ac 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -68,16 +68,25 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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())); + 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; _logger.LogInformation( "Dev Proxy (Kestrel engine) listening on {Address}:{Port}", ipAddress.ToString(), - port.ToString(CultureInfo.InvariantCulture)); + boundPort.ToString(CultureInfo.InvariantCulture)); try { diff --git a/DevProxy/Proxy/ProxyEngine.cs b/DevProxy/Proxy/ProxyEngine.cs index f114b7df..62492642 100755 --- a/DevProxy/Proxy/ProxyEngine.cs +++ b/DevProxy/Proxy/ProxyEngine.cs @@ -358,34 +358,7 @@ 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); + var hostRegex = WatchedHostExtractor.ToHostRegex(urlToWatch.Url); // don't add the same host twice if (!_hostsToWatch.Any(h => h.Url.ToString() == hostRegex.ToString())) { From d0a6ef620a04bab20d62723d9f913a07fcf49f8d Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 20:38:35 +0200 Subject: [PATCH 24/46] Phase 6 slice: engine-agnostic system-proxy on/off (no Titanium) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-implement turning the OS system-proxy on and off without Titanium, behind a new ISystemProxyManager abstraction (Abstractions) so the Kestrel engine can use it without referencing the host (mirrors IRootCertificateTrust): - SystemProxyManager (host): Windows via WinINET (registry ProxyServer/ProxyEnable + InternetSetOption settings-changed/refresh broadcast), macOS via the existing toggle-proxy.sh, Linux warns. DllImport (not LibraryImport) to avoid enabling AllowUnsafeBlocks project-wide. - SystemProxyAddress (Abstractions, pure): normalizes wildcard/empty bind addresses to loopback for the client-facing proxy address; unit-tested. - KestrelProxyEngine: enable on start (using the actually-bound port, matters for --port 0), disable on stop. Previously the Kestrel engine set no system proxy at all — a parity gap. - StopCommand --force crash-cleanup now uses the shared manager, so a force-killed daemon restores the system proxy on Windows too (was macOS-only before). 259 tests green (+11 SystemProxyAddress). Windows WinINET path is parser/logic-tested but needs verification on a Windows host (cannot run on macOS). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Proxy/SystemProxyAddressTests.cs | 39 +++++ .../Proxy/ISystemProxyManager.cs | 32 ++++ .../Proxy/SystemProxyAddress.cs | 44 +++++ DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 16 +- DevProxy/Commands/StopCommand.cs | 49 +----- .../IServiceCollectionExtensions.cs | 4 +- DevProxy/Proxy/SystemProxyManager.cs | 163 ++++++++++++++++++ 7 files changed, 302 insertions(+), 45 deletions(-) create mode 100644 DevProxy.Abstractions.Tests/Proxy/SystemProxyAddressTests.cs create mode 100644 DevProxy.Abstractions/Proxy/ISystemProxyManager.cs create mode 100644 DevProxy.Abstractions/Proxy/SystemProxyAddress.cs create mode 100644 DevProxy/Proxy/SystemProxyManager.cs 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/Proxy/ISystemProxyManager.cs b/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs new file mode 100644 index 00000000..8069e97b --- /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: both the +/// Titanium and Kestrel engines (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/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.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index 7fd604ac..bfa20454 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -34,7 +34,8 @@ public sealed class KestrelProxyEngine( IProxyConfiguration configuration, Dictionary globalData, ILoggerFactory loggerFactory, - IRootCertificateTrust? rootCertificateTrust = null) : BackgroundService + IRootCertificateTrust? rootCertificateTrust = null, + ISystemProxyManager? systemProxyManager = null) : BackgroundService { private readonly ILogger _logger = loggerFactory.CreateLogger(); @@ -88,6 +89,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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); @@ -98,6 +107,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } finally { + if (systemProxyEnabled) + { + systemProxyManager!.Disable(); + } + await app.StopAsync(CancellationToken.None).ConfigureAwait(false); } } 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/Extensions/IServiceCollectionExtensions.cs b/DevProxy/Extensions/IServiceCollectionExtensions.cs index 7beb6e95..3e66656d 100644 --- a/DevProxy/Extensions/IServiceCollectionExtensions.cs +++ b/DevProxy/Extensions/IServiceCollectionExtensions.cs @@ -52,13 +52,15 @@ static IServiceCollection AddProxyEngine(this IServiceCollection services) if (string.Equals(engine, "kestrel", StringComparison.OrdinalIgnoreCase)) { _ = services.AddSingleton(); + _ = services.AddSingleton(); _ = services.AddHostedService(sp => new KestrelProxyEngine( sp.GetServices(), sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService().GlobalData, sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); } else { diff --git a/DevProxy/Proxy/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs new file mode 100644 index 00000000..f93cc783 --- /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 Titanium engine, 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 mechanism Titanium +/// used internally; 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."); + } + } +} From 74aefce5634fb16ac1035dbd6d15cbbb62cbc661 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 20:42:43 +0200 Subject: [PATCH 25/46] Phase 6 slice: replace Titanium RunTime with System.OperatingSystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the Titanium RunTime OS-check helper for the .NET built-in equivalents (RunTime.IsWindows → OperatingSystem.IsWindows(), RunTime.IsMac → OperatingSystem.IsMacOS()) in ProxyEngine and CertCommand, and drop the now-unused `using Titanium.Web.Proxy.Helpers;` import from both. Strictly equivalent behavior; reduces Titanium API surface ahead of the cut-over. Left untouched (intentionally): - CertificateDiskCache.RunTime.IsUwpOnWindows — no built-in equivalent, and the file (a Titanium ICertificateCache impl) is deleted at cut-over anyway. - The Titanium.Web.Proxy.* log filters — still suppress real Titanium engine logs while it remains the default engine; their removal belongs in the cut-over commit. 259 tests green; Titanium engine boot-smoked clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DevProxy/Commands/CertCommand.cs | 5 ++--- DevProxy/Proxy/ProxyEngine.cs | 13 ++++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/DevProxy/Commands/CertCommand.cs b/DevProxy/Commands/CertCommand.cs index b30077e4..b49a562d 100644 --- a/DevProxy/Commands/CertCommand.cs +++ b/DevProxy/Commands/CertCommand.cs @@ -5,7 +5,6 @@ using DevProxy.Proxy; using System.CommandLine; using System.CommandLine.Parsing; -using Titanium.Web.Proxy.Helpers; namespace DevProxy.Commands; @@ -60,7 +59,7 @@ private async Task EnsureCertAsync() _logger.LogInformation("Ensuring certificate exists and is trusted..."); await ProxyEngine.ProxyServer.CertificateManager.EnsureRootCertificateAsync(); - if (RunTime.IsMac) + if (OperatingSystem.IsMacOS()) { var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate; if (certificate is not null) @@ -107,7 +106,7 @@ public int RemoveCert(ParseResult parseResult) // Ensure ProxyServer is initialized with LoggerFactory for Unobtanium logging ProxyEngine.EnsureProxyServerInitialized(_loggerFactory); - if (RunTime.IsMac) + if (OperatingSystem.IsMacOS()) { var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate; if (certificate is not null) diff --git a/DevProxy/Proxy/ProxyEngine.cs b/DevProxy/Proxy/ProxyEngine.cs index 62492642..9fa28cb5 100755 --- a/DevProxy/Proxy/ProxyEngine.cs +++ b/DevProxy/Proxy/ProxyEngine.cs @@ -19,7 +19,6 @@ 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; @@ -90,7 +89,7 @@ internal static void EnsureProxyServerInitialized(ILoggerFactory? loggerFactory // 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 = new(userTrustRootCertificate: OperatingSystem.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(); @@ -163,12 +162,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (_config.AsSystemProxy) { - if (RunTime.IsWindows) + if (OperatingSystem.IsWindows()) { ProxyServer.SetAsSystemHttpProxy(_explicitEndPoint); ProxyServer.SetAsSystemHttpsProxy(_explicitEndPoint); } - else if (RunTime.IsMac) + else if (OperatingSystem.IsMacOS()) { ToggleSystemProxy(ToggleSystemProxyAction.On, _config.IPAddress, _config.Port); } @@ -258,7 +257,7 @@ private async Task SaveInstanceStateAsync() private void FirstRunSetup() { - if (!RunTime.IsMac || + if (!OperatingSystem.IsMacOS() || _config.NoFirstRun || !HasRunFlag.CreateIfMissing() || !_config.InstallCert) @@ -395,7 +394,7 @@ private void StopProxy() _inactivityTimer?.Stop(); - if (RunTime.IsMac && _config.AsSystemProxy) + if (OperatingSystem.IsMacOS() && _config.AsSystemProxy) { ToggleSystemProxy(ToggleSystemProxyAction.Off); } @@ -730,7 +729,7 @@ private static void ToggleSystemProxy(ToggleSystemProxyAction toggle, string? ip private static int GetProcessId(TunnelConnectSessionEventArgs e) { - if (RunTime.IsWindows) + if (OperatingSystem.IsWindows()) { return e.HttpClient.ProcessId.Value; } From a59a66cb093a33be46a40a8400d11760fb18ecae Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 20:56:35 +0200 Subject: [PATCH 26/46] Add hermetic Kestrel parity-test gate (controllable rows) Build the DevProxy.Parity.Tests project that boots the real KestrelProxyEngine on a random localhost port against a deterministic FakeOrigin and asserts the observable proxy contract for every controllable parity-matrix row: - plain HTTP: GET/POST (small/large), large download, status relay, 204 empty-body framing, request-header passthrough - chunked request body reframing + SSE chunked response streaming - keep-alive reuse + Connection: close - plugin Respond() mocking short-circuit (origin never contacted) - Content-Length + Transfer-Encoding smuggling -> 400 guard 15 hermetic rows green. External/OS rows (HTTPS real-cert MITM, h2/gRPC, WebSocket, system-proxy, process filter) remain the scripted live pass and are already slice-verified; see files/parity-results.md for the full matrix. The harness boots only the Kestrel engine (the Titanium engine's process-global static ProxyServer + IServer make in-process co-hosting flaky, and it is deleted at cut-over); it asserts the reference behavior Titanium already exhibits. Total: 274 tests green (71 Abstractions + 38 Titanium + 150 Kestrel + 15 Parity). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Parity.Tests.csproj | 29 ++++ DevProxy.Parity.Tests/FakeOrigin.cs | 106 ++++++++++++++ DevProxy.Parity.Tests/KestrelProxyHarness.cs | 135 ++++++++++++++++++ .../MockShortCircuitPlugin.cs | 44 ++++++ .../MockingAndSmugglingParityTests.cs | 83 +++++++++++ DevProxy.Parity.Tests/NetUtil.cs | 32 +++++ DevProxy.Parity.Tests/PlainHttpParityTests.cs | 133 +++++++++++++++++ .../StreamingAndConnectionParityTests.cs | 99 +++++++++++++ .../TestProxyConfiguration.cs | 37 +++++ DevProxy.sln | 14 ++ 10 files changed, 712 insertions(+) create mode 100644 DevProxy.Parity.Tests/DevProxy.Parity.Tests.csproj create mode 100644 DevProxy.Parity.Tests/FakeOrigin.cs create mode 100644 DevProxy.Parity.Tests/KestrelProxyHarness.cs create mode 100644 DevProxy.Parity.Tests/MockShortCircuitPlugin.cs create mode 100644 DevProxy.Parity.Tests/MockingAndSmugglingParityTests.cs create mode 100644 DevProxy.Parity.Tests/NetUtil.cs create mode 100644 DevProxy.Parity.Tests/PlainHttpParityTests.cs create mode 100644 DevProxy.Parity.Tests/StreamingAndConnectionParityTests.cs create mode 100644 DevProxy.Parity.Tests/TestProxyConfiguration.cs diff --git a/DevProxy.Parity.Tests/DevProxy.Parity.Tests.csproj b/DevProxy.Parity.Tests/DevProxy.Parity.Tests.csproj new file mode 100644 index 00000000..197fb541 --- /dev/null +++ b/DevProxy.Parity.Tests/DevProxy.Parity.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + + $(NoWarn);CA1707;CA1861 + + + + + + + + + + + + + + + + + + diff --git a/DevProxy.Parity.Tests/FakeOrigin.cs b/DevProxy.Parity.Tests/FakeOrigin.cs new file mode 100644 index 00000000..5b38cb55 --- /dev/null +++ b/DevProxy.Parity.Tests/FakeOrigin.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.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.Parity.Tests; + +/// +/// A deterministic upstream origin server used as the parity 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) +/// +/// +internal sealed class FakeOrigin : IAsyncDisposable +{ + private readonly WebApplication _app; + + public int Port { get; } + + public string Host => $"127.0.0.1:{Port.ToString(CultureInfo.InvariantCulture)}"; + + private FakeOrigin(WebApplication app, int port) + { + _app = app; + Port = port; + } + + 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(); + + 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))); + + await app.StartAsync().ConfigureAwait(false); + return new FakeOrigin(app, port); + } + + public async ValueTask DisposeAsync() + { + await _app.StopAsync().ConfigureAwait(false); + await _app.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/DevProxy.Parity.Tests/KestrelProxyHarness.cs b/DevProxy.Parity.Tests/KestrelProxyHarness.cs new file mode 100644 index 00000000..0dc82cec --- /dev/null +++ b/DevProxy.Parity.Tests/KestrelProxyHarness.cs @@ -0,0 +1,135 @@ +// 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 Microsoft.Extensions.Logging.Abstractions; + +namespace DevProxy.Parity.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; + } + + public static async Task StartAsync( + string watchedHost, + IEnumerable? plugins = 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 escapedHost = Regex.Escape(watchedHost); + var urlsToWatch = new HashSet + { + new(new Regex( + $"^https?://{escapedHost}/.*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase)), + }; + + var engine = new KestrelProxyEngine( + plugins ?? [], + urlsToWatch, + configuration, + [], + 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.Parity.Tests/MockShortCircuitPlugin.cs b/DevProxy.Parity.Tests/MockShortCircuitPlugin.cs new file mode 100644 index 00000000..995c4fc5 --- /dev/null +++ b/DevProxy.Parity.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.Parity.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.Parity.Tests/MockingAndSmugglingParityTests.cs b/DevProxy.Parity.Tests/MockingAndSmugglingParityTests.cs new file mode 100644 index 00000000..1c77abd0 --- /dev/null +++ b/DevProxy.Parity.Tests/MockingAndSmugglingParityTests.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.Parity.Tests; + +/// +/// Parity rows for the mocking short-circuit and the request-smuggling guard. +/// +public sealed class MockingAndSmugglingParityTests +{ + [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.Parity.Tests/NetUtil.cs b/DevProxy.Parity.Tests/NetUtil.cs new file mode 100644 index 00000000..40c8d9ef --- /dev/null +++ b/DevProxy.Parity.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.Parity.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.Parity.Tests/PlainHttpParityTests.cs b/DevProxy.Parity.Tests/PlainHttpParityTests.cs new file mode 100644 index 00000000..9781032d --- /dev/null +++ b/DevProxy.Parity.Tests/PlainHttpParityTests.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.Parity.Tests; + +/// +/// Parity rows 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 PlainHttpParityTests +{ + [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.Parity.Tests/StreamingAndConnectionParityTests.cs b/DevProxy.Parity.Tests/StreamingAndConnectionParityTests.cs new file mode 100644 index 00000000..b7d2b96b --- /dev/null +++ b/DevProxy.Parity.Tests/StreamingAndConnectionParityTests.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.Parity.Tests; + +/// +/// Parity rows for streaming bodies, chunked request framing, and connection reuse — +/// all over plain HTTP so they need no TLS trust. +/// +public sealed class StreamingAndConnectionParityTests +{ + [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.Parity.Tests/TestProxyConfiguration.cs b/DevProxy.Parity.Tests/TestProxyConfiguration.cs new file mode 100644 index 00000000..139586dc --- /dev/null +++ b/DevProxy.Parity.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.Parity.Tests; + +/// +/// Minimal for the parity 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 => "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.sln b/DevProxy.sln index 107678c0..119572b8 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Kestrel", "D 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.Parity.Tests", "DevProxy.Parity.Tests\DevProxy.Parity.Tests.csproj", "{90E10CFA-DEF0-456B-8641-102C7072931B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -132,6 +134,18 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 81a2b10ea2927a9ed70ca3868c7a77925969c687 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 27 Jun 2026 21:16:25 +0200 Subject: [PATCH 27/46] Hard cut-over to the Kestrel proxy engine; remove Titanium Make the ASP.NET Core Kestrel engine the sole proxy engine and remove the Unobtanium/Titanium.Web.Proxy adapter, dependency, and dev toggle in one change. - Remove the DEV_PROXY_ENGINE toggle; AddProxyEngine always wires Kestrel, the shared CertificateAuthority, IRootCertificateTrust, and ISystemProxyManager. - Introduce a single shared CertificateAuthority DI singleton (Kestrel AddKestrelCertificateAuthority) with the root X509Certificate2 derived from it; the engine, CertCommand, ProxyController, and EntraMockResponsePlugin all resolve the same root cert. The engine no longer self-creates/disposes its CA. - Add Trust/Untrust to IRootCertificateTrust and implement them host-side (mac keychain / Windows root store / Linux warn); CertCommand uses them. - Delete ProxyEngine, CertificateDiskCache, the DevProxy.Proxy.Titanium and DevProxy.Proxy.Titanium.Tests projects, the Unobtanium PackageReference, the Titanium.Web.Proxy.* log filters, and the THIRD PARTY NOTICES Unobtanium entry. - Update docs (CONTRIBUTING, copilot-instructions) and stale comments to Kestrel. Build 0/0; 236 tests green (71 Abstractions + 150 Kestrel + 15 Parity). Live-verified on macOS: watched HTTPS MITM without -k + req-log, cert-download API, and `cert ensure` trust path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 +- CONTRIBUTING.md | 2 +- .../Proxy/IRootCertificateTrust.cs | 14 + .../Proxy/ISystemProxyManager.cs | 6 +- DevProxy.Parity.Tests/KestrelProxyHarness.cs | 2 + .../IServiceCollectionExtensions.cs | 37 + .../Internal/CertificateAuthority.cs | 2 +- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 15 +- .../DevProxy.Proxy.Titanium.Tests.csproj | 25 - .../TitaniumHeaderCollectionTests.cs | 165 ---- .../TitaniumRequestAdapterTests.cs | 106 --- .../TitaniumResponseAdapterTests.cs | 120 --- .../DevProxy.Proxy.Titanium.csproj | 24 - .../TitaniumHeaderCollection.cs | 103 --- .../TitaniumHttpMessageAdapter.cs | 92 --- .../TitaniumProxySession.cs | 85 -- .../TitaniumRequestAdapter.cs | 47 -- .../TitaniumResponseAdapter.cs | 47 -- DevProxy.Proxy.Titanium/packages.lock.json | 330 -------- DevProxy.sln | 28 - DevProxy/ApiControllers/ProxyController.cs | 19 +- DevProxy/Commands/CertCommand.cs | 50 +- DevProxy/DevProxy.csproj | 2 - .../Extensions/ILoggingBuilderExtensions.cs | 6 +- .../IServiceCollectionExtensions.cs | 40 +- DevProxy/Logging/ProxyConsoleFormatter.cs | 2 +- DevProxy/Proxy/CertificateDiskCache.cs | 142 ---- DevProxy/Proxy/ProxyEngine.cs | 776 ------------------ DevProxy/Proxy/ProxyStateController.cs | 3 +- DevProxy/Proxy/RootCertificateTrust.cs | 66 ++ DevProxy/Proxy/SystemProxyManager.cs | 6 +- DevProxy/packages.lock.json | 21 - THIRD PARTY NOTICES | 27 - 33 files changed, 172 insertions(+), 2240 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel/IServiceCollectionExtensions.cs delete mode 100644 DevProxy.Proxy.Titanium.Tests/DevProxy.Proxy.Titanium.Tests.csproj delete mode 100644 DevProxy.Proxy.Titanium.Tests/TitaniumHeaderCollectionTests.cs delete mode 100644 DevProxy.Proxy.Titanium.Tests/TitaniumRequestAdapterTests.cs delete mode 100644 DevProxy.Proxy.Titanium.Tests/TitaniumResponseAdapterTests.cs delete mode 100644 DevProxy.Proxy.Titanium/DevProxy.Proxy.Titanium.csproj delete mode 100644 DevProxy.Proxy.Titanium/TitaniumHeaderCollection.cs delete mode 100644 DevProxy.Proxy.Titanium/TitaniumHttpMessageAdapter.cs delete mode 100644 DevProxy.Proxy.Titanium/TitaniumProxySession.cs delete mode 100644 DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs delete mode 100644 DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs delete mode 100644 DevProxy.Proxy.Titanium/packages.lock.json delete mode 100644 DevProxy/Proxy/CertificateDiskCache.cs delete mode 100755 DevProxy/Proxy/ProxyEngine.cs delete mode 100644 THIRD PARTY NOTICES 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/Proxy/IRootCertificateTrust.cs b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs index 78e0e09f..90c3dfd3 100644 --- a/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs +++ b/DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs @@ -25,4 +25,18 @@ public interface IRootCertificateTrust /// 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 index 8069e97b..d29269a3 100644 --- a/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs +++ b/DevProxy.Abstractions/Proxy/ISystemProxyManager.cs @@ -5,9 +5,9 @@ namespace DevProxy.Abstractions.Proxy; /// -/// Turns the operating-system HTTP/HTTPS proxy on and off. Engine-agnostic: both the -/// Titanium and Kestrel engines (and the stop command's crash-cleanup path) drive -/// the same implementation, so there is one place that owns the OS proxy state. +/// 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 + diff --git a/DevProxy.Parity.Tests/KestrelProxyHarness.cs b/DevProxy.Parity.Tests/KestrelProxyHarness.cs index 0dc82cec..9b5724e4 100644 --- a/DevProxy.Parity.Tests/KestrelProxyHarness.cs +++ b/DevProxy.Parity.Tests/KestrelProxyHarness.cs @@ -9,6 +9,7 @@ using DevProxy.Abstractions.Plugins; using DevProxy.Abstractions.Proxy; using DevProxy.Proxy.Kestrel; +using DevProxy.Proxy.Kestrel.Internal; using Microsoft.Extensions.Logging.Abstractions; namespace DevProxy.Parity.Tests; @@ -60,6 +61,7 @@ public static async Task StartAsync( }; var engine = new KestrelProxyEngine( + CertificateAuthority.CreateDefault(), plugins ?? [], urlsToWatch, configuration, 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 index c594399e..c77391af 100644 --- a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs +++ b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs @@ -49,7 +49,7 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// └─ no ──► mint leaf signed by root ──► save (best-effort) ──► use /// /// -internal sealed class CertificateAuthority : IDisposable +public sealed class CertificateAuthority : IDisposable { private const string RootCertCommonName = "Dev Proxy CA"; private const string ConfigFolderName = "dev-proxy"; diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index bfa20454..570b17f9 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -17,18 +17,13 @@ namespace DevProxy.Proxy.Kestrel; /// -/// A forward-proxy engine built on ASP.NET Core Kestrel — the replacement for the -/// Titanium-based engine. Hosts a raw TCP endpoint (Kestrel's HTTP middleware is +/// 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. -/// -/// -/// Selected via the engine dev-toggle so it can run side-by-side with the Titanium -/// engine during development for golden-output comparison. Not a shipped fallback — -/// it becomes the only engine at cut-over. -/// /// public sealed class KestrelProxyEngine( + CertificateAuthority certificateAuthority, IEnumerable plugins, ISet urlsToWatch, IProxyConfiguration configuration, @@ -46,7 +41,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) : IPAddress.Parse(configuration.IPAddress); var port = configuration.Port; - using var ca = CertificateAuthority.CreateDefault(_logger); + // 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 { diff --git a/DevProxy.Proxy.Titanium.Tests/DevProxy.Proxy.Titanium.Tests.csproj b/DevProxy.Proxy.Titanium.Tests/DevProxy.Proxy.Titanium.Tests.csproj deleted file mode 100644 index 14d41b45..00000000 --- a/DevProxy.Proxy.Titanium.Tests/DevProxy.Proxy.Titanium.Tests.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - $(NoWarn);CA1707;CA1861 - - - - - - - - - - - - - - diff --git a/DevProxy.Proxy.Titanium.Tests/TitaniumHeaderCollectionTests.cs b/DevProxy.Proxy.Titanium.Tests/TitaniumHeaderCollectionTests.cs deleted file mode 100644 index 126114eb..00000000 --- a/DevProxy.Proxy.Titanium.Tests/TitaniumHeaderCollectionTests.cs +++ /dev/null @@ -1,165 +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.Proxy.Http; -using Xunit; -using DevProxy.Proxy.Titanium; -using TitaniumHeaders = Titanium.Web.Proxy.Http.HeaderCollection; -using TitaniumHttpHeader = Titanium.Web.Proxy.Models.HttpHeader; - -namespace DevProxy.Proxy.Titanium.Tests; - -public class TitaniumHeaderCollectionTests -{ - private static TitaniumHeaderCollection Wrap(params (string Name, string Value)[] headers) - { - var titanium = new TitaniumHeaders(); - foreach (var (name, value) in headers) - { - titanium.AddHeader(name, value); - } - - return new TitaniumHeaderCollection(titanium); - } - - [Fact] - public void Constructor_NullHeaders_Throws() => - Assert.Throws(() => new TitaniumHeaderCollection(null!)); - - [Fact] - public void Count_ReflectsUnderlyingHeaders() - { - var sut = Wrap(("Accept", "application/json"), ("Host", "example.com")); - Assert.Equal(2, sut.Count); - } - - [Fact] - public void Count_CountsDuplicateHeadersSeparately() - { - var sut = Wrap(("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")); - Assert.Equal(2, sut.Count); - } - - [Fact] - public void Contains_IsCaseInsensitive() - { - var sut = Wrap(("Content-Type", "text/plain")); - Assert.True(sut.Contains("content-type")); - Assert.True(sut.Contains("CONTENT-TYPE")); - Assert.False(sut.Contains("X-Missing")); - } - - [Fact] - public void GetFirst_ReturnsFirstMatch() - { - var sut = Wrap(("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")); - var header = sut.GetFirst("set-cookie"); - Assert.NotNull(header); - Assert.Equal("Set-Cookie", header!.Name); - Assert.Equal("a=1", header.Value); - } - - [Fact] - public void GetFirst_MissingHeader_ReturnsNull() - { - var sut = Wrap(("Accept", "application/json")); - Assert.Null(sut.GetFirst("X-Missing")); - } - - [Fact] - public void GetAll_ReturnsEveryOccurrenceInOrder() - { - var sut = Wrap(("Set-Cookie", "a=1"), ("Set-Cookie", "b=2"), ("Set-Cookie", "c=3")); - var values = sut.GetAll("Set-Cookie").Select(h => h.Value).ToArray(); - Assert.Equal(["a=1", "b=2", "c=3"], values); - } - - [Fact] - public void GetAll_MissingHeader_ReturnsEmpty() - { - var sut = Wrap(("Accept", "application/json")); - Assert.Empty(sut.GetAll("X-Missing")); - } - - [Fact] - public void Add_NameValue_AppendsHeader() - { - var sut = Wrap(); - sut.Add("X-Custom", "value"); - Assert.Equal("value", sut.GetFirst("X-Custom")!.Value); - } - - [Fact] - public void Add_Header_AppendsHeader() - { - var sut = Wrap(); - sut.Add(new HttpHeader("X-Custom", "value")); - Assert.Equal("value", sut.GetFirst("X-Custom")!.Value); - } - - [Fact] - public void AddRange_AppendsAllHeaders() - { - var sut = Wrap(); - sut.AddRange([new HttpHeader("A", "1"), new HttpHeader("B", "2")]); - Assert.Equal("1", sut.GetFirst("A")!.Value); - Assert.Equal("2", sut.GetFirst("B")!.Value); - } - - [Fact] - public void Replace_RemovesExistingAndSetsSingleValue() - { - var sut = Wrap(("X-Dup", "old1"), ("X-Dup", "old2")); - sut.Replace("X-Dup", "new"); - var all = sut.GetAll("X-Dup").Select(h => h.Value).ToArray(); - Assert.Equal(["new"], all); - } - - [Fact] - public void Replace_MissingHeader_AddsIt() - { - var sut = Wrap(); - sut.Replace("X-New", "value"); - Assert.Equal("value", sut.GetFirst("X-New")!.Value); - } - - [Fact] - public void Remove_ExistingHeader_ReturnsTrueAndRemoves() - { - var sut = Wrap(("X-Custom", "value")); - Assert.True(sut.Remove("X-Custom")); - Assert.False(sut.Contains("X-Custom")); - } - - [Fact] - public void Remove_MissingHeader_ReturnsFalse() - { - var sut = Wrap(); - Assert.False(sut.Remove("X-Missing")); - } - - [Fact] - public void Enumeration_YieldsAllHeadersIncludingDuplicates() - { - var sut = Wrap(("Set-Cookie", "a=1"), ("Set-Cookie", "b=2"), ("Host", "example.com")); - var pairs = sut.Select(h => (h.Name, h.Value)).ToArray(); - Assert.Equal(3, pairs.Length); - Assert.Contains(("Set-Cookie", "a=1"), pairs); - Assert.Contains(("Set-Cookie", "b=2"), pairs); - Assert.Contains(("Host", "example.com"), pairs); - } - - [Fact] - public void Mutation_IsVisibleOnUnderlyingTitaniumCollection() - { - var titanium = new TitaniumHeaders(); - titanium.AddHeader(new TitaniumHttpHeader("X-Seed", "seed")); - var sut = new TitaniumHeaderCollection(titanium); - - sut.Add("X-Added", "added"); - - Assert.True(titanium.HeaderExists("X-Added")); - Assert.Equal("added", titanium.GetFirstHeader("X-Added")!.Value); - } -} diff --git a/DevProxy.Proxy.Titanium.Tests/TitaniumRequestAdapterTests.cs b/DevProxy.Proxy.Titanium.Tests/TitaniumRequestAdapterTests.cs deleted file mode 100644 index aaffb8db..00000000 --- a/DevProxy.Proxy.Titanium.Tests/TitaniumRequestAdapterTests.cs +++ /dev/null @@ -1,106 +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.Text; -using DevProxy.Proxy.Titanium; -using Xunit; -using TitaniumRequest = Titanium.Web.Proxy.Http.Request; - -namespace DevProxy.Proxy.Titanium.Tests; - -public class TitaniumRequestAdapterTests -{ - private static TitaniumRequest NewRequest() => new() - { - Method = "GET", - HttpVersion = new Version(1, 1), - RequestUri = new Uri("https://example.com/api/items?id=1"), - }; - - [Fact] - public void RequestUri_IsProjected() - { - var sut = new TitaniumRequestAdapter(NewRequest()); - Assert.Equal(new Uri("https://example.com/api/items?id=1"), sut.RequestUri); - } - - [Fact] - public void Url_IsProjected() - { - var sut = new TitaniumRequestAdapter(NewRequest()); - Assert.Equal("https://example.com/api/items?id=1", sut.Url); - } - - [Fact] - public void Method_IsProjected() - { - var request = NewRequest(); - request.Method = "POST"; - var sut = new TitaniumRequestAdapter(request); - Assert.Equal("POST", sut.Method); - } - - [Fact] - public void HttpVersion_IsProjected() - { - var sut = new TitaniumRequestAdapter(NewRequest()); - Assert.Equal(new Version(1, 1), sut.HttpVersion); - } - - [Fact] - public void IsWebSocketRequest_DefaultsFalse() - { - var sut = new TitaniumRequestAdapter(NewRequest()); - Assert.False(sut.IsWebSocketRequest); - } - - [Fact] - public void Headers_AreProjected() - { - var request = NewRequest(); - request.Headers.AddHeader("X-Custom", "value"); - var sut = new TitaniumRequestAdapter(request); - Assert.True(sut.Headers.Contains("X-Custom")); - Assert.Equal("value", sut.Headers.GetFirst("X-Custom")!.Value); - } - - [Fact] - public void SetBodyString_WithSetter_RoutesUtf8BytesToSetter() - { - byte[]? captured = null; - var sut = new TitaniumRequestAdapter(NewRequest(), b => captured = b); - - sut.SetBodyString("hello"); - - Assert.NotNull(captured); - Assert.Equal("hello", Encoding.UTF8.GetString(captured!)); - } - - [Fact] - public void SetBody_WithSetter_RoutesBytesToSetter() - { - byte[]? captured = null; - var sut = new TitaniumRequestAdapter(NewRequest(), b => captured = b); - - sut.SetBody(new byte[] { 1, 2, 3 }); - - Assert.Equal(new byte[] { 1, 2, 3 }, captured); - } - - [Fact] - public void SetBody_WithoutSetter_Throws() - { - var sut = new TitaniumRequestAdapter(NewRequest()); - Assert.Throws(() => sut.SetBody(new byte[] { 1 })); - } - - [Fact] - public void BodyAndBodyString_NoBody_ReturnEmpty() - { - var sut = new TitaniumRequestAdapter(NewRequest()); - Assert.False(sut.HasBody); - Assert.True(sut.Body.IsEmpty); - Assert.Equal(string.Empty, sut.BodyString); - } -} diff --git a/DevProxy.Proxy.Titanium.Tests/TitaniumResponseAdapterTests.cs b/DevProxy.Proxy.Titanium.Tests/TitaniumResponseAdapterTests.cs deleted file mode 100644 index 75d641ad..00000000 --- a/DevProxy.Proxy.Titanium.Tests/TitaniumResponseAdapterTests.cs +++ /dev/null @@ -1,120 +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.Net; -using System.Text; -using DevProxy.Proxy.Titanium; -using Xunit; -using TitaniumResponse = Titanium.Web.Proxy.Http.Response; - -namespace DevProxy.Proxy.Titanium.Tests; - -public class TitaniumResponseAdapterTests -{ - [Fact] - public void StatusCode_IsMappedToHttpStatusCode() - { - var response = new TitaniumResponse { StatusCode = 404 }; - var sut = new TitaniumResponseAdapter(response); - Assert.Equal(HttpStatusCode.NotFound, sut.StatusCode); - } - - [Fact] - public void StatusCode_SetterWritesIntToTitanium() - { - var response = new TitaniumResponse { StatusCode = 200 }; - var sut = new TitaniumResponseAdapter(response) - { - StatusCode = HttpStatusCode.InternalServerError, - }; - Assert.Equal(500, response.StatusCode); - } - - [Fact] - public void StatusDescription_RoundTrips() - { - var response = new TitaniumResponse { StatusDescription = "OK" }; - var sut = new TitaniumResponseAdapter(response); - Assert.Equal("OK", sut.StatusDescription); - - sut.StatusDescription = "Created"; - Assert.Equal("Created", response.StatusDescription); - } - - [Fact] - public void StatusDescription_NullSetter_WritesEmptyString() - { - var response = new TitaniumResponse { StatusDescription = "OK" }; - var sut = new TitaniumResponseAdapter(response) - { - StatusDescription = null, - }; - Assert.Equal(string.Empty, response.StatusDescription); - } - - [Fact] - public void Body_WhenPresent_IsProjectedAsBytes() - { - var response = new TitaniumResponse(Encoding.UTF8.GetBytes("hello world")); - var sut = new TitaniumResponseAdapter(response); - Assert.True(sut.HasBody); - Assert.Equal("hello world", Encoding.UTF8.GetString(sut.Body.Span)); - } - - [Fact] - public void BodyString_WhenPresent_IsProjected() - { - var response = new TitaniumResponse(Encoding.UTF8.GetBytes("payload")); - var sut = new TitaniumResponseAdapter(response); - Assert.Equal("payload", sut.BodyString); - } - - [Fact] - public void BodyAndBodyString_NoBody_ReturnEmpty() - { - var response = new TitaniumResponse(); - var sut = new TitaniumResponseAdapter(response); - Assert.False(sut.HasBody); - Assert.True(sut.Body.IsEmpty); - Assert.Equal(string.Empty, sut.BodyString); - } - - [Fact] - public void ContentType_IsProjected() - { - var response = new TitaniumResponse { ContentType = "application/json" }; - var sut = new TitaniumResponseAdapter(response); - Assert.Equal("application/json", sut.ContentType); - } - - [Fact] - public void Headers_AreProjected() - { - var response = new TitaniumResponse(); - response.Headers.AddHeader("X-Trace", "abc"); - var sut = new TitaniumResponseAdapter(response); - Assert.Equal("abc", sut.Headers.GetFirst("X-Trace")!.Value); - } - - [Fact] - public void SetBody_WithSetter_RoutesBytesToSetter() - { - byte[]? captured = null; - var response = new TitaniumResponse(Encoding.UTF8.GetBytes("seed")); - var sut = new TitaniumResponseAdapter(response, b => captured = b); - - sut.SetBodyString("updated"); - - Assert.NotNull(captured); - Assert.Equal("updated", Encoding.UTF8.GetString(captured!)); - } - - [Fact] - public void SetBody_WithoutSetter_Throws() - { - var response = new TitaniumResponse(Encoding.UTF8.GetBytes("seed")); - var sut = new TitaniumResponseAdapter(response); - Assert.Throws(() => sut.SetBodyString("nope")); - } -} diff --git a/DevProxy.Proxy.Titanium/DevProxy.Proxy.Titanium.csproj b/DevProxy.Proxy.Titanium/DevProxy.Proxy.Titanium.csproj deleted file mode 100644 index 539fad24..00000000 --- a/DevProxy.Proxy.Titanium/DevProxy.Proxy.Titanium.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net10.0 - DevProxy.Proxy.Titanium - enable - enable - 3.1.0 - false - true - true - AllEnabledByDefault - false - - - - - - - - - - - diff --git a/DevProxy.Proxy.Titanium/TitaniumHeaderCollection.cs b/DevProxy.Proxy.Titanium/TitaniumHeaderCollection.cs deleted file mode 100644 index 8ac59e36..00000000 --- a/DevProxy.Proxy.Titanium/TitaniumHeaderCollection.cs +++ /dev/null @@ -1,103 +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.Collections; -using DevProxy.Abstractions.Proxy.Http; -using TitaniumHeaders = Titanium.Web.Proxy.Http.HeaderCollection; - -namespace DevProxy.Proxy.Titanium; - -/// -/// Projects a Titanium onto the canonical -/// . Reads and writes operate directly on the -/// underlying Titanium collection so mutations are visible to the engine. -/// -public sealed class TitaniumHeaderCollection : IHeaderCollection -{ - private readonly TitaniumHeaders _headers; - - /// Wraps an existing Titanium header collection. - public TitaniumHeaderCollection(TitaniumHeaders headers) - { - ArgumentNullException.ThrowIfNull(headers); - _headers = headers; - } - - /// - public int Count => _headers.GetAllHeaders().Count; - - /// - public bool Contains(string name) - { - ArgumentNullException.ThrowIfNull(name); - return _headers.HeaderExists(name); - } - - /// - public IHttpHeader? GetFirst(string name) - { - ArgumentNullException.ThrowIfNull(name); - var header = _headers.GetFirstHeader(name); - return header is null ? null : new HttpHeader(header.Name, header.Value); - } - - /// - public IEnumerable GetAll(string name) - { - ArgumentNullException.ThrowIfNull(name); - var headers = _headers.GetHeaders(name); - return headers is null - ? [] - : headers.Select(h => (IHttpHeader)new HttpHeader(h.Name, h.Value)); - } - - /// - public void Add(string name, string value) - { - ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(value); - _headers.AddHeader(name, value); - } - - /// - public void Add(IHttpHeader header) - { - ArgumentNullException.ThrowIfNull(header); - _headers.AddHeader(header.Name, header.Value); - } - - /// - public void AddRange(IEnumerable headers) - { - ArgumentNullException.ThrowIfNull(headers); - foreach (var header in headers) - { - _headers.AddHeader(header.Name, header.Value); - } - } - - /// - public void Replace(string name, string value) - { - ArgumentNullException.ThrowIfNull(name); - ArgumentNullException.ThrowIfNull(value); - _ = _headers.RemoveHeader(name); - _headers.AddHeader(name, value); - } - - /// - public bool Remove(string name) - { - ArgumentNullException.ThrowIfNull(name); - return _headers.RemoveHeader(name); - } - - /// - public IEnumerator GetEnumerator() => - _headers.GetAllHeaders() - .Select(h => (IHttpHeader)new HttpHeader(h.Name, h.Value)) - .GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} diff --git a/DevProxy.Proxy.Titanium/TitaniumHttpMessageAdapter.cs b/DevProxy.Proxy.Titanium/TitaniumHttpMessageAdapter.cs deleted file mode 100644 index 39d38997..00000000 --- a/DevProxy.Proxy.Titanium/TitaniumHttpMessageAdapter.cs +++ /dev/null @@ -1,92 +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.Text; -using DevProxy.Abstractions.Proxy.Http; -using TitaniumMessage = Titanium.Web.Proxy.Http.RequestResponseBase; - -namespace DevProxy.Proxy.Titanium; - -/// -/// Shared projection of a Titanium (the common base -/// of request and response) onto the canonical surface. -/// -/// -/// Body access mirrors the engine's contract: Titanium throws -/// BodyNotFoundException when a body is accessed before one exists, so -/// / are guarded by HasBody and -/// return empty otherwise. The Dev Proxy engine force-reads watched -/// request/response bodies before invoking plugins, so these synchronous -/// accessors are populated by the time a plugin runs. -/// -/// -/// -/// Titanium exposes no public body setter on the message itself, so mutations are -/// routed through (the session's -/// SetRequestBody/SetResponseBody, which also keep Titanium's -/// internal state consistent). An adapter constructed without a setter is -/// read-only and throws. -/// -/// -public abstract class TitaniumHttpMessageAdapter : IHttpMessage -{ - private readonly TitaniumMessage _message; - private readonly Action? _setBody; - private readonly TitaniumHeaderCollection _headers; - - /// The Titanium request or response to wrap. - /// - /// Optional body setter that keeps the owning session consistent. When - /// null, the adapter is read-only and throws. - /// - protected TitaniumHttpMessageAdapter(TitaniumMessage message, Action? setBody) - { - ArgumentNullException.ThrowIfNull(message); - _message = message; - _setBody = setBody; - _headers = new TitaniumHeaderCollection(message.Headers); - } - - /// - public IHeaderCollection Headers => _headers; - - /// - public string? ContentType => _message.ContentType; - - /// - public bool HasBody => _message.HasBody; - - /// - public ReadOnlyMemory Body => - _message.HasBody && _message.Body is { } bytes ? bytes : ReadOnlyMemory.Empty; - - /// - public string BodyString => _message.HasBody ? _message.BodyString : string.Empty; - - /// - public void SetBody(ReadOnlyMemory body, string? contentType = null) - { - if (_setBody is null) - { - throw new InvalidOperationException( - "This message cannot be mutated because it was not constructed with a body setter. " + - "Body mutation requires a session-bound adapter (the engine supplies the session's " + - "SetRequestBody/SetResponseBody). Titanium exposes no public body setter on the message itself."); - } - - _setBody(body.ToArray()); - - if (contentType is not null) - { - _message.ContentType = contentType; - } - } - - /// - public void SetBodyString(string body, string? contentType = null) - { - ArgumentNullException.ThrowIfNull(body); - SetBody(Encoding.UTF8.GetBytes(body), contentType); - } -} diff --git a/DevProxy.Proxy.Titanium/TitaniumProxySession.cs b/DevProxy.Proxy.Titanium/TitaniumProxySession.cs deleted file mode 100644 index 6562ab4f..00000000 --- a/DevProxy.Proxy.Titanium/TitaniumProxySession.cs +++ /dev/null @@ -1,85 +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.Net; -using DevProxy.Abstractions.Proxy.Http; -using Titanium.Web.Proxy.EventArguments; -using TitaniumHttpHeader = Titanium.Web.Proxy.Models.HttpHeader; - -namespace DevProxy.Proxy.Titanium; - -/// -/// Projects a Titanium onto the canonical -/// . -/// -/// -/// Titanium always exposes a non-null HttpClient.Response object, even -/// before an upstream response has been received. A response is considered -/// present once its status code is non-zero (the upstream replied, or a plugin -/// produced a mock via ). -/// -/// -public sealed class TitaniumProxySession : IProxySession -{ - private readonly SessionEventArgs _session; - private readonly TitaniumRequestAdapter _request; - private TitaniumResponseAdapter? _response; - - /// The logical session identifier for this exchange. - /// The Titanium session to wrap. - public TitaniumProxySession(string sessionId, SessionEventArgs session) - { - ArgumentNullException.ThrowIfNull(sessionId); - ArgumentNullException.ThrowIfNull(session); - - SessionId = sessionId; - _session = session; - _request = new TitaniumRequestAdapter(session.HttpClient.Request, session.SetRequestBody); - } - - /// - public string SessionId { get; } - - /// - public IHttpRequest Request => _request; - - /// - public IHttpResponse? Response - { - get - { - if (!HasResponse) - { - return null; - } - - _response ??= new TitaniumResponseAdapter(_session.HttpClient.Response, _session.SetResponseBody); - return _response; - } - } - - /// - public int? ProcessId => _session.HttpClient.ProcessId is { } processId ? processId.Value : null; - - /// - public bool HasResponse => _session.HttpClient.Response.StatusCode != 0; - - /// - public void Respond(string body, HttpStatusCode statusCode, IEnumerable headers) - { - ArgumentNullException.ThrowIfNull(body); - ArgumentNullException.ThrowIfNull(headers); - _session.GenericResponse(body, statusCode, ToTitaniumHeaders(headers)); - } - - /// - public void Respond(ReadOnlyMemory body, HttpStatusCode statusCode, IEnumerable headers) - { - ArgumentNullException.ThrowIfNull(headers); - _session.GenericResponse(body.ToArray(), statusCode, ToTitaniumHeaders(headers)); - } - - private static IEnumerable ToTitaniumHeaders(IEnumerable headers) => - headers.Select(h => new TitaniumHttpHeader(h.Name, h.Value)); -} diff --git a/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs b/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs deleted file mode 100644 index b87e77da..00000000 --- a/DevProxy.Proxy.Titanium/TitaniumRequestAdapter.cs +++ /dev/null @@ -1,47 +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.Proxy.Http; -using TitaniumRequest = Titanium.Web.Proxy.Http.Request; - -namespace DevProxy.Proxy.Titanium; - -/// -/// Projects a Titanium onto the canonical -/// . -/// -public sealed class TitaniumRequestAdapter : TitaniumHttpMessageAdapter, IHttpRequest -{ - private readonly TitaniumRequest _request; - - /// The Titanium request to wrap. - /// - /// Optional body setter (typically the session's SetRequestBody) that - /// keeps Titanium's request state consistent on mutation. - /// - public TitaniumRequestAdapter(TitaniumRequest request, Action? setBody = null) - : base(request, setBody) - { - _request = request; - } - - /// - public Uri RequestUri => _request.RequestUri!; - - /// - public string Url - { - get => _request.Url; - set => _request.Url = value; - } - - /// - public string Method => _request.Method!; - - /// - public Version HttpVersion => _request.HttpVersion; - - /// - public bool IsWebSocketRequest => _request.UpgradeToWebSocket; -} diff --git a/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs b/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs deleted file mode 100644 index 4db992e7..00000000 --- a/DevProxy.Proxy.Titanium/TitaniumResponseAdapter.cs +++ /dev/null @@ -1,47 +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.Net; -using DevProxy.Abstractions.Proxy.Http; -using TitaniumResponse = Titanium.Web.Proxy.Http.Response; - -namespace DevProxy.Proxy.Titanium; - -/// -/// Projects a Titanium onto the canonical -/// . Titanium stores the status as an ; -/// the canonical model exposes it as a strongly-typed . -/// -public sealed class TitaniumResponseAdapter : TitaniumHttpMessageAdapter, IHttpResponse -{ - private readonly TitaniumResponse _response; - - /// The Titanium response to wrap. - /// - /// Optional body setter (typically the session's SetResponseBody) that - /// keeps Titanium's response state consistent on mutation. - /// - public TitaniumResponseAdapter(TitaniumResponse response, Action? setBody = null) - : base(response, setBody) - { - _response = response; - } - - /// - public HttpStatusCode StatusCode - { - get => (HttpStatusCode)_response.StatusCode; - set => _response.StatusCode = (int)value; - } - - /// - public string? StatusDescription - { - get => _response.StatusDescription; - set => _response.StatusDescription = value ?? string.Empty; - } - - /// - public Version HttpVersion => _response.HttpVersion; -} diff --git a/DevProxy.Proxy.Titanium/packages.lock.json b/DevProxy.Proxy.Titanium/packages.lock.json deleted file mode 100644 index 32f02e1a..00000000 --- a/DevProxy.Proxy.Titanium/packages.lock.json +++ /dev/null @@ -1,330 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net10.0": { - "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" - } - }, - "BouncyCastle.Cryptography": { - "type": "Transitive", - "resolved": "2.4.0", - "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" - }, - "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.Extensions.Caching.Memory": "10.0.9", - "Microsoft.Extensions.Logging": "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.Extensions.Caching.Memory": "10.0.9", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", - "Microsoft.Extensions.Logging": "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.Caching.Memory": "10.0.9", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", - "Microsoft.Extensions.DependencyModel": "10.0.9", - "Microsoft.Extensions.Logging": "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.Caching.Memory": "10.0.9", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", - "Microsoft.Extensions.DependencyModel": "10.0.9", - "Microsoft.Extensions.Logging": "10.0.9", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "5fGxcw2vuYp8s0wio9H1ECiuk4iKSdTIlNuigdLIrkhg+5XAwgFVDB/5Ots3pfN/QhABLYXutA79JFtnUKDSHA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.9" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "G9mregdatGWMCQWeCw012LDeJVP7G/XIxH8Ddbjc8bD1//dA+8VVQdcRE9jI1moyoJxSSZhHITUnNQ8FUDl5+Q==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.9", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", - "Microsoft.Extensions.Logging.Abstractions": "10.0.9", - "Microsoft.Extensions.Options": "10.0.9", - "Microsoft.Extensions.Primitives": "10.0.9" - } - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", - "Microsoft.Extensions.Primitives": "10.0.9" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.9" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.9", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" - } - }, - "Microsoft.Extensions.Configuration.FileExtensions": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "NgLB9cYnIb0/djSDcnqo4GIGGWooxGmr/gCUe3/CRXcKqLizOFui8MyW4EVkTB/KNJL+oXdMXnD6ZRm3Y+qkrQ==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.9", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", - "Microsoft.Extensions.FileProviders.Physical": "10.0.9", - "Microsoft.Extensions.Primitives": "10.0.9" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "LiFKJgc9jZEW+7RhcSfsvCwoikt1lDdOqOn+whZC5zVHyg/gExftHl2QPtmfiHsEdDNg+Y+BDr6835tOfj8Y7A==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.9", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "SCDTQ6HubnRvTUjR7dgMKHZvNoCb03t44ttHL8trlFTGgfDteWn/0nRdOxDhcI+lTWhKgd/flCVJEtAOPhSLNg==" - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.9" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "zm8WVod4swgprGrkxkuSILlbXqdDRqF+3y6U0I7jlmj4PMyKN6d8pzXZHUn5lr/gZVULzk/+FeTYlTupt6akpg==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.9", - "Microsoft.Extensions.Primitives": "10.0.9" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "mvRf9qOH/LslWIee/h+lsElnoUyKotEwoPL31soqScmO/eoxObaTCLCdx2DdqPdRi9LnB+7qKZ49jfyrLZuc+w==" - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.9", - "Microsoft.Extensions.Logging.Abstractions": "10.0.9", - "Microsoft.Extensions.Options": "10.0.9" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", - "Microsoft.Extensions.Primitives": "10.0.9" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.9", - "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" - }, - "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.Extensions.Configuration": "[10.0.9, )", - "Microsoft.Extensions.Configuration.Binder": "[10.0.9, )", - "Microsoft.Extensions.Configuration.Json": "[10.0.9, )", - "Microsoft.Extensions.FileSystemGlobbing": "[10.0.9, )", - "Microsoft.Extensions.Logging.Abstractions": "[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, )", - "Unobtanium.Web.Proxy": "[0.1.5, )", - "YamlDotNet": "[18.0.0, )" - } - } - } - } -} \ No newline at end of file diff --git a/DevProxy.sln b/DevProxy.sln index 119572b8..889a4f34 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -18,10 +18,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevProxy.Abstractions", "De 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.Titanium", "DevProxy.Proxy.Titanium\DevProxy.Proxy.Titanium.csproj", "{E333351F-3772-488F-B78C-31D6AABF7A7A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Titanium.Tests", "DevProxy.Proxy.Titanium.Tests\DevProxy.Proxy.Titanium.Tests.csproj", "{A3784E2F-7CB4-4F1B-8A96-C17104D5C868}" -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}" @@ -86,30 +82,6 @@ Global {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 - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|x64.ActiveCfg = Debug|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|x64.Build.0 = Debug|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|x86.ActiveCfg = Debug|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Debug|x86.Build.0 = Debug|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|Any CPU.Build.0 = Release|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|x64.ActiveCfg = Release|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|x64.Build.0 = Release|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|x86.ActiveCfg = Release|Any CPU - {E333351F-3772-488F-B78C-31D6AABF7A7A}.Release|x86.Build.0 = Release|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|x64.ActiveCfg = Debug|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|x64.Build.0 = Debug|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|x86.ActiveCfg = Debug|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Debug|x86.Build.0 = Debug|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|Any CPU.Build.0 = Release|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x64.ActiveCfg = Release|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x64.Build.0 = Release|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.Release|x86.ActiveCfg = Release|Any CPU - {A3784E2F-7CB4-4F1B-8A96-C17104D5C868}.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 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 b49a562d..4c2a4e16 100644 --- a/DevProxy/Commands/CertCommand.cs +++ b/DevProxy/Commands/CertCommand.cs @@ -2,26 +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 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(); } @@ -47,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 (OperatingSystem.IsMacOS()) - { - var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate; - if (certificate is not null) - { - MacCertificateHelper.TrustCertificate(certificate, _logger); - } - } - + _rootCertificateTrust.Trust(_rootCertificate); _logger.LogInformation("DONE"); } catch (Exception ex) @@ -76,6 +71,7 @@ private async Task EnsureCertAsync() } _logger.LogTrace("EnsureCertAsync() finished"); + return Task.CompletedTask; } public int RemoveCert(ParseResult parseResult) @@ -103,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 (OperatingSystem.IsMacOS()) - { - 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/DevProxy.csproj b/DevProxy/DevProxy.csproj index ecfce66a..ecfbe2df 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -42,12 +42,10 @@ - - 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 3e66656d..86234f4a 100644 --- a/DevProxy/Extensions/IServiceCollectionExtensions.cs +++ b/DevProxy/Extensions/IServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using DevProxy.Commands; using DevProxy.Proxy; using DevProxy.Proxy.Kestrel; +using DevProxy.Proxy.Kestrel.Internal; using Microsoft.Extensions.Logging; #pragma warning disable IDE0130 @@ -42,30 +43,23 @@ public static IServiceCollection ConfigureDevProxyServices( return services; } - // Engine selection (dev-toggle). The Titanium engine is the default; setting - // DEV_PROXY_ENGINE=kestrel selects the new Kestrel engine so the two can run - // side-by-side during development for golden-output comparison. This toggle is - // NOT a shipped fallback — it is removed at the hard cut-over (decision #3). + // 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) { - var engine = Environment.GetEnvironmentVariable("DEV_PROXY_ENGINE"); - if (string.Equals(engine, "kestrel", StringComparison.OrdinalIgnoreCase)) - { - _ = services.AddSingleton(); - _ = services.AddSingleton(); - _ = services.AddHostedService(sp => new KestrelProxyEngine( - sp.GetServices(), - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService().GlobalData, - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); - } - else - { - _ = services.AddHostedService(); - } + _ = 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; } @@ -81,10 +75,8 @@ static IServiceCollection AddApplicationServices( .AddSingleton() .AddSingleton() .AddHostedService() - .AddSingleton(sp => ProxyEngine.Certificate!) .AddSingleton(sp => LanguageModelClientFactory.Create(sp, configuration)) .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton() .AddHttpClient(); diff --git a/DevProxy/Logging/ProxyConsoleFormatter.cs b/DevProxy/Logging/ProxyConsoleFormatter.cs index 1fcca3d1..62b4604c 100644 --- a/DevProxy/Logging/ProxyConsoleFormatter.cs +++ b/DevProxy/Logging/ProxyConsoleFormatter.cs @@ -248,7 +248,7 @@ private void WritePluginName(TextWriter textWriter, string? categoryOrPluginName } var pluginName = categoryOrPluginName[(categoryOrPluginName.LastIndexOf('.') + 1)..]; - if (pluginName != nameof(ProxyEngine) && pluginName != nameof(KestrelProxyEngine)) + if (pluginName != nameof(KestrelProxyEngine)) { textWriter.Write($"{pluginName}: "); } 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/ProxyEngine.cs b/DevProxy/Proxy/ProxyEngine.cs deleted file mode 100755 index 9fa28cb5..00000000 --- a/DevProxy/Proxy/ProxyEngine.cs +++ /dev/null @@ -1,776 +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.Proxy.Titanium; -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.Globalization; -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.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: OperatingSystem.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 (OperatingSystem.IsWindows()) - { - ProxyServer.SetAsSystemHttpProxy(_explicitEndPoint); - ProxyServer.SetAsSystemHttpsProxy(_explicitEndPoint); - } - else if (OperatingSystem.IsMacOS()) - { - 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 (!OperatingSystem.IsMacOS() || - _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) - { - var hostRegex = WatchedHostExtractor.ToHostRegex(urlToWatch.Url); - // 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 (OperatingSystem.IsMacOS() && _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(CreateProxySession(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(proxyRequestArgs.ProxySession); - _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 static TitaniumProxySession CreateProxySession(SessionEventArgs e) => - new(e.GetHashCode().ToString(CultureInfo.InvariantCulture), e); - - 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(proxyRequestArgs.ProxySession)); - 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(CreateProxySession(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(CreateProxySession(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(proxyResponseArgs.ProxySession); - _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 (OperatingSystem.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 index ad2e9e9e..d3db001a 100644 --- a/DevProxy/Proxy/RootCertificateTrust.cs +++ b/DevProxy/Proxy/RootCertificateTrust.cs @@ -69,6 +69,46 @@ public void EnsureTrusted(X509Certificate2 rootCertificate) } } + 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(); @@ -113,4 +153,30 @@ private void InstallIntoWindowsRootStore(X509Certificate2 rootCertificate) 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/SystemProxyManager.cs b/DevProxy/Proxy/SystemProxyManager.cs index f93cc783..28ebf961 100644 --- a/DevProxy/Proxy/SystemProxyManager.cs +++ b/DevProxy/Proxy/SystemProxyManager.cs @@ -12,7 +12,7 @@ namespace DevProxy.Proxy; /// /// Host-side . Engine-agnostic OS proxy on/off, shared by -/// the Titanium engine, the Kestrel engine, and the stop --force crash-cleanup path. +/// the Kestrel engine and the stop --force crash-cleanup path. /// /// /// Enable(ip, port) Disable() @@ -27,8 +27,8 @@ namespace DevProxy.Proxy; /// /// 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 mechanism Titanium -/// used internally; it cannot be exercised on non-Windows hosts. +/// 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 diff --git a/DevProxy/packages.lock.json b/DevProxy/packages.lock.json index 0ba1840c..8bd50a93 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", @@ -362,13 +348,6 @@ "dependencies": { "DevProxy.Abstractions": "[3.1.0, )" } - }, - "devproxy.proxy.titanium": { - "type": "Project", - "dependencies": { - "DevProxy.Abstractions": "[3.1.0, )", - "Unobtanium.Web.Proxy": "[0.1.5, )" - } } } } 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. From 23b77ffcb3b29b558dca5bd3d387200f9401a13b Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 10:00:07 +0200 Subject: [PATCH 28/46] Fix detached-daemon state registration and bound graceful-shutdown drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cut-over regressions surfaced by the macOS system-proxy live test: 1. Detached mode (`--detach`) was broken. The deleted Titanium ProxyEngine wrote the initial ProxyInstanceState (gated on IsInternalDaemon); the Kestrel engine never did, so the daemon orphaned, the parent's readiness poll timed out, and the system proxy was left on. The host's UpdateStateWithApiUrlAsync only updated an existing state file, so it silently no-opped. - KestrelProxyEngine now publishes its actually-bound port back to the shared IProxyConfiguration after binding (resolves --port 0 to the OS-assigned port). - DevProxyCommand.WriteDaemonStateAsync writes the full ProxyInstanceState (Pid, ApiUrl, LogFile, ConfigFile, resolved Port, AsSystemProxy) once the engine has published its port, satisfying the parent's `Port > 0` readiness contract used by `devproxy stop` and `devproxy status`. 2. Graceful shutdown stalled after real traffic. The engine awaited app.StopAsync(CancellationToken.None), which waited unbounded for lingering client keep-alive connections to drain — fast when idle, but >10s after traffic, making `devproxy stop` falsely report "did not stop in time". The drain is now bounded (5s) so remaining connections are force-closed. Adds EnginePortPublishingTests regression coverage (verified failing without the port write-back). 237 tests green. Live-verified on macOS: `--detach` registers the daemon, `devproxy status`/`stop` find it, and the system proxy is restored on both graceful API stop and SIGTERM. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../EnginePortPublishingTests.cs | 78 +++++++++++++++++++ DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 20 ++++- DevProxy/Commands/DevProxyCommand.cs | 33 ++++++-- 3 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 DevProxy.Parity.Tests/EnginePortPublishingTests.cs diff --git a/DevProxy.Parity.Tests/EnginePortPublishingTests.cs b/DevProxy.Parity.Tests/EnginePortPublishingTests.cs new file mode 100644 index 00000000..c70521a0 --- /dev/null +++ b/DevProxy.Parity.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.Parity.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.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index 570b17f9..68b46437 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -81,6 +81,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // 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(), @@ -109,7 +114,20 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) systemProxyManager!.Disable(); } - await app.StopAsync(CancellationToken.None).ConfigureAwait(false); + // 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/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 From 7ea37f6f87fae2f4c22102eb72c351ade2f6d063 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 10:11:18 +0200 Subject: [PATCH 29/46] Rename Parity.Tests to Integration.Tests post-cut-over With Titanium removed there is no second engine to compare against, so the "parity" framing no longer applies. The suite is now a black-box integration test that boots the Kestrel engine over a real socket against a FakeOrigin. Renamed the project, csproj, namespaces, *ParityTests classes/files, and the solution entry (project GUID unchanged); reworded "parity" comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Integration.Tests.csproj | 0 .../EnginePortPublishingTests.cs | 2 +- .../FakeOrigin.cs | 4 ++-- .../KestrelProxyHarness.cs | 2 +- .../MockShortCircuitPlugin.cs | 2 +- .../MockingAndSmugglingIntegrationTests.cs | 6 +++--- .../NetUtil.cs | 2 +- .../PlainHttpIntegrationTests.cs | 6 +++--- .../StreamingAndConnectionIntegrationTests.cs | 6 +++--- .../TestProxyConfiguration.cs | 4 ++-- DevProxy.sln | 2 +- 11 files changed, 18 insertions(+), 18 deletions(-) rename DevProxy.Parity.Tests/DevProxy.Parity.Tests.csproj => DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj (100%) rename {DevProxy.Parity.Tests => DevProxy.Integration.Tests}/EnginePortPublishingTests.cs (98%) rename {DevProxy.Parity.Tests => DevProxy.Integration.Tests}/FakeOrigin.cs (96%) rename {DevProxy.Parity.Tests => DevProxy.Integration.Tests}/KestrelProxyHarness.cs (99%) rename {DevProxy.Parity.Tests => DevProxy.Integration.Tests}/MockShortCircuitPlugin.cs (97%) rename DevProxy.Parity.Tests/MockingAndSmugglingParityTests.cs => DevProxy.Integration.Tests/MockingAndSmugglingIntegrationTests.cs (94%) rename {DevProxy.Parity.Tests => DevProxy.Integration.Tests}/NetUtil.cs (96%) rename DevProxy.Parity.Tests/PlainHttpParityTests.cs => DevProxy.Integration.Tests/PlainHttpIntegrationTests.cs (96%) rename DevProxy.Parity.Tests/StreamingAndConnectionParityTests.cs => DevProxy.Integration.Tests/StreamingAndConnectionIntegrationTests.cs (95%) rename {DevProxy.Parity.Tests => DevProxy.Integration.Tests}/TestProxyConfiguration.cs (92%) diff --git a/DevProxy.Parity.Tests/DevProxy.Parity.Tests.csproj b/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj similarity index 100% rename from DevProxy.Parity.Tests/DevProxy.Parity.Tests.csproj rename to DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj diff --git a/DevProxy.Parity.Tests/EnginePortPublishingTests.cs b/DevProxy.Integration.Tests/EnginePortPublishingTests.cs similarity index 98% rename from DevProxy.Parity.Tests/EnginePortPublishingTests.cs rename to DevProxy.Integration.Tests/EnginePortPublishingTests.cs index c70521a0..e8ee4e2b 100644 --- a/DevProxy.Parity.Tests/EnginePortPublishingTests.cs +++ b/DevProxy.Integration.Tests/EnginePortPublishingTests.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Xunit; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// /// Regression coverage for the daemon-readiness contract: when started with diff --git a/DevProxy.Parity.Tests/FakeOrigin.cs b/DevProxy.Integration.Tests/FakeOrigin.cs similarity index 96% rename from DevProxy.Parity.Tests/FakeOrigin.cs rename to DevProxy.Integration.Tests/FakeOrigin.cs index 5b38cb55..20178db4 100644 --- a/DevProxy.Parity.Tests/FakeOrigin.cs +++ b/DevProxy.Integration.Tests/FakeOrigin.cs @@ -12,10 +12,10 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// -/// A deterministic upstream origin server used as the parity target. Each scenario +/// A deterministic upstream origin server used as the integration target. Each scenario /// asserts the proxy faithfully relays this origin's responses. /// /// diff --git a/DevProxy.Parity.Tests/KestrelProxyHarness.cs b/DevProxy.Integration.Tests/KestrelProxyHarness.cs similarity index 99% rename from DevProxy.Parity.Tests/KestrelProxyHarness.cs rename to DevProxy.Integration.Tests/KestrelProxyHarness.cs index 9b5724e4..358a71e0 100644 --- a/DevProxy.Parity.Tests/KestrelProxyHarness.cs +++ b/DevProxy.Integration.Tests/KestrelProxyHarness.cs @@ -12,7 +12,7 @@ using DevProxy.Proxy.Kestrel.Internal; using Microsoft.Extensions.Logging.Abstractions; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// /// Boots a on a free localhost port, watching the diff --git a/DevProxy.Parity.Tests/MockShortCircuitPlugin.cs b/DevProxy.Integration.Tests/MockShortCircuitPlugin.cs similarity index 97% rename from DevProxy.Parity.Tests/MockShortCircuitPlugin.cs rename to DevProxy.Integration.Tests/MockShortCircuitPlugin.cs index 995c4fc5..c20d0c81 100644 --- a/DevProxy.Parity.Tests/MockShortCircuitPlugin.cs +++ b/DevProxy.Integration.Tests/MockShortCircuitPlugin.cs @@ -8,7 +8,7 @@ using DevProxy.Abstractions.Proxy.Http; using Microsoft.Extensions.Logging.Abstractions; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// /// Minimal plugin that short-circuits every matched request with a canned response — diff --git a/DevProxy.Parity.Tests/MockingAndSmugglingParityTests.cs b/DevProxy.Integration.Tests/MockingAndSmugglingIntegrationTests.cs similarity index 94% rename from DevProxy.Parity.Tests/MockingAndSmugglingParityTests.cs rename to DevProxy.Integration.Tests/MockingAndSmugglingIntegrationTests.cs index 1c77abd0..1c11bf9a 100644 --- a/DevProxy.Parity.Tests/MockingAndSmugglingParityTests.cs +++ b/DevProxy.Integration.Tests/MockingAndSmugglingIntegrationTests.cs @@ -10,12 +10,12 @@ using DevProxy.Abstractions.Proxy; using Xunit; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// -/// Parity rows for the mocking short-circuit and the request-smuggling guard. +/// Integration scenarios for the mocking short-circuit and the request-smuggling guard. /// -public sealed class MockingAndSmugglingParityTests +public sealed class MockingAndSmugglingIntegrationTests { [Fact] public async Task PluginRespond_ShortCircuits_OriginNeverContacted() diff --git a/DevProxy.Parity.Tests/NetUtil.cs b/DevProxy.Integration.Tests/NetUtil.cs similarity index 96% rename from DevProxy.Parity.Tests/NetUtil.cs rename to DevProxy.Integration.Tests/NetUtil.cs index 40c8d9ef..f9eb7faa 100644 --- a/DevProxy.Parity.Tests/NetUtil.cs +++ b/DevProxy.Integration.Tests/NetUtil.cs @@ -4,7 +4,7 @@ using System.Net.Sockets; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// /// Test networking helpers. diff --git a/DevProxy.Parity.Tests/PlainHttpParityTests.cs b/DevProxy.Integration.Tests/PlainHttpIntegrationTests.cs similarity index 96% rename from DevProxy.Parity.Tests/PlainHttpParityTests.cs rename to DevProxy.Integration.Tests/PlainHttpIntegrationTests.cs index 9781032d..fa8d7ffd 100644 --- a/DevProxy.Parity.Tests/PlainHttpParityTests.cs +++ b/DevProxy.Integration.Tests/PlainHttpIntegrationTests.cs @@ -6,14 +6,14 @@ using System.Text; using Xunit; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// -/// Parity rows that exercise the Kestrel engine over plain HTTP (absolute-form +/// 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 PlainHttpParityTests +public sealed class PlainHttpIntegrationTests { [Fact] public async Task Get_NoBody_RelaysStatusAndBody() diff --git a/DevProxy.Parity.Tests/StreamingAndConnectionParityTests.cs b/DevProxy.Integration.Tests/StreamingAndConnectionIntegrationTests.cs similarity index 95% rename from DevProxy.Parity.Tests/StreamingAndConnectionParityTests.cs rename to DevProxy.Integration.Tests/StreamingAndConnectionIntegrationTests.cs index b7d2b96b..13c50814 100644 --- a/DevProxy.Parity.Tests/StreamingAndConnectionParityTests.cs +++ b/DevProxy.Integration.Tests/StreamingAndConnectionIntegrationTests.cs @@ -7,13 +7,13 @@ using System.Text; using Xunit; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// -/// Parity rows for streaming bodies, chunked request framing, and connection reuse — +/// Integration scenarios for streaming bodies, chunked request framing, and connection reuse — /// all over plain HTTP so they need no TLS trust. /// -public sealed class StreamingAndConnectionParityTests +public sealed class StreamingAndConnectionIntegrationTests { [Fact] public async Task Sse_IsRelayedChunked_WithAllEvents() diff --git a/DevProxy.Parity.Tests/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs similarity index 92% rename from DevProxy.Parity.Tests/TestProxyConfiguration.cs rename to DevProxy.Integration.Tests/TestProxyConfiguration.cs index 139586dc..c48713ca 100644 --- a/DevProxy.Parity.Tests/TestProxyConfiguration.cs +++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs @@ -6,10 +6,10 @@ using DevProxy.Abstractions.Proxy; using Microsoft.Extensions.Logging; -namespace DevProxy.Parity.Tests; +namespace DevProxy.Integration.Tests; /// -/// Minimal for the parity harness. Only the +/// 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. /// diff --git a/DevProxy.sln b/DevProxy.sln index 889a4f34..2d2c6479 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -22,7 +22,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Kestrel", "D 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.Parity.Tests", "DevProxy.Parity.Tests\DevProxy.Parity.Tests.csproj", "{90E10CFA-DEF0-456B-8641-102C7072931B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Integration.Tests", "DevProxy.Integration.Tests\DevProxy.Integration.Tests.csproj", "{90E10CFA-DEF0-456B-8641-102C7072931B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution From 66259967436cc801f15fd24b8d0ff9952936d8fd Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 10:39:20 +0200 Subject: [PATCH 30/46] Add per-plugin integration tests: harness infra + behavior/manipulation/mocking plugins Adds a CapturingLoggerFactory (collects engine-emitted RequestLogs), an inline PluginConfig section builder, and extends KestrelProxyHarness to accept a logger factory and expose its urls-to-watch set. Covers 9 hermetic, HTTP-observable plugins end-to-end through the Kestrel engine: GenericRandomError, Latency, RateLimiting, RetryAfter, Rewrite, MockResponse, Auth (reject + allow), and GraphRandomError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BehaviorPluginsIntegrationTests.cs | 168 ++++++++++++++++ .../CapturingLoggerFactory.cs | 82 ++++++++ .../DevProxy.Integration.Tests.csproj | 1 + .../KestrelProxyHarness.cs | 31 ++- .../ManipulationAndMockingIntegrationTests.cs | 182 ++++++++++++++++++ DevProxy.Integration.Tests/PluginConfig.cs | 39 ++++ 6 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs create mode 100644 DevProxy.Integration.Tests/CapturingLoggerFactory.cs create mode 100644 DevProxy.Integration.Tests/ManipulationAndMockingIntegrationTests.cs create mode 100644 DevProxy.Integration.Tests/PluginConfig.cs 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 index 197fb541..ea8c0784 100644 --- a/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj +++ b/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/DevProxy.Integration.Tests/KestrelProxyHarness.cs b/DevProxy.Integration.Tests/KestrelProxyHarness.cs index 358a71e0..73e313ab 100644 --- a/DevProxy.Integration.Tests/KestrelProxyHarness.cs +++ b/DevProxy.Integration.Tests/KestrelProxyHarness.cs @@ -10,6 +10,7 @@ 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; @@ -39,9 +40,27 @@ private KestrelProxyHarness(KestrelProxyEngine engine, int port) 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) + IEnumerable? plugins = null, + ILoggerFactory? loggerFactory = null) { var port = NetUtil.GetFreePort(); var configuration = new TestProxyConfiguration @@ -52,13 +71,7 @@ public static async Task StartAsync( }; // Watch http(s):///* so the engine MITMs/inspects the origin. - var escapedHost = Regex.Escape(watchedHost); - var urlsToWatch = new HashSet - { - new(new Regex( - $"^https?://{escapedHost}/.*$", - RegexOptions.Compiled | RegexOptions.IgnoreCase)), - }; + var urlsToWatch = BuildUrlsToWatch(watchedHost); var engine = new KestrelProxyEngine( CertificateAuthority.CreateDefault(), @@ -66,7 +79,7 @@ public static async Task StartAsync( urlsToWatch, configuration, [], - NullLoggerFactory.Instance); + loggerFactory ?? NullLoggerFactory.Instance); var harness = new KestrelProxyHarness(engine, port); await engine.StartAsync(harness._cts.Token).ConfigureAwait(false); 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/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); +} From b90d0106108c01c5de7279293cdfc71b9aa3848d Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 10:44:19 +0200 Subject: [PATCH 31/46] Add CrudApi + MockRequest integration tests with InitializeAsync infra Adds PluginTestHost (a minimal DI ServiceProvider supplying HttpClient/ILogger<>/ IProxyConfiguration so plugins whose InitializeAsync builds file loaders can run in-process), TestDefaults (shared HttpClient), a FakeOrigin request recorder + /json endpoint, and a settable ConfigFile on TestProxyConfiguration. Mirrors the host's runtime-only package assets in the test csproj so plugin runtime dependencies (OpenIdConnect, JWT, OpenApi, OpenTelemetry, EF Sqlite, Newtonsoft, Azure.Identity) resolve. Covers MockRequestPlugin (outbound fire) and CrudApiPlugin (in-memory REST served through the engine, origin never contacted). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Integration.Tests.csproj | 15 +++ DevProxy.Integration.Tests/FakeOrigin.cs | 25 +++- ...tializingMockingPluginsIntegrationTests.cs | 108 ++++++++++++++++++ DevProxy.Integration.Tests/PluginTestHost.cs | 45 ++++++++ DevProxy.Integration.Tests/TestDefaults.cs | 14 +++ .../TestProxyConfiguration.cs | 2 +- 6 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 DevProxy.Integration.Tests/InitializingMockingPluginsIntegrationTests.cs create mode 100644 DevProxy.Integration.Tests/PluginTestHost.cs create mode 100644 DevProxy.Integration.Tests/TestDefaults.cs diff --git a/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj b/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj index ea8c0784..baf7ffd7 100644 --- a/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj +++ b/DevProxy.Integration.Tests/DevProxy.Integration.Tests.csproj @@ -21,6 +21,21 @@ + + + + + + + + + + + + + diff --git a/DevProxy.Integration.Tests/FakeOrigin.cs b/DevProxy.Integration.Tests/FakeOrigin.cs index 20178db4..d9bd16de 100644 --- a/DevProxy.Integration.Tests/FakeOrigin.cs +++ b/DevProxy.Integration.Tests/FakeOrigin.cs @@ -25,20 +25,26 @@ namespace DevProxy.Integration.Tests; /// 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)}"; - private FakeOrigin(WebApplication app, int port) + /// 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() @@ -54,6 +60,13 @@ public static async Task StartAsync() 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) => @@ -94,8 +107,14 @@ public static async Task StartAsync() 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); + return new FakeOrigin(app, port, received); } public async ValueTask DisposeAsync() @@ -104,3 +123,5 @@ public async ValueTask DisposeAsync() await _app.DisposeAsync().ConfigureAwait(false); } } + +internal sealed record ReceivedRequest(string Method, string PathAndQuery); 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/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/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/TestProxyConfiguration.cs b/DevProxy.Integration.Tests/TestProxyConfiguration.cs index c48713ca..86126d18 100644 --- a/DevProxy.Integration.Tests/TestProxyConfiguration.cs +++ b/DevProxy.Integration.Tests/TestProxyConfiguration.cs @@ -17,7 +17,7 @@ internal sealed class TestProxyConfiguration : IProxyConfiguration { public int ApiPort { get; set; } public bool AsSystemProxy { get; set; } - public string ConfigFile => "devproxyrc.json"; + public string ConfigFile { get; set; } = "devproxyrc.json"; public Dictionary Env { get; set; } = []; public IEnumerable? FilterByHeaders { get; } public bool InstallCert { get; set; } From 08489771b773db10e08223ec7ca6883d0296692b Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 10:48:32 +0200 Subject: [PATCH 32/46] Add guidance plugin integration tests (7 hermetic plugins) Adds TestExchange, which constructs the engine's real canonical session/request/ response types so guidance plugins gated on a fixed upstream host (graph.microsoft.com) can be driven at their hooks with production-fidelity inputs the loopback origin can't impersonate. Covers GraphBetaSupport, GraphClientRequestId, GraphSdk, ODataPaging, ODSPSearch, GraphConnector, and CachingGuidance, asserting the advisory log each emits. GraphSelectGuidancePlugin is documented as non-hermetic (needs the Graph metadata DB) and deferred. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GuidancePluginsIntegrationTests.cs | 127 ++++++++++++++++++ DevProxy.Integration.Tests/TestExchange.cs | 91 +++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 DevProxy.Integration.Tests/GuidancePluginsIntegrationTests.cs create mode 100644 DevProxy.Integration.Tests/TestExchange.cs 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/TestExchange.cs b/DevProxy.Integration.Tests/TestExchange.cs new file mode 100644 index 00000000..54b085f9 --- /dev/null +++ b/DevProxy.Integration.Tests/TestExchange.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.Net; +using System.Text; +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; + } +} From a9e2dce0c5294e906936d71bf8660f5568ed90fd Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 10:51:45 +0200 Subject: [PATCH 33/46] Add reporting plugin integration tests (UrlDiscovery, ExecutionSummary, MinimalPermissions) Drives reporter plugins through AfterRecordingStopAsync exactly as the host's recording controller does: feeds engine-shaped RequestLogs (TestExchange.AsRequestLog) into RecordingArgs with a seeded Reports GlobalData slot, then asserts the structured report stored under the plugin name. MinimalPermissions runs against a temp OpenAPI spec fixture to verify offline permission analysis. GraphMinimalPermissions(+Guidance), MinimalCsom, and ApiCenter reporters are documented as live-backend (Bucket 3) and deferred. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ReportingPluginsIntegrationTests.cs | 153 ++++++++++++++++++ DevProxy.Integration.Tests/TestExchange.cs | 9 ++ 2 files changed, 162 insertions(+) create mode 100644 DevProxy.Integration.Tests/ReportingPluginsIntegrationTests.cs 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/TestExchange.cs b/DevProxy.Integration.Tests/TestExchange.cs index 54b085f9..34090c8b 100644 --- a/DevProxy.Integration.Tests/TestExchange.cs +++ b/DevProxy.Integration.Tests/TestExchange.cs @@ -4,6 +4,7 @@ using System.Net; using System.Text; +using DevProxy.Abstractions.Models; using DevProxy.Abstractions.Proxy; using DevProxy.Abstractions.Proxy.Http; using DevProxy.Proxy.Kestrel.Http; @@ -88,4 +89,12 @@ public TestExchange WithResponse( 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)); } From fba7fc578ce46d66e91dfb4296282ad4f71e7f1d Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 10:53:58 +0200 Subject: [PATCH 34/46] Add generation plugin integration tests (HAR, mock, .http, OpenAPI, TypeSpec) Drives each generator through AfterRecordingStopAsync with an InterceptedResponse log, asserting the stored report and the generated artifact. Generators write to the process CWD, so tests redirect it to a temp dir; assembly test parallelization is disabled to keep that global swap safe. A DisabledLanguageModelClient keeps the OpenAPI/TypeSpec generators hermetic (LM is cosmetic-only; they fall back to deterministic generation). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DisabledLanguageModelClient.cs | 30 +++ .../GenerationPluginsIntegrationTests.cs | 185 ++++++++++++++++++ .../TestParallelization.cs | 9 + 3 files changed, 224 insertions(+) create mode 100644 DevProxy.Integration.Tests/DisabledLanguageModelClient.cs create mode 100644 DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs create mode 100644 DevProxy.Integration.Tests/TestParallelization.cs 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/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/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)] From a2afac12d085adbc77195327e8e6b17b17248661 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 10:56:34 +0200 Subject: [PATCH 35/46] Add process-level smoke test for config-driven plugin loading Spawns the real DevProxy host with a devproxyrc.json + mocks file, waits for the Kestrel engine to report its bound port (--port 0), routes an HTTP request through it as an explicit proxy, and asserts MockResponsePlugin short-circuits with the configured body. Proves end-to-end that config-driven plugin discovery + the engine wire survive the Titanium -> Kestrel cut-over. Uses a plain-HTTP watched URL so no CA trust is needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProcessSmokeTests.cs | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 DevProxy.Integration.Tests/ProcessSmokeTests.cs 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" + } + } + ] + } + """; +} From e5137cb3a12c0dcc7537d5c9e402bf365ec2e86b Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 11:30:47 +0200 Subject: [PATCH 36/46] Remove unwired body-mode subsystem; fix misleading body docs The BodyModeResolver + BodyMode/BodyCapabilities/BodyContext/BodyDirection types (319 LOC, 12 tests) were a spike-era design that the Kestrel engine never wired: Resolve() is never called in production, no plugin declares BodyCapabilities, and IHttpMessage exposes no capability property. The engine uses a fixed 4 MiB in-memory inspection cap instead. Worse, the IHttpMessage doc claimed body buffering was 'driven by plugin BodyCapabilities', misleading plugin authors toward an API that does not exist. - Delete BodyModeResolver.cs, BodyHandling.cs, and BodyModeResolverTests.cs. - Inline the one used constant as ProxyConnectionHandler.InMemoryInspectionCapBytes. - Correct the IHttpMessage body-visibility doc to describe actual behavior. - Drop stale 'every engine adapter' language (abandoned multi-engine design) from ForwardingInvariants and HeaderCollection docs. The design is preserved in git and can be restored from this parent commit if/when capability-based body handling is actually needed. A code comment + plan.md follow-up track the genuine, separate unbounded-buffered-body memory risk. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Proxy/Http/BodyModeResolverTests.cs | 114 ------------------ .../Proxy/Http/BodyHandling.cs | 106 ---------------- .../Proxy/Http/BodyModeResolver.cs | 99 --------------- .../Proxy/Http/ForwardingInvariants.cs | 4 +- .../Proxy/Http/HeaderCollection.cs | 2 +- .../Proxy/Http/IHttpMessage.cs | 12 +- .../Internal/ProxyConnectionHandler.cs | 14 ++- 7 files changed, 23 insertions(+), 328 deletions(-) delete mode 100644 DevProxy.Abstractions.Tests/Proxy/Http/BodyModeResolverTests.cs delete mode 100644 DevProxy.Abstractions/Proxy/Http/BodyHandling.cs delete mode 100644 DevProxy.Abstractions/Proxy/Http/BodyModeResolver.cs diff --git a/DevProxy.Abstractions.Tests/Proxy/Http/BodyModeResolverTests.cs b/DevProxy.Abstractions.Tests/Proxy/Http/BodyModeResolverTests.cs deleted file mode 100644 index ee9a3ed7..00000000 --- a/DevProxy.Abstractions.Tests/Proxy/Http/BodyModeResolverTests.cs +++ /dev/null @@ -1,114 +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.Proxy.Http; -using Xunit; - -namespace DevProxy.Abstractions.Tests.Proxy.Http; - -public class BodyModeResolverTests -{ - private static BodyContext Bounded(long? length, BodyDirection dir = BodyDirection.Response) => - new(dir, IsUpgrade: false, IsUnboundedStream: false, ContentLength: length); - - [Fact] - public void Upgrade_AlwaysRaw_RegardlessOfCapabilities() - { - var ctx = new BodyContext(BodyDirection.Request, IsUpgrade: true, IsUnboundedStream: false, ContentLength: 10); - var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullRequestBody | BodyCapabilities.CanMutate, ctx); - Assert.Equal(BodyMode.UpgradedRaw, mode); - } - - [Fact] - public void NoBodyNeed_NoInspect_StreamsThrough() - { - var mode = BodyModeResolver.Resolve(BodyCapabilities.None, Bounded(10)); - Assert.Equal(BodyMode.StreamingPassThrough, mode); - } - - [Fact] - public void NoBodyNeed_WithInspect_Tees() - { - var mode = BodyModeResolver.Resolve(BodyCapabilities.CanStreamInspect, Bounded(10)); - Assert.Equal(BodyMode.TeeForInspection, mode); - } - - [Fact] - public void FullBody_SmallBounded_BuffersInMemory() - { - var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, Bounded(1024)); - Assert.Equal(BodyMode.BufferedInMemory, mode); - } - - [Fact] - public void Mutation_ImpliesFullBody_BuffersInMemory() - { - var mode = BodyModeResolver.Resolve(BodyCapabilities.CanMutate, Bounded(1024)); - Assert.Equal(BodyMode.BufferedInMemory, mode); - } - - [Fact] - public void FullBody_MediumBounded_SpoolsToDisk() - { - var length = BodyModeResolver.DefaultInMemoryLimitBytes + 1; - var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, Bounded(length)); - Assert.Equal(BodyMode.SpooledToDisk, mode); - } - - [Fact] - public void FullBody_UnknownLengthButBounded_SpoolsDefensively() - { - var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, Bounded(null)); - Assert.Equal(BodyMode.SpooledToDisk, mode); - } - - [Fact] - public void FullBody_TooLargeToSpool_WithInspect_Tees() - { - var length = BodyModeResolver.DefaultSpoolLimitBytes + 1; - var caps = BodyCapabilities.NeedsFullResponseBody | BodyCapabilities.CanStreamInspect; - var mode = BodyModeResolver.Resolve(caps, Bounded(length)); - Assert.Equal(BodyMode.TeeForInspection, mode); - } - - [Fact] - public void FullBody_TooLargeToSpool_NoInspect_Streams() - { - var length = BodyModeResolver.DefaultSpoolLimitBytes + 1; - var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, Bounded(length)); - Assert.Equal(BodyMode.StreamingPassThrough, mode); - } - - [Fact] - public void FullBody_UnboundedStream_NeverBuffers_DegradesToTee() - { - var ctx = new BodyContext(BodyDirection.Response, IsUpgrade: false, IsUnboundedStream: true, ContentLength: null); - var caps = BodyCapabilities.NeedsFullResponseBody - | BodyCapabilities.CannotRunOnInfiniteStreams - | BodyCapabilities.CanStreamInspect; - var mode = BodyModeResolver.Resolve(caps, ctx); - Assert.Equal(BodyMode.TeeForInspection, mode); - } - - [Fact] - public void FullBody_UnboundedStream_NoInspect_Streams() - { - var ctx = new BodyContext(BodyDirection.Response, IsUpgrade: false, IsUnboundedStream: true, ContentLength: null); - var mode = BodyModeResolver.Resolve(BodyCapabilities.NeedsFullResponseBody, ctx); - Assert.Equal(BodyMode.StreamingPassThrough, mode); - } - - [Fact] - public void Direction_SelectsCorrectFlag() - { - // A plugin that needs the REQUEST body should not force buffering of the RESPONSE. - var responseMode = BodyModeResolver.Resolve( - BodyCapabilities.NeedsFullRequestBody, Bounded(1024, BodyDirection.Response)); - Assert.Equal(BodyMode.StreamingPassThrough, responseMode); - - var requestMode = BodyModeResolver.Resolve( - BodyCapabilities.NeedsFullRequestBody, Bounded(1024, BodyDirection.Request)); - Assert.Equal(BodyMode.BufferedInMemory, requestMode); - } -} diff --git a/DevProxy.Abstractions/Proxy/Http/BodyHandling.cs b/DevProxy.Abstractions/Proxy/Http/BodyHandling.cs deleted file mode 100644 index 9ecad7ca..00000000 --- a/DevProxy.Abstractions/Proxy/Http/BodyHandling.cs +++ /dev/null @@ -1,106 +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. - -namespace DevProxy.Abstractions.Proxy.Http; - -/// -/// How the engine handles a message body for one exchange. The effective mode is -/// resolved per exchange from the union of declared -/// by the plugins whose URL filter matches, plus the body's size/streaming shape. -/// See . -/// -/// -/// no full-body need ──► CanStreamInspect? ──yes─► TeeForInspection -/// │ └──no──────────────► StreamingPassThrough -/// ▼ (full body needed / mutation) -/// unbounded stream? ──yes─► degrade (Tee or PassThrough) -/// ▼ no -/// fits in memory? ──yes─► BufferedInMemory -/// ▼ no -/// fits on disk? ──yes─► SpooledToDisk -/// ▼ no -/// degrade (Tee or PassThrough) -/// -/// -public enum BodyMode -{ - /// - /// Bytes flow client↔origin without retention. Plugins cannot read or mutate - /// the body. Required for unbounded streams (SSE, long-poll) and oversized - /// payloads. This is the safe default. - /// - StreamingPassThrough, - - /// - /// The full body is buffered in memory; plugins can read and mutate it. Only - /// chosen for bounded bodies within the in-memory limit. - /// - BufferedInMemory, - - /// - /// The full body is spooled to a temporary file; plugins can read and mutate - /// large but finite bodies without exhausting RAM. - /// - SpooledToDisk, - - /// - /// Bytes stream through unbuffered while a copy is delivered to read-only - /// inspectors (e.g. logging). No mutation; the body is not materialized on the - /// forwarding path. - /// - TeeForInspection, - - /// - /// The connection is upgraded (WebSocket) or blind-tunnelled. Opaque byte - /// relay with no HTTP body semantics. - /// - UpgradedRaw, -} - -/// -/// Which side of an exchange a body belongs to. Selects the relevant -/// flag during resolution. -/// -public enum BodyDirection -{ - /// The request body (client → origin). - Request, - - /// The response body (origin → client). - Response, -} - -/// -/// Declares what a plugin needs to do with message bodies. The engine aggregates -/// these across all matching plugins to pick a per exchange, -/// reconciling "stream SSE unbuffered" with "let plugins read full bodies". -/// -[Flags] -public enum BodyCapabilities -{ - /// The plugin does not touch bodies. Compatible with streaming. - None = 0, - - /// The plugin reads the complete request body before forwarding. - NeedsFullRequestBody = 1 << 0, - - /// The plugin reads the complete response body before forwarding. - NeedsFullResponseBody = 1 << 1, - - /// - /// The plugin can inspect body bytes incrementally as they stream (read-only), - /// without requiring the whole body at once. - /// - CanStreamInspect = 1 << 2, - - /// The plugin may modify body bytes (implies the body must be buffered). - CanMutate = 1 << 3, - - /// - /// The plugin must not be run against unbounded streams (it would otherwise - /// buffer forever). On such exchanges the plugin is skipped and the engine - /// degrades to a streaming mode. - /// - CannotRunOnInfiniteStreams = 1 << 4, -} diff --git a/DevProxy.Abstractions/Proxy/Http/BodyModeResolver.cs b/DevProxy.Abstractions/Proxy/Http/BodyModeResolver.cs deleted file mode 100644 index e5c4b7bd..00000000 --- a/DevProxy.Abstractions/Proxy/Http/BodyModeResolver.cs +++ /dev/null @@ -1,99 +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. - -namespace DevProxy.Abstractions.Proxy.Http; - -/// -/// Shape of a body as seen at resolution time, plus the engine's buffering limits. -/// -/// Which body (request/response) is being resolved. -/// True for WebSocket upgrade / blind tunnel exchanges. -/// -/// True when the body has no finite end the engine can rely on: text/event-stream, -/// chunked transfer with no declared length, or an explicitly streamed response. -/// -/// Declared body length in bytes, when known. -/// Largest body buffered in memory. -/// Largest body spooled to disk (must be ≥ in-memory limit). -public readonly record struct BodyContext( - BodyDirection Direction, - bool IsUpgrade, - bool IsUnboundedStream, - long? ContentLength, - long InMemoryLimitBytes = BodyModeResolver.DefaultInMemoryLimitBytes, - long SpoolLimitBytes = BodyModeResolver.DefaultSpoolLimitBytes); - -/// -/// Pure decision logic that maps the union of plugin -/// and a to a single for an -/// exchange. Deterministic and side-effect free so it can be unit-tested in -/// isolation and shared by every engine adapter. -/// -public static class BodyModeResolver -{ - /// Default maximum body size buffered in memory (4 MiB). - public const long DefaultInMemoryLimitBytes = 4L * 1024 * 1024; - - /// Default maximum body size spooled to disk (256 MiB). - public const long DefaultSpoolLimitBytes = 256L * 1024 * 1024; - - /// - /// Resolves the effective for an exchange. - /// - /// - /// Bitwise-OR of the of every plugin whose URL - /// filter matches this exchange. - /// - /// The body's shape and the engine's limits. - public static BodyMode Resolve(BodyCapabilities aggregatedCapabilities, BodyContext context) - { - if (context.IsUpgrade) - { - return BodyMode.UpgradedRaw; - } - - var needsFullBody = context.Direction == BodyDirection.Request - ? aggregatedCapabilities.HasFlag(BodyCapabilities.NeedsFullRequestBody) - : aggregatedCapabilities.HasFlag(BodyCapabilities.NeedsFullResponseBody); - - // Mutation implies the body must be buffered before write-back. - needsFullBody |= aggregatedCapabilities.HasFlag(BodyCapabilities.CanMutate); - - var canStreamInspect = aggregatedCapabilities.HasFlag(BodyCapabilities.CanStreamInspect); - - if (!needsFullBody) - { - return canStreamInspect ? BodyMode.TeeForInspection : BodyMode.StreamingPassThrough; - } - - // Full body wanted, but the stream may never end: never buffer unbounded. - if (context.IsUnboundedStream) - { - return Degrade(canStreamInspect); - } - - if (context.ContentLength is long length) - { - if (length <= context.InMemoryLimitBytes) - { - return BodyMode.BufferedInMemory; - } - - if (length <= context.SpoolLimitBytes) - { - return BodyMode.SpooledToDisk; - } - - // Bounded but larger than we are willing to spool. - return Degrade(canStreamInspect); - } - - // Bounded (not an unbounded stream) yet length unknown: spool defensively - // rather than risk an unbounded in-memory buffer. - return BodyMode.SpooledToDisk; - } - - private static BodyMode Degrade(bool canStreamInspect) => - canStreamInspect ? BodyMode.TeeForInspection : BodyMode.StreamingPassThrough; -} diff --git a/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs b/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs index 434c8803..de11004c 100644 --- a/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs +++ b/DevProxy.Abstractions/Proxy/Http/ForwardingInvariants.cs @@ -7,8 +7,8 @@ namespace DevProxy.Abstractions.Proxy.Http; /// -/// The forwarding contract every engine adapter must honor so that plugins see a -/// consistent model regardless of the underlying proxy engine. These are the +/// 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. diff --git a/DevProxy.Abstractions/Proxy/Http/HeaderCollection.cs b/DevProxy.Abstractions/Proxy/Http/HeaderCollection.cs index e2d95dfe..a0d7660d 100644 --- a/DevProxy.Abstractions/Proxy/Http/HeaderCollection.cs +++ b/DevProxy.Abstractions/Proxy/Http/HeaderCollection.cs @@ -8,7 +8,7 @@ namespace DevProxy.Abstractions.Proxy.Http; /// /// Default in-memory . Preserves insertion (wire) -/// order and matches names case-insensitively. Used by adapters, mocked +/// order and matches names case-insensitively. Used by the proxy engine, mocked /// responses, and tests. /// public sealed class HeaderCollection : IHeaderCollection diff --git a/DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs b/DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs index 639045e5..ba9f822e 100644 --- a/DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs +++ b/DevProxy.Abstractions/Proxy/Http/IHttpMessage.cs @@ -18,11 +18,13 @@ namespace DevProxy.Abstractions.Proxy.Http; /// /// /// -/// The body is only materialized when the active for -/// the exchange buffers it (driven by plugin ). -/// In a streaming pass-through exchange the body is not retained; accessing -/// then yields an empty buffer and may -/// be true while the bytes are not available to inspect. +/// 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 diff --git a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index 44c2e35e..bbe2096f 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -56,6 +56,18 @@ internal sealed class ProxyConnectionHandler( 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); @@ -450,7 +462,7 @@ private async Task WriteStreamingResponseAsync( bool keepAlive, CancellationToken ct) { - const int accumulateCap = (int)BodyModeResolver.DefaultInMemoryLimitBytes; + const int accumulateCap = InMemoryInspectionCapBytes; if (phase == RequestPhase.Watched) { From 844a877e22502cdd76da4332060b86cce031a320 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 11:34:59 +0200 Subject: [PATCH 37/46] Rename IProxyLogger.cs to LoggingContext.cs to match contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file's 'I' prefix implied an interface, but it contains no interface — only the MessageType enum and the LoggingContext / StdioLoggingContext classes. Rename to match its primary type and theme. (StdioEvents.cs was left as-is: it correctly groups the stdio event-arg family, mirroring the existing ProxyEvents.cs convention.) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Proxy/{IProxyLogger.cs => LoggingContext.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename DevProxy.Abstractions/Proxy/{IProxyLogger.cs => LoggingContext.cs} (100%) diff --git a/DevProxy.Abstractions/Proxy/IProxyLogger.cs b/DevProxy.Abstractions/Proxy/LoggingContext.cs similarity index 100% rename from DevProxy.Abstractions/Proxy/IProxyLogger.cs rename to DevProxy.Abstractions/Proxy/LoggingContext.cs From 3c3538aa9dc49960f6e7564788573b50b9b362a9 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 12:16:45 +0200 Subject: [PATCH 38/46] Add WebSocketMockResponsePlugin (reactive WS frame mocking) Net-new capability unlocked by the Kestrel engine: mock WebSocket frames end-to-end. The engine owns transport (101 handshake + framing via the framework's WebSocket.CreateFromStream); the plugin owns behavior (on-connect scripted sends + match incoming client messages by equals/contains/regex/json and reply with mapped messages, plus optional close). A watched WS upgrade is dispatched to the plugin responder (instead of the origin relay) when the plugin calls IProxySession.HandleWebSocket(handler), leaving the session in the Watched phase. The plugin must NOT set ResponseState.HasBeenSet, which would short-circuit to the mocked-HTTP ResponseWriter path before the WS dispatch branch runs. DRY scheme-normalization footgun fix: the engine reports an intercepted WS upgrade with an http(s) scheme, but authors naturally write wss://host/* in urlsToWatch and mock URLs. A shared ProxyUtils.NormalizeWebSocketScheme normalizes wss->https / ws->http and is applied at both the watch-regex build site (PluginServiceExtensions.ConvertToRegex) and the plugin's mock-URL match, so either form works and a wss:// watch entry is no longer silently relayed to origin. Tests: responder loopback unit tests, matcher unit tests, 2 e2e ClientWebSocket-over-wss-MITM integration tests, and NormalizeWebSocketScheme unit tests. Full suite green (284). Live-verified through the host binary with urlsToWatch ["wss://ws.example.test/*"]: OPEN -> welcome (onConnect) -> pong (reactive ping->pong); proxy logged "Mocking WebSocket" + "WebSocket mock established". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Utils/ProxyUtilsTests.cs | 38 +++ .../Proxy/Http/IProxySession.cs | 10 + .../Proxy/Http/IWebSocketConnection.cs | 59 ++++ DevProxy.Abstractions/Utils/ProxyUtils.cs | 26 ++ .../WebSocketMessageMatcherTests.cs | 66 +++++ .../WebSocketMockIntegrationTests.cs | 142 ++++++++++ DevProxy.Plugins/DevProxy.Plugins.csproj | 3 + .../Mocking/WebSocketMockModels.cs | 174 ++++++++++++ .../Mocking/WebSocketMockResponsePlugin.cs | 265 ++++++++++++++++++ .../Mocking/WebSocketMockResponsesLoader.cs | 48 ++++ .../WebSocketMockResponderTests.cs | 227 +++++++++++++++ .../Http/CanonicalProxySession.cs | 18 ++ .../Internal/ProxyConnectionHandler.cs | 14 +- .../Internal/WebSocketMockResponder.cs | 219 +++++++++++++++ DevProxy/Plugins/PluginServiceExtensions.cs | 2 +- ...etmockresponseplugin.mocksfile.schema.json | 109 +++++++ .../websocketmockresponseplugin.schema.json | 19 ++ 17 files changed, 1437 insertions(+), 2 deletions(-) create mode 100644 DevProxy.Abstractions.Tests/Utils/ProxyUtilsTests.cs create mode 100644 DevProxy.Abstractions/Proxy/Http/IWebSocketConnection.cs create mode 100644 DevProxy.Integration.Tests/WebSocketMessageMatcherTests.cs create mode 100644 DevProxy.Integration.Tests/WebSocketMockIntegrationTests.cs create mode 100644 DevProxy.Plugins/Mocking/WebSocketMockModels.cs create mode 100644 DevProxy.Plugins/Mocking/WebSocketMockResponsePlugin.cs create mode 100644 DevProxy.Plugins/Mocking/WebSocketMockResponsesLoader.cs create mode 100644 DevProxy.Proxy.Kestrel.Tests/WebSocketMockResponderTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/WebSocketMockResponder.cs create mode 100644 schemas/v3.1.0/websocketmockresponseplugin.mocksfile.schema.json create mode 100644 schemas/v3.1.0/websocketmockresponseplugin.schema.json 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/Proxy/Http/IProxySession.cs b/DevProxy.Abstractions/Proxy/Http/IProxySession.cs index 020dab6c..550da2fd 100644 --- a/DevProxy.Abstractions/Proxy/Http/IProxySession.cs +++ b/DevProxy.Abstractions/Proxy/Http/IProxySession.cs @@ -61,4 +61,14 @@ public interface IProxySession /// 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/Utils/ProxyUtils.cs b/DevProxy.Abstractions/Utils/ProxyUtils.cs index e398bd27..ccd33c8c 100644 --- a/DevProxy.Abstractions/Utils/ProxyUtils.cs +++ b/DevProxy.Abstractions/Utils/ProxyUtils.cs @@ -540,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.Integration.Tests/WebSocketMessageMatcherTests.cs b/DevProxy.Integration.Tests/WebSocketMessageMatcherTests.cs new file mode 100644 index 00000000..09dca154 --- /dev/null +++ b/DevProxy.Integration.Tests/WebSocketMessageMatcherTests.cs @@ -0,0 +1,66 @@ +// 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.Plugins.Mocking; +using Xunit; + +namespace DevProxy.Integration.Tests; + +/// +/// Pure unit coverage for — the four match +/// operators plus the catch-all / malformed-input behavior — without a live socket. +/// +public sealed class WebSocketMessageMatcherTests +{ + [Theory] + [InlineData("hello", "hello", true)] // exact, case-sensitive + [InlineData("hello", "Hello", false)] // case matters for Equals + [InlineData("hello", "hell", false)] // not a substring match + public void Equals_IsOrdinalAndCaseSensitive(string body, string message, bool expected) => + Assert.Equal(expected, Match(body, WebSocketMatchType.Equals, message)); + + [Theory] + [InlineData("ell", "hello", true)] // substring + [InlineData("ELL", "hello", true)] // case-insensitive + [InlineData("xyz", "hello", false)] + public void Contains_IsCaseInsensitiveSubstring(string body, string message, bool expected) => + Assert.Equal(expected, Match(body, WebSocketMatchType.Contains, message)); + + [Theory] + [InlineData("^h.*o$", "hello", true)] + [InlineData("^\\d+$", "12345", true)] + [InlineData("^\\d+$", "12a45", false)] + public void Regex_MatchesPattern(string body, string message, bool expected) => + Assert.Equal(expected, Match(body, WebSocketMatchType.Regex, message)); + + [Fact] + public void Regex_MalformedPattern_DoesNotThrow_AndDoesNotMatch() => + Assert.False(Match("(unclosed", WebSocketMatchType.Regex, "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 Json_ComparesStructurally(string body, string message, bool expected) => + Assert.Equal(expected, Match(body, WebSocketMatchType.Json, message)); + + [Fact] + public void Json_InvalidJson_DoesNotThrow_AndDoesNotMatch() => + Assert.False(Match("""{ "a": 1 }""", "not json", WebSocketMatchType.Json)); + + [Fact] + public void NullMatch_IsCatchAll() => + Assert.True(WebSocketMessageMatcher.Matches(null, "literally anything")); + + [Fact] + public void NullBody_IsCatchAll() => + Assert.True(WebSocketMessageMatcher.Matches( + new WebSocketMessageMatch { Body = null, MatchType = WebSocketMatchType.Equals }, "anything")); + + private static bool Match(string body, WebSocketMatchType type, string message) => + WebSocketMessageMatcher.Matches(new WebSocketMessageMatch { Body = body, MatchType = type }, message); + + private static bool Match(string body, string message, WebSocketMatchType type) => + Match(body, type, message); +} diff --git a/DevProxy.Integration.Tests/WebSocketMockIntegrationTests.cs b/DevProxy.Integration.Tests/WebSocketMockIntegrationTests.cs new file mode 100644 index 00000000..efa80958 --- /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": { "body": "ping" }, "responses": [ { "body": "pong" } ] }, + { + "match": { "body": "bye", "matchType": "contains" }, + "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/DevProxy.Plugins.csproj b/DevProxy.Plugins/DevProxy.Plugins.csproj index 1c7d0709..cc01aaf0 100644 --- a/DevProxy.Plugins/DevProxy.Plugins.csproj +++ b/DevProxy.Plugins/DevProxy.Plugins.csproj @@ -72,6 +72,9 @@ runtime + + + + $(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 2d2c6479..9df1c538 100644 --- a/DevProxy.sln +++ b/DevProxy.sln @@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevProxy.Proxy.Kestrel.Test 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 @@ -118,6 +120,18 @@ Global {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/DevProxy.csproj b/DevProxy/DevProxy.csproj index ecfbe2df..3e7ca103 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -49,6 +49,10 @@ + + + + PreserveNewest diff --git a/DevProxy/Extensions/IServiceCollectionExtensions.cs b/DevProxy/Extensions/IServiceCollectionExtensions.cs index 86234f4a..cdf53e50 100644 --- a/DevProxy/Extensions/IServiceCollectionExtensions.cs +++ b/DevProxy/Extensions/IServiceCollectionExtensions.cs @@ -74,7 +74,9 @@ static IServiceCollection AddApplicationServices( .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddHostedService() + .AddHostedService() .AddSingleton(sp => LanguageModelClientFactory.Create(sp, configuration)) .AddSingleton() .AddSingleton() 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..4a7cf7b8 --- /dev/null +++ b/DevProxy/Proxy/InteractiveConsoleService.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 DevProxy.Abstractions.Proxy; +using DevProxy.Commands; + +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, + 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; + } + + 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/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); +} From efd678d14a6f0fff44aa7e28b4babf714c0f602f Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 14:46:41 +0200 Subject: [PATCH 41/46] Add Windows verification checklist for the Kestrel engine The Windows-specific runtime paths (WinINET system proxy, current-user root-store cert trust, netstat-based process filter) are unit-tested but were never live-verified on a real Windows host after the Kestrel cut-over. Add a self-contained, clone-and-run checklist covering build/tests, cert trust, system proxy on/off, detached daemon lifecycle, process filter, the restored interactive console, and a core proxy + plugin smoke pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- WINDOWS-VERIFICATION.md | 180 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 WINDOWS-VERIFICATION.md 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) +``` From 52200d583b41ae2f98fe083285d85910f9bda911 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 17:11:51 +0200 Subject: [PATCH 42/46] Preserve Content-Length on HEAD responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A HEAD response carries the Content-Length a GET would return but no body (RFC 9110 §9.3.2). The engine was recomputing Content-Length from the (empty) HEAD body, so clients saw Content-Length: 0 instead of the real resource size. ResponseWriter and UpstreamForwarder are now method-aware: HEAD preserves the origin's Content-Length, never takes the streaming path, and never writes a body. --- .../ResponseWriterTests.cs | 71 ++++++++++++++++++- .../UpstreamForwarderTests.cs | 35 ++++++++- .../Internal/ProxyConnectionHandler.cs | 6 +- .../Internal/ResponseWriter.cs | 28 ++++++-- .../Internal/UpstreamForwarder.cs | 13 +++- 5 files changed, 139 insertions(+), 14 deletions(-) diff --git a/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs index 3c27a2e3..b7064a07 100644 --- a/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/ResponseWriterTests.cs @@ -14,10 +14,10 @@ namespace DevProxy.Proxy.Kestrel.Tests; public class ResponseWriterTests { - private static async Task WriteAsync(MutableHttpResponse response, bool keepAlive = false) + private static async Task WriteAsync(MutableHttpResponse response, bool keepAlive = false, string method = "GET") { using var stream = new MemoryStream(); - await ResponseWriter.WriteAsync(stream, response, keepAlive, CancellationToken.None); + await ResponseWriter.WriteAsync(stream, response, keepAlive, method, CancellationToken.None); return Encoding.ASCII.GetString(stream.ToArray()); } @@ -130,4 +130,71 @@ public async Task WriteAsync_PrefersExplicitStatusDescription() 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/UpstreamForwarderTests.cs b/DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs index f4ae16a0..c88d32ed 100644 --- a/DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/UpstreamForwarderTests.cs @@ -15,8 +15,8 @@ namespace DevProxy.Proxy.Kestrel.Tests; public class UpstreamForwarderTests { - private static MutableHttpRequest Request() => - new("GET", new Uri("https://origin.test/sse"), HttpVersion.Version11, new HeaderCollection(), ReadOnlyMemory.Empty); + 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))); @@ -67,6 +67,37 @@ public async Task ForwardAsync_StripsFramingHeaders() 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) => diff --git a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index 702b6b2c..8918f329 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -351,7 +351,7 @@ await WriteErrorAsync(clientStream, HttpStatusCode.BadRequest, if (phase == RequestPhase.Mocked) { await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); - await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, keepAlive, ct).ConfigureAwait(false); + await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, keepAlive, head.Method, ct).ConfigureAwait(false); return keepAlive; } @@ -446,12 +446,12 @@ await _webSocketMockResponder.RespondAsync( { session.SetOriginResponse(origin.Response); await pipeline.RunResponseAsync(session, ct).ConfigureAwait(false); - await ResponseWriter.WriteAsync(clientStream, session.MutableResponse!, keepAlive, 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, ct).ConfigureAwait(false); + await ResponseWriter.WriteAsync(clientStream, origin.Response, keepAlive, head.Method, ct).ConfigureAwait(false); } return keepAlive; diff --git a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs index a6090aae..d35b1d70 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ResponseWriter.cs @@ -36,8 +36,15 @@ public static async Task WriteContinueAsync(Stream clientStream, CancellationTok await clientStream.FlushAsync(ct).ConfigureAwait(false); } - public static async Task WriteAsync(Stream clientStream, IHttpResponse response, bool keepAlive, CancellationToken ct) + 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) @@ -46,23 +53,36 @@ public static async Task WriteAsync(Stream clientStream, IHttpResponse response, _ = 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-Length", StringComparison.OrdinalIgnoreCase) || 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; - _ = head.Append(CultureInfo.InvariantCulture, $"Content-Length: {body.Length}\r\n"); + 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 (!body.IsEmpty) + if (!isHead && !body.IsEmpty) { await clientStream.WriteAsync(body, ct).ConfigureAwait(false); } diff --git a/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs index 926f1294..526ac170 100644 --- a/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs +++ b/DevProxy.Proxy.Kestrel/Internal/UpstreamForwarder.cs @@ -64,14 +64,21 @@ public async Task ForwardAsync(IHttpRequest request, Cancellatio CopyHeaders(originResponse.Headers, headers); CopyHeaders(originResponse.Content.Headers, headers); - var isStreaming = IsEventStream(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. + // is re-framed as chunked. HEAD keeps the origin's Content-Length. _ = headers.Remove("Content-Encoding"); - _ = headers.Remove("Content-Length"); _ = headers.Remove("Transfer-Encoding"); + if (!isHead) + { + _ = headers.Remove("Content-Length"); + } if (isStreaming) { From 7ac1c913e6e5da6d7c3e95f6f30677dd649e1fd7 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Sun, 28 Jun 2026 17:37:58 +0200 Subject: [PATCH 43/46] Fix API URL banner showing port 0 with --api-port 0 Resolve the OS-assigned API port from the bound server address before printing the JSON-mode banner so the curl commands reference the actual port instead of the literal 0. --- DevProxy/Proxy/InteractiveConsoleService.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/DevProxy/Proxy/InteractiveConsoleService.cs b/DevProxy/Proxy/InteractiveConsoleService.cs index 4a7cf7b8..a371f50a 100644 --- a/DevProxy/Proxy/InteractiveConsoleService.cs +++ b/DevProxy/Proxy/InteractiveConsoleService.cs @@ -4,6 +4,8 @@ using DevProxy.Abstractions.Proxy; using DevProxy.Commands; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; namespace DevProxy.Proxy; @@ -37,6 +39,7 @@ internal sealed class InteractiveConsoleService( IProxyStateController controller, IProxyConfiguration configuration, ISystemConsole console, + IServer server, IHostApplicationLifetime lifetime, ILogger logger) : BackgroundService { @@ -64,6 +67,15 @@ protected override async Task ExecuteAsync(CancellationToken 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(); From 9f0667705c453a024a42e37d2d23901040758a88 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 18:48:03 +0200 Subject: [PATCH 44/46] Return 504 on upstream timeout instead of silently dropping the connection The forward catch excluded all OperationCanceledException so client cancellation would bubble up as a silent teardown. But an HttpClient request timeout also throws TaskCanceledException (an OperationCanceledException), so a stalled origin was misclassified as a client disconnect: the client got a dropped/reset connection after the default 100s timeout instead of a gateway error. Add a pure UpstreamFailure.Classify helper (mirroring ConnectionTeardown) that tells the two apart by the connection token: OperationCanceledException with the token NOT cancelled is an upstream timeout -> 504 Gateway Timeout; with the token cancelled it is a genuine client teardown -> silent; anything else (TLS handshake failure on an invalid upstream cert, DNS, connection refused) -> 502 Bad Gateway. Wire it into the single forward catch in ProxyConnectionHandler and add the 504 reason phrase. Closes the "upstream timeout" and "invalid upstream cert" parity rows in code with 7 unit tests on the classifier. Full suite green (306 tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../UpstreamFailureTests.cs | 88 +++++++++++++++++++ .../Internal/ProxyConnectionHandler.cs | 15 +++- .../Internal/UpstreamFailure.cs | 48 ++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/UpstreamFailureTests.cs create mode 100644 DevProxy.Proxy.Kestrel/Internal/UpstreamFailure.cs 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/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index 8918f329..c4a99f88 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -423,11 +423,21 @@ await _webSocketMockResponder.RespondAsync( { origin = await forwarder.ForwardAsync(request, ct).ConfigureAwait(false); } - catch (Exception ex) when (ex is not OperationCanceledException) + 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, HttpStatusCode.BadGateway, "Upstream request failed", ct).ConfigureAwait(false); + await WriteErrorAsync(clientStream, outcome.Value.Status, outcome.Value.Message, ct).ConfigureAwait(false); return false; } @@ -576,6 +586,7 @@ private static async Task WriteErrorAsync(Stream clientStream, HttpStatusCode st { HttpStatusCode.BadRequest => "Bad Request", HttpStatusCode.BadGateway => "Bad Gateway", + HttpStatusCode.GatewayTimeout => "Gateway Timeout", _ => status.ToString(), }; 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"); + } +} From 58b4aed06770e763cb10856f9243218695934f86 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 18:53:02 +0200 Subject: [PATCH 45/46] Clear CA2201 warning in ConnectionTeardownTests The test deliberately constructs a NullReferenceException to prove the teardown classifier does not mask a latent bug as an expected connection close. CA2201 flags constructing reserved exception types, so scope a justified suppression to that single line instead of weakening the test. Solution now builds with 0 warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs b/DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs index df28e24b..bc03e7f5 100644 --- a/DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/ConnectionTeardownTests.cs @@ -41,9 +41,14 @@ public void IsExpected_ConnectionAbortedException_True() => public void IsExpected_InvalidOperationException_False() => Assert.False(ConnectionTeardown.IsExpected(new InvalidOperationException())); + // A NullReferenceException signals a latent bug, never a connection teardown — it must + // not be swallowed as "expected". CA2201 only objects to constructing the reserved type, + // which is precisely what this test needs to verify the classifier's behavior. +#pragma warning disable CA2201 // Do not raise reserved exception types [Fact] public void IsExpected_NullReferenceException_False() => Assert.False(ConnectionTeardown.IsExpected(new NullReferenceException())); +#pragma warning restore CA2201 [Fact] public void IsExpected_UnrelatedException_False() => From ad9d1df5e3bb7982373e7f81f6f36cef9bdfa1cc Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 28 Jun 2026 19:05:09 +0200 Subject: [PATCH 46/46] Bump version to 4.0.0 (breaking: Kestrel engine + canonical plugin API) This is a major version bump because the migration from Titanium/Unobtanium to the Kestrel-based proxy engine introduces breaking changes: - Plugin API surface moved from Titanium types (SessionEventArgs/Request/ Response/HttpHeader) to the canonical model (IProxySession/IHttpRequest/ IHttpResponse/HeaderCollection). Third-party plugins must recompile. - Behavioral changes around HTTP/2 handling (h2-only/gRPC blind-tunnel). Copies schemas/v3.1.0 -> schemas/v4.0.0 verbatim (41 files) and updates in all four shipping projects (incl. DevProxy.Proxy.Kestrel), schema URLs in config JSON + MockResponsePlugin, installer/script/Dockerfile version strings, and refreshes packages.lock.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevProxy.Abstractions.csproj | 2 +- DevProxy.Plugins/DevProxy.Plugins.csproj | 2 +- .../Mocking/MockResponsePlugin.cs | 2 +- .../DevProxy.Proxy.Kestrel.csproj | 2 +- DevProxy/DevProxy.csproj | 2 +- DevProxy/config/m365-mocks.json | 2 +- DevProxy/config/m365.json | 18 +- .../config/microsoft-graph-rate-limiting.json | 2 +- DevProxy/config/microsoft-graph.json | 6 +- DevProxy/config/spo-csom-types.json | 2 +- DevProxy/devproxy-errors.json | 2 +- DevProxy/devproxyrc.json | 4 +- DevProxy/packages.lock.json | 2 +- Dockerfile | 2 +- Dockerfile_beta | 2 +- install-beta.iss | 4 +- install.iss | 4 +- ...centerminimalpermissionsplugin.schema.json | 38 ++++ .../apicenteronboardingplugin.schema.json | 37 ++++ ...icenterproductionversionplugin.schema.json | 33 +++ schemas/v4.0.0/authplugin.schema.json | 133 ++++++++++++ .../v4.0.0/cachingguidanceplugin.schema.json | 16 ++ .../v4.0.0/crudapiplugin.apifile.schema.json | 177 ++++++++++++++++ schemas/v4.0.0/crudapiplugin.schema.json | 19 ++ schemas/v4.0.0/devtoolsplugin.schema.json | 21 ++ .../v4.0.0/executionsummaryplugin.schema.json | 20 ++ ...icrandomerrorplugin.errorsfile.schema.json | 103 +++++++++ .../genericrandomerrorplugin.schema.json | 30 +++ ...nimalpermissionsguidanceplugin.schema.json | 20 ++ .../graphminimalpermissionsplugin.schema.json | 20 ++ .../v4.0.0/graphrandomerrorplugin.schema.json | 32 +++ schemas/v4.0.0/hargeneratorplugin.schema.json | 20 ++ .../httpfilegeneratorplugin.schema.json | 16 ++ .../languagemodelfailureplugin.schema.json | 19 ++ ...itingplugin.customresponsefile.schema.json | 46 +++++ ...anguagemodelratelimitingplugin.schema.json | 43 ++++ schemas/v4.0.0/latencyplugin.schema.json | 22 ++ .../minimalcsompermissions.types.schema.json | 58 ++++++ .../minimalcsompermissionsplugin.schema.json | 16 ++ ...nimalpermissionsguidanceplugin.schema.json | 31 +++ .../minimalpermissionsplugin.schema.json | 23 +++ .../mockrequestplugin.mockfile.schema.json | 70 +++++++ schemas/v4.0.0/mockrequestplugin.schema.json | 19 ++ .../mockresponseplugin.mocksfile.schema.json | 104 ++++++++++ schemas/v4.0.0/mockresponseplugin.schema.json | 23 +++ ...kstdioresponseplugin.mocksfile.schema.json | 65 ++++++ .../mockstdioresponseplugin.schema.json | 23 +++ ...enaitelemetryplugin.pricesfile.schema.json | 37 ++++ .../v4.0.0/openaitelemetryplugin.schema.json | 52 +++++ .../openapispecgeneratorplugin.schema.json | 44 ++++ ...itingplugin.customresponsefile.schema.json | 46 +++++ schemas/v4.0.0/ratelimitingplugin.schema.json | 69 +++++++ schemas/v4.0.0/rc.schema.json | 195 ++++++++++++++++++ .../rewriteplugin.rewritesfile.schema.json | 50 +++++ schemas/v4.0.0/rewriteplugin.schema.json | 19 ++ .../typespecgeneratorplugin.schema.json | 16 ++ ...etmockresponseplugin.mocksfile.schema.json | 113 ++++++++++ .../websocketmockresponseplugin.schema.json | 19 ++ scripts/Dockerfile_local | 2 +- scripts/local-setup.ps1 | 2 +- scripts/version.ps1 | 2 +- 61 files changed, 1990 insertions(+), 33 deletions(-) create mode 100644 schemas/v4.0.0/apicenterminimalpermissionsplugin.schema.json create mode 100644 schemas/v4.0.0/apicenteronboardingplugin.schema.json create mode 100644 schemas/v4.0.0/apicenterproductionversionplugin.schema.json create mode 100644 schemas/v4.0.0/authplugin.schema.json create mode 100644 schemas/v4.0.0/cachingguidanceplugin.schema.json create mode 100644 schemas/v4.0.0/crudapiplugin.apifile.schema.json create mode 100644 schemas/v4.0.0/crudapiplugin.schema.json create mode 100644 schemas/v4.0.0/devtoolsplugin.schema.json create mode 100644 schemas/v4.0.0/executionsummaryplugin.schema.json create mode 100644 schemas/v4.0.0/genericrandomerrorplugin.errorsfile.schema.json create mode 100644 schemas/v4.0.0/genericrandomerrorplugin.schema.json create mode 100644 schemas/v4.0.0/graphminimalpermissionsguidanceplugin.schema.json create mode 100644 schemas/v4.0.0/graphminimalpermissionsplugin.schema.json create mode 100644 schemas/v4.0.0/graphrandomerrorplugin.schema.json create mode 100644 schemas/v4.0.0/hargeneratorplugin.schema.json create mode 100644 schemas/v4.0.0/httpfilegeneratorplugin.schema.json create mode 100644 schemas/v4.0.0/languagemodelfailureplugin.schema.json create mode 100644 schemas/v4.0.0/languagemodelratelimitingplugin.customresponsefile.schema.json create mode 100644 schemas/v4.0.0/languagemodelratelimitingplugin.schema.json create mode 100644 schemas/v4.0.0/latencyplugin.schema.json create mode 100644 schemas/v4.0.0/minimalcsompermissions.types.schema.json create mode 100644 schemas/v4.0.0/minimalcsompermissionsplugin.schema.json create mode 100644 schemas/v4.0.0/minimalpermissionsguidanceplugin.schema.json create mode 100644 schemas/v4.0.0/minimalpermissionsplugin.schema.json create mode 100644 schemas/v4.0.0/mockrequestplugin.mockfile.schema.json create mode 100644 schemas/v4.0.0/mockrequestplugin.schema.json create mode 100644 schemas/v4.0.0/mockresponseplugin.mocksfile.schema.json create mode 100644 schemas/v4.0.0/mockresponseplugin.schema.json create mode 100644 schemas/v4.0.0/mockstdioresponseplugin.mocksfile.schema.json create mode 100644 schemas/v4.0.0/mockstdioresponseplugin.schema.json create mode 100644 schemas/v4.0.0/openaitelemetryplugin.pricesfile.schema.json create mode 100644 schemas/v4.0.0/openaitelemetryplugin.schema.json create mode 100644 schemas/v4.0.0/openapispecgeneratorplugin.schema.json create mode 100644 schemas/v4.0.0/ratelimitingplugin.customresponsefile.schema.json create mode 100644 schemas/v4.0.0/ratelimitingplugin.schema.json create mode 100644 schemas/v4.0.0/rc.schema.json create mode 100644 schemas/v4.0.0/rewriteplugin.rewritesfile.schema.json create mode 100644 schemas/v4.0.0/rewriteplugin.schema.json create mode 100644 schemas/v4.0.0/typespecgeneratorplugin.schema.json create mode 100644 schemas/v4.0.0/websocketmockresponseplugin.mocksfile.schema.json create mode 100644 schemas/v4.0.0/websocketmockresponseplugin.schema.json diff --git a/DevProxy.Abstractions/DevProxy.Abstractions.csproj b/DevProxy.Abstractions/DevProxy.Abstractions.csproj index 5d534a4e..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 diff --git a/DevProxy.Plugins/DevProxy.Plugins.csproj b/DevProxy.Plugins/DevProxy.Plugins.csproj index cc01aaf0..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 diff --git a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs index 1a1f7d2f..ce115b10 100644 --- a/DevProxy.Plugins/Mocking/MockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/MockResponsePlugin.cs @@ -35,7 +35,7 @@ public sealed class MockResponseConfiguration [JsonIgnore] public bool NoMocks { get; set; } [JsonPropertyName("$schema")] - public string Schema { get; set; } = "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/mockresponseplugin.mocksfile.schema.json"; + public string Schema { get; set; } = "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v4.0.0/mockresponseplugin.mocksfile.schema.json"; } public class MockResponsePlugin( diff --git a/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj index 54dad4af..bb6142cf 100644 --- a/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj +++ b/DevProxy.Proxy.Kestrel/DevProxy.Proxy.Kestrel.csproj @@ -5,7 +5,7 @@ DevProxy.Proxy.Kestrel enable enable - 3.1.0 + 4.0.0 false true true diff --git a/DevProxy/DevProxy.csproj b/DevProxy/DevProxy.csproj index 3e7ca103..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 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 8bd50a93..ffadc8de 100644 --- a/DevProxy/packages.lock.json +++ b/DevProxy/packages.lock.json @@ -346,7 +346,7 @@ "devproxy.proxy.kestrel": { "type": "Project", "dependencies": { - "DevProxy.Abstractions": "[3.1.0, )" + "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/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/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"