Skip to content

Commit a5cc723

Browse files
CopilotwaldekmastykarzCopilot
authored
Pass ILoggerFactory to Unobtanium ProxyServer for internal logging (#1448)
* Initial plan * Pass ILoggerFactory to Unobtanium ProxyServer for logging integration Co-authored-by: waldekmastykarz <11164679+waldekmastykarz@users.noreply.github.com> * Refactor certificate management for macOS: implement MacCertificateHelper and remove shell scripts * Update DevProxy/Proxy/ProxyEngine.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update DevProxy/Proxy/MacCertificateHelper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: waldekmastykarz <11164679+waldekmastykarz@users.noreply.github.com> Co-authored-by: waldekmastykarz <waldek@mastykarz.nl> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 2f9f51b commit a5cc723

7 files changed

Lines changed: 219 additions & 107 deletions

File tree

DevProxy/ApiControllers/ProxyController.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ namespace DevProxy.ApiControllers;
1414
[ApiController]
1515
[Route("[controller]")]
1616
#pragma warning disable CA1515 // required for the API controller
17-
public sealed class ProxyController(IProxyStateController proxyStateController, IProxyConfiguration proxyConfiguration) : ControllerBase
17+
public sealed class ProxyController(IProxyStateController proxyStateController, IProxyConfiguration proxyConfiguration, ILoggerFactory loggerFactory) : ControllerBase
1818
#pragma warning restore CA1515
1919
{
2020
private readonly IProxyStateController _proxyStateController = proxyStateController;
2121
private readonly IProxyConfiguration _proxyConfiguration = proxyConfiguration;
22+
private readonly ILoggerFactory _loggerFactory = loggerFactory;
2223

2324
[HttpGet]
2425
public ProxyInfo Get() => ProxyInfo.From(_proxyStateController.ProxyState, _proxyConfiguration);
@@ -114,6 +115,9 @@ public IActionResult GetRootCertificate([FromQuery][Required] string format)
114115
return ValidationProblem(ModelState);
115116
}
116117

118+
// Ensure ProxyServer is initialized with LoggerFactory for Unobtanium logging
119+
ProxyEngine.EnsureProxyServerInitialized(_loggerFactory);
120+
117121
var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate;
118122
if (certificate == null)
119123
{

DevProxy/Commands/CertCommand.cs

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,27 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5-
using DevProxy.Abstractions.Utils;
65
using DevProxy.Proxy;
76
using System.CommandLine;
87
using System.CommandLine.Parsing;
9-
using System.Diagnostics;
108
using Titanium.Web.Proxy.Helpers;
119

1210
namespace DevProxy.Commands;
1311

1412
sealed class CertCommand : Command
1513
{
1614
private readonly ILogger _logger;
15+
private readonly ILoggerFactory _loggerFactory;
1716
private readonly Option<bool> _forceOption = new("--force", "-f")
1817
{
1918
Description = "Don't prompt for confirmation when removing the certificate"
2019
};
2120

22-
public CertCommand(ILogger<CertCommand> logger) :
21+
public CertCommand(ILogger<CertCommand> logger, ILoggerFactory loggerFactory) :
2322
base("cert", "Manage the Dev Proxy certificate")
2423
{
2524
_logger = logger;
25+
_loggerFactory = loggerFactory;
2626

2727
ConfigureCommand();
2828
}
@@ -49,8 +49,21 @@ private async Task EnsureCertAsync()
4949

5050
try
5151
{
52+
// Ensure ProxyServer is initialized with LoggerFactory for Unobtanium logging
53+
ProxyEngine.EnsureProxyServerInitialized(_loggerFactory);
54+
5255
_logger.LogInformation("Ensuring certificate exists and is trusted...");
5356
await ProxyEngine.ProxyServer.CertificateManager.EnsureRootCertificateAsync();
57+
58+
if (RunTime.IsMac)
59+
{
60+
var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate;
61+
if (certificate is not null)
62+
{
63+
MacCertificateHelper.TrustCertificate(certificate, _logger);
64+
}
65+
}
66+
5467
_logger.LogInformation("DONE");
5568
}
5669
catch (Exception ex)
@@ -79,8 +92,23 @@ public void RemoveCert(ParseResult parseResult)
7992

8093
_logger.LogInformation("Uninstalling the root certificate...");
8194

82-
RemoveTrustedCertificateOnMac();
83-
ProxyEngine.ProxyServer.CertificateManager.RemoveTrustedRootCertificate(machineTrusted: false);
95+
// Ensure ProxyServer is initialized with LoggerFactory for Unobtanium logging
96+
ProxyEngine.EnsureProxyServerInitialized(_loggerFactory);
97+
98+
if (RunTime.IsMac)
99+
{
100+
var certificate = ProxyEngine.ProxyServer.CertificateManager.RootCertificate;
101+
if (certificate is not null)
102+
{
103+
MacCertificateHelper.RemoveTrustedCertificate(certificate, _logger);
104+
}
105+
106+
HasRunFlag.Remove();
107+
}
108+
else
109+
{
110+
ProxyEngine.ProxyServer.CertificateManager.RemoveTrustedRootCertificate(machineTrusted: false);
111+
}
84112

85113
_logger.LogInformation("DONE");
86114
}
@@ -115,27 +143,4 @@ private static bool PromptConfirmation(string message, bool acceptByDefault)
115143
}
116144
}
117145
}
118-
119-
private static void RemoveTrustedCertificateOnMac()
120-
{
121-
if (!RunTime.IsMac)
122-
{
123-
return;
124-
}
125-
126-
var bashScriptPath = Path.Join(ProxyUtils.AppFolder, "remove-cert.sh");
127-
var startInfo = new ProcessStartInfo()
128-
{
129-
FileName = "/bin/bash",
130-
Arguments = bashScriptPath,
131-
UseShellExecute = false,
132-
CreateNoWindow = true,
133-
};
134-
135-
using var process = new Process() { StartInfo = startInfo };
136-
_ = process.Start();
137-
process.WaitForExit();
138-
139-
HasRunFlag.Remove();
140-
}
141146
}

DevProxy/DevProxy.csproj

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,9 @@
6060
<None Update="devproxy-errors.json">
6161
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
6262
</None>
63-
<None Update="remove-cert.sh">
64-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
65-
</None>
6663
<None Update="toggle-proxy.sh">
6764
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
6865
</None>
69-
<None Update="trust-cert.sh">
70-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
71-
</None>
7266
<None Update="config\m365.json">
7367
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
7468
</None>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Diagnostics;
6+
using System.Security.Cryptography.X509Certificates;
7+
8+
namespace DevProxy.Proxy;
9+
10+
internal static class MacCertificateHelper
11+
{
12+
internal static void TrustCertificate(X509Certificate2 certificate, ILogger logger)
13+
{
14+
var pemFilePath = ExportCertificateToPem(certificate, logger);
15+
if (pemFilePath is null)
16+
{
17+
return;
18+
}
19+
20+
try
21+
{
22+
var keychainPath = Path.Combine(
23+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
24+
"Library", "Keychains", "login.keychain-db");
25+
RunSecurityCommand(
26+
$"add-trusted-cert -r trustRoot -k \"{keychainPath}\" \"{pemFilePath}\"",
27+
"trust",
28+
logger);
29+
}
30+
finally
31+
{
32+
CleanupFile(pemFilePath);
33+
}
34+
}
35+
36+
internal static void RemoveTrustedCertificate(X509Certificate2 certificate, ILogger logger)
37+
{
38+
var pemFilePath = ExportCertificateToPem(certificate, logger);
39+
if (pemFilePath is null)
40+
{
41+
return;
42+
}
43+
44+
try
45+
{
46+
RunSecurityCommand(
47+
$"remove-trusted-cert \"{pemFilePath}\"",
48+
"remove trust for",
49+
logger);
50+
}
51+
finally
52+
{
53+
CleanupFile(pemFilePath);
54+
}
55+
}
56+
57+
private static string? ExportCertificateToPem(X509Certificate2 certificate, ILogger logger)
58+
{
59+
try
60+
{
61+
var certBytes = certificate.Export(X509ContentType.Cert);
62+
var base64Cert = Convert.ToBase64String(certBytes, Base64FormattingOptions.InsertLineBreaks);
63+
var pem = $"-----BEGIN CERTIFICATE-----\n{base64Cert}\n-----END CERTIFICATE-----";
64+
65+
var configDir = Path.Combine(
66+
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
67+
"dev-proxy");
68+
_ = Directory.CreateDirectory(configDir);
69+
var pemFilePath = Path.Combine(configDir, "dev-proxy-ca.pem");
70+
File.WriteAllText(pemFilePath, pem);
71+
return pemFilePath;
72+
}
73+
catch (Exception ex)
74+
{
75+
logger.LogError(ex, "Failed to export certificate to PEM");
76+
return null;
77+
}
78+
}
79+
80+
private static void RunSecurityCommand(string arguments, string action, ILogger logger)
81+
{
82+
var startInfo = new ProcessStartInfo
83+
{
84+
FileName = "/usr/bin/security",
85+
Arguments = arguments,
86+
UseShellExecute = false,
87+
CreateNoWindow = true,
88+
RedirectStandardError = true,
89+
};
90+
91+
using var process = new Process { StartInfo = startInfo };
92+
_ = process.Start();
93+
var stderr = process.StandardError.ReadToEnd();
94+
process.WaitForExit();
95+
96+
if (process.ExitCode != 0)
97+
{
98+
logger.LogError("Failed to {Action} certificate: {Error}", action, stderr);
99+
}
100+
else
101+
{
102+
logger.LogInformation("Successfully completed {Action} certificate operation.", action);
103+
}
104+
}
105+
106+
private static void CleanupFile(string filePath)
107+
{
108+
try
109+
{
110+
File.Delete(filePath);
111+
}
112+
catch
113+
{
114+
// ignore cleanup failures
115+
}
116+
}
117+
}

DevProxy/Proxy/ProxyEngine.cs

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,16 @@ sealed class ProxyEngine(
3030
IProxyConfiguration proxyConfiguration,
3131
ISet<UrlToWatch> urlsToWatch,
3232
IProxyStateController proxyController,
33-
ILogger<ProxyEngine> logger) : BackgroundService, IDisposable
33+
ILogger<ProxyEngine> logger,
34+
ILoggerFactory loggerFactory) : BackgroundService, IDisposable
3435
{
3536
private readonly IEnumerable<IPlugin> _plugins = plugins;
3637
private readonly ILogger _logger = logger;
3738
private readonly IProxyConfiguration _config = proxyConfiguration;
3839

39-
internal static ProxyServer ProxyServer { get; private set; }
40+
internal static ProxyServer ProxyServer { get; private set; } = null!;
41+
private static bool _isProxyServerInitialized;
42+
private static readonly object _initLock = new();
4043
private ExplicitProxyEndPoint? _explicitEndPoint;
4144
// lists of URLs to watch, used for intercepting requests
4245
private readonly ISet<UrlToWatch> _urlsToWatch = urlsToWatch;
@@ -56,23 +59,53 @@ sealed class ProxyEngine(
5659

5760
static ProxyEngine()
5861
{
59-
ProxyServer = new();
60-
ProxyServer.CertificateManager.PfxFilePath = Environment.GetEnvironmentVariable("DEV_PROXY_CERT_PATH") ?? string.Empty;
61-
ProxyServer.CertificateManager.RootCertificateName = "Dev Proxy CA";
62-
ProxyServer.CertificateManager.CertificateStorage = new CertificateDiskCache();
63-
// we need to change this to a value lower than 397
64-
// to avoid the ERR_CERT_VALIDITY_TOO_LONG error in Edge
65-
ProxyServer.CertificateManager.CertificateValidDays = 365;
66-
67-
using var joinableTaskContext = new JoinableTaskContext();
68-
var joinableTaskFactory = new JoinableTaskFactory(joinableTaskContext);
69-
_ = joinableTaskFactory.Run(async () => await ProxyServer.CertificateManager.LoadOrCreateRootCertificateAsync());
62+
// ProxyServer initialization moved to EnsureProxyServerInitialized
63+
// to enable passing ILoggerFactory for Unobtanium logging
64+
}
65+
66+
// Ensure ProxyServer is initialized with the given ILoggerFactory
67+
// This method can be called from multiple places (ProxyEngine, CertCommand, etc.)
68+
internal static void EnsureProxyServerInitialized(ILoggerFactory? loggerFactory = null)
69+
{
70+
if (_isProxyServerInitialized)
71+
{
72+
return;
73+
}
74+
75+
lock (_initLock)
76+
{
77+
if (_isProxyServerInitialized)
78+
{
79+
return;
80+
}
81+
82+
// On macOS/Linux, don't let Unobtanium try to install the cert
83+
// in the Root store via .NET's X509Store API — it requires admin
84+
// privileges and fails with "Access is denied".
85+
// On macOS, Dev Proxy handles trust via MacCertificateHelper instead.
86+
ProxyServer = new(userTrustRootCertificate: RunTime.IsWindows, loggerFactory: loggerFactory);
87+
ProxyServer.CertificateManager.PfxFilePath = Environment.GetEnvironmentVariable("DEV_PROXY_CERT_PATH") ?? string.Empty;
88+
ProxyServer.CertificateManager.RootCertificateName = "Dev Proxy CA";
89+
ProxyServer.CertificateManager.CertificateStorage = new CertificateDiskCache();
90+
// we need to change this to a value lower than 397
91+
// to avoid the ERR_CERT_VALIDITY_TOO_LONG error in Edge
92+
ProxyServer.CertificateManager.CertificateValidDays = 365;
93+
94+
using var joinableTaskContext = new JoinableTaskContext();
95+
var joinableTaskFactory = new JoinableTaskFactory(joinableTaskContext);
96+
_ = joinableTaskFactory.Run(async () => await ProxyServer.CertificateManager.LoadOrCreateRootCertificateAsync());
97+
98+
_isProxyServerInitialized = true;
99+
}
70100
}
71101

72102
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
73103
{
74104
_cancellationToken = stoppingToken;
75105

106+
// Initialize ProxyServer with LoggerFactory for Unobtanium logging
107+
EnsureProxyServerInitialized(loggerFactory);
108+
76109
Debug.Assert(ProxyServer is not null, "Proxy server is not initialized");
77110

78111
if (!_urlsToWatch.Any())
@@ -188,18 +221,26 @@ private void FirstRunSetup()
188221
return;
189222
}
190223

191-
var bashScriptPath = Path.Join(ProxyUtils.AppFolder, "trust-cert.sh");
192-
ProcessStartInfo startInfo = new()
224+
Console.WriteLine();
225+
Console.WriteLine("Dev Proxy uses a self-signed certificate to intercept and inspect HTTPS traffic.");
226+
Console.Write("Update the certificate in your Keychain so that it's trusted by your browser? (Y/n): ");
227+
var answer = Console.ReadLine()?.Trim();
228+
229+
if (string.Equals(answer, "n", StringComparison.OrdinalIgnoreCase))
193230
{
194-
FileName = "/bin/bash",
195-
Arguments = bashScriptPath,
196-
UseShellExecute = true,
197-
CreateNoWindow = false
198-
};
231+
_logger.LogWarning("Trust the certificate in your Keychain manually to avoid errors.");
232+
return;
233+
}
199234

200-
using var process = new Process() { StartInfo = startInfo };
201-
_ = process.Start();
202-
process.WaitForExit();
235+
var certificate = ProxyServer.CertificateManager.RootCertificate;
236+
if (certificate is null)
237+
{
238+
_logger.LogError("Root certificate not found. Cannot trust certificate.");
239+
return;
240+
}
241+
242+
MacCertificateHelper.TrustCertificate(certificate, _logger);
243+
_logger.LogInformation("Certificate trusted successfully.");
203244
}
204245

205246
private async Task ReadKeysAsync(CancellationToken cancellationToken)

0 commit comments

Comments
 (0)