summaryrefslogtreecommitdiffstats
path: root/src/main.lib/Plugins/ValidationPlugins/Http/HttpValidation.cs
blob: 11a5c4557b163eeb5f99cc6d7931725594b13716 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
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
{
    /// <summary>
    /// Base implementation for HTTP-01 validation plugins
    /// </summary>
    internal abstract class HttpValidation<TOptions, TPlugin> :
        Validation<Http01ChallengeValidationDetails>
        where TOptions : HttpValidationOptions<TPlugin>
        where TPlugin : IValidationPlugin
    {
        private readonly List<string> _filesWritten = new List<string>();

        protected TOptions _options;
        protected ILogService _log;
        protected IInputService _input;
        protected ISettingsService _settings;
        protected Renewal _renewal;
        protected RunLevel _runLevel;

        /// <summary>
        /// Multiple http-01 validation challenges can be answered at the same time
        /// </summary>
        public override ParallelOperations Parallelism => ParallelOperations.Answer;

        /// <summary>
        /// Path used for the current renewal, may not be same as _options.Path
        /// because of the "Split" function employed by IISSites target
        /// </summary>
        protected string? _path;

        /// <summary>
        /// Provides proxy settings for site warmup
        /// </summary>
        private readonly ProxyService _proxy;

        /// <summary>
        /// Where to find the template for the web.config that's copied to the webroot
        /// </summary>
        protected string TemplateWebConfig => Path.Combine(Path.GetDirectoryName(_settings.ExePath), "web_config.xml");

        /// <summary>
        /// Character to seperate folders, different for FTP 
        /// </summary>
        protected virtual char PathSeparator => '\\';

        /// <summary>
        /// Constructor
        /// </summary>
        /// <param name="log"></param>
        /// <param name="input"></param>
        /// <param name="options"></param>
        /// <param name="proxy"></param>
        /// <param name="renewal"></param>
        /// <param name="target"></param>
        /// <param name="runLevel"></param>
        /// <param name="identifier"></param>
        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;
        }

        /// <summary>
        /// Handle http challenge
        /// </summary>
        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");
            }
        }

        /// <summary>
        /// Default commit function, doesn't do anything because 
        /// default doesn't do parallel operation
        /// </summary>
        /// <returns></returns>
        public override Task Commit() => Task.CompletedTask;

        /// <summary>
        /// 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
        /// </summary>
        /// <param name="uri"></param>
        private async Task<string> WarmupSite(Http01ChallengeValidationDetails challenge)
        {
            using var client = _proxy.GetHttpClient(false);
            var response = await client.GetAsync(challenge.HttpResourceUrl);
            return await response.Content.ReadAsStringAsync();
        }

        /// <summary>
        /// Should create any directory structure needed and write the file for authorization
        /// </summary>
        /// <param name="answerPath">where the answerFile should be located</param>
        /// <param name="fileContents">the contents of the file to write</param>
        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);
            }
        }

        /// <summary>
        /// Can be used to write out server specific configuration, to handle extensionless files etc.
        /// </summary>
        /// <param name="target"></param>
        /// <param name="answerPath"></param>
        /// <param name="token"></param>
        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); ;
                }
            }
        }

        /// <summary>
        /// Get the template for the web.config
        /// </summary>
        /// <returns></returns>
        private Lazy<string?> GetWebConfig() => new Lazy<string?>(() => {
            try
            {
                return File.ReadAllText(TemplateWebConfig);
            } 
            catch 
            {
                return null;
            }
        });

        /// <summary>
        /// Combine root path with relative path
        /// </summary>
        /// <param name="root"></param>
        /// <param name="path"></param>
        /// <returns></returns>
        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)}";
        }

        /// <summary>
        /// Delete folder if it's empty
        /// </summary>
        /// <param name="path"></param>
        /// <returns></returns>
        private async Task<bool> 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;
            }
        }

        /// <summary>
        /// Write file with content to a specific location
        /// </summary>
        /// <param name="root"></param>
        /// <param name="path"></param>
        /// <param name="content"></param>
        protected abstract Task WriteFile(string path, string content);

        /// <summary>
        /// Delete file from specific location
        /// </summary>
        /// <param name="root"></param>
        /// <param name="path"></param>
        protected abstract Task DeleteFile(string path);

        /// <summary>
        /// Check if folder is empty
        /// </summary>
        /// <param name="root"></param>
        /// <param name="path"></param>
        protected abstract Task<bool> IsEmpty(string path);

        /// <summary>
        /// Delete folder if not empty
        /// </summary>
        /// <param name="root"></param>
        /// <param name="path"></param>
        protected abstract Task DeleteFolder(string path);

        /// <summary>
        /// Refresh
        /// </summary>
        /// <param name="scheduled"></param>
        /// <returns></returns>
        protected virtual void Refresh(TargetPart targetPart) { }

        /// <summary>
        /// Dispose
        /// </summary>
        public override async Task CleanUp()
        {
            try
            {
                if (_path != null)
                {
                    var folders = new List<string>();
                    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");
            }
        }
    }
}