Self Signed Certificates and Sub-Domains with Local IIS - Automated Developer Machine Setup Part III

As any developer can tell you, having their machine match production as close as possible is key. A lot of websites today now make use of subdomains, IE platform.mysite.com. In addition, it is very rare to run across a website today which doesn't use SSL. There is just one problem, out of the box ASP.NET development makes use of localhost which doesn't support subdomains.

This was a problem I recently had to solve for my team and I wanted to share how I went about solving it. This one problem very quickly several small problems which required their own unique solution.

Subdomain Solution

Out of the box, Visual Studio likes to use the IIS Express web server for developing websites. IIS Express is fine for a demo or a proof of concept, but it shouldn't be a developer's primary local web server. Windows Server, where ASP.NET will be hosted, runs IIS, so developers should be running that as well.

IIS supports a feature known as host headers. Basically, if you come to a server with the header platform.mysite.com and a website is set up with that host header IIS will route all traffic to that. But how can you tell your computer to send all traffic for platform.mysite.com to your local machine? Simple, make use of the hosts file. What you do is add an entry in the file which says platform.mysite.com will navigate to 127.0.0.1 (which is the loopback address).

127.0.0.1 platform.mysite.com

But updating that can be a real pain. You have to find the location of the file, open it up as an Administrator using notepad++ (or some similar tool). All that work can easily be done by making use of a PowerShell script. What this function will do is look for the entry in the hosts file, if it cannot find it then it will add it.

function UpdateHostsFile($domainName) {
    If ((Get-Content "$($env:windir)\system32\Drivers\etc\hosts" ) -notcontains "127.0.0.1 $domainName") {
        ac -Encoding UTF8  "$($env:windir)\system32\Drivers\etc\hosts" "127.0.0.1 $domainName" 
    }
}

Certificate Solution

The domain is in place in the hosts file. Now it is time to set up the website. Except for one little problem, you want to make use of https locally. This can be for a variety of reasons, it could be something as simple as a setting the secure flag in a cookie or not allowing OAuth authentication to work unless it comes through https. Whatever the reason may be, it is important to make sure the code you deploy is the same code you test with locally. Simple, just create a self-signed certificate.

But browsers, well browsers get really pissy with self-signed certificates. Chrome requires you to add an exception, while Firefox makes you jump through what seems like a hundred loops just to work. This is because the self-signed certificate did not come from a certificate authority.

It is possible to set this up manually. But why do that when you can have a script do the work for you? What this script will do is look at the local certificate store for two certs, a local root authority cert and a local website cert. If either doesn't exist it will create them and add them to the store.

$items = Get-ChildItem -Path "Cert:\LocalMachine\My"
$localAuthority = $false
$localWebSiteThumbprint = $false

# Try to find the existing certs
foreach($item in $items)
{
    $name = $item.DnsNameList
    Write-Host $name

    if ($name -contains "Local Development Root Certificate Authority")
    {
        Write-Host "Found the Root Authority Certificate!"
        $localAuthority = $item.Thumbprint
    }
    elseif ($name -contains "www.localwebsite.com")
    {
        Write-Host "Found the local certificate!"
        $localWebSiteThumbprint = $item.Thumbprint
    }
}

# Check to see if the local authority is found
if ($localAuthority -eq $false)
{
    Write-Host "Creating the Local Development Root Certificate Authority"
    $rootcert = New-SelfSignedCertificate -CertStoreLocation Cert:\LocalMachine\my -DnsName "Local Development Root Certificate Authority" -NotAfter (Get-Date).AddYears(3) -KeyUsage CertSign
	# Replace the password!!!!
    $rootCertPwd = ConvertTo-SecureString -String "[[ReplaceMEEEEEEEEE!!!!!!!]]" -Force -AsPlainText
    $localAuthority = $rootcert.Thumbprint
}

# Check to see if the local website is found, if it is not then create it
if ($localWebSiteThumbprint -eq $false)
{   
    Write-Host "Creating the local certificate" 
    $signer = (Get-ChildItem -Path "Cert:\Localmachine\my\$localAuthority")
	
	# This will make the cert a wild card cert
    $domainCert = New-SelfSignedCertificate -DnsName "www.localwebsite.com", "*.localwebsite.com", "localwebsite.com", "localhost" -CertStoreLocation "cert:\LocalMachine\My" -Signer $signer -NotAfter (Get-Date).AddYears(3) -KeyUsage DigitalSignature    
}

$rootItems = Get-ChildItem -Path Cert:\LocalMachine\Root
$localCAIsInRoot = $false

foreach ($rootItem in $rootItems)
{
    $rootItemName = $rootItem.DnsNameList    

    if ($rootItemName -contains "Local Development Root Certificate Authority")
    {
        Write-Host "Found the Root Authority Certificate!"
        $localCAIsInRoot = $true
    }
}

if ($localCAIsInRoot -eq $false)
{
    $myCertificates = Get-ChildItem -Path "Cert:\LocalMachine\My"

    $localRootCA = $false
    foreach ($cert in $myCertificates)
    {
        $certName = $cert.DnsNameList
        if ($certName -contains "Local Development Root Certificate Authority")
        {
            Write-Host "Found the Root Authority Certificate to Add!"
            $localRootCA = $cert
        }   
    }

    $DestStoreScope = 'LocalMachine'
    $DestStoreName = 'root'

    Write-Host "Adding Local Certificate To Root"
    $DestStore = New-Object  -TypeName System.Security.Cryptography.X509Certificates.X509Store  -ArgumentList $DestStoreName, $DestStoreScope
    $DestStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
    $DestStore.Add($localRootCA)

    $DestStore.Close()
}

Subdomains within IIS

Okay, we have domain setup in the hosts file and the certificate ready to go. Now it is time to setup IIS. Depending on the number of subdomains you have to create this could prove to be a very manual process that ends up being a bit of a time suck. Why do that when you can have PowerShell do that for you?

This script actually does quite a bit. If you send it a root domain, such as www.mysite.com, it will create that website. If you send it platform.mysite.com/myapp/v1 it will go through the URL, create the website platform.mysite.com as well as the applications myapp and v1.

function SetupIISFromUrl($iisUrl, $projectFilePath, $rootProjectFolder) {    
    $uri = [System.Uri]$iisUrl

    Write-Host "Setting up IIS for the URL $iisUrl"

    UpdateHostsFile $uri.DnsSafeHost
    CreateApplicationPool $uri.DnsSafeHost
        
    if ($uri.Segments.Count -eq 1)
    {
        Write-Host "Detected root application setting up IIS to point site to location of $projectFilePath"
        CreateIISSite $uri.DnsSafeHost $projectFilePath            
    }
    else
    {
        Write-Host "Detected subdomain project, setting IIS to point site to $rootProjectFolder"
        CreateIISSite $uri.DnsSafeHost $rootProjectFolder
        CreateVirtualDirectoryForProject $iisUrl $projectFilePath
    }
}

function UpdateHostsFile($domainName) {
    If ((Get-Content "$($env:windir)\system32\Drivers\etc\hosts" ) -notcontains "127.0.0.1 $domainName") {
        ac -Encoding UTF8  "$($env:windir)\system32\Drivers\etc\hosts" "127.0.0.1 $domainName" 
    }
}

function CreateApplicationPool($domainName){
    $appPoolName = "IIS:\AppPools\$domainName"

    if (Test-Path $appPoolName -PathType Container) {
        Write-Host "Application pool $appPoolName already exists"
        return    
    }

    Write-Host "Creating $appPoolName"
    $appPool = New-Item $appPoolName
    $appPool | Set-ItemProperty -Name "managedRuntimeVersion" -Value "v4.0"
}

function CreateIISSite($domainName, $directoryPath) {   
    $siteName = "IIS:\Sites\$domainName"

    if (Test-Path $siteName -PathType Container) {
        Write-Host "Site $domainName already exists"
        return
    }

    Write-Host "Creating $siteName"
    $iisApp = New-Item $siteName -bindings @{protocol="https";bindingInformation="*:443:$domainName";} -physicalPath $directoryPath
    $iisApp | Set-ItemProperty -Name "applicationPool" -Value $domainName
}

function CreateVirtualDirectoryForProject($iisUrl, $projectFilePath) {    
    $uri = [System.Uri]$iisUrl

    $siteToCreate = $uri.DnsSafeHost + "/"
    $applicationToCreate = ""
    $applicationPoolToCreate = $uri.DnsSafeHost

    Write-Host "Going to create the application pool $applicationPoolToCreate"
    CreateApplicationPool $applicationPoolToCreate

    foreach($segment in $uri.Segments)
    {
        Write-Host $segment

        if ($segment -eq "/")
        {
            #do nothing
        }
        elseif ($segment -eq $uri.Segments[$uri.Segments.Count - 1])
        {
            $applicationToCreate = "$siteToCreate$segment" -replace "/", "\"            

            Write-Host "Going to create the application $applicationToCreate"                
            
            CreateWebApplication $applicationToCreate $applicationPoolToCreate $projectFilePath
        }
        else
        {
            $siteToCreate = "$siteToCreate$segment" -replace "/", "\"
            Write-Host "Going to create the an application for directory $siteToCreate"            
            CreateWebApplication $siteToCreate $applicationPoolToCreate $projectFilePath
        }
    }            
}

function CreateVirtualDirectory($siteName, $physicalPath) {
    $virtualDirectoryPath = "IIS:\Sites\$siteName"

    if (Test-Path $virtualDirectoryPath -PathType Container){
        Write-Host "Virtual Directory $virtualDirectoryPath already exists"
        return
    }

    Write-Host "Creating virtual directory $virtualDirectoryPath and pointing to $physicalPath"
    New-Item $virtualDirectoryPath -Type VirtualDirectory -physicalPath $physicalPath
}

function CreateWebApplication($applicationName, $applicationPool, $physicalPath) {
    $applicationPath = "IIS:\Sites\$applicationName"

    if (Test-Path $applicationPath -PathType Container){
        Write-Host "Application $applicationPath already exists"
        return
    }

    Write-Host "Creating application $applicationPath and pointing to $physicalPath"
    $iisApp = New-Item $applicationPath -Type Application -physicalPath $physicalPath -applicationPool $applicationPool    
}

Putting it all together

The asute readers will notice the script above makes use of the function "UpdateHostsFile." That is 2/3 of the above solutions in a single script. Let's put the whole thing together.

function SetupIISFromUrl($iisUrl, $projectFilePath, $rootProjectFolder) {    
    $uri = [System.Uri]$iisUrl

    Write-Host "Setting up IIS for the URL $iisUrl"

    InstallCerts $uri.DnsSafeHost
    UpdateHostsFile $uri.DnsSafeHost
    CreateApplicationPool $uri.DnsSafeHost
        
    if ($uri.Segments.Count -eq 1)
    {
        Write-Host "Detected root application setting up IIS to point site to location of $projectFilePath"
        CreateIISSite $uri.DnsSafeHost $projectFilePath            
    }
    else
    {
        Write-Host "Detected subdomain project, setting IIS to point site to $rootProjectFolder"
        CreateIISSite $uri.DnsSafeHost $rootProjectFolder
        CreateVirtualDirectoryForProject $iisUrl $projectFilePath
    }
}

function UpdateHostsFile($domainName) {
    If ((Get-Content "$($env:windir)\system32\Drivers\etc\hosts" ) -notcontains "127.0.0.1 $domainName") {
        ac -Encoding UTF8  "$($env:windir)\system32\Drivers\etc\hosts" "127.0.0.1 $domainName" 
    }
}

function CreateApplicationPool($domainName){
    $appPoolName = "IIS:\AppPools\$domainName"

    if (Test-Path $appPoolName -PathType Container) {
        Write-Host "Application pool $appPoolName already exists"
        return    
    }

    Write-Host "Creating $appPoolName"
    $appPool = New-Item $appPoolName
    $appPool | Set-ItemProperty -Name "managedRuntimeVersion" -Value "v4.0"
}

function CreateIISSite($domainName, $directoryPath) {   
    $siteName = "IIS:\Sites\$domainName"

    if (Test-Path $siteName -PathType Container) {
        Write-Host "Site $domainName already exists"
        return
    }

    Write-Host "Creating $siteName"
    $iisApp = New-Item $siteName -bindings @{protocol="https";bindingInformation="*:443:$domainName";} -physicalPath $directoryPath
    $iisApp | Set-ItemProperty -Name "applicationPool" -Value $domainName
}

function CreateVirtualDirectoryForProject($iisUrl, $projectFilePath) {    
    $uri = [System.Uri]$iisUrl

    $siteToCreate = $uri.DnsSafeHost + "/"
    $applicationToCreate = ""
    $applicationPoolToCreate = $uri.DnsSafeHost

    Write-Host "Going to create the application pool $applicationPoolToCreate"
    CreateApplicationPool $applicationPoolToCreate

    foreach($segment in $uri.Segments)
    {
        Write-Host $segment

        if ($segment -eq "/")
        {
            #do nothing
        }
        elseif ($segment -eq $uri.Segments[$uri.Segments.Count - 1])
        {
            $applicationToCreate = "$siteToCreate$segment" -replace "/", "\"            

            Write-Host "Going to create the application $applicationToCreate"                
            
            CreateWebApplication $applicationToCreate $applicationPoolToCreate $projectFilePath
        }
        else
        {
            $siteToCreate = "$siteToCreate$segment" -replace "/", "\"
            Write-Host "Going to create the an application for directory $siteToCreate"            
            CreateWebApplication $siteToCreate $applicationPoolToCreate $projectFilePath
        }
    }            
}

function CreateVirtualDirectory($siteName, $physicalPath) {
    $virtualDirectoryPath = "IIS:\Sites\$siteName"

    if (Test-Path $virtualDirectoryPath -PathType Container){
        Write-Host "Virtual Directory $virtualDirectoryPath already exists"
        return
    }

    Write-Host "Creating virtual directory $virtualDirectoryPath and pointing to $physicalPath"
    New-Item $virtualDirectoryPath -Type VirtualDirectory -physicalPath $physicalPath
}

function CreateWebApplication($applicationName, $applicationPool, $physicalPath) {
    $applicationPath = "IIS:\Sites\$applicationName"

    if (Test-Path $applicationPath -PathType Container){
        Write-Host "Application $applicationPath already exists"
        return
    }

    Write-Host "Creating application $applicationPath and pointing to $physicalPath"
    $iisApp = New-Item $applicationPath -Type Application -physicalPath $physicalPath -applicationPool $applicationPool    
}

function InstallCerts($domainName) {    
    $items = Get-ChildItem -Path "Cert:\LocalMachine\My"
    $localAuthority = $false
    $localWebSiteThumbprint = $false

    # Try to find the existing certs
    foreach($item in $items)
    {
        $name = $item.DnsNameList
        Write-Host $name

        if ($name -contains "Local Development Root Certificate Authority")
        {
            Write-Host "Found the Root Authority Certificate!"
            $localAuthority = $item.Thumbprint
        }
        elseif ($name -contains "www.$domainName.com")
        {
            Write-Host "Found the local certificate!"
            $localWebSiteThumbprint = $item.Thumbprint
        }
    }

    # Check to see if the local authority is found
    if ($localAuthority -eq $false)
    {
        Write-Host "Creating the Local Development Root Certificate Authority"
        $rootcert = New-SelfSignedCertificate -CertStoreLocation Cert:\LocalMachine\my -DnsName "Local Development Root Certificate Authority" -NotAfter (Get-Date).AddYears(3) -KeyUsage CertSign
	    # Replace the password!!!!
        $rootCertPwd = ConvertTo-SecureString -String "[[ReplaceMEEEEEEEEE!!!!!!!]]" -Force -AsPlainText
        $localAuthority = $rootcert.Thumbprint
    }

    # Check to see if the local website is found, if it is not then create it
    if ($localWebSiteThumbprint -eq $false)
    {   
        Write-Host "Creating the local certificate" 
        $signer = (Get-ChildItem -Path "Cert:\Localmachine\my\$localAuthority")
	
	    # This will make the cert a wild card cert
        $domainCert = New-SelfSignedCertificate -DnsName "www.$domainName.com", "*.$domainName.com", "$domainName.com", "localhost" -CertStoreLocation "cert:\LocalMachine\My" -Signer $signer -NotAfter (Get-Date).AddYears(3) -KeyUsage DigitalSignature    
    }

    $rootItems = Get-ChildItem -Path Cert:\LocalMachine\Root
    $localCAIsInRoot = $false

    foreach ($rootItem in $rootItems)
    {
        $rootItemName = $rootItem.DnsNameList    

        if ($rootItemName -contains "Local Development Root Certificate Authority")
        {
            Write-Host "Found the Root Authority Certificate!"
            $localCAIsInRoot = $true
        }
    }

    if ($localCAIsInRoot -eq $false)
    {
        $myCertificates = Get-ChildItem -Path "Cert:\LocalMachine\My"

        $localRootCA = $false
        foreach ($cert in $myCertificates)
        {
            $certName = $cert.DnsNameList
            if ($certName -contains "Local Development Root Certificate Authority")
            {
                Write-Host "Found the Root Authority Certificate to Add!"
                $localRootCA = $cert
            }   
        }

        $DestStoreScope = 'LocalMachine'
        $DestStoreName = 'root'

        Write-Host "Adding Local Certificate To Root"
        $DestStore = New-Object  -TypeName System.Security.Cryptography.X509Certificates.X509Store  -ArgumentList $DestStoreName, $DestStoreScope
        $DestStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
        $DestStore.Add($localRootCA)

        $DestStore.Close()
    }
}

# All the functions have been declared, all we need to do is call the function to set everything up
SetupIISFromUrl www.mysite.com "C:\Code\SamePath" "C:\Code"

Conclusion

I hope all of this helps in automating your developer machine setup. If you have any problems with the scripts or have any general questions, please let me know on twitter. You can DM me or just message me directly.