/// <summary> /// Generate a list of actions which will be performed on the next renewal of this managed certificate, populating /// the description of each action with a Markdown format description /// </summary> /// <param name="item"></param> /// <param name="serverProvider"></param> /// <param name="certifyManager"></param> /// <returns></returns> public async Task <List <ActionStep> > GeneratePreview( ManagedCertificate item, ICertifiedServer serverProvider, ICertifyManager certifyManager, ICredentialsManager credentialsManager ) { var newLine = "\r\n"; var steps = new List <ActionStep>(); var stepIndex = 1; var hasDomains = true; var allTaskProviders = await certifyManager.GetDeploymentProviders(); var certificateAuthorities = await certifyManager.GetCertificateAuthorities(); // ensure defaults are applied for the deployment mode, overwriting any previous selections item.RequestConfig.ApplyDeploymentOptionDefaults(); if (string.IsNullOrEmpty(item.RequestConfig.PrimaryDomain)) { hasDomains = false; } if (hasDomains) { var allCredentials = await credentialsManager.GetCredentials(); var allDomains = new List <string> { item.RequestConfig.PrimaryDomain }; if (item.RequestConfig.SubjectAlternativeNames != null) { allDomains.AddRange(item.RequestConfig.SubjectAlternativeNames); } allDomains = allDomains.Distinct().OrderBy(d => d).ToList(); // certificate summary var certDescription = new StringBuilder(); var ca = certificateAuthorities.FirstOrDefault(c => c.Id == item.CertificateAuthorityId); certDescription.AppendLine( $"A new certificate will be requested from the *{ca?.Title ?? "Default"}* certificate authority for the following domains:" ); certDescription.AppendLine($"\n**{item.RequestConfig.PrimaryDomain}** (Primary Domain)"); if (item.RequestConfig.SubjectAlternativeNames.Any(s => s != item.RequestConfig.PrimaryDomain)) { certDescription.AppendLine($" and will include the following *Subject Alternative Names*:"); foreach (var d in item.RequestConfig.SubjectAlternativeNames) { certDescription.AppendLine($"* {d} "); } } steps.Add(new ActionStep { Title = "Summary", Description = certDescription.ToString() }); // validation steps : // TODO: preview description should come from the challenge type provider var challengeInfo = new StringBuilder(); foreach (var challengeConfig in item.RequestConfig.Challenges) { challengeInfo.AppendLine( $"{newLine}Authorization will be attempted using the **{challengeConfig.ChallengeType}** challenge type." + newLine ); var matchingDomains = item.GetChallengeConfigDomainMatches(challengeConfig, allDomains); if (matchingDomains.Any()) { challengeInfo.AppendLine( $"{newLine}The following matching domains will use this challenge: " + newLine ); foreach (var d in matchingDomains) { challengeInfo.AppendLine($"{newLine} * {d}"); } challengeInfo.AppendLine( $"{newLine}**Please review the Deployment section below to ensure this certificate will be applied to the expected website bindings (if any).**" + newLine ); } else { challengeInfo.AppendLine( $"{newLine}*No domains will match this challenge type.* Either the challenge is not required or domain matches are not fully configured." ); } challengeInfo.AppendLine(newLine); if (challengeConfig.ChallengeType == SupportedChallengeTypes.CHALLENGE_TYPE_HTTP) { challengeInfo.AppendLine( $"This will involve the creation of a randomly named (extensionless) text file for each domain (website) included in the certificate." + newLine ); if (CoreAppSettings.Current.EnableHttpChallengeServer) { challengeInfo.AppendLine( $"The *Http Challenge Server* option is enabled. This will create a temporary web service on port 80 during validation. " + $"This process co-exists with your main web server and listens for http challenge requests to /.well-known/acme-challenge/. " + $"If you are using a web server on port 80 other than IIS (or other http.sys enabled server), that will be used instead." + newLine ); } if (!string.IsNullOrEmpty(item.RequestConfig.WebsiteRootPath) && string.IsNullOrEmpty(challengeConfig.ChallengeRootPath)) { challengeInfo.AppendLine( $"The file will be created at the path `{item.RequestConfig.WebsiteRootPath}\\.well-known\\acme-challenge\\` " + newLine ); } if (!string.IsNullOrEmpty(challengeConfig.ChallengeRootPath)) { challengeInfo.AppendLine( $"The file will be created at the path `{challengeConfig.ChallengeRootPath}\\.well-known\\acme-challenge\\` " + newLine ); } challengeInfo.AppendLine( $"The text file will need to be accessible from the URL `http://<yourdomain>/.well-known/acme-challenge/<randomfilename>` " + newLine); challengeInfo.AppendLine( $"The issuing Certificate Authority will follow any redirection in place (such as rewriting the URL to *https*) but the initial request will be made via *http* on port 80. " + newLine); } if (challengeConfig.ChallengeType == SupportedChallengeTypes.CHALLENGE_TYPE_DNS) { challengeInfo.AppendLine( $"This will involve the creation of a DNS TXT record named `_acme-challenge.yourdomain.com` for each domain or subdomain included in the certificate. " + newLine); if (!string.IsNullOrEmpty(challengeConfig.ChallengeCredentialKey)) { var creds = allCredentials.FirstOrDefault(c => c.StorageKey == challengeConfig.ChallengeCredentialKey); if (creds != null) { challengeInfo.AppendLine( $"The following DNS API Credentials will be used: **{creds.Title}** " + newLine); } else { challengeInfo.AppendLine( $"**Invalid credential settngs.** The currently selected credential does not exist." ); } } else { challengeInfo.AppendLine( $"No DNS API Credentials have been set. API Credentials are normally required to make automatic updates to DNS records." ); } challengeInfo.AppendLine( newLine + $"The issuing Certificate Authority will follow any redirection in place (such as a substitute CNAME pointing to another domain) but the initial request will be made against any of the domain's nameservers. " ); } if (!string.IsNullOrEmpty(challengeConfig.DomainMatch)) { challengeInfo.AppendLine( $"{newLine}This challenge type will be selected based on matching domain **{challengeConfig.DomainMatch}** "); } else { if (item.RequestConfig.Challenges.Count > 1) { challengeInfo.AppendLine( $"{newLine}This challenge type will be selected for any domain not matched by another challenge. "); } else { challengeInfo.AppendLine( $"{newLine}**This challenge type will be selected for all domains.**"); } } challengeInfo.AppendLine($"{newLine}---{newLine}"); } steps.Add(new ActionStep { Title = $"{stepIndex}. Domain Validation", Category = "Validation", Description = challengeInfo.ToString() }); stepIndex++; // pre request tasks if (item.PreRequestTasks?.Any() == true) { var substeps = item.PreRequestTasks.Select(t => new ActionStep { Key = t.Id, Title = $"{t.TaskName} ({allTaskProviders.FirstOrDefault(p => p.Id == t.TaskTypeId)?.Title})", Description = t.Description }); steps.Add(new ActionStep { Title = $"{stepIndex}. Pre-Request Tasks", Category = "PreRequestTasks", Description = $"Execute {substeps.Count()} Pre-Request Tasks", Substeps = substeps.ToList() }); stepIndex++; } // cert request step var certRequest = $"A Certificate Signing Request (CSR) will be submitted to the Certificate Authority, using the **{item.RequestConfig.CSRKeyAlg}** signing algorithm."; steps.Add(new ActionStep { Title = $"{stepIndex}. Certificate Request", Category = "CertificateRequest", Description = certRequest }); stepIndex++; // deployment & binding steps var deploymentDescription = new StringBuilder(); var deploymentStep = new ActionStep { Title = $"{stepIndex}. Deployment", Category = "Deployment", Description = "" }; if ( item.RequestConfig.DeploymentSiteOption == DeploymentOption.Auto || item.RequestConfig.DeploymentSiteOption == DeploymentOption.AllSites || item.RequestConfig.DeploymentSiteOption == DeploymentOption.SingleSite ) { // deploying to single or multiple Site if (item.RequestConfig.DeploymentBindingMatchHostname) { deploymentDescription.AppendLine( "* Deploy to hostname bindings matching certificate domains."); } if (item.RequestConfig.DeploymentBindingBlankHostname) { deploymentDescription.AppendLine("* Deploy to bindings with blank hostname."); } if (item.RequestConfig.DeploymentBindingReplacePrevious) { deploymentDescription.AppendLine("* Deploy to bindings with previous certificate."); } if (item.RequestConfig.DeploymentBindingOption == DeploymentBindingOption.AddOrUpdate) { deploymentDescription.AppendLine("* Add or Update https bindings as required"); } if (item.RequestConfig.DeploymentBindingOption == DeploymentBindingOption.UpdateOnly) { deploymentDescription.AppendLine("* Update https bindings as required (no auto-created https bindings)"); } if (item.RequestConfig.DeploymentSiteOption == DeploymentOption.SingleSite) { if (!string.IsNullOrEmpty(item.ServerSiteId)) { try { var siteInfo = await serverProvider.GetSiteById(item.ServerSiteId); deploymentDescription.AppendLine($"## Deploying to Site" + newLine + newLine + $"`{siteInfo.Name}`" + newLine); } catch (Exception exp) { deploymentDescription.AppendLine($"Error: **cannot identify selected site.** {exp.Message} "); } } } else { deploymentDescription.AppendLine($"## Deploying to all matching sites:"); } // add deployment sub-steps (if any) var bindingRequest = await certifyManager.DeployCertificate(item, null, true); if (bindingRequest.Actions == null || !bindingRequest.Actions.Any()) { deploymentStep.Substeps = new List <ActionStep> { new ActionStep { Description = newLine + "**There are no matching targets to deploy to. Certificate will be stored but currently no bindings will be updated.**" } }; } else { deploymentStep.Substeps = bindingRequest.Actions; deploymentDescription.AppendLine(" Action | Site | Binding "); deploymentDescription.Append(" ------ | ---- | ------- "); } } else if (item.RequestConfig.DeploymentSiteOption == DeploymentOption.DeploymentStoreOnly) { deploymentDescription.AppendLine("* The certificate will be saved to the local machines Certificate Store only (Personal/My Store)"); } else if (item.RequestConfig.DeploymentSiteOption == DeploymentOption.NoDeployment) { deploymentDescription.AppendLine("* The certificate will be saved to local disk only."); } deploymentStep.Description = deploymentDescription.ToString(); steps.Add(deploymentStep); stepIndex++; // post request deployment tasks if (item.PostRequestTasks?.Any() == true) { var substeps = item.PostRequestTasks.Select(t => new ActionStep { Key = t.Id, Title = $"{t.TaskName} ({allTaskProviders.FirstOrDefault(p => p.Id == t.TaskTypeId)?.Title})", Description = t.Description }); steps.Add(new ActionStep { Title = $"{stepIndex}. Post-Request (Deployment) Tasks", Category = "PostRequestTasks", Description = $"Execute {substeps.Count()} Post-Request Tasks", Substeps = substeps.ToList() }); stepIndex++; } stepIndex = steps.Count; } else { steps.Add(new ActionStep { Title = "Certificate has no domains", Description = "No domains have been added to this certificate, so a certificate cannot be requested. Each certificate requires a primary domain (a 'subject') and an optional list of additional domains (subject alternative names)." }); } return(steps); }
/// <summary> /// Prepares IIS to respond to a http-01 challenge /// </summary> /// <returns> Test the challenge response locally. </returns> private async Task <ActionResult> PerformChallengeResponse_Http01(ILog log, ICertifiedServer iisManager, string domain, ManagedCertificate managedCertificate, PendingAuthorization pendingAuth) { var requestConfig = managedCertificate.RequestConfig; var httpChallenge = pendingAuth.Challenges.FirstOrDefault(c => c.ChallengeType == SupportedChallengeTypes.CHALLENGE_TYPE_HTTP); if (httpChallenge == null) { var msg = $"No http challenge to complete for {managedCertificate.Name}. Request cannot continue."; log.Warning(msg); return(new ActionResult { IsSuccess = false, Message = msg }); } log.Information($"Preparing challenge response for the issuing Certificate Authority to check at: {httpChallenge.ResourceUri} with content {httpChallenge.Value}"); log.Information("If the challenge response file is not accessible at this exact URL the validation will fail and a certificate will not be issued."); // get website root path (from challenge config or fallback to deprecated // WebsiteRootPath), expand environment variables if required var websiteRootPath = requestConfig.WebsiteRootPath; var challengeConfig = managedCertificate.GetChallengeConfig(domain); if (!string.IsNullOrEmpty(challengeConfig.ChallengeRootPath)) { websiteRootPath = challengeConfig.ChallengeRootPath; } if (!string.IsNullOrEmpty(managedCertificate.ServerSiteId)) { var siteInfo = await iisManager.GetSiteById(managedCertificate.ServerSiteId); if (siteInfo == null) { return(new ActionResult { IsSuccess = false, Message = "IIS Website unavailable. Site may be removed or IIS is unavailable" }); } // if website root path not specified, determine it now if (string.IsNullOrEmpty(websiteRootPath)) { websiteRootPath = siteInfo.Path; } if (!string.IsNullOrEmpty(websiteRootPath) && websiteRootPath.Contains("%")) { // if websiteRootPath contains %websiteroot% variable, replace that with the // current physical path for the site if (websiteRootPath.Contains("%websiteroot%")) { // sets env variable for this process only Environment.SetEnvironmentVariable("websiteroot", siteInfo.Path); } // expand any environment variables present in site path websiteRootPath = Environment.ExpandEnvironmentVariables(websiteRootPath); } } log.Information("Using website path {path}", websiteRootPath ?? "[Auto]"); if (string.IsNullOrEmpty(websiteRootPath) || !Directory.Exists(websiteRootPath)) { // our website no longer appears to exist on disk, continuing would potentially // create unwanted folders, so it's time for us to give up var msg = $"The website root path for {managedCertificate.Name} could not be determined. Request cannot continue."; log.Error(msg); return(new ActionResult { IsSuccess = false, Message = msg }); } // copy temp file to path challenge expects in web folder var destFile = Path.Combine(websiteRootPath, httpChallenge.ResourcePath); var destPath = Path.GetDirectoryName(destFile); if (!Directory.Exists(destPath)) { try { Directory.CreateDirectory(destPath); } catch (Exception exp) { // failed to create directory, probably permissions or may be invalid config var msg = $"Pre-config check failed: Could not create directory: {destPath}"; log.Error(exp, msg); return(new ActionResult { IsSuccess = false, Message = msg }); } } // copy challenge response to web folder /.well-known/acme-challenge. Check if it already // exists (as in 'configcheck' file) as can cause conflicts. if (!File.Exists(destFile) || !destFile.EndsWith("configcheck")) { try { File.WriteAllText(destFile, httpChallenge.Value); } catch (Exception exp) { // failed to create configcheck file, probably permissions or may be invalid config var msg = $"Pre-config check failed: Could not create file: {destFile}"; log.Error(exp, msg); return(new ActionResult { IsSuccess = false, Message = msg }); } } // prepare cleanup - should this be configurable? Because in some case many sites // renewing may all point to the same web root, we keep the configcheck file pendingAuth.Cleanup = () => { if (!destFile.EndsWith("configcheck") && File.Exists(destFile)) { log.Debug("Challenge Cleanup: Removing {file}", destFile); try { File.Delete(destFile); } catch { } } }; // if config checks are enabled but our last renewal was successful, skip auto config // until we have failed twice if (requestConfig.PerformExtensionlessConfigChecks) { if (managedCertificate.DateRenewed != null && managedCertificate.RenewalFailureCount < 2) { return(new ActionResult { IsSuccess = true, Message = $"Skipping URL access checks and auto config (if applicable): {httpChallenge.ResourceUri}. Will resume checks if renewal failure count exceeds 2 attempts." }); } // first check if it already works with no changes if (await _netUtil.CheckURL(log, httpChallenge.ResourceUri)) { return(new ActionResult { IsSuccess = true, Message = $"Verified URL is accessible: {httpChallenge.ResourceUri}" }); } // initial check didn't work, if auto config enabled attempt to find a working config if (requestConfig.PerformAutoConfig) { // FIXME: need to only overwrite config we have auto populated, not user // specified config, compare to our preconfig and only overwrite if same as ours? // Or include preset key in our config, or make behaviour configurable LogAction($"Pre-config check failed: Auto-config will overwrite existing config: {destPath}\\web.config"); var configOptions = Directory.EnumerateFiles(Path.Combine(Environment.CurrentDirectory, "Scripts", "Web.config"), "*.config"); foreach (var configFile in configOptions) { // create a web.config for extensionless files, then test it (make a request // for the extensionless configcheck file over http) var webConfigContent = File.ReadAllText(configFile); // no existing config, attempt auto config and perform test LogAction($"Testing config alternative: " + configFile); try { System.IO.File.WriteAllText(Path.Combine(destPath, "web.config"), webConfigContent); } catch (Exception exp) { LogAction($"Failed to write config: " + exp.Message); } if (await _netUtil.CheckURL(log, httpChallenge.ResourceUri)) { return(new ActionResult { IsSuccess = true, Message = $"Verified URL is accessible: {httpChallenge.ResourceUri}" }); } } } // failed to auto configure or confirm resource is accessible return(new ActionResult { IsSuccess = false, Message = $"Could not verify URL is accessible: {httpChallenge.ResourceUri}" }); } else { return(new ActionResult { IsSuccess = false, Message = $"Config checks disabled. Did not verify URL access: {httpChallenge.ResourceUri}" }); } }