From d5ea7b56cb9a7abd53f51d2846dfb733ba90c527 Mon Sep 17 00:00:00 2001 From: ebolo Date: Sun, 26 Jul 2026 12:55:50 +0700 Subject: [PATCH] feat(caddy): support renaming configuration files --- CaddyManager.Contracts/Caddy/ICaddyService.cs | 8 + CaddyManager.Services/Caddy/CaddyService.cs | 85 ++++++++ .../Services/Caddy/CaddyServiceTests.cs | 181 ++++++++++++++++++ .../CaddyReverseProxiesPage.razor.cs | 6 +- .../CaddyfileEditor/CaddyfileEditor.razor | 3 +- .../CaddyfileEditor/CaddyfileEditor.razor.cs | 72 ++++--- scripts/dotnet-docker.sh | 22 +++ 7 files changed, 350 insertions(+), 27 deletions(-) create mode 100755 scripts/dotnet-docker.sh diff --git a/CaddyManager.Contracts/Caddy/ICaddyService.cs b/CaddyManager.Contracts/Caddy/ICaddyService.cs index 3d1786a..3d47be7 100644 --- a/CaddyManager.Contracts/Caddy/ICaddyService.cs +++ b/CaddyManager.Contracts/Caddy/ICaddyService.cs @@ -41,6 +41,14 @@ public interface ICaddyService /// CaddyOperationResponse SaveCaddyGlobalConfiguration(string content); + /// + /// Method to rename an existing Caddy configuration file + /// + /// + /// + /// + CaddyOperationResponse RenameCaddyConfiguration(string oldFileName, string newFileName); + /// /// Method to delete the given Caddy configurations by name /// diff --git a/CaddyManager.Services/Caddy/CaddyService.cs b/CaddyManager.Services/Caddy/CaddyService.cs index b2e637f..9dfbb3c 100644 --- a/CaddyManager.Services/Caddy/CaddyService.cs +++ b/CaddyManager.Services/Caddy/CaddyService.cs @@ -106,6 +106,91 @@ public class CaddyService( 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) { diff --git a/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs b/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs index 0c1c416..21323fb 100644 --- a/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs +++ b/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs @@ -466,6 +466,187 @@ public class CaddyServiceTests : IDisposable #endregion + #region RenameCaddyConfiguration Tests + + /// + /// Tests that the Caddy service renames an existing configuration file while preserving its content. + /// Setup: Creates a configuration file with known content, then requests a rename to an unused name. + /// Expectation: The service should move the file to the new name, remove the old one and keep the content intact, so a user can correct a configuration's name without losing its reverse proxy definition. + /// + [Fact] + public void RenameCaddyConfiguration_WithExistingFile_RenamesSuccessfully() + { + // Arrange + const string content = "example.com {\n reverse_proxy localhost:8080\n}"; + File.WriteAllText(Path.Combine(_tempConfigDir, "old-name.caddy"), content); + + // Act + var result = _service.RenameCaddyConfiguration("old-name", "new-name"); + + // Assert + result.Success.Should().BeTrue(); + result.Message.Should().Be("Configuration file renamed successfully"); + File.Exists(Path.Combine(_tempConfigDir, "old-name.caddy")).Should().BeFalse(); + File.ReadAllText(Path.Combine(_tempConfigDir, "new-name.caddy")).Should().Be(content); + } + + /// + /// Tests that the Caddy service refuses to rename a configuration onto a name that is already taken. + /// Setup: Creates two configuration files with distinct content, then attempts to rename the first onto the second. + /// Expectation: The service should fail without touching either file, preventing a rename from silently overwriting an unrelated reverse proxy configuration. + /// + [Fact] + public void RenameCaddyConfiguration_WithExistingTarget_ReturnsFailureAndLeavesFilesUntouched() + { + // Arrange + var sourcePath = Path.Combine(_tempConfigDir, "source.caddy"); + var targetPath = Path.Combine(_tempConfigDir, "target.caddy"); + File.WriteAllText(sourcePath, "source content"); + File.WriteAllText(targetPath, "target content"); + + // Act + var result = _service.RenameCaddyConfiguration("source", "target"); + + // Assert + result.Success.Should().BeFalse(); + result.Message.Should().Be("The configuration file already exists"); + File.ReadAllText(sourcePath).Should().Be("source content"); + File.ReadAllText(targetPath).Should().Be("target content"); + } + + /// + /// Tests that the Caddy service reports a failure when the configuration to rename is not present. + /// Setup: Requests a rename for a configuration name that has no file in the configuration directory. + /// Expectation: The service should return a descriptive failure rather than creating anything, so a stale UI listing cannot produce an empty configuration file. + /// + [Fact] + public void RenameCaddyConfiguration_WithMissingSource_ReturnsFailure() + { + // Act + var result = _service.RenameCaddyConfiguration("does-not-exist", "new-name"); + + // Assert + result.Success.Should().BeFalse(); + result.Message.Should().Be("The configuration file to rename does not exist"); + File.Exists(Path.Combine(_tempConfigDir, "new-name.caddy")).Should().BeFalse(); + } + + /// + /// Tests that the Caddy service rejects an empty new name for a rename. + /// Setup: Creates a configuration file, then attempts to rename it to whitespace. + /// Expectation: The service should fail and leave the original file in place, since an unnamed configuration file cannot be addressed or loaded by Caddy. + /// + [Fact] + public void RenameCaddyConfiguration_WithEmptyNewName_ReturnsFailure() + { + // Arrange + var sourcePath = Path.Combine(_tempConfigDir, "source.caddy"); + File.WriteAllText(sourcePath, "content"); + + // Act + var result = _service.RenameCaddyConfiguration("source", " "); + + // Assert + result.Success.Should().BeFalse(); + result.Message.Should().Be("The configuration file name is required"); + File.Exists(sourcePath).Should().BeTrue(); + } + + /// + /// Tests that the Caddy service protects the global Caddyfile from being renamed in either direction. + /// Setup: Creates the global Caddyfile plus a regular configuration, then attempts a rename using "Caddyfile" as the source and as the target. + /// Expectation: Both attempts should fail and leave the files untouched, because Caddy loads the global configuration by that exact name and renaming it would break the whole proxy. + /// + [Fact] + public void RenameCaddyConfiguration_WithGlobalCaddyfile_ReturnsFailure() + { + // Arrange + var globalPath = Path.Combine(_tempConfigDir, "Caddyfile"); + var regularPath = Path.Combine(_tempConfigDir, "regular.caddy"); + File.WriteAllText(globalPath, "global content"); + File.WriteAllText(regularPath, "regular content"); + + // Act + var renameGlobalAway = _service.RenameCaddyConfiguration("Caddyfile", "something-else"); + var renameOntoGlobal = _service.RenameCaddyConfiguration("regular", "Caddyfile"); + + // Assert + renameGlobalAway.Success.Should().BeFalse(); + renameGlobalAway.Message.Should().Be("The global Caddyfile cannot be renamed"); + renameOntoGlobal.Success.Should().BeFalse(); + renameOntoGlobal.Message.Should().Be("The global Caddyfile cannot be renamed"); + File.ReadAllText(globalPath).Should().Be("global content"); + File.ReadAllText(regularPath).Should().Be("regular content"); + } + + /// + /// Tests that the Caddy service treats a rename to the unchanged name as a successful no-op. + /// Setup: Creates a configuration file and requests a rename to the exact same name. + /// Expectation: The service should succeed and leave the file as is, letting the editor call rename unconditionally on save without special-casing an untouched name field. + /// + [Fact] + public void RenameCaddyConfiguration_WithUnchangedName_SucceedsAsNoOp() + { + // Arrange + var filePath = Path.Combine(_tempConfigDir, "same-name.caddy"); + File.WriteAllText(filePath, "content"); + + // Act + var result = _service.RenameCaddyConfiguration("same-name", "same-name"); + + // Assert + result.Success.Should().BeTrue(); + File.ReadAllText(filePath).Should().Be("content"); + } + + /// + /// Tests that the Caddy service allows a rename that only changes letter casing. + /// Setup: Creates a lowercase configuration file, then renames it to the same name with different casing. + /// Expectation: The service should succeed and the content should be reachable under the new casing, since on case insensitive file systems a naive collision check would otherwise see the source file as an existing target and reject a legitimate rename. + /// + [Fact] + public void RenameCaddyConfiguration_WithCaseOnlyChange_RenamesSuccessfully() + { + // Arrange + File.WriteAllText(Path.Combine(_tempConfigDir, "myapp.caddy"), "content"); + + // Act + var result = _service.RenameCaddyConfiguration("myapp", "MyApp"); + + // Assert + result.Success.Should().BeTrue(); + File.ReadAllText(Path.Combine(_tempConfigDir, "MyApp.caddy")).Should().Be("content"); + _service.GetExistingCaddyConfigurations().Select(c => c.FileName).Should().BeEquivalentTo(["MyApp"]); + } + + /// + /// Tests that the Caddy service rejects rename targets that would escape the configuration directory or use characters the file system disallows. + /// Setup: Creates a configuration file, then attempts renames using a path traversal segment, a directory separator and an invalid file name character. + /// Expectation: Every attempt should fail with an invalid characters message and write nothing outside the configuration directory, since the new name arrives from user input and is used directly to build a file path. + /// + [Theory] + [InlineData("../escaped")] + [InlineData("nested/name")] + [InlineData("invalid\0name")] + public void RenameCaddyConfiguration_WithUnsafeNewName_ReturnsFailure(string newFileName) + { + // Arrange + var sourcePath = Path.Combine(_tempConfigDir, "source.caddy"); + File.WriteAllText(sourcePath, "content"); + + // Act + var result = _service.RenameCaddyConfiguration("source", newFileName); + + // Assert + result.Success.Should().BeFalse(); + result.Message.Should().Be("The configuration file name contains invalid characters"); + File.ReadAllText(sourcePath).Should().Be("content"); + Directory.GetFiles(_tempConfigDir).Should().HaveCount(1); + File.Exists(Path.Combine(_tempConfigDir, "..", "escaped.caddy")).Should().BeFalse(); + } + + #endregion + #region DeleteCaddyConfigurations Tests /// diff --git a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs index ac9c9b3..f2dbd31 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs +++ b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxiesPage.razor.cs @@ -90,7 +90,8 @@ public partial class CaddyReverseProxiesPage : ComponentBase private void Refresh() { var notSearching = string.IsNullOrWhiteSpace(_debouncedText); - var configurations = CaddyService.GetExistingCaddyConfigurations() + var allConfigurations = CaddyService.GetExistingCaddyConfigurations(); + var configurations = allConfigurations .Where(conf => notSearching || conf.FileName.Contains(_debouncedText, StringComparison.OrdinalIgnoreCase) || conf.ReverseProxyHostname.Contains(_debouncedText, StringComparison.OrdinalIgnoreCase) || conf.Tags.Any(tag => tag.Contains(_debouncedText, StringComparison.OrdinalIgnoreCase))) .OrderBy(conf => conf.FileName) .ToList(); @@ -112,6 +113,9 @@ public partial class CaddyReverseProxiesPage : ComponentBase } _availableCaddyConfigurations = [..configurations]; + // Drop selections that no longer exist, e.g. after a rename, so a later delete does not target a stale name. + // Checked against every configuration on disk so an active search filter does not clear the selection. + _selectedCaddyConfigurations = [.. _selectedCaddyConfigurations.Where(allConfigurations.Contains)]; StateHasChanged(); } diff --git a/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor b/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor index 77a42aa..6f6b1db 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor +++ b/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor @@ -5,8 +5,7 @@ + ShrinkLabel="true"> File content + /// The file name the dialog was opened with, used to detect a rename since FileName follows the text field + /// + private string _originalFileName = string.Empty; + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; /// @@ -38,6 +43,7 @@ public partial class CaddyfileEditor : ComponentBase protected override Task OnInitializedAsync() { IsNew = string.IsNullOrWhiteSpace(FileName); + _originalFileName = FileName; if (!IsNew) { @@ -74,10 +80,33 @@ public partial class CaddyfileEditor : ComponentBase } /// - /// Saves the Caddy configuration file + /// Renames the Caddy configuration file when needed, then saves its content /// - private async Task Submit() + /// True when the configuration was persisted + private async Task Save() { + // Rename first so a failed rename never leaves the content written under a stale name + if (!IsNew && !string.Equals(_originalFileName, FileName, StringComparison.Ordinal)) + { + var renameResponse = CaddyService.RenameCaddyConfiguration(_originalFileName, FileName); + + if (!renameResponse.Success) + { + Snackbar.Add(renameResponse.Message, Severity.Error); + return false; + } + + Snackbar.Add($"Renamed {_originalFileName} to {FileName}", Severity.Info); + + if (CaddyService.GetCaddyGlobalConfigurationContent().Contains(_originalFileName)) + { + Snackbar.Add($"The global Caddyfile still references {_originalFileName}, update its import", + Severity.Warning); + } + + _originalFileName = FileName; + } + var response = CaddyService.SaveCaddyConfiguration(new CaddySaveConfigurationRequest { IsNew = IsNew, @@ -85,15 +114,24 @@ public partial class CaddyfileEditor : ComponentBase Content = await _codeEditor.GetValue(), }); - if (response.Success) - { - Snackbar.Add($"{FileName} Caddy configuration saved successfully", Severity.Success); - MudDialog.Close(DialogResult.Ok(false)); // Indicate successful save but no restart - } - else + if (!response.Success) { Snackbar.Add(response.Message, Severity.Error); - // MudDialog.Close(DialogResult.Ok(false)); // Indicate failed save + return false; + } + + Snackbar.Add($"{FileName} Caddy configuration saved successfully", Severity.Success); + return true; + } + + /// + /// Saves the Caddy configuration file + /// + private async Task Submit() + { + if (await Save()) + { + MudDialog.Close(DialogResult.Ok(false)); // Indicate successful save but no restart } } @@ -110,25 +148,11 @@ public partial class CaddyfileEditor : ComponentBase /// private async Task SaveAndRestart() { - var submitResponse = CaddyService.SaveCaddyConfiguration(new CaddySaveConfigurationRequest + if (await Save()) { - IsNew = IsNew, - FileName = FileName, - Content = await _codeEditor.GetValue(), - }); - - if (submitResponse.Success) - { - Snackbar.Add($"{FileName} Caddy configuration saved successfully", Severity.Success); // Indicate successful save and that a restart is required by the calling component MudDialog.Close(DialogResult.Ok(true)); } - else - { - Snackbar.Add(submitResponse.Message, Severity.Error); - // Indicate failed save, no restart needed - // MudDialog.Close(DialogResult.Ok(false)); - } } /// diff --git a/scripts/dotnet-docker.sh b/scripts/dotnet-docker.sh new file mode 100755 index 0000000..da7d1ec --- /dev/null +++ b/scripts/dotnet-docker.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# +# Runs any dotnet command inside the official .NET SDK container, for machines +# without a local SDK installed. The repository is mounted at /src. +# +# Usage: +# ./scripts/dotnet-docker.sh test +# ./scripts/dotnet-docker.sh build CaddyManager.sln +# ./scripts/dotnet-docker.sh test --filter FullyQualifiedName~RenameCaddyConfiguration +# +# NuGet packages are cached in a named volume so only the first run downloads them. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +exec docker run --rm \ + --volume "$repo_root":/src \ + --workdir /src \ + --volume caddymanager-nuget:/root/.nuget/packages \ + mcr.microsoft.com/dotnet/sdk:9.0 \ + dotnet "$@"