using ACMESharp.Authorizations; using PKISharp.WACS.Context; using PKISharp.WACS.DomainObjects; using PKISharp.WACS.Plugins.Interfaces; using PKISharp.WACS.Services; using System; using System.Collections.Generic; 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 readonly List _filesWritten = new List(); protected TOptions _options; protected ILogService _log; protected IInputService _input; protected ISettingsService _settings; protected Renewal _renewal; protected RunLevel _runLevel; /// /// Multiple http-01 validation challenges can be answered at the same time /// public override ParallelOperations Parallelism => ParallelOperations.Answer; /// /// 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) { // Should always have a value, confirmed by RenewalExecutor // check only to satifiy the compiler if (context.TargetPart != null) { 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"); } } /// /// Default commit function, doesn't do anything because /// default doesn't do parallel operation /// /// public override Task Commit() => Task.CompletedTask; /// /// 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("No path specified for HttpValidation"); } var path = CombinePath(_path, challenge.HttpResourcePath); WriteFile(path, challenge.HttpResourceValue); if (!_filesWritten.Contains(path)) { _filesWritten.Add(path); } } /// /// 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("No path specified for HttpValidation"); } if (_options.CopyWebConfig == true) { try { var partialPath = challenge.HttpResourcePath.Split('/').Last(); var destination = CombinePath(_path, challenge.HttpResourcePath.Replace(partialPath, "web.config")); if (!_filesWritten.Contains(destination)) { var content = GetWebConfig().Value; if (content != null) { _log.Debug("Writing web.config"); WriteFile(destination, content); _filesWritten.Add(destination); } } } catch (Exception ex) { _log.Warning("Unable to write web.config: {ex}", ex.Message); ; } } } /// /// Get the template for the web.config /// /// private Lazy GetWebConfig() => new Lazy(() => { try { return File.ReadAllText(TemplateWebConfig); } catch { return null; } }); /// /// 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 async Task DeleteFolderIfEmpty(string path) { if (await IsEmpty(path)) { await DeleteFolder(path); return true; } else { _log.Debug("Not deleting {path} because it doesn't exist or it's not empty.", path); return false; } } /// /// Write file with content to a specific location /// /// /// /// protected abstract Task WriteFile(string path, string content); /// /// Delete file from specific location /// /// /// protected abstract Task DeleteFile(string path); /// /// Check if folder is empty /// /// /// protected abstract Task IsEmpty(string path); /// /// Delete folder if not empty /// /// /// protected abstract Task DeleteFolder(string path); /// /// Refresh /// /// /// protected virtual void Refresh(TargetPart targetPart) { } /// /// Dispose /// public override async Task CleanUp() { try { if (_path != null) { var folders = new List(); foreach (var file in _filesWritten) { _log.Debug("Deleting files"); await DeleteFile(file); var folder = file.Substring(0, file.LastIndexOf(PathSeparator)); if (!folders.Contains(folder)) { folders.Add(folder); } } if (_settings.Validation.CleanupFolders) { _log.Debug("Deleting empty folders"); foreach (var folder in folders) { if (await DeleteFolderIfEmpty(folder)) { var idx = folder.LastIndexOf(PathSeparator); if (idx >= 0) { var parent = folder.Substring(0, folder.LastIndexOf(PathSeparator)); await DeleteFolderIfEmpty(parent); } } } } } } catch (Exception ex) { _log.Error(ex, "Error occured while deleting folder structure"); } } } }