feat(caddy): support renaming configuration files

This commit is contained in:
2026-07-26 12:55:50 +07:00
parent f5a1f99dec
commit d5ea7b56cb
7 changed files with 350 additions and 27 deletions

View File

@@ -41,6 +41,14 @@ public interface ICaddyService
/// <returns></returns>
CaddyOperationResponse SaveCaddyGlobalConfiguration(string content);
/// <summary>
/// Method to rename an existing Caddy configuration file
/// </summary>
/// <param name="oldFileName"></param>
/// <param name="newFileName"></param>
/// <returns></returns>
CaddyOperationResponse RenameCaddyConfiguration(string oldFileName, string newFileName);
/// <summary>
/// Method to delete the given Caddy configurations by name
/// </summary>

View File

@@ -106,6 +106,91 @@ public class CaddyService(
Content = content
});
/// <inheritdoc />
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
};
/// <summary>
/// Guards the rename against path traversal and characters the file system would reject
/// </summary>
private static bool IsInvalidFileName(string fileName) =>
fileName.Contains("..") || fileName.IndexOfAny(InvalidFileNameChars) >= 0;
private static readonly char[] InvalidFileNameChars =
[.. Path.GetInvalidFileNameChars().Union(['/', '\\'])];
/// <inheritdoc />
public CaddyDeleteOperationResponse DeleteCaddyConfigurations(List<string> configurationNames)
{

View File

@@ -466,6 +466,187 @@ public class CaddyServiceTests : IDisposable
#endregion
#region RenameCaddyConfiguration Tests
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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"]);
}
/// <summary>
/// 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.
/// </summary>
[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
/// <summary>

View File

@@ -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();
}

View File

@@ -5,8 +5,7 @@
<MudFocusTrap>
<MudTextField @bind-Value="FileName" Label="File name" Variant="Variant.Outlined"
Style="margin-bottom: 8px;"
ShrinkLabel="true"
ReadOnly="@(!IsNew)"></MudTextField>
ShrinkLabel="true"></MudTextField>
</MudFocusTrap>
<MudText Typo="Typo.caption" class="pl-4">File content</MudText>
<StandaloneCodeEditor @ref="_codeEditor"

View File

@@ -14,6 +14,11 @@ public partial class CaddyfileEditor : ComponentBase
private string _caddyConfigurationContent = string.Empty;
private StandaloneCodeEditor _codeEditor = null!;
/// <summary>
/// The file name the dialog was opened with, used to detect a rename since FileName follows the text field
/// </summary>
private string _originalFileName = string.Empty;
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
/// <summary>
@@ -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
}
/// <summary>
/// Saves the Caddy configuration file
/// Renames the Caddy configuration file when needed, then saves its content
/// </summary>
private async Task Submit()
/// <returns>True when the configuration was persisted</returns>
private async Task<bool> 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;
}
/// <summary>
/// Saves the Caddy configuration file
/// </summary>
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
/// </summary>
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));
}
}
/// <summary>

22
scripts/dotnet-docker.sh Executable file
View File

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