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;
}
}
}