Skip to content
This repository was archived by the owner on Dec 18, 2018. It is now read-only.

Add "zero config" HTTPS support using local development certificate. #2093

Merged
merged 20 commits into from
Oct 25, 2017

Conversation

cesarblum
Copy link
Contributor

@cesarblum cesarblum commented Oct 3, 2017

This change enables automatic HTTPS using a development certificate found in the user store. Any URLs containing the https scheme will be configured with that certificate.

Paging @Eilon since this is a breaking change (previously we would throw upon seeing an https URL).

So now if you set ASPNETCORE_URLS (or use UseUrls()) to e.g. https://localhost:5001, you'll get HTTPS if you add Kestrel with no listen options i.e.

var host = new WebHostBuilder()
    ...
    .UseKestrel()
    ...

This turned out to be more convoluted than I anticipated. I'm hoping someone can suggest a simpler way to achieve the same result.

store.Open(OpenFlags.ReadOnly);

var certificates = store.Certificates.OfType<X509Certificate2>();
var certificate = certificates
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to check that it has an OID, a private key and hasn't expired

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When do you think you will have dotnet/extensions#263 merged? We shouldn't replicate all that logic here. I should be able to get the certificate by calling CertificateManager.ListCertificates(CertificatePurpose.HTTPS, StoreName.My, StoreLocation.CurrentUser, isValid: true), right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working on it

@javiercn
Copy link
Member

javiercn commented Oct 3, 2017

Paging @Eilon since this is a breaking change (previously we would throw upon seeing an https URL).

I don't see this as a breaking change. At most a behavior change, and you are weakening the precondition (allowing something that wasn't allowed before) so I don't think this is a breaking change.

@muratg
Copy link
Contributor

muratg commented Oct 3, 2017

To me this looks like a minor breaking change, but an acceptable one. If you specify https for your URL, your app would fail to start before this. Now it'll do what you (presumably) wanted anyway.

Copy link
Member

@halter73 halter73 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a guide on how to setup an X509Store for my current user so I can test this out locally?

@@ -52,6 +54,8 @@ internal KestrelServer(ITransportFactory transportFactory, ServiceContext servic
Features = new FeatureCollection();
_serverAddresses = new ServerAddressesFeature();
Features.Set(_serverAddresses);

_defaultHttpsProvider = serviceContext.ServerOptions.ApplicationServices.GetService<IDefaultHttpsProvider>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't call GetService for this. Just make IDefaultHttpsProvider a param to the public ctor.

@@ -19,7 +19,9 @@ internal class AddressBinder
{
public static async Task BindAsync(IServerAddressesFeature addresses,
List<ListenOptions> listenOptions,
KestrelServerOptions serverOptions,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like this is actually used by the DefaultHttpsProvider or anything else. I see that you are setting this on ListenOptions before calling ConfigureHttps, but I don't see why that's necessary. Save some lines of code and don't bother plumbing this through here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It blows up here:

var loggerFactory = listenOptions.KestrelServerOptions.ApplicationServices.GetRequiredService<ILoggerFactory>();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may have misunderstood what you said. There's indeed no need to pass it as an arg to BindAsync, since I can get the KestrelServerOptions instance from one of the ListenOptions in the list. I still need that object though, otherwise a NRE is thrown at the line above.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I forgot about the call to UseHttps().

@cesarblum
Copy link
Contributor Author

cesarblum commented Oct 3, 2017

@halter73 To get the test cert set up on your machine, clone this PR dotnet/extensions#263 and run the tests. @javiercn can give you more info.

@cesarblum cesarblum force-pushed the cesarbs/magic-https branch 2 times, most recently from acca4ee to c714e4a Compare October 3, 2017 23:29
@cesarblum
Copy link
Contributor Author

Injecting IDefaultHttpsProvider in the KestrelServer ctor is a breaking change - I had to add an exception for ApiCheck. However, given these are injected parameters, is that an acceptable breaking change for a minor version? @muratg @Eilon

@halter73
Copy link
Member

halter73 commented Oct 3, 2017

@CesarBS Couldn't you just add a second ctor, and have the original ctor call the new one with a null IDefaultHttpsProvider? Of course, you'd want to keep the old behavior if IDefaultHttpsProvider is null.

@Eilon
Copy link
Contributor

Eilon commented Oct 4, 2017

Yeah just do what @halter73 suggested and there's no breaking change at all in terms of source compat, binary compat, runtime behavior, etc. It's nearly always fine to throw fewer exceptions and be more permissive.

@@ -31,7 +32,9 @@ internal class AddressBinder
{
Addresses = addresses.Addresses,
ListenOptions = listenOptions,
ServerOptions = listenOptions.FirstOrDefault()?.KestrelServerOptions,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty gross

@Tratcher
Copy link
Member

Tratcher commented Oct 4, 2017

New/updated sample?

if (https)
{
options.KestrelServerOptions = context.ServerOptions;
context.DefaultHttpsProvider?.ConfigureHttps(options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want this to throw if DefaultHttpsProvider is null, no? You asked for https and you're not getting it.


if (certificate == null)
{
throw new InvalidOperationException("Unable to find ASP.NET Core development certificate.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This level of magic deserves an FW link explaining what to do when it fails.

Copy link
Contributor Author

@cesarblum cesarblum Oct 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danroth27 @javiercn Do we have an FW link already?

Copy link
Contributor

@Eilon Eilon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good regarding breaking changes (that there aren't any). Had one unrelated comment.


var certificates = store.Certificates.OfType<X509Certificate2>();
var certificate = certificates
.FirstOrDefault(c => HasOid(c, AspNetHttpsOid) && !IsExpired(c) /*&& HasPrivateKey(c)*/);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the commented code needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Thanks for catching that, I forgot I had commented it out while testing something.

@cesarblum cesarblum force-pushed the cesarbs/magic-https branch 3 times, most recently from ae922be to 256e486 Compare October 9, 2017 23:43
@cesarblum
Copy link
Contributor Author

@@ -12,10 +12,13 @@

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Hosting" />
<PackageReference Include="Microsoft.AspNetCore.Certificates.Generation.Sources" PrivateAssets="All" />
<PackageReference Include="System.Security.Cryptography.Cng" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this for?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

He’s using the same code we have in tooling to locate the certificate (that’s why it’s a shared source library)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need that reference for the RSACng type.

@cesarblum
Copy link
Contributor Author

Rebased.

Copy link
Contributor

@analogrelay analogrelay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you don't have time to do this before you leave, I can take it over. One small testing comment and that's it.

$"HTTPS endpoints can only be configured using {nameof(KestrelServerOptions)}.{nameof(KestrelServerOptions.Listen)}().",
exception.Message);
Assert.Equal(1, testLogger.CriticalErrorsLogged);
mockDefaultHttpsProvider.Verify(provider => provider.ConfigureHttps(It.IsAny<ListenOptions>()), Times.Once);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of tests that verify that a certain method was called. Is there a way to build a simple dummy DefaultHttpsProvider and then verify the certificate on the ListenOptions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the issue with this kind of test? To test with DefaultHttpsProvider, I'd have to make the test project depend on the Kestrel project, instead of just Kestrel.Core.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. I'll take a bit of a look at it and see if we can make something work. Tests that use mock verification tend to be brittle whenever you change the implementation and they aren't actually testing the desired behavior of the function, they're just testing that the code does what is written. It's a little like a comment over x += 1 that says // Adds 1 to x :). If that's all that we can do, then I suppose it's OK, but I'd like to try and avoid it.

@analogrelay analogrelay self-assigned this Oct 18, 2017
@analogrelay
Copy link
Contributor

🆙 📅 Using CertificateManager shared-source package now. Also, added some more logging and exception messages.

<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="HttpsUrlProvidedButNoDevelopmentCertificateFound" xml:space="preserve">
<value>An 'https' URL was provided, but a development certificate could not be found.</value>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add something along the lines of the text that I sent you on email and add the forwarding link that @danroth27 provided?

Copy link
Member

@javiercn javiercn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything looks good AFAIK. But we need to get a more descriptive error message when the certificate is not present.
We need to mention what gesture to perform from the command line and a forwarding link to the documentation.

[Fact]
public void KestrelServerThrowsUsefulExceptionIfDefaultHttpsProviderNotAdded()
{
var mockDefaultHttpsProvider = new Mock<IDefaultHttpsProvider>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It doesn't look like mockDefaultHttpsProvider is used in this or the following two test methods.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, copy🍝

@@ -274,6 +315,11 @@ private static KestrelServer CreateServer(KestrelServerOptions options, ILogger
return new KestrelServer(Options.Create(options), new MockTransportFactory(), new LoggerFactory(new[] { new KestrelTestLoggerProvider(testLogger) }));
}

private static KestrelServer CreateServer(KestrelServerOptions options, IDefaultHttpsProvider defaultHttpsProvider)
{
return new KestrelServer(Options.Create(options), new MockTransportFactory(), new LoggerFactory(new[] { new KestrelTestLoggerProvider(Mock.Of<ILogger>()) }), defaultHttpsProvider);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use the default ctor for KestrelTestLoggerProvider. It makes it easier to see tracing when running random tests and should fail test runs if a critical error is logged through the use of TestApplicationErrorLogger.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well. After changing that I'm now getting this error:

Failed   KestrelServerThrowsUsefulExceptionIfDefaultHttpsProviderNotAdded
Error Message:
 Assert.Throws() Failure
Expected: typeof(System.InvalidOperationException)
Actual:   typeof(System.AggregateException): An error occurred while writing to logger(s).
Stack Trace:
   at Microsoft.Extensions.Logging.Logger.Log[TState](LogLevel logLevel, EventId eventId, TState state, Exception exception, Func`3 formatter)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.KestrelTrace.Log[TState](LogLevel logLevel, EventId eventId, TState state, Exception exception, Func`3 formatter) in C:\Users\anurse\Code\aspnet\KestrelHttpServer\src\Kestrel.Core\Internal\Infrastructure\KestrelTrace.cs:line 198
   at Microsoft.Extensions.Logging.LoggerExtensions.LogCritical(ILogger logger, EventId eventId, Exception exception, String message, Object[] args)
   at Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServer.<StartAsync>d__24`1.MoveNext() in C:\Users\anurse\Code\aspnet\KestrelHttpServer\src\Kestrel.Core\KestrelServer.cs:line 166
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at Microsoft.AspNetCore.Server.Kestrel.Core.Tests.KestrelServerTests.StartDummyApplication(IServer server) in C:\Users\anurse\Code\aspnet\KestrelHttpServer\test\Kestrel.Core.Tests\KestrelServerTests.cs:line 321
   at Microsoft.AspNetCore.Server.Kestrel.Core.Tests.KestrelServerTests.<>c__DisplayClass2_0.<KestrelServerThrowsUsefulExceptionIfDefaultHttpsProviderNotAdded>b__0() in C:\Users\anurse\Code\aspnet\KestrelHttpServer\test\Kestrel.Core.Tests\KestrelServerTests.cs:line 62

I think I'm going to put it back for now :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, it's because the default ctor uses TestApplicationErrorLogger which throws an exception when a Critical message is logged by default. I'm going to change the test to use a NullLogger instance though instead of the Mock.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed KestrelTestLoggerProvider to set ThrowOnCriticalErrors to false, since we don't seem to use that behavior anywhere.


public void ConfigureHttps(ListenOptions listenOptions)
{
var cert = DefaultCertificateResolver();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the Func? Was this replaced in a test at one point? It seems like you could just call FindDevelopmentCertificate directly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was wondering that too. I suppose so that you could construct it and replace the func, but that doesn't really make sense now that it's pubternal rather than public. It's also a simple enough interface to implement. I'll yank the func.

.FirstOrDefault();
if (certificate != null)
{
_logger.LogDebug("Using development certificate: {certificateSubjectName} (Thumbprint: {certificateThumbprint})", certificate.Subject, certificate.Thumbprint);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For common/expected logs, I think it's best to use resource strings.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, we use literals for all the other logs (see KestrelTrace) so I wasn't sure exactly what the right pattern was here. I thought about adding some LoggerMessage.Define action here but even with that, I've not seen our code use resource strings for that. I guess we could though.

}
else
{
_logger.LogDebug("Development certificate could not be found");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be given a higher log level? I'm assuming this would normally cause the application to crash on startup, but in the event the exception is somehow swallowed, I think it's a good idea to log this as an error.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, an exception will be thrown by ConfigureHttps, which calls this. But when refactoring to remove the delegate, I'll probably just do exactly as you suggest here :).

@analogrelay
Copy link
Contributor

🆙 📅 Responded to feedback, including @javiercn 's comments re messaging.

@@ -11,7 +11,7 @@ public class KestrelTestLoggerProvider : ILoggerProvider
private readonly ILogger _testLogger;

public KestrelTestLoggerProvider()
: this(new TestApplicationErrorLogger())
: this(new TestApplicationErrorLogger() { ThrowOnCriticalErrors = false })
Copy link
Member

@halter73 halter73 Oct 25, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't do this for all tests. For tests where you are expecting a critical errors to be logged, I would just call new KestrelTestLoggerProvider(new TestApplicationErrorLogger() { ThrowOnCriticalErrors = false }).

The idea is that most tests should cause critical errors to be logged, so having this can help us catch issues that might have otherwise been missed.

@analogrelay
Copy link
Contributor

🆙 📅 resolved last few pieces of feedback

@analogrelay analogrelay merged commit 8c4bdbc into dev Oct 25, 2017
@analogrelay analogrelay deleted the cesarbs/magic-https branch October 25, 2017 20:59
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants