I'm attempting to automate the process of renewing my SSL certificates for a few different publicly accessible endpoints. I'm using Certify the Web's Certify SSL/TLS Certificate Management to complete the CSR and SSL generation and validation via Let's Encrypt and Certify DNS. This generates the .pfx
files, which are then copied across the network to a location where my main "daily processing" application can access them and try to install them. I've been able to successfully get that application to install the certificates to the remote servers' certificate stores, but I'm unable to get the IIS 10 bindings reconfigured on the sites to use the new certificates.
For reference, here's the code for installing the certificate in the remote server's certificate store, which seems to work perfectly:
Imports System.Security.Cryptography.X509Certificates
Imports System.Security.Permissions
Imports Microsoft.Web.Administration
Private Sub AddCertificateToRemoteStore(ByVal HostName As String, ByVal StoreName As String, ByVal Certificate As X509Certificate2)
If HostName Is Nothing OrElse String.IsNullOrEmpty(HostName) Then
Throw New ArgumentNullException("HostName", "You must specify a server hostname")
ElseIf StoreName Is Nothing OrElse String.IsNullOrEmpty(StoreName) Then
Throw New ArgumentNullException("StoreName", "You must specify a certificate store name")
ElseIf Certificate Is Nothing Then
Throw New ArgumentNullException("Certificate", "A valid X509Certificate2 object is required")
Else
Dim CertStorePath As String = String.Format("\\{0}\{1}", HostName, StoreName)
Dim RootStorePath As String = String.Format("\\{0}\Root", HostName)
Dim IntermediateStorePath As String = String.Format("\\{0}\CA", HostName)
Using CertChain As New X509Chain
If CertChain.Build(Certificate) Then
Dim FindResults As X509Certificate2Collection
Dim StorePermissions As New StorePermission(PermissionState.Unrestricted)
With StorePermissions
.Flags = StorePermissionFlags.OpenStore Or StorePermissionFlags.AddToStore
.Assert()
End With
For C = 0 To CertChain.ChainElements.Count - 1
If C = 0 Then
Using CertificateStore As New X509Store(CertStorePath, StoreLocation.LocalMachine)
With CertificateStore
.Open(OpenFlags.ReadWrite Or OpenFlags.OpenExistingOnly)
FindResults = .Certificates.Find(X509FindType.FindByThumbprint, CertChain.ChainElements(C).Certificate.Thumbprint, False)
If FindResults.Count <= 0 Then
.Add(CertChain.ChainElements(C).Certificate)
End If
FindResults.Clear()
.Close()
End With
End Using
ElseIf C = CertChain.ChainElements.Count - 1 Then
Using RootStore As New X509Store(RootStorePath, StoreLocation.LocalMachine)
With RootStore
.Open(OpenFlags.ReadWrite Or OpenFlags.OpenExistingOnly)
FindResults = .Certificates.Find(X509FindType.FindByThumbprint, CertChain.ChainElements(C).Certificate.Thumbprint, False)
If FindResults.Count <= 0 Then
.Add(CertChain.ChainElements(C).Certificate)
End If
FindResults.Clear()
.Close()
End With
End Using
Else
Using IntermediateStore As New X509Store(IntermediateStorePath, StoreLocation.LocalMachine)
With IntermediateStore
.Open(OpenFlags.ReadWrite Or OpenFlags.OpenExistingOnly)
FindResults = .Certificates.Find(X509FindType.FindByThumbprint, CertChain.ChainElements(C).Certificate.Thumbprint, False)
If FindResults.Count <= 0 Then
.Add(CertChain.ChainElements(C).Certificate)
End If
FindResults.Clear()
.Close()
End With
End Using
End If
Next C
End If
End Using
End If
End Sub
With the certificate successfully added to the store (I've verified that it's there through certlm.msc
), the next obvious step is to apply the new certificate to the existing IIS 10 site's bindings so it can actually be used for SSL/TLS communication. Here's what I'm currently using to try to accomplish that with the Microsoft.Web.Administration
namespace:
Private Sub ApplyCertificateBinding(ByVal HostName As String, ByVal StoreName As String, ByVal ActiveCertificate As X509Certificate2)
If HostName Is Nothing OrElse String.IsNullOrEmpty(HostName) Then
Throw New ArgumentNullException("HostName", "You must specify a server hostname")
ElseIf StoreName Is Nothing OrElse String.IsNullOrEmpty(StoreName) Then
Throw New ArgumentNullException("StoreName", "You must specify a certificate store name")
ElseIf ActiveCertificate Is Nothing Then
Throw New ArgumentNullException("ActiveCertificate", "A valid X509Certificate2 object is required")
Else
Dim SSLSiteName As String = ActiveCertificate.GetNameInfo(X509NameType.DnsName, False)
Dim HostSites As New List(Of Site)
Using HostManager As ServerManager = ServerManager.OpenRemote(HostName)
For Each Site As Site In HostManager.Sites
If Site.Name = SSLSiteName Then
HostSites.Add(Site)
Else
For Each Binding In Site.Bindings
If Binding.Host = SSLSiteName Then
HostSites.Add(Site)
Exit For
End If
Next Binding
End If
Next Site
For Each Site As Site In HostSites
For Each SiteBinding In Site.Bindings
If SiteBinding.Protocol = "https" Then
Dim NewBinding As Binding = Site.Bindings.CreateElement
NewBinding.CertificateStoreName = StoreName
NewBinding.Protocol = "https"
NewBinding.CertificateHash = ActiveCertificate.GetCertHash
NewBinding.BindingInformation = SiteBinding.BindingInformation
SiteBinding = NewBinding
HostManager.CommitChanges()
End If
Next SiteBinding
Site.Stop()
'PROBABLY A BETTER WAY TO HANDLE THIS
Do While Site.State <> ObjectState.Stopped
Loop
Site.Start()
'AND THIS
Do While Site.State <> ObjectState.Started
Loop
Next Site
End Using
End If
End Sub
This code gets all the way through the process without error, but it doesn't seem to actually make the necessary changes for the site to start using the new certificate. I manually restarted/refreshed the site from the IIS interface on the host, but it still doesn't seem to take effect. I've checked both the binding settings in IIS and the site itself (browser) and confirmed that it's still using the "old" certificate.
I've also tried to directly set the certificate hash of the SiteBinding
object to the X509Certificate2.GetCertHash
value, as well as assigning the SiteBinding
object to the NewBinding
object before trying to set the CertificateHash
property as above. Unfortunately, both of these methods throw a NotSupportedException
stating: The specified operation is not supported when a server name is specified.
Additionally, there are settings from the "live" SiteBinding
object that can't be set on the NewBinding
object (like the .Host
property). All I really want to be able to do is to change the active certificate on that site and not muck around with any of the binding's other properties. The wording of the exception seems to indicate that what I'm trying to do can't be done remotely (at least, not with the Microsoft.Web.Administration
API), but I can't imagine that there isn't a way to accomplish this goal. I'm sure I'm simply missing/overlooking something here, but my Google-fu is failing me and I need to get this project functional as soon as possible.
EDIT #1
I added the Site.Stop()
and Site.Start()
methods to restart the site from code, but it didn't make any difference. Plus, I'm sure there's probably a better way to implement that than what I've added to the code above.
EDIT #2
I've refactored some things to align with the suggestion from Joel Coehoorn in the comments. The code above represents the current state but still produces the same result: No exception occurs, but I cannot get the bindings updated to use the new certificate, even though it's apparently added to the store.
Just to triple-check, I went to the site's bindings in IIS and the new certificate does show up as available to apply to the site. I know there's a lot of blur, but the one highlighted in blue is what I'm trying to apply to the binding:
EDIT #3
After reading Conrado Clark's Developer Log entry titled Adding SSL Binding to a remote website using Microsoft.Web.Administration, I decided to try to add the NewBinding
to IIS instead of just updating the existing one:
Site.Bindings.Add(NewBinding)
Site.Bindings.Remove(SiteBinding)
HostManager.CommitChanges()
This produced a different exception: Cannot add duplicate collection entry of type 'binding' with combined key attributes 'protocol, bindingInformation' respectively set to 'https, XXX.XXX.XXX.XXX:443:'
So, I tried removing the existing binding first:
Site.Bindings.Remove(SiteBinding)
Site.Bindings.Add(NewBinding)
HostManager.CommitChanges()
This time it made it through the first two steps (Site.Bindings.Remove()
and Site.Bindings.Add()
), but when it tried to execute HostManager.CommitChanges()
, I got another "new" exception: A specified logon session does not exist. It may already have been terminated. (Exception from HRESULT: 0x80070520)
. Additionally, it "reset" the binding so there was no certificate installed on the site.
Just to see what would happen, I tried to commit the Site.Bindings.Remove()
before trying to add it back.
Site.Bindings.Remove(SiteBinding)
HostManager.CommitChanges()
Site.Bindings.Add(NewBinding)
HostManager.CommitChanges()
The initial commit seemed to work fine (and the binding disappeared completely from IIS), but when it went to add the new binding, I got this: The configuration object is read only, because it has been committed by a call to ServerManager.CommitChanges(). If write access is required, use ServerManager to get a new reference.
I manually recreated the binding (thankfully I had taken a quick screenshot before I started messing with it), but that last error has given me an idea for my next attempt. I'm going to try to break the Add()
and Remove()
methods out to new methods where I can open new instances of the ServerManager
object specifically for this purpose. I'll come back when I've had a chance to write/test that.
EDIT #4
I tried the above and still ended up with an error stating that A specified logon session does not exist. It may already have been terminated. (Exception from HRESULT: 0x80070520)
. So, just to see if I could determine the cause of the problem, I went to IIS and manually tried to apply the new certificate. I got the SAME EXACT ERROR from IIS! (Yes, I know... I probably should have checked that bit a long time ago, but here we are)
It looks like there's a problem with the certificate in the store. Digging around a little deeper, I found an old reference on the MSDN forums talking about this error being related to a missing private key. This makes it sound like I missed a step in the certificate installation process, so I guess I need to take another step back and figure out what's wrong with that method before proceeding.