using CaddyManager.Contracts.Configurations.Caddy; using CaddyManager.Contracts.Caddy; using CaddyManager.Contracts.Configurations; using CaddyManager.Contracts.Models.Caddy; namespace CaddyManager.Services.Caddy; /// public class CaddyService( IConfigurationsService configurationsService, ICaddyConfigurationParsingService parsingService) : ICaddyService { /// /// File name of the global configuration Caddyfile /// private const string CaddyGlobalConfigName = "Caddyfile"; private CaddyServiceConfigurations Configurations => configurationsService.Get(); /// public List GetExistingCaddyConfigurations() { if (!Directory.Exists(Configurations.ConfigDir)) { Directory.CreateDirectory(Configurations.ConfigDir); } return [.. Directory.GetFiles(Configurations.ConfigDir) .Where(filePath => Path.GetFileName(filePath) != CaddyGlobalConfigName) .Select(filePath => { var fileName = Path.GetFileNameWithoutExtension(filePath); var info = GetCaddyConfigurationInfo(fileName); info.FileName = fileName; return info; }) .OrderBy(info => info.FileName)]; } /// public string GetCaddyConfigurationContent(string configurationName) { var path = configurationName == CaddyGlobalConfigName ? Path.Combine(Configurations.ConfigDir, CaddyGlobalConfigName) : Path.Combine(Configurations.ConfigDir, $"{configurationName}.caddy"); if (File.Exists(path)) { return File.ReadAllText(path); } return string.Empty; } /// public string GetCaddyGlobalConfigurationContent() => GetCaddyConfigurationContent(CaddyGlobalConfigName); /// public CaddyOperationResponse SaveCaddyConfiguration(CaddySaveConfigurationRequest request) { if (string.IsNullOrWhiteSpace(request.FileName)) { return new CaddyOperationResponse { Success = false, Message = "The configuration file name is required" }; } if (request.FileName != CaddyGlobalConfigName && IsInvalidFileName(request.FileName)) { return Failure("The configuration file name contains invalid characters"); } var filePath = Path.Combine(Configurations.ConfigDir, request.FileName == CaddyGlobalConfigName ? CaddyGlobalConfigName : $"{request.FileName}.caddy"); // if in the new mode, we would have to check if the file already exists if (request.IsNew && File.Exists(filePath)) { return new CaddyOperationResponse { Success = false, Message = "The configuration file already exists" }; } try { File.WriteAllText(filePath, request.Content); return new CaddyOperationResponse { Success = true, Message = "Configuration file saved successfully" }; } catch (Exception e) { return new CaddyOperationResponse { Success = false, Message = e.Message }; } } /// public CaddyOperationResponse SaveCaddyGlobalConfiguration(string content) => SaveCaddyConfiguration( new CaddySaveConfigurationRequest { FileName = CaddyGlobalConfigName, Content = content }); /// public CaddyOperationResponse RenameCaddyConfiguration(string oldFileName, string newFileName) { if (string.IsNullOrWhiteSpace(newFileName)) { return Failure("The configuration file name is required"); } if (oldFileName == CaddyGlobalConfigName || newFileName == CaddyGlobalConfigName) { return Failure("The global Caddyfile cannot be renamed"); } if (oldFileName == newFileName) { return new CaddyOperationResponse { Success = true, Message = "Configuration file renamed successfully" }; } if (IsInvalidFileName(newFileName)) { return Failure("The configuration file name contains invalid characters"); } var oldPath = Path.Combine(Configurations.ConfigDir, $"{oldFileName}.caddy"); var newPath = Path.Combine(Configurations.ConfigDir, $"{newFileName}.caddy"); if (!File.Exists(oldPath)) { return Failure("The configuration file to rename does not exist"); } // On case insensitive file systems the target resolves to the source for a case only rename, // so the collision check has to be skipped there var caseOnlyRename = string.Equals(oldFileName, newFileName, StringComparison.OrdinalIgnoreCase); if (!caseOnlyRename && File.Exists(newPath)) { return Failure("The configuration file already exists"); } try { if (caseOnlyRename) { // A direct move would be rejected as an existing destination on a case insensitive // file system, so go through an intermediate name that cannot collide var tempPath = Path.Combine(Configurations.ConfigDir, $"{newFileName}.caddy.renaming"); File.Move(oldPath, tempPath); File.Move(tempPath, newPath); } else { File.Move(oldPath, newPath); } return new CaddyOperationResponse { Success = true, Message = "Configuration file renamed successfully" }; } catch (Exception e) { return Failure(e.Message); } } private static CaddyOperationResponse Failure(string message) => new() { Success = false, Message = message }; /// /// Guards the rename against path traversal and characters the file system would reject /// private static bool IsInvalidFileName(string fileName) => fileName.Contains("..") || fileName.IndexOfAny(InvalidFileNameChars) >= 0; private static readonly char[] InvalidFileNameChars = [.. Path.GetInvalidFileNameChars().Union(['/', '\\'])]; /// public CaddyDeleteOperationResponse DeleteCaddyConfigurations(List configurationNames) { var failed = new List(); foreach (var configurationName in configurationNames) { if (configurationName != CaddyGlobalConfigName && IsInvalidFileName(configurationName)) { failed.Add(configurationName); continue; } var filePath = Path.Combine(Configurations.ConfigDir, configurationName == CaddyGlobalConfigName ? CaddyGlobalConfigName : $"{configurationName}.caddy"); if (File.Exists(filePath)) { try { File.Delete(filePath); } catch { failed.Add(configurationName); } } else { failed.Add(configurationName); } } return new CaddyDeleteOperationResponse { Success = failed.Count == 0, Message = failed.Count == 0 ? "Configuration(s) deleted successfully" : $"Failed to delete the following configuration(s): {string.Join(", ", failed)}", DeletedConfigurations = configurationNames.Except(failed).ToList() }; } /// public CaddyConfigurationInfo GetCaddyConfigurationInfo(string configurationName) { var result = new CaddyConfigurationInfo { FileName = configurationName }; var content = GetCaddyConfigurationContent(configurationName); if (string.IsNullOrWhiteSpace(content)) { return result; } result.Hostnames = parsingService.GetHostnamesFromCaddyfileContent(content); result.ReverseProxyHostname = parsingService.GetReverseProxyTargetFromCaddyfileContent(content); result.ReverseProxyPorts = parsingService.GetReverseProxyPortsFromCaddyfileContent(content); result.Tags = parsingService.GetTagsFromCaddyfileContent(content); return result; } }