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 "$@"