using ACMESharp.Authorizations; using PKISharp.WACS.DomainObjects; using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Threading.Tasks; namespace PKISharp.WACS.Plugins.ValidationPlugins { /// /// Base implementation for HTTP-01 validation plugins /// internal abstract class HttpValidation : Validation where TOptions : HttpValidationOptions where TPlugin : IValidationPlugin { private bool _webConfigWritten = false; private bool _challengeWritten = false; protected TOptions _options; protected ILogService _log; protected IInputService _input; protected ISettingsService _settings; protected Renewal _renewal; protected RunLevel _runLevel; /// /// Path used for the current renewal, may not be same as _options.Path /// because of the "Split" function employed by IISSites target /// protected string? _path; /// /// Provides proxy settings for site warmup /// private readonly ProxyService _proxy; /// /// Where to find the template for the web.config that's copied to the webroot /// protected string TemplateWebConfig => Path.Combine(Path.GetDirectoryName(_settings.ExePath), "web_config.xml"); /// /// Character to seperate folders, different for FTP /// protected virtual char PathSeparator => '\\'; /// /// Constructor /// /// /// /// /// /// /// /// /// public HttpValidation(TOptions options, RunLevel runLevel, HttpValidationParameters pars) { _options = options; _runLevel = runLevel; _path = options.Path; _log = pars.LogService; _input = pars.InputService; _proxy = pars.ProxyService; _settings = pars.Settings; _renewal = pars.Renewal; } /// /// Handle http challenge /// public async override Task PrepareChallenge(ValidationContext context, Http01ChallengeValidationDetails challenge) { Refresh(context.TargetPart); WriteAuthorizationFile(challenge); WriteWebConfig(challenge); _log.Information("Answer should now be browsable at {answerUri}", challenge.HttpResourceUrl); if (_runLevel.HasFlag(RunLevel.Test) && _renewal.New) { if (await _input.PromptYesNo("[--test] Try in default browser?", false)) { Process.Start(new ProcessStartInfo { FileName = challenge.HttpResourceUrl, UseShellExecute = true }); await _input.Wait(); } } string? foundValue = null; try { var value = await WarmupSite(challenge); if (Equals(value, challenge.HttpResourceValue)) { _log.Information("Preliminary validation looks good, but the ACME server will be more thorough"); } else { _log.Warning("Preliminary validation failed, the server answered '{value}' instead of '{expected}'. The ACME server might have a different perspective", foundValue ?? "(null)", challenge.HttpResourceValue); } } catch (HttpRequestException hrex) { _log.Warning("Preliminary validation failed because '{hrex}'", hrex.Message); } catch (Exception ex) { _log.Error(ex, "Preliminary validation failed"); } } /// /// Warm up the target site, giving the application a little /// time to start up before the validation request comes in. /// Mostly relevant to classic FileSystem validation /// /// private async Task WarmupSite(Http01ChallengeValidationDetails challenge) { using var client = _proxy.GetHttpClient(false); var response = await client.GetAsync(challenge.HttpResourceUrl); return await response.Content.ReadAsStringAsync(); } /// /// Should create any directory structure needed and write the file for authorization /// /// where the answerFile should be located /// the contents of the file to write private void WriteAuthorizationFile(Http01ChallengeValidationDetails challenge) { if (_path == null) { throw new InvalidOperationException(); } WriteFile(CombinePath(_path, challenge.HttpResourcePath), challenge.HttpResourceValue); _challengeWritten = true; } /// /// Can be used to write out server specific configuration, to handle extensionless files etc. /// /// /// /// private void WriteWebConfig(Http01ChallengeValidationDetails challenge) { if (_path == null) { throw new InvalidOperationException(); } if (_options.CopyWebConfig == true) { try { _log.Debug("Writing web.config"); var partialPath = challenge.HttpResourcePath.Split('/').Last(); var destination = CombinePath(_path, challenge.HttpResourcePath.Replace(partialPath, "web.config")); var content = GetWebConfig(); WriteFile(destination, content); _webConfigWritten = true; } catch (Exception ex) { _log.Warning("Unable to write web.config: {ex}", ex.Message); ; } } } /// /// Get the template for the web.config /// /// private string GetWebConfig() => File.ReadAllText(TemplateWebConfig); /// /// Can be used to write out server specific configuration, to handle extensionless files etc. /// /// /// /// private void DeleteWebConfig(Http01ChallengeValidationDetails challenge) { if (_path == null) { throw new InvalidOperationException(); } if (_webConfigWritten) { _log.Debug("Deleting web.config"); var partialPath = challenge.HttpResourcePath.Split('/').Last(); var destination = CombinePath(_path, challenge.HttpResourcePath.Replace(partialPath, "web.config")); DeleteFile(destination); } } /// /// Should delete any authorizations /// /// where the answerFile should be located /// the token /// the website root path /// the file path for the authorization file private void DeleteAuthorization(Http01ChallengeValidationDetails challenge) { try { if (_path != null && _challengeWritten) { _log.Debug("Deleting answer"); var path = CombinePath(_path, challenge.HttpResourcePath); var partialPath = challenge.HttpResourcePath.Split('/').Last(); DeleteFile(path); if (_settings.Validation.CleanupFolders) { path = path.Replace($"{PathSeparator}{partialPath}", ""); if (DeleteFolderIfEmpty(path)) { var idx = path.LastIndexOf(PathSeparator); if (idx >= 0) { path = path.Substring(0, path.LastIndexOf(PathSeparator)); DeleteFolderIfEmpty(path); } } } } } catch (Exception ex) { _log.Warning("Error occured while deleting folder structure. Error: {@ex}", ex); } } /// /// Combine root path with relative path /// /// /// /// protected virtual string CombinePath(string root, string path) { if (root == null) { root = string.Empty; } var expandedRoot = Environment.ExpandEnvironmentVariables(root); var trim = new[] { '/', '\\' }; return $"{expandedRoot.TrimEnd(trim)}{PathSeparator}{path.TrimStart(trim).Replace('/', PathSeparator)}"; } /// /// Delete folder if it's empty /// /// /// private bool DeleteFolderIfEmpty(string path) { if (IsEmpty(path)) { DeleteFolder(path); return true; } else { _log.Debug("Additional files or folders exist in {folder}, not deleting.", path); return false; } } /// /// Write file with content to a specific location /// /// /// /// protected abstract void WriteFile(string path, string content); /// /// Delete file from specific location /// /// /// protected abstract void DeleteFile(string path); /// /// Check if folder is empty /// /// /// protected abstract bool IsEmpty(string path); /// /// Delete folder if not empty /// /// /// protected abstract void DeleteFolder(string path); /// /// Refresh /// /// /// protected virtual void Refresh(TargetPart targetPart) { } /// /// Dispose /// public override Task CleanUp(ValidationContext context, Http01ChallengeValidationDetails challenge) { DeleteWebConfig(challenge); DeleteAuthorization(challenge); return Task.CompletedTask; } } }