Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/dotnetcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ jobs:
/d:sonar.token="$env:SONAR_TOKEN"
/d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml"
/d:sonar.exclusions="**/docs/**,**/examples/**,**/benchmarks/**,**/tools/**,**/*.axaml,**/website/**,**/.github/**"
/d:sonar.coverage.exclusions="**/examples/**,**/benchmarks/**,**/docs/**,**/Http3/Http3OriginBridge.cs,**/Http3/Http3OriginClientSession.cs,**/Titanium.Plus/Dashboard/**,**/Titanium.Inspector/Views/**,**/Titanium.Inspector/Services/AppContainerLoopback.cs,**/Titanium.Inspector/App.axaml.cs,**/Titanium.Inspector/Program.cs,**/Titanium.Inspector/InspectorAppFactory.cs,**/Titanium.Inspector/Services/UpdateService.cs,**/Titanium.Cli/Program.cs,**/Titanium.Cli/AsyncConsole.cs,**/Titanium.Cli/Http3/Http3DepsCommand.cs,**/Titanium.Cli/Updates/VersionAndUpdateCommands.cs,**/Titanium.Cli/Certificates/CertificateBootstrap.cs,**/Titanium.Plus/Discovery/**,**/Titanium.Plus/Security/**,**/Titanium.Plus/State/**,**/Titanium.Plus/Resilience/**,**/Titanium.Plus/PlusLog.cs,**/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs"
/d:sonar.coverage.exclusions="**/examples/**,**/benchmarks/**,**/docs/**,**/Http3/Http3OriginBridge.cs,**/Http3/Http3OriginClientSession.cs,**/Titanium.Plus/Dashboard/**,**/Titanium.Inspector/Views/**,**/Titanium.Inspector/Services/AppContainerLoopback.cs,**/Titanium.Inspector/App.axaml.cs,**/Titanium.Inspector/Program.cs,**/Titanium.Inspector/InspectorAppFactory.cs,**/Titanium.Inspector/Services/UpdateService.cs,**/Titanium.Cli/Program.cs,**/Titanium.Cli/AsyncConsole.cs,**/Titanium.Cli/Http3/Http3DepsCommand.cs,**/Titanium.Cli/Updates/VersionAndUpdateCommands.cs,**/Titanium.Cli/Certificates/CertificateBootstrap.cs,**/Titanium.Plus/Discovery/**,**/Titanium.Plus/Security/**,**/Titanium.Plus/State/**,**/Titanium.Plus/Resilience/**,**/Titanium.Plus/PlusLog.cs,**/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/IElevationPrompt.cs,**/Titanium.Web.Proxy/Helpers/IProcessRunner.cs"

- name: Build for SonarCloud analysis
if: env.SONAR_TOKEN != ''
Expand Down
4 changes: 2 additions & 2 deletions src/Titanium.Inspector/Services/SessionArchive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public static async Task ExportNativeArchiveAsync(IEnumerable<SessionSnapshot> s
{
ct.ThrowIfCancellationRequested();
var entry = zip.CreateEntry($"session-{index:D5}.json");
await using var stream = entry.Open();
await using var stream = await entry.OpenAsync(ct);
await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct);
index++;
}
Expand Down Expand Up @@ -94,7 +94,7 @@ public static async Task<List<SessionSnapshot>> ImportNativeArchiveAsync(string
continue;
}

await using var stream = entry.Open();
await using var stream = await entry.OpenAsync(ct);
var snap = await JsonSerializer.DeserializeAsync<SessionSnapshot>(stream, cancellationToken: ct);
if (snap is not null)
{
Expand Down
22 changes: 18 additions & 4 deletions src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1053,7 +1053,7 @@
public bool SystemProxy
{
get => _systemProxy;
set // NOSONAR S4275 -- fail paths leave _systemProxy unchanged and re-raise PropertyChanged to snap the checkbox back

Check warning on line 1056 in src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / build

Refactor this setter so that it actually refers to the field '_systemProxy'.
{
if (_systemProxy == value)
{
Expand Down Expand Up @@ -1142,7 +1142,7 @@
public bool DecryptHttps
{
get => _decryptHttps;
set // NOSONAR S4275 -- true path updates _decryptHttps via SetDecryptHttpsCore after async trust flow

Check warning on line 1145 in src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / build

Refactor this setter so that it actually refers to the field '_decryptHttps'.
{
if (_decryptHttpsBusy || _decryptHttps == value)
{
Expand Down Expand Up @@ -1830,8 +1830,15 @@
return;
}

await SessionArchive.ExportHarAsync(_all, path);
StatusText = $"Exported {_all.Count} sessions to {path}";
try
{
await SessionArchive.ExportHarAsync(_all, path);
await MarshalToUiAsync(() => StatusText = $"Exported {_all.Count} sessions to {path}");
}
catch (Exception ex)
{
await MarshalToUiAsync(() => StatusText = "Export HAR failed: " + Truncate(ex.Message, 160));
}
}

private async Task ExportSelectedHarAsync()
Expand All @@ -1850,8 +1857,15 @@
return;
}

await SessionArchive.ExportHarAsync(sessions, path);
StatusText = $"Exported {sessions.Count} sessions to {path}";
try
{
await SessionArchive.ExportHarAsync(sessions, path);
await MarshalToUiAsync(() => StatusText = $"Exported {sessions.Count} sessions to {path}");
}
catch (Exception ex)
{
await MarshalToUiAsync(() => StatusText = "Export HAR failed: " + Truncate(ex.Message, 160));
}
}

private async Task ImportHarAsync()
Expand Down
245 changes: 241 additions & 4 deletions tests/Titanium.Cli.Tests/ServerConfigApplierTests.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
using System.Net;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Titanium.Cli.Config;
using Titanium.Web.Proxy;
using Titanium.Web.Proxy.Configuration.Models;
using Titanium.Web.Proxy.Models;
using Titanium.Web.Proxy.Network;
using Titanium.Web.Proxy.Options;

#pragma warning disable TWP001

namespace Titanium.Cli.Tests;

[TestClass]
public class ServerConfigApplierTests
{
[TestMethod]
public void Apply_NullServer_IsNoOp()
{
using var proxy = new ProxyServer(userTrustRootCertificate: false);
var before = proxy.EnableHttp2;
ServerConfigApplier.Apply(proxy, null);
Assert.AreEqual(before, proxy.EnableHttp2);
}

[TestMethod]
public void Apply_PublicFacingProfile_SetsAdmissionAndTimeouts()
{
Expand Down Expand Up @@ -50,20 +66,241 @@ public void Apply_EnableHttp2False()
Assert.IsFalse(proxy.EnableHttp2);
}

[TestMethod]
public void Apply_ProtocolFlags_TimeoutsPoolingLimitsPolicyTlsUpstreamAndCerts()
{
using var proxy = new ProxyServer(userTrustRootCertificate: false);
ServerConfigApplier.Apply(proxy, new ServerConfig
{
EnableHttp3 = false,
EnableRfc8441 = false,
EnableQpackDynamicTable = false,
EnableHttpsSvcbDnsDiscovery = false,
Enable100ContinueBehaviour = true,
CompatibilityMode100Continue = true,
EnableWinAuth = false,
OriginHttpVersionPolicy = "NormalizeToHttp11",
ViaHeaderPseudonym = "twp-test",
BlockPrivateNetworkDestinations = true,
CheckCertificateRevocation = "Offline",
DnsServerEndPoint = "8.8.8.8:53",
Timeouts = new TimeoutsConfig
{
ConnectionTimeOutSeconds = 11,
ConnectTimeOutSeconds = 12,
ClientHeaderTimeoutSeconds = 13,
ResponseHeaderTimeoutSeconds = 14,
IdleReadTimeoutSeconds = 15,
IdleWriteTimeoutSeconds = 16,
RequestTimeoutSeconds = 17,
NetworkFailureRetryAttempts = 3,
},
Pooling = new PoolingConfig
{
EnableConnectionPool = true,
EnableTcpServerConnectionPrefetch = false,
EnableIpv6UnreachableSoftSkip = true,
MaxCachedConnections = 9,
MaxConcurrentHttp11HttpsOriginCreates = 4,
MaxConcurrentClientConnections = 123,
NoDelay = true,
EnableTcpKeepAlive = true,
TcpTimeWaitSeconds = 30,
ListenerBackLog = 64,
ReuseSocket = true,
ThreadPoolWorkerThread = 8,
},
Limits = new LimitsConfig
{
MaxHeaderLineBytes = 2048,
MaxHeaderCount = 50,
MaxHeaderAggregateBytes = 8192,
MaxEncodedBodyBytes = 1_000_000,
MaxDecodedBodyBytes = 2_000_000,
MaxDecompressionRatio = 12.5,
MaxConcurrentClients = 40,
MaxConcurrentStreamsPerConnection = 20,
MaxPeerInitiatedIncompleteStreamResets = 7,
MaxOpenHeaderBlockFrames = 5,
MaxOpenHeaderBlockDurationSeconds = 9,
ConnectionPoolingEnabled = true,
MaxCachedConnectionsPerHost = 6,
MaxOriginHttp2ConnectionsPerAuthority = 3,
MaxCertificateCacheEntries = 100,
MaxCertificateDiskCacheEntries = 50,
MaxBufferedBodyBytes = 4096,
MaxDecodedHeaderListBytes = 1024,
MaxWebSocketFramePayloadBytes = 65536,
},
PolicyModes = new PolicyModesConfig
{
BodyBudget = "Observe",
DecompressionRatio = "Enforce",
HeaderLimits = "Observe",
AdmissionControl = "Enforce",
Http2AbuseBudget = "Disabled",
AllowAmbiguousFraming = true,
},
Tls = new TlsConfig
{
SupportedSslProtocols = ["Tls12", "Tls13"],
SupportedServerSslProtocols = ["Tls13"],
},
Upstream = new UpstreamConfig
{
ForwardToUpstreamGateway = true,
UpstreamProxyConfigurationScript = "https://example.test/proxy.pac",
HttpProxy = new ExternalProxyConfig
{
HostName = "proxy.example",
Port = 8080,
UserName = "u",
Password = "p",
ProxyType = "Http",
BypassLocalhost = true,
ProxyDnsRequests = true,
NextHop = new ExternalProxyConfig
{
HostName = "next.example",
Port = 1080,
ProxyType = "Socks5",
},
},
HttpsProxy = new ExternalProxyConfig
{
HostName = "secure-proxy.example",
Port = 8443,
ProxyType = "Http",
},
UpStreamEndPoint = "0.0.0.0:0",
UpStreamEndPointIPv4 = "127.0.0.1:0",
UpStreamEndPointIPv6 = "[::1]:0",
},
CertificateManager = new CertificateManagerConfig
{
CertificateEngine = "BouncyCastle",
LeafCertificateKeyAlgorithm = "Rsa2048",
PfxFilePath = "certs/root.pfx",
PfxPassword = "secret",
OverwritePfxFile = true,
CertificateValidDays = 365,
CertificateGraceDays = 7,
CertificateCacheTimeOutMinutes = 60,
RootCertificateName = "TWP Test Root",
RootCertificateIssuerName = "TWP Test Issuer",
SaveFakeCertificates = true,
DisableWildCardCertificates = true,
},
});

Assert.IsFalse(proxy.EnableHttp3);
Assert.IsFalse(proxy.EnableRfc8441);
Assert.IsFalse(proxy.EnableQpackDynamicTable);
Assert.IsFalse(proxy.EnableHttpsSvcbDnsDiscovery);
Assert.IsTrue(proxy.Enable100ContinueBehaviour);
Assert.IsTrue(proxy.CompatibilityMode100Continue);
Assert.IsFalse(proxy.EnableWinAuth);
Assert.AreEqual(OriginHttpVersionPolicy.NormalizeToHttp11, proxy.OriginHttpVersionPolicy);
Assert.AreEqual("twp-test", proxy.ViaHeaderPseudonym);
Assert.IsTrue(proxy.BlockPrivateNetworkDestinations);
Assert.AreEqual(X509RevocationMode.Offline, proxy.CheckCertificateRevocation);
Assert.AreEqual(new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53), proxy.DnsServerEndPoint);

Assert.AreEqual(11, proxy.ConnectionTimeOutSeconds);
Assert.AreEqual(12, proxy.ConnectTimeOutSeconds);
Assert.AreEqual(13, proxy.ClientHeaderTimeoutSeconds);
Assert.AreEqual(14, proxy.ResponseHeaderTimeoutSeconds);
Assert.AreEqual(15, proxy.IdleReadTimeoutSeconds);
Assert.AreEqual(16, proxy.IdleWriteTimeoutSeconds);
Assert.AreEqual(17, proxy.RequestTimeoutSeconds);
Assert.AreEqual(3, proxy.NetworkFailureRetryAttempts);

Assert.IsTrue(proxy.EnableConnectionPool);
Assert.IsFalse(proxy.EnableTcpServerConnectionPrefetch);
Assert.IsTrue(proxy.EnableIpv6UnreachableSoftSkip);
Assert.AreEqual(9, proxy.MaxCachedConnections);
Assert.AreEqual(4, proxy.MaxConcurrentHttp11HttpsOriginCreates);
Assert.AreEqual(123, proxy.MaxConcurrentClientConnections);
Assert.IsTrue(proxy.NoDelay);
Assert.IsTrue(proxy.EnableTcpKeepAlive);
Assert.AreEqual(30, proxy.TcpTimeWaitSeconds);
Assert.AreEqual(64, proxy.ListenerBackLog);
Assert.IsTrue(proxy.ReuseSocket);
Assert.AreEqual(8, proxy.ThreadPoolWorkerThread);

Assert.AreEqual(2048, proxy.ResourceLimits.MaxHeaderLineBytes);
Assert.AreEqual(50, proxy.ResourceLimits.MaxHeaderCount);
Assert.AreEqual(4096, proxy.MaxBufferedBodyBytes);
Assert.AreEqual(1024, proxy.MaxDecodedHeaderListBytes);
Assert.AreEqual(65536, proxy.MaxWebSocketFramePayloadBytes);

Assert.AreEqual(PolicyMode.Observe, proxy.PolicyModes[PolicyFamily.BodyBudget]);
Assert.AreEqual(PolicyMode.Enforce, proxy.PolicyModes[PolicyFamily.DecompressionRatio]);
Assert.IsTrue(proxy.PolicyModes.AllowAmbiguousFraming);

Assert.AreEqual(SslProtocols.Tls12 | SslProtocols.Tls13, proxy.SupportedSslProtocols);
Assert.AreEqual(SslProtocols.Tls13, proxy.SupportedServerSslProtocols);

Assert.IsTrue(proxy.ForwardToUpstreamGateway);
Assert.AreEqual(new Uri("https://example.test/proxy.pac"), proxy.UpstreamProxyConfigurationScript);
Assert.IsNotNull(proxy.UpStreamHttpProxy);
Assert.AreEqual("proxy.example", proxy.UpStreamHttpProxy!.HostName);
Assert.AreEqual(8080, proxy.UpStreamHttpProxy.Port);
Assert.IsNotNull(proxy.UpStreamHttpProxy.NextHop);
Assert.AreEqual(ExternalProxyType.Socks5, proxy.UpStreamHttpProxy.NextHop!.ProxyType);
Assert.IsNotNull(proxy.UpStreamHttpsProxy);
Assert.AreEqual(new IPEndPoint(IPAddress.Any, 0), proxy.UpStreamEndPoint);
Assert.AreEqual(new IPEndPoint(IPAddress.Loopback, 0), proxy.UpStreamEndPointIPv4);
Assert.AreEqual(new IPEndPoint(IPAddress.IPv6Loopback, 0), proxy.UpStreamEndPointIPv6);

Assert.AreEqual(CertificateEngine.BouncyCastle, proxy.CertificateManager.CertificateEngine);
Assert.AreEqual(CertificateKeyAlgorithm.Rsa2048, proxy.CertificateManager.LeafCertificateKeyAlgorithm);
Assert.AreEqual("certs/root.pfx", proxy.CertificateManager.PfxFilePath);
Assert.AreEqual("secret", proxy.CertificateManager.PfxPassword);
Assert.IsTrue(proxy.CertificateManager.OverwritePfxFile);
Assert.AreEqual(365, proxy.CertificateManager.CertificateValidDays);
Assert.AreEqual(7, proxy.CertificateManager.CertificateGraceDays);
Assert.AreEqual(60, proxy.CertificateManager.CertificateCacheTimeOutMinutes);
Assert.AreEqual("TWP Test Root", proxy.CertificateManager.RootCertificateName);
Assert.AreEqual("TWP Test Issuer", proxy.CertificateManager.RootCertificateIssuerName);
Assert.IsTrue(proxy.CertificateManager.SaveFakeCertificates);
Assert.IsTrue(proxy.CertificateManager.DisableWildCardCertificates);
}

[TestMethod]
public void TryParseSslProtocols_OrsFlags()
{
Assert.IsTrue(ServerConfigApplier.TryParseSslProtocols(["Tls12", "Tls13"], out var protocols));
Assert.AreEqual(
System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13,
protocols);
Assert.AreEqual(SslProtocols.Tls12 | SslProtocols.Tls13, protocols);
}

[TestMethod]
public void TryParseSslProtocols_Invalid_ReturnsFalse()
{
Assert.IsFalse(ServerConfigApplier.TryParseSslProtocols(["Tls12", "Nope"], out var protocols));
Assert.AreEqual(SslProtocols.None, protocols);
}

[TestMethod]
public void TryParseEndPoint_IPv4()
{
Assert.IsTrue(ServerConfigApplier.TryParseEndPoint("127.0.0.1:53", out var ep));
Assert.AreEqual(53, ep.Port);
Assert.AreEqual(System.Net.IPAddress.Loopback, ep.Address);
Assert.AreEqual(IPAddress.Loopback, ep.Address);
}

[TestMethod]
public void TryParseEndPoint_IPv6Bracketed()
{
Assert.IsTrue(ServerConfigApplier.TryParseEndPoint("[::1]:443", out var ep));
Assert.AreEqual(443, ep.Port);
Assert.AreEqual(IPAddress.IPv6Loopback, ep.Address);
}

[TestMethod]
public void TryParseEndPoint_Invalid_ReturnsFalse()
{
Assert.IsFalse(ServerConfigApplier.TryParseEndPoint("not-an-endpoint", out _));
Assert.IsFalse(ServerConfigApplier.TryParseEndPoint("", out _));
}
}
22 changes: 22 additions & 0 deletions tests/Titanium.E2E.Tests/Harness/InspectorHeadlessFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ public async Task DispatchAsync(Func<Task> action) =>
Dispatcher.UIThread.RunJobs();
}, CancellationToken.None);

/// <summary>
/// Wait until <paramref name="condition"/> is true, pumping the Avalonia dispatcher each poll so
/// async RelayCommand continuations (StatusText after file I/O) can run in headless.
/// </summary>
public async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, int pollMs = 50)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
await DispatchAsync(() => { });
if (condition())
{
return;
}

await Task.Delay(pollMs);
}

// Final pump so callers assert against the latest UI state.
await DispatchAsync(() => { });
}

public async ValueTask DisposeAsync()
{
if (_session is not null)
Expand Down
Loading
Loading