Compare commits

...

9 Commits

Author SHA1 Message Date
8f0019592e fix(proxy): honor forwarded headers from Caddy
All checks were successful
Caddy Manager CI build / docker (push) Successful in 1m14s
Bump the application version to 1.2.2 and configure forwarded headers so
OpenAPI generates HTTPS URLs when TLS is terminated by Caddy.
2026-07-26 20:40:35 +07:00
4c271241d7 ci: publish only the web application container
All checks were successful
Caddy Manager CI build / docker (push) Successful in 2m45s
Disable container support for tests and bump the application version to
1.2.1.
2026-07-26 20:02:13 +07:00
9f7cb79bad feat(api): Add authenticated Caddy management API
All checks were successful
Caddy Manager CI build / docker (push) Successful in 4m24s
2026-07-26 18:34:02 +07:00
a94abca127 build(deps): declare Alpine runtime identifier
All checks were successful
Caddy Manager CI build / docker (push) Successful in 3m25s
2026-07-26 16:20:04 +07:00
96f0112449 ci(build): target .NET 10 Alpine with low-memory tuning
Some checks failed
Caddy Manager CI build / docker (push) Failing after 32s
2026-07-26 16:15:00 +07:00
7380680230 feat: upgrade application to .NET 10
All checks were successful
Caddy Manager CI build / docker (push) Successful in 6m37s
Preserve configuration defaults with the .NET 10 binder and update
dependencies, Docker builds, CI, and test tooling.
2026-07-26 15:55:11 +07:00
b5cbad3664 refactor(caddy): centralize reverse proxy editor dialogs
All checks were successful
Caddy Manager CI build / docker (push) Successful in 5m28s
2026-07-26 13:29:46 +07:00
6f6a37d5a6 chore(version): bump application version to 1.1.0
All checks were successful
Caddy Manager CI build / docker (push) Successful in 5m32s
2026-07-26 12:57:12 +07:00
d5ea7b56cb feat(caddy): support renaming configuration files 2026-07-26 12:55:50 +07:00
27 changed files with 1176 additions and 580 deletions

View File

@@ -22,15 +22,15 @@ jobs:
config-inline: |
[registry."${{ vars.DOCKER_GITEA_DOMAIN }}"]
http = true
insecure = true
insecure = true
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v3
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9' # SDK Version to use.
dotnet-version: '10' # SDK Version to use.
dotnet-quality: 'ga'
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore dependencies
- name: Restore dependencies
run: dotnet restore --locked-mode
- name: Application metadata
id: metadata
@@ -50,10 +50,3 @@ jobs:
--configuration Release --os linux --arch x64 \
/t:PublishContainer -p ContainerRegistry=${{ vars.DOCKER_GITEA_DOMAIN }} \
-p ContainerRepository=ebolo/caddy-manager -p:ContainerImageTags='"${{ steps.metadata.outputs.APP_VERSION }};latest"'
- name: Deploy to Komodo
uses: fjogeleit/http-request-action@v1
if: success()
with:
url: '${{ vars.WINDMILL_DOMAIN }}/komodo/pull-stack/${{ secrets.KOMODO_STACK_ID }}'
method: 'PUT'
customHeaders: '{"Auth-Key": "${{ secrets.WINDMILL_KEY }}"}'

View File

@@ -25,12 +25,14 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v3
with:
dotnet-version: '9' # SDK Version to use.
dotnet-version: '10' # SDK Version to use.
dotnet-quality: 'ga'
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore dependencies
run: dotnet restore --locked-mode
- name: Restore dependencies
# Scoped to the web app so the test project is never restored or built here; it pulls in
# Contracts and Services through its project references. Tests run locally, not in this job.
run: dotnet restore CaddyManager/CaddyManager.csproj --locked-mode
- name: Application metadata
id: metadata
run: |
@@ -41,7 +43,7 @@ jobs:
sed -i "s/public static readonly string CommitHash = \"\[DEVELOPMENT\]\";/public static readonly string CommitHash = \"${{ steps.metadata.outputs.COMMIT_HASH }}\";/" CaddyManager/Configurations/Application/ApplicationInfo.cs
- name: Publish container
run: |
dotnet publish \
--configuration Release --os linux --arch x64 \
dotnet publish CaddyManager/CaddyManager.csproj \
--configuration Release --os linux-musl --arch x64 \
/t:PublishContainer -p ContainerRegistry=ghcr.io \
-p ContainerRepository=${{ github.repository }} -p:ContainerImageTags='"${{ steps.metadata.outputs.APP_VERSION }};latest"'

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

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

View File

@@ -67,6 +67,11 @@ public class CaddyService(
};
}
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
@@ -106,6 +111,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)
{
@@ -113,6 +203,12 @@ public class CaddyService(
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");

View File

@@ -5,19 +5,19 @@
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NetCore.AutoRegisterDi" Version="2.2.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10" />
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="Humanizer" Version="3.0.0-beta.96" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageReference Include="Humanizer" Version="3.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
</ItemGroup>
</Project>

View File

@@ -19,6 +19,18 @@ public class ConfigurationsService(IConfiguration configuration) : IConfiguratio
else if (section.EndsWith("Configuration"))
section = section[..^"Configuration".Length];
return configuration.GetSection(section).Get<T>() ?? Activator.CreateInstance<T>();
var result = configuration.GetSection(section).Get<T>();
var defaults = Activator.CreateInstance<T>();
if (result is null) return defaults;
// The .NET 10 binder writes nulls over property initialisers, so put the defaults back
foreach (var property in typeof(T).GetProperties())
{
if (property is { CanRead: true, CanWrite: true } && property.GetValue(result) is null)
property.SetValue(result, property.GetValue(defaults));
}
return result;
}
}

View File

@@ -1,15 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!--
The test SDK makes this project an executable, so a solution wide
`dotnet publish /t:PublishContainer` containerizes it alongside the web app, pushes both
to the same repository and tag, and lets the last one to finish win. That shipped an
image whose entrypoint was the test assembly, which exits 0 without output, so the
container looked like it started and stopped for no reason.
The pipelines publish the web app project explicitly; this keeps a solution wide publish
from ever producing an image from the tests again.
-->
<EnableSdkContainerSupport>false</EnableSdkContainerSupport>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="coverlet.collector" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -21,17 +31,17 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.0" />
<PackageReference Include="AwesomeAssertions" Version="9.5.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
@@ -41,7 +51,7 @@
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
<Using Include="AwesomeAssertions" />
<Using Include="Moq" />
</ItemGroup>

View File

@@ -66,7 +66,7 @@ Coverage reports exclude:
## Test Frameworks and Libraries
- **xUnit**: Primary testing framework
- **FluentAssertions**: For readable assertions
- **AwesomeAssertions**: For readable assertions
- **Moq**: For mocking dependencies
- **Coverlet**: For code coverage analysis
@@ -122,7 +122,7 @@ Common test data is provided through the `TestHelper` class:
2. **Single Responsibility**: Each test should verify one specific behavior
3. **Independence**: Tests should not depend on each other and should be able to run in any order
4. **Cleanup**: Use `IDisposable` or cleanup methods to remove temporary resources
5. **Readable Assertions**: Use FluentAssertions for more readable test assertions
5. **Readable Assertions**: Use AwesomeAssertions for more readable test assertions
6. **Mock Verification**: Verify that mocked methods are called as expected when relevant
## Continuous Integration

View File

@@ -436,6 +436,37 @@ public class CaddyServiceTests : IDisposable
File.Exists(filePath).Should().BeTrue();
}
/// <summary>
/// Tests that the Caddy service refuses to save configurations whose file name escapes the configuration directory.
/// Setup: Provides save requests with traversal segments and directory separators in the file name.
/// Expectation: The service should reject the request and write nothing outside the configuration directory, so an untrusted caller such as the HTTP API cannot write arbitrary files on the host.
/// </summary>
[Theory]
[InlineData("../escape")]
[InlineData("../../etc/escape")]
[InlineData("sub/escape")]
[InlineData("sub\\escape")]
public void SaveCaddyConfiguration_WithFileNameEscapingConfigDir_ReturnsFailureAndWritesNothing(string fileName)
{
// Arrange
var request = new CaddySaveConfigurationRequest
{
FileName = fileName,
Content = TestHelper.SampleCaddyfiles.SimpleReverseProxy,
IsNew = true
};
var escapedPath = Path.GetFullPath(Path.Combine(_tempConfigDir, $"{fileName}.caddy"));
// Act
var result = _service.SaveCaddyConfiguration(request);
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Be("The configuration file name contains invalid characters");
File.Exists(escapedPath).Should().BeFalse();
Directory.GetFiles(_tempConfigDir).Should().BeEmpty();
}
#endregion
#region SaveCaddyGlobalConfiguration Tests
@@ -466,6 +497,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>
@@ -574,6 +786,37 @@ public class CaddyServiceTests : IDisposable
result.DeletedConfigurations.Should().BeEmpty();
}
/// <summary>
/// Tests that the Caddy service refuses to delete configurations whose file name escapes the configuration directory.
/// Setup: Creates a file outside the configuration directory and asks the service to delete it through a traversal file name.
/// Expectation: The service should report the name as failed and leave the outside file untouched, so an untrusted caller such as the HTTP API cannot delete arbitrary files on the host.
/// </summary>
[Fact]
public void DeleteCaddyConfigurations_WithFileNameEscapingConfigDir_ReportsFailureAndDeletesNothing()
{
// Arrange
var outsideDir = TestHelper.CreateTempDirectory();
try
{
var outsidePath = Path.Combine(outsideDir, "victim.caddy");
File.WriteAllText(outsidePath, "content");
var traversalName = Path.Combine("..", Path.GetFileName(outsideDir), "victim");
// Act
var result = _service.DeleteCaddyConfigurations([traversalName]);
// Assert
result.Success.Should().BeFalse();
result.Message.Should().Contain(traversalName);
result.DeletedConfigurations.Should().BeEmpty();
File.Exists(outsidePath).Should().BeTrue();
}
finally
{
TestHelper.CleanupDirectory(outsideDir);
}
}
#endregion
#region GetCaddyConfigurationInfo Tests

View File

@@ -15,7 +15,7 @@
<Exclude>[*]Microsoft.*</Exclude>
<Exclude>[*]System.*</Exclude>
<Exclude>[*]Moq.*</Exclude>
<Exclude>[*]FluentAssertions.*</Exclude>
<Exclude>[*]AwesomeAssertions.*</Exclude>
<Exclude>[*]xunit.*</Exclude>
<ExcludeByAttribute>Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute</ExcludeByAttribute>
<SingleHit>false</SingleHit>

View File

@@ -0,0 +1,252 @@
using System.Security.Cryptography;
using System.Text;
using CaddyManager.Contracts.Caddy;
using CaddyManager.Contracts.Docker;
using CaddyManager.Contracts.Models.Caddy;
namespace CaddyManager.Api;
/// <summary>
/// HTTP surface over the same services the Blazor UI uses, so scripts can manage reverse proxy
/// configurations and reload Caddy without a browser
/// </summary>
public static class CaddyApi
{
/// <summary>
/// Header carrying the shared API key
/// </summary>
public const string ApiKeyHeader = "X-Api-Key";
private const string ReverseProxiesTag = "Reverse proxies";
private const string CaddyTag = "Caddy";
/// <summary>
/// Maps every /api endpoint behind the shared key check
/// </summary>
public static void MapCaddyApi(this WebApplication app)
{
// Read once at startup; the key is a single value and does not warrant a configuration class
var apiKey = app.Configuration["Api:Key"];
var api = app.MapGroup("/api")
// Antiforgery only validates form content types, but the API is JSON only and should
// never start failing because a caller switched to form encoding
.DisableAntiforgery()
.AddEndpointFilter(async (context, next) =>
{
if (string.IsNullOrWhiteSpace(apiKey))
{
// Fail closed: this API writes reverse proxy configuration and can restart Caddy
return Results.Problem("API disabled: Api:Key is not configured",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
var provided = context.HttpContext.Request.Headers[ApiKeyHeader].ToString();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(apiKey)))
{
return Results.Problem($"Invalid or missing {ApiKeyHeader} header",
statusCode: StatusCodes.Status401Unauthorized);
}
return await next(context);
});
MapConfigurationEndpoints(api);
MapCaddyEndpoints(api);
}
private static void MapConfigurationEndpoints(RouteGroupBuilder api)
{
var group = api.MapGroup("/configurations").WithTags(ReverseProxiesTag);
group.MapGet("", (ICaddyService caddyService) => caddyService.GetExistingCaddyConfigurations())
.WithSummary("List reverse proxy configurations")
.WithDescription("Returns every *.caddy file in the configuration directory, parsed for hostnames, upstream target, ports and tags. The global Caddyfile is not included.");
group.MapGet("/{name}", IResult (string name, ICaddyService caddyService) =>
{
if (!Exists(caddyService, name))
{
return Results.NotFound(Failure($"The configuration {name} does not exist"));
}
return Results.Ok(new ConfigurationResponse(
caddyService.GetCaddyConfigurationInfo(name),
caddyService.GetCaddyConfigurationContent(name)));
})
.WithSummary("Get a reverse proxy configuration")
.Produces<ConfigurationResponse>()
.Produces<CaddyOperationResponse>(StatusCodes.Status404NotFound);
group.MapPost("", IResult (CreateConfigurationRequest request, ICaddyService caddyService) =>
{
var response = caddyService.SaveCaddyConfiguration(new CaddySaveConfigurationRequest
{
IsNew = true,
FileName = request.FileName,
Content = request.Content,
});
return response.Success
? Results.Created($"/api/configurations/{request.FileName}", response)
: ToResult(response);
})
.WithSummary("Create a reverse proxy configuration")
.WithDescription("Fails if a configuration with the same file name already exists; use PUT to overwrite one.")
.Produces<CaddyOperationResponse>(StatusCodes.Status201Created)
.Produces<CaddyOperationResponse>(StatusCodes.Status400BadRequest)
.Produces<CaddyOperationResponse>(StatusCodes.Status409Conflict);
group.MapPut("/{name}", IResult (string name, SaveContentRequest request, ICaddyService caddyService) =>
{
if (!Exists(caddyService, name))
{
return Results.NotFound(Failure($"The configuration {name} does not exist"));
}
return ToResult(caddyService.SaveCaddyConfiguration(new CaddySaveConfigurationRequest
{
IsNew = false,
FileName = name,
Content = request.Content,
}));
})
.WithSummary("Replace the content of a reverse proxy configuration")
.Produces<CaddyOperationResponse>()
.Produces<CaddyOperationResponse>(StatusCodes.Status400BadRequest)
.Produces<CaddyOperationResponse>(StatusCodes.Status404NotFound);
group.MapPost("/{name}/rename",
IResult (string name, RenameConfigurationRequest request, ICaddyService caddyService) =>
ToResult(caddyService.RenameCaddyConfiguration(name, request.NewFileName)))
.WithSummary("Rename a reverse proxy configuration")
.WithDescription("Renames the file only. An import of the old name in the global Caddyfile is left untouched and has to be updated separately.")
.Produces<CaddyOperationResponse>()
.Produces<CaddyOperationResponse>(StatusCodes.Status400BadRequest)
.Produces<CaddyOperationResponse>(StatusCodes.Status404NotFound)
.Produces<CaddyOperationResponse>(StatusCodes.Status409Conflict);
group.MapDelete("/{name}", IResult (string name, ICaddyService caddyService) =>
{
var response = caddyService.DeleteCaddyConfigurations([name]);
return response.Success
? Results.Ok(response)
: Results.NotFound(response);
})
.WithSummary("Delete a reverse proxy configuration")
.Produces<CaddyDeleteOperationResponse>()
.Produces<CaddyDeleteOperationResponse>(StatusCodes.Status404NotFound);
}
private static void MapCaddyEndpoints(RouteGroupBuilder api)
{
// Empty prefix so the tag stays scoped to these endpoints instead of leaking onto the whole /api group
var group = api.MapGroup("").WithTags(CaddyTag);
group.MapGet("/caddyfile",
(ICaddyService caddyService) => new ContentResponse(caddyService.GetCaddyGlobalConfigurationContent()))
.WithSummary("Get the global Caddyfile")
.WithDescription("The global Caddyfile is the entry point Caddy loads; it usually imports the individual *.caddy configurations.");
group.MapPut("/caddyfile",
IResult (SaveContentRequest request, ICaddyService caddyService) =>
ToResult(caddyService.SaveCaddyGlobalConfiguration(request.Content)))
.WithSummary("Replace the global Caddyfile")
.Produces<CaddyOperationResponse>()
.Produces<CaddyOperationResponse>(StatusCodes.Status400BadRequest);
group.MapPost("/caddy/reload", async Task<IResult> (IDockerService dockerService) =>
{
var response = await dockerService.ReloadCaddyContainerAsync();
return response.Success
? Results.Ok(response)
// The reload itself failed (bad config, container down); the request was fine
: Results.Json(response, statusCode: StatusCodes.Status502BadGateway);
})
.WithSummary("Reload the Caddy configuration")
.WithDescription("Runs `caddy reload` inside the Caddy container, which applies configuration changes without dropping connections.")
.Produces<CaddyReloadResponse>()
.Produces<CaddyReloadResponse>(StatusCodes.Status502BadGateway);
group.MapPost("/caddy/restart", async Task<IResult> (IDockerService dockerService) =>
{
try
{
await dockerService.RestartCaddyContainerAsync();
return Results.Accepted();
}
catch (Exception e)
{
// Restart reports problems by throwing rather than by a response object, the way
// reload does, so it needs the same try/catch the UI puts around it
return Results.Json(Failure(e.Message), statusCode: StatusCodes.Status502BadGateway);
}
})
.WithSummary("Restart the Caddy container")
.WithDescription("Returns 202 once the restart has been requested. A missing Caddy container is not reported as an error, matching the UI behaviour.")
.Produces(StatusCodes.Status202Accepted)
.Produces<CaddyOperationResponse>(StatusCodes.Status502BadGateway);
}
/// <summary>
/// The global Caddyfile is excluded from the listing, and reading a missing file yields an empty
/// string, so the listing is the only way to tell missing from empty
/// </summary>
private static bool Exists(ICaddyService caddyService, string name) =>
caddyService.GetExistingCaddyConfigurations().Any(configuration => configuration.FileName == name);
private static CaddyOperationResponse Failure(string message) => new()
{
Success = false,
Message = message,
};
/// <summary>
/// Turns a service response into a status code. The service reports failures as messages rather
/// than typed errors, so the known ones are matched here and anything else is treated as a fault
/// </summary>
private static IResult ToResult(CaddyOperationResponse response) => response.Success
? Results.Ok(response)
: Results.Json(response, statusCode: response.Message switch
{
"The configuration file already exists" => StatusCodes.Status409Conflict,
"The configuration file to rename does not exist" => StatusCodes.Status404NotFound,
"The global Caddyfile cannot be renamed" => StatusCodes.Status400BadRequest,
var message when message.StartsWith("The configuration file name") =>
StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError,
});
}
/// <summary>
/// A reverse proxy configuration together with its raw Caddyfile content
/// </summary>
/// <param name="Info">Parsed summary of the configuration</param>
/// <param name="Content">Raw content of the .caddy file</param>
public record ConfigurationResponse(CaddyConfigurationInfo Info, string Content);
/// <summary>
/// Raw content of a configuration file
/// </summary>
/// <param name="Content">Raw Caddyfile content</param>
public record ContentResponse(string Content);
/// <summary>
/// Request to create a new reverse proxy configuration
/// </summary>
/// <param name="FileName">File name without the .caddy extension</param>
/// <param name="Content">Raw Caddyfile content</param>
public record CreateConfigurationRequest(string FileName, string Content);
/// <summary>
/// Request to replace the content of an existing configuration
/// </summary>
/// <param name="Content">Raw Caddyfile content</param>
public record SaveContentRequest(string Content);
/// <summary>
/// Request to rename a configuration file
/// </summary>
/// <param name="NewFileName">New file name without the .caddy extension</param>
public record RenameConfigurationRequest(string NewFileName);

View File

@@ -1,17 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<LangVersion>13</LangVersion>
<LangVersion>14</LangVersion>
<ContainerRepository>caddy-manager</ContainerRepository>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<!-- To avoid error on lock file not found -->
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
<!--
Low-traffic single-user admin UI: trade GC throughput for a much smaller heap.
Server GC (the Web SDK default) reserves one heap per CPU core.
-->
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
<TieredPGO>false</TieredPGO>
<!-- No localized content, so skip loading ICU entirely (alpine ships no ICU anyway) -->
<InvariantGlobalization>true</InvariantGlobalization>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0-alpine</ContainerBaseImage>
<!--
The alpine base is musl. Declared here so a plain `dotnet restore` evaluates the
same RID the container publish uses, keeping packages.lock.json consistent for
CI's locked-mode restore (otherwise NU1004).
-->
<RuntimeIdentifiers>linux-musl-x64</RuntimeIdentifiers>
</PropertyGroup>
<ItemGroup>
<!-- No MSBuild property exists for this one; lands in runtimeconfig.json for both build paths -->
<RuntimeHostConfigurationOption Include="System.GC.ConserveMemory" Value="9" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
@@ -19,11 +40,15 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BlazorMonaco" Version="3.3.0" />
<PackageReference Include="BlazorMonaco" Version="3.5.0" />
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="Humanizer" Version="3.0.0-beta.96" />
<PackageReference Include="MudBlazor" Version="8.0.0" />
<PackageReference Include="Humanizer" Version="3.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<!-- Overrides the 2.0.0 that Microsoft.AspNetCore.OpenApi pulls in, which has advisory GHSA-v5pm-xwqc-g5wc -->
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="MudBlazor" Version="9.7.0" />
<PackageReference Include="NetCore.AutoRegisterDi" Version="2.2.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.16" />
</ItemGroup>
<ItemGroup>

View File

@@ -21,8 +21,8 @@ public partial class MainLayout
if (firstRender)
{
// Get the system preference for dark mode
_isDarkMode = await _mudThemeProvider.GetSystemPreference();
await _mudThemeProvider.WatchSystemPreference(OnSystemPreferenceChanged);
_isDarkMode = await _mudThemeProvider.GetSystemDarkModeAsync();
await _mudThemeProvider.WatchSystemDarkModeAsync(OnSystemPreferenceChanged);
_isInitialing = false;
StateHasChanged();
}

View File

@@ -35,8 +35,8 @@
@bind-SelectedValues="_selectedCaddyConfigurations">
@foreach (var (index, caddyConfig) in _availableCaddyConfigurations.Index())
{
<CaddyReverseProxyItem ConfigurationInfo="@caddyConfig" OnCaddyRestartRequired="@ReloadCaddy"
OnCaddyfileDuplicateRequested="@HandleDuplicateRequest" />
<CaddyReverseProxyItem ConfigurationInfo="@caddyConfig"
OnEditRequested="@(fileName => ShowCaddyfileEditorDialog(fileName))" />
@if (index < _availableCaddyConfigurations.Count - 1)
{

View File

@@ -63,7 +63,8 @@ public partial class CaddyReverseProxiesPage : ComponentBase
/// <returns></returns>
private async Task ShowCaddyfileEditorDialog(string fileName, string initialContent = "")
{
var dialog = await DialogService.ShowAsync<CaddyfileEditorComponent>("New configuration",
var dialog = await DialogService.ShowAsync<CaddyfileEditorComponent>(
string.IsNullOrWhiteSpace(fileName) ? "New configuration" : "Caddy file",
options: new DialogOptions
{
FullWidth = true,
@@ -71,7 +72,8 @@ public partial class CaddyReverseProxiesPage : ComponentBase
}, parameters: new DialogParameters<CaddyfileEditorComponent>
{
{ p => p.FileName, fileName },
{ p => p.InitialContent, initialContent }
{ p => p.InitialContent, initialContent },
{ p => p.OnDuplicate, EventCallback.Factory.Create<string>(this, HandleDuplicateRequest) }
});
var result = await dialog.Result;
@@ -81,6 +83,7 @@ public partial class CaddyReverseProxiesPage : ComponentBase
await ReloadCaddy();
}
// Always rebuild the list, the configuration may have been renamed or had its hostnames and ports changed
Refresh();
}
@@ -90,7 +93,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 +116,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

@@ -1,7 +1,5 @@
using CaddyManager.Contracts.Caddy;
using CaddyManager.Contracts.Models.Caddy;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace CaddyManager.Components.Pages.Caddy.CaddyReverseProxies;
@@ -12,52 +10,18 @@ namespace CaddyManager.Components.Pages.Caddy.CaddyReverseProxies;
public partial class CaddyReverseProxyItem : ComponentBase
{
/// <summary>
/// Callback to refresh the Caddy reverse proxies on the main page
/// Callback asking the main page to open the editor for this configuration. The page owns the dialog so that it
/// can refresh the whole list afterwards, which matters when the configuration is renamed or deleted.
/// </summary>
[Parameter]
public EventCallback OnCaddyRestartRequired { get; set; }
[Parameter]
public EventCallback<string> OnCaddyfileDuplicateRequested { get; set; }
public EventCallback<string> OnEditRequested { get; set; }
[Parameter]
public CaddyConfigurationInfo ConfigurationInfo { get; set; } = null!;
/// <summary>
/// Dialog service for showing the Caddy file editor dialog
/// </summary>
[Inject]
private IDialogService DialogService { get; set; } = null!;
/// <summary>
/// Caddy service for ops on the Caddy configuration
/// </summary>
[Inject] private ICaddyService CaddyService { get; set; } = null!;
/// <summary>
/// Show the Caddy file editor dialog
/// Request the Caddy file editor dialog for this configuration
/// </summary>
/// <returns></returns>
private async Task Edit()
{
var dialog = await DialogService.ShowAsync<CaddyfileEditor.CaddyfileEditor>("Caddy file", options: new DialogOptions
{
FullWidth = true,
MaxWidth = MaxWidth.Medium,
}, parameters: new DialogParameters<CaddyfileEditor.CaddyfileEditor>
{
{ p => p.FileName, ConfigurationInfo.FileName },
{ p => p.OnDuplicate, EventCallback.Factory.Create(this, OnCaddyfileDuplicateRequested) }
});
var result = await dialog.Result;
ConfigurationInfo = CaddyService.GetCaddyConfigurationInfo(ConfigurationInfo.FileName);
await InvokeAsync(StateHasChanged);
if (result is { Data: bool, Canceled: false } && (bool)result.Data)
{
await OnCaddyRestartRequired.InvokeAsync();
}
}
}
private Task Edit() => OnEditRequested.InvokeAsync(ConfigurationInfo.FileName);
}

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"
@@ -20,6 +19,6 @@
<MudButton OnClick="Duplicate">Duplicate</MudButton>
}
<MudButton Color="Color.Primary" OnClick="Submit">Save</MudButton>
<MudButton Color="Color.Secondary" OnClick="SaveAndRestart">Save & Restart</MudButton>
<MudButton Color="Color.Secondary" OnClick="SaveAndReload">Save &amp; Reload</MudButton>
</DialogActions>
</MudDialog>

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
}
}
@@ -106,29 +144,15 @@ public partial class CaddyfileEditor : ComponentBase
}
/// <summary>
/// Saves the Caddy configuration file and restarts the Caddy container
/// Saves the Caddy configuration file and reloads the Caddy configuration
/// </summary>
private async Task SaveAndRestart()
private async Task SaveAndReload()
{
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
// Indicate successful save and that a reload 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>

View File

@@ -6,12 +6,12 @@ namespace CaddyManager.Configurations.Application;
public class ApplicationInfo
{
/// <summary>
/// The version of the application, to be defined and tagged
/// The version of the application, to be defined and tagged
/// </summary>
public static readonly string Version = "1.0.0";
public static readonly string Version = "1.2.2";
/// <summary>
/// The commit hash of the application
/// </summary>
public static readonly string CommitHash = "[DEVELOPMENT]";
}
}

View File

@@ -1,23 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["CaddyManager/CaddyManager.csproj", "CaddyManager/"]
COPY ["CaddyManager/CaddyManager.csproj", "CaddyManager/packages.lock.json", "CaddyManager/"]
COPY ["CaddyManager.Contracts/CaddyManager.Contracts.csproj", "CaddyManager.Contracts/"]
COPY ["CaddyManager.Services/CaddyManager.Services.csproj", "CaddyManager.Services/"]
RUN dotnet restore "CaddyManager/CaddyManager.csproj"
COPY . .
WORKDIR "/src/CaddyManager"
RUN dotnet build "CaddyManager.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "CaddyManager.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "CaddyManager/CaddyManager.csproj" -c $BUILD_CONFIGURATION -o /app/publish --no-restore /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "CaddyManager.dll"]

View File

@@ -1,12 +1,16 @@
using CaddyManager.Api;
using CaddyManager.Components;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.OpenApi;
using MudBlazor.Services;
using NetCore.AutoRegisterDi;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services
.AddMudServices()
.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -15,7 +19,56 @@ builder.Services.RegisterAssemblyPublicNonGenericClasses(System.Reflection.Assem
.Where(t => t.Name.EndsWith("Service"))
.AsPublicImplementedInterfaces();
builder.Services.AddSignalR(e => { e.MaximumReceiveMessageSize = 102400000; });
builder.Services.AddSignalR(e =>
{
// Caddyfiles are kilobytes; this is generous headroom for Monaco editor round-trips
e.MaximumReceiveMessageSize = 512 * 1024;
e.StreamBufferCapacity = 5;
});
// Keep as little per-circuit state resident as practical for a single-user admin UI
builder.Services.Configure<CircuitOptions>(o =>
{
o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(1);
o.DisconnectedCircuitMaxRetained = 5;
o.MaxBufferedUnacknowledgedRenderBatches = 3;
});
builder.Services.AddOpenApi(options =>
{
// Declare the shared key scheme so the docs page offers an auth box and marks every endpoint as secured
options.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[CaddyApi.ApiKeyHeader] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Name = CaddyApi.ApiKeyHeader,
Description = "Shared key configured through Api:Key (environment variable Api__Key)",
};
document.Security ??= [];
document.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference(CaddyApi.ApiKeyHeader, document)] = [],
});
return Task.CompletedTask;
});
});
// Caddy terminates TLS and forwards plain HTTP, so without this the app thinks every request is
// http and the OpenAPI document advertises http:// server URLs, which the browser blocks as mixed
// content when the docs page itself was served over https
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
// The proxy is another container on a Docker network, so its address is not known up front;
// this app is only ever meant to be reached through that proxy
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddMudServices(config =>
{
@@ -26,6 +79,9 @@ builder.Services.AddMudServices(config =>
var app = builder.Build();
// Has to run before anything that reads the scheme, host or client address
app.UseForwardedHeaders();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
@@ -43,4 +99,8 @@ app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapCaddyApi();
app.MapOpenApi();
app.MapScalarApiReference();
app.Run();

View File

@@ -6,6 +6,9 @@
}
},
"AllowedHosts": "*",
"Api": {
"Key": ""
},
"CaddyService": {
"ConfigDir": "/root/compose/caddy/config"
},

View File

@@ -1,16 +1,12 @@
{
"version": 1,
"dependencies": {
"net9.0": {
"net10.0": {
"BlazorMonaco": {
"type": "Direct",
"requested": "[3.3.0, )",
"resolved": "3.3.0",
"contentHash": "ywtUCZMfmaNBadhQbZEGPerdptHUcyVbPB2Ug1HTNzarJlMCJFSBRvQZefS383h/vDhFgHUrR7/pE6scIc6lJg==",
"dependencies": {
"Microsoft.AspNetCore.Components": "9.0.0",
"Microsoft.AspNetCore.Components.Web": "9.0.0"
}
"requested": "[3.5.0, )",
"resolved": "3.5.0",
"contentHash": "MfEzmLlhGEUCi4O65jnDipDUf0UZz3sO9tWupp09hMSvHWcYIP7NRG6r3PIfBUz78nfEkU+aW7Jyg71gd8QcPQ=="
},
"Docker.DotNet": {
"type": "Direct",
@@ -18,694 +14,525 @@
"resolved": "3.125.15",
"contentHash": "XN8FKxVv8Mjmwu104/Hl9lM61pLY675s70gzwSj8KR5pwblo8HfWLcCuinh9kYsqujBkMH4HVRCEcRuU6al4BQ==",
"dependencies": {
"Newtonsoft.Json": "13.0.1",
"System.Buffers": "4.5.1",
"System.Threading.Tasks.Extensions": "4.5.4"
"Newtonsoft.Json": "13.0.1"
}
},
"Humanizer": {
"type": "Direct",
"requested": "[3.0.0-beta.96, )",
"resolved": "3.0.0-beta.96",
"contentHash": "T1X21b+0l3jYDH1DztroJIeXdCwlNGmNYDidru9TzcLahwceQ48UGMjOQeWSa1v8zncPvhJzLzVAYWmOZcOySA==",
"requested": "[3.0.10, )",
"resolved": "3.0.10",
"contentHash": "13+6RiWdpVCzSn2SSEd1hAIr/UHScNdLCUhRgjvI60UcD5u3vXu3u1/Z+74OcHWum1itjiBTjYXgJKQna1jTgw==",
"dependencies": {
"Humanizer.Core.af": "3.0.0-beta.96",
"Humanizer.Core.ar": "3.0.0-beta.96",
"Humanizer.Core.az": "3.0.0-beta.96",
"Humanizer.Core.bg": "3.0.0-beta.96",
"Humanizer.Core.bn-BD": "3.0.0-beta.96",
"Humanizer.Core.cs": "3.0.0-beta.96",
"Humanizer.Core.da": "3.0.0-beta.96",
"Humanizer.Core.de": "3.0.0-beta.96",
"Humanizer.Core.el": "3.0.0-beta.96",
"Humanizer.Core.es": "3.0.0-beta.96",
"Humanizer.Core.fa": "3.0.0-beta.96",
"Humanizer.Core.fi-FI": "3.0.0-beta.96",
"Humanizer.Core.fr": "3.0.0-beta.96",
"Humanizer.Core.fr-BE": "3.0.0-beta.96",
"Humanizer.Core.he": "3.0.0-beta.96",
"Humanizer.Core.hr": "3.0.0-beta.96",
"Humanizer.Core.hu": "3.0.0-beta.96",
"Humanizer.Core.hy": "3.0.0-beta.96",
"Humanizer.Core.id": "3.0.0-beta.96",
"Humanizer.Core.is": "3.0.0-beta.96",
"Humanizer.Core.it": "3.0.0-beta.96",
"Humanizer.Core.ja": "3.0.0-beta.96",
"Humanizer.Core.ko-KR": "3.0.0-beta.96",
"Humanizer.Core.ku": "3.0.0-beta.96",
"Humanizer.Core.lb": "3.0.0-beta.96",
"Humanizer.Core.lt": "3.0.0-beta.96",
"Humanizer.Core.lv": "3.0.0-beta.96",
"Humanizer.Core.ms-MY": "3.0.0-beta.96",
"Humanizer.Core.mt": "3.0.0-beta.96",
"Humanizer.Core.nb": "3.0.0-beta.96",
"Humanizer.Core.nb-NO": "3.0.0-beta.96",
"Humanizer.Core.nl": "3.0.0-beta.96",
"Humanizer.Core.pl": "3.0.0-beta.96",
"Humanizer.Core.pt": "3.0.0-beta.96",
"Humanizer.Core.ro": "3.0.0-beta.96",
"Humanizer.Core.ru": "3.0.0-beta.96",
"Humanizer.Core.sk": "3.0.0-beta.96",
"Humanizer.Core.sl": "3.0.0-beta.96",
"Humanizer.Core.sr": "3.0.0-beta.96",
"Humanizer.Core.sr-Latn": "3.0.0-beta.96",
"Humanizer.Core.sv": "3.0.0-beta.96",
"Humanizer.Core.th-TH": "3.0.0-beta.96",
"Humanizer.Core.tr": "3.0.0-beta.96",
"Humanizer.Core.uk": "3.0.0-beta.96",
"Humanizer.Core.uz-Cyrl-UZ": "3.0.0-beta.96",
"Humanizer.Core.uz-Latn-UZ": "3.0.0-beta.96",
"Humanizer.Core.vi": "3.0.0-beta.96",
"Humanizer.Core.zh-CN": "3.0.0-beta.96",
"Humanizer.Core.zh-Hans": "3.0.0-beta.96",
"Humanizer.Core.zh-Hant": "3.0.0-beta.96"
"Humanizer.Core.af": "3.0.10",
"Humanizer.Core.ar": "3.0.10",
"Humanizer.Core.az": "3.0.10",
"Humanizer.Core.bg": "3.0.10",
"Humanizer.Core.bn": "3.0.10",
"Humanizer.Core.ca": "3.0.10",
"Humanizer.Core.cs": "3.0.10",
"Humanizer.Core.da": "3.0.10",
"Humanizer.Core.de": "3.0.10",
"Humanizer.Core.el": "3.0.10",
"Humanizer.Core.es": "3.0.10",
"Humanizer.Core.fa": "3.0.10",
"Humanizer.Core.fi": "3.0.10",
"Humanizer.Core.fil": "3.0.10",
"Humanizer.Core.fr": "3.0.10",
"Humanizer.Core.he": "3.0.10",
"Humanizer.Core.hr": "3.0.10",
"Humanizer.Core.hu": "3.0.10",
"Humanizer.Core.hy": "3.0.10",
"Humanizer.Core.id": "3.0.10",
"Humanizer.Core.is": "3.0.10",
"Humanizer.Core.it": "3.0.10",
"Humanizer.Core.ja": "3.0.10",
"Humanizer.Core.ko": "3.0.10",
"Humanizer.Core.ku": "3.0.10",
"Humanizer.Core.lb": "3.0.10",
"Humanizer.Core.lt": "3.0.10",
"Humanizer.Core.lv": "3.0.10",
"Humanizer.Core.ms": "3.0.10",
"Humanizer.Core.mt": "3.0.10",
"Humanizer.Core.nb": "3.0.10",
"Humanizer.Core.nl": "3.0.10",
"Humanizer.Core.pl": "3.0.10",
"Humanizer.Core.pt": "3.0.10",
"Humanizer.Core.pt-BR": "3.0.10",
"Humanizer.Core.ro": "3.0.10",
"Humanizer.Core.ru": "3.0.10",
"Humanizer.Core.sk": "3.0.10",
"Humanizer.Core.sl": "3.0.10",
"Humanizer.Core.sr": "3.0.10",
"Humanizer.Core.sr-Latn": "3.0.10",
"Humanizer.Core.sv": "3.0.10",
"Humanizer.Core.th": "3.0.10",
"Humanizer.Core.tr": "3.0.10",
"Humanizer.Core.uk": "3.0.10",
"Humanizer.Core.uz-Cyrl-UZ": "3.0.10",
"Humanizer.Core.uz-Latn-UZ": "3.0.10",
"Humanizer.Core.vi": "3.0.10",
"Humanizer.Core.zh-CN": "3.0.10",
"Humanizer.Core.zh-Hans": "3.0.10",
"Humanizer.Core.zh-Hant": "3.0.10"
}
},
"Microsoft.AspNetCore.App.Internal.Assets": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "8k0KoYKvNrSqP8E3oDJMBVKavlTEE6BacdLGtEDcxv9Hz0XxWFkD6JdLJsxXUlGeAfZ6YfvWmxKenl73qeCTSA=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "d4Atx9IHq7JgX0F/h7Db+m9zAUzC+cKdI9k+OWnnyQIOUQtfvjIEuhvbjPigVMkAmPUgCbJ8Yp6M9ghUqHtJSQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.OpenApi": {
"type": "Direct",
"requested": "[2.11.0, )",
"resolved": "2.11.0",
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
},
"MudBlazor": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "itY10dugofdvLYnJ1VZSzUIiKVqNvnTz4eEMYlHUdrOfsk9xShj7k1EW5H6ssMmkbQzShlLBHlUt+VPFGoEJGw==",
"dependencies": {
"Microsoft.AspNetCore.Components": "9.0.1",
"Microsoft.AspNetCore.Components.Web": "9.0.1",
"Microsoft.Extensions.Localization": "9.0.1"
}
"requested": "[9.7.0, )",
"resolved": "9.7.0",
"contentHash": "uWOgNn9B556J/TL8KFoSqpA3YD2heqOpK5etcofwlj1RiEX1IDy0NmwYE9urlTUQ8zProEK5GqwTBnoObFPN3g=="
},
"NetCore.AutoRegisterDi": {
"type": "Direct",
"requested": "[2.2.1, )",
"resolved": "2.2.1",
"contentHash": "qRda/VP+Lxak/GCGfT3PqXE6VA+bCbf2wlExcUWwnkwY1d6cfWv4Fp5RRN6dChlFhI8tbmzlNutYwxlA8kBb1A==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "2.1.1"
}
"contentHash": "qRda/VP+Lxak/GCGfT3PqXE6VA+bCbf2wlExcUWwnkwY1d6cfWv4Fp5RRN6dChlFhI8tbmzlNutYwxlA8kBb1A=="
},
"Scalar.AspNetCore": {
"type": "Direct",
"requested": "[2.16.16, )",
"resolved": "2.16.16",
"contentHash": "Ax0e0bIh+Upf92k1+pTBUom3e/kbpu20qsrDYmmS1NM721Eq2xF8c789x6IbiNrvW8WwySRzPv/icYYhb6idSg=="
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "anMn7QrFRWthyt08MSDFdUKoOwMqNqDpI9AJ3YIgJZJq1qmJd9sjHiKgJM/Ov0WyLEE8pARzyxOkIbXX1pBd0w=="
"resolved": "3.0.10",
"contentHash": "yZIhtw8sYuvsONzQbZxWpR60tMWYHXoo0DL6nyOqSFiU5POjBTSEyWFpTQtJEZuy+oqiYTXKXY/Mjx7KnqIQFw=="
},
"Humanizer.Core.af": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "8/9iF3PY2Dm5M87AyU02LoUZV634znBVQsuMQu+yjxtAgGYAksL0skikU1IblDEp2Hh7I2ejOa48Jb/MjIvSEw==",
"resolved": "3.0.10",
"contentHash": "jIpC73aOXfhqGEnpFKgO2AJ2Esb2t40c/cKqR+RgK1yg1bcBZBuhtjs+PrAIHf5Lp7Znd4teLjCGDS5ut8O1cw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ar": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "TfwuryVUAE8GLA3ZuTh2VE8gz+15kyBpA+UjMsrgVm9b8biySFegHRNvcdb2H1w/mHYKD6eQbEq/dXGELu1C1A==",
"resolved": "3.0.10",
"contentHash": "LdDeLE9nmlqbl14RinjII0v8APKZKUqJEClILvdLAoUEdv8UhgsHhDX2BHJKZv2aNfCnb19z9otE8Ffaa11Sow==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.az": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "CuBTV+VWWkFUdXK0f11KASlx3zAsx701bG/sbhvWWEfEQsXwr8fqNl4S74iNE2TWFSP8e1nPPS4Lu23qpXfwkw==",
"resolved": "3.0.10",
"contentHash": "+tfsgP5PkTY5mpx7t0rpMVVNXJQMcRkFSsWtnPpntuC/z7I8GS68CBcsHItw5/qbTJxWRY/cLPe/Psxh/SjEkA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.bg": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "AFZc1q0s9zFmxlaBYkZLlXWIAy6uxznU424g5RuNllAjuRKIjSZ7VSF/9yRC4PasVZf1hYiqL5M9oQLXz10yqA==",
"resolved": "3.0.10",
"contentHash": "xBQMDuLOjmj1U0h39tv7B39vmVZXBbRUcy3xkHBKHAyuRoxskDJ3F1LUOOw1/E41+dGQziAe2I0EVukwvQ/h0g==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.bn-BD": {
"Humanizer.Core.bn": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "nkgVbmYNo9G8brbR0EmVxsN1nF2eqn+GSCMNY3+Nr2VnEo6RlESd8ZYz6+Q93RpelVq2jmsmXWgnp+TLa43eQA==",
"resolved": "3.0.10",
"contentHash": "U5Li886U/hPdhMxyN9P3eF5dlB15sT/w4F3NOioXsaTQxnJKdxUKglRHiGbakX+aSrsFGok1MoQwVcH7Sac1Lg==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ca": {
"type": "Transitive",
"resolved": "3.0.10",
"contentHash": "D29T+OWcYG4rlOXKwAGpNNrwLgE44oE1JYqlNuNs3lVYmTDEgItyT+SfjG2TXuTQXkpQLzsxfRjqtA/APygjgw==",
"dependencies": {
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.cs": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "ERRbgPC2/Jpj0sSoKFS28dpluDwNteHtmaH2NHq6d9xTbNSNMnINKBR+osDhpFJboJA5EsSEj4ZZwnNDMSnrUw==",
"resolved": "3.0.10",
"contentHash": "kaG0//ULYkyB8Rk4g5PgC2o8jrmG2joc8lgtyDzCiFCaobr0jgUn393JAIB0JUygbngEIfA0PMK3fLbMXcBNLQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.da": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "cf6/r5QJ74pQZVNGCr+P0jXJ7WtP8Wqvur053Rwc+Sfvuj+W3Y7A+Bkwnz1kreGhaH5ikz/7tirnKIkdA119cQ==",
"resolved": "3.0.10",
"contentHash": "/MCePoHsSaRo03I1NSRut5iUzhYRIKL+UncbtpN8AZ3Vl0TOu0Psi9/6QyjD4gLLx45puiXB2S0lUjCdYmlP7w==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.de": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "XdinGY9a+vyZ9HOgqTzH+vs0oFSe8bncw+QlbT9N5dyx9ssTnmo94DhSi9+lAK6TONrv9gFJJdLKLgi2xtvdhw==",
"resolved": "3.0.10",
"contentHash": "UoJk/DmAOFZivuMulLoB52Pn5iFvSqurF7qQQAyUAz1/IUZjxkoeoiI5mMILEjVeGoQJqrylt6O+f8johCo0Bg==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.el": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "wib7nnI+RS8WapMRUOXxV7tToWwfm6mjytsZ9xIRDlMk+kN1/YgBdKU+A/gPrKsYIBPzHbCtP8PgrdUm1YsnYA==",
"resolved": "3.0.10",
"contentHash": "5oIHs25LMm1IBrhcxOYYLTFHZQfBaqgKxm0jAkYmsxedpDZkv1k+BHs2D5Kg1672/ZhTs4jsLbqYNXkHm671vw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.es": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "OBQm6ONiwNgPT1FXYHAdEwgEh/F/C3zu99v/SQVxgUYOP0MxCIjt9heizUOAjkB7iuV7mwC8Ni0TNzDt/CXplQ==",
"resolved": "3.0.10",
"contentHash": "epxhWWQJ/I610mJGTfQPTocjp7/xCIH6tiREVIlHb/sA1kDAD1zHQcHI+HgWFzn/LzzbVg99y8HVFFHUvjnfXA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.fa": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "f1mSmnmTZhEx10qHiYzX7te6cXlF55TwJDrA1bXnIwe5/pEXBYVp9xvQD76zOEOER57HRNaCwum6ZHNkwwquHw==",
"resolved": "3.0.10",
"contentHash": "9BEFCpbWoUdq2JnLbkohqBfpjjdeMqFhkhCNk3qYmutZwSxuZHrCozppY14SSWI6b6qD1Veb3rVyHGz9Fex5AA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.fi-FI": {
"Humanizer.Core.fi": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "M4rtABPYIbJZI41fNbIwY18ly3uwi04pelqG4Ox5PChmDo1KFYeTI+MjqCC3J16CDtV9IYq43JYmz2aS9KkzxQ==",
"resolved": "3.0.10",
"contentHash": "9Nbbxs1WZH2Li6Q28tZC06Y0mXwmIGnCEO/2nRzHvTqf07153frndezl5ads4uX8mNs+hMnUGUsr03IsFhI3jQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.fil": {
"type": "Transitive",
"resolved": "3.0.10",
"contentHash": "zpUvc+riMHZ2QGYETwnaDiT4Y4fHv2/Ctpi8Nm5SujNmOuxQyQMEAcalbUqWaLjzfTcysKqlU/NkiNP86kGlCA==",
"dependencies": {
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.fr": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "Zf6DKTXvKUQ4751UuWdpgLvF0sHoWSdU2nYLd0NRkjxkko+GA1VCo3z6jHJKRNFYrOPXYRfl6e1RqXEEnLmhAw==",
"resolved": "3.0.10",
"contentHash": "k+STDkiJVeO/nA5s0/GeBdwZI17D9KgqyYA4XGxfGxtFACwbFyVKQLWLzlIR2BsrT53EkyKLAzuFJ9FCT2CyfQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
}
},
"Humanizer.Core.fr-BE": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "6JpbLjoEkYzLTvN9qedAkFW+MCyi8u3zx2DFNur64uLkO5gIKMUUbGLfFT9DJiji9iTukryGejmFGC4gqd5HnA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.he": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "2EkoQaHlsPYNOvfGwioQfa5PB5tYCuRI2sbVKDd71SujUUuFmvQo7Y1cqrXkB0aRw4IXRMV4kqVw7WC54vQjIw==",
"resolved": "3.0.10",
"contentHash": "4WIzAg5hocnbnzaTnueYW/BRhCrQoPg/0U6WiUC4BJKGJDuVYbqIa8iVcHqhr7vVKd5ngFbhSsXuCgkYqOMaXQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.hr": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "5eKjP6mxl5F7CmD4++u2aRfPkm7Ky1jdC322lOhORXIMY2QUG7nSk6wdP3n4gZX/35GI3ZbCs2SOA67rc8QGiw==",
"resolved": "3.0.10",
"contentHash": "USin9T/FsHjmxvaolEXgL6FOdw+1Juh7geqcjTvJRCdoo/U4Y/usoAiZRbFtDA//1od4yqSxEbmj+F16nKwbkQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.hu": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "Hfyn9yfC4SkQ2IDV2x74oQauosvbXJdg43bzA/W3uPP3gBBKeZHsGnYdZtlWoXcsH9bbBj6PEi2PQwrHI5Euzg==",
"resolved": "3.0.10",
"contentHash": "Umon7VBTVDs8QFDqNzGm6MBD+43UZJoN44htW/+iJUDF2gl8K8meRup27Dkl2zvewW3ynZic/xwP2hTDaPjIjw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.hy": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "noClOyB3R9YfDQ3R8rMFLXTBlb9qwz6oEnDwgOojv+gUQ6uN+39DNEB3NiTAnL9hryC4ArnCbixOjCXghjIr4Q==",
"resolved": "3.0.10",
"contentHash": "PzdcqQMwyDYO81PYbzBf+gFMj+wzOYF3kz3mlJCcBX5mSmNRpLIwB2s6PC9OiNN/Y/ya1orHV/z0OY9j7NFc5Q==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.id": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "6uxBc4LeJxm2UWGjkF4BbXYeaNmy6oM8OVaaDmcMtWWanJNpxQ0q7i0UssuLKeFEOhm2KnDVmTcHqkOS3p26wA==",
"resolved": "3.0.10",
"contentHash": "rMb9jvglGeeUxb6Z4EkXTg+et1Ri3DWZjlXs2WFle1Qpu39zWWF0l9rh9PQJb0fzeiZ8jOjiUhUI9lRnoAHHCQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.is": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "DH6j66s1l2OX0rXM22uaFDN6bwh6Ss6LBckaUWUb4t1aEzDIgW09mzZoMG+3zx/LhRibmsur86rf/jN3+2weCQ==",
"resolved": "3.0.10",
"contentHash": "xnNgWY9azqXyclRYmy3D6kgnbdCcPFBAEN4yNdhu2IN10p7Y2T9YUw5R9jLWo5+NGM2uzbbyTxPGf4+9YYUc1w==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.it": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "WzxjrUGtRWrB623K8M0wW7qmAdfLroind4yEvsPTRnDFa0prFHLFZxOWF9ypBu6yLXR3T2TdT4Z3r7jYsT+bLA==",
"resolved": "3.0.10",
"contentHash": "iCrKxOX6q/PZgEk3vYyU1fHC0jnq0hiq7nwWXiuV7uF5VypOxwVeao3vkE6MLoNKW7RQN/TbP06n1TOqyhTA7A==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ja": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "6aHU1JB1gF0azIodYsPFJ5s/5fiDoDiCg5kTI7O+SoOyJrpjKJ8umaMN6r/VwhEUPW1yHxFsEkX/XHO+mXrvAA==",
"resolved": "3.0.10",
"contentHash": "UicfcXwMibwWN3jLesdk702IRQVndgBUqueFr3iXVEn2POue06pHInSIruxNaPTscaNdGKxXZiIiJrN7pCVO9Q==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ko-KR": {
"Humanizer.Core.ko": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "gcYmdBIZ+oBFRTnDZ3RMx4DIOoLcaTa5XPbmefl0GdDJeG7xsFxZQHeRh5sHXNIiMchSYjkPgCgfJTuiwHumIQ==",
"resolved": "3.0.10",
"contentHash": "Zr/GwEilhAcmeiDxVKqr79ZK/SEpHfhKzXD15JZ1NpsfYnIfNAN/9i0o535UaadaDDT6EIVpkoGIYAGuYQ0Z4g==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ku": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "4tMhhNYFOEljlp+y5C/xMVpXga5rGUiYv1gEQJC82tRMhxm/q5elVMQEGtx/iJWZSNiCgj4VZ2Ur4Sqh186gJQ==",
"resolved": "3.0.10",
"contentHash": "j5Qm+CY+c8onfjO1O+TReCDg/epCYdfv5N5LNB+Ydcmn06gkR4HvbWJnk5UFLVHz8NMPmtCrv7CYwmPJ0+lJqw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.lb": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "QAObL6iaSztyoYnA8yZlrGraXKXGcxPufRpDbn7S3+boTXY3tkdK4ytZH61AB6wJvuN2ZVqphOMYgCnttO8DUg==",
"resolved": "3.0.10",
"contentHash": "5lpkw49T6MQ7Gwa0k2JV4cw6aH5FdT1Ku1nfGeey/iScajHDV9mvdxjkxRZpFAhJ5W/5yCtMlHH992LpXFJkeA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.lt": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "ePE7K9tngIOLwbKf6lQjtRhTTpiRUvy/DxnHAXI282RDA9DiUB7OE4Zr6NHJeIdrJA4JGat+HosJDPwDp7vV7A==",
"resolved": "3.0.10",
"contentHash": "1OO4XZ/74tt5nkxYHnpj+ti1w0HdeMGi+AHR59asxcJ3A/iTDZR4RNRIvoj1MdYRe0HdTUjtnGXfA7tXXOkiYg==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.lv": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "HL/3ilL1doE8MublQyMFB4ro/C6M7miAfHvtOXUtc7wsbHJPc9HnbIcBhnyxh3JmYgKbThWoZ6ssfmDeV73qbg==",
"resolved": "3.0.10",
"contentHash": "S4RN1q/saAQxlFlRRUnmi3Oz+mIXRqWtIRZJA3VrvMmBy12cXIdpeY0+d+HCA58WXpI2diOrWxHq9KPUDPunIw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ms-MY": {
"Humanizer.Core.ms": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "CuUek2ZvjaR3l4zSsb0lhUujTvtExLkSgP1RlQiaux0RB9Kbh88odwLTDaPhWYcA7D0h0nCkBxSZNwxhP4kWdg==",
"resolved": "3.0.10",
"contentHash": "m0+BJa9vG0GeuAWhmdmMxdUUa1cfGjyHZQeJoeivqxJrrtdUUFekXH0xbpvI3ErC8qu5Ufpmfnr6QAX0WQfkIw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.mt": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "ss4Uxs9mjzaBw5Fhy68ttAP3MeyxHD1Rbk+bLERBMWtwQibER1fcH4JYu/EuRJ7yDe4oObOjRqu3GzNrXGLbXg==",
"resolved": "3.0.10",
"contentHash": "8GfXTko/iweepZHcUcOANreClfvTeCDfSuiMxLtFbUZQOBegLZ6VnTVun2gVtv5OANdOk64qhqUKIVLq2rG0rw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.nb": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "o579CpW97Oj8Xp4IgTzlwieH+QOV/gRbEXUKapaQdiK/bOCOMm9N1z+wNhbDYmY/Uym6K34PG7xpbqio0Nj9QQ==",
"resolved": "3.0.10",
"contentHash": "UcdV9TWXG2MFVEkqHy8oD2ZbuFhFPLY7ScCJKWBu7OsgInyuHZe72hHP/i52T0FAgiVbrB+Urd3uoWiUT7hP9Q==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
}
},
"Humanizer.Core.nb-NO": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "epxcGNzQMeyhGNEypcb3bFYePcoARkBHWHfKttOdNKI91uuSMt7lG9/Y7l8cGGVPfvOLu6brU6KZISQ+85TyaA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.nl": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "FmFCjnc53MH5tk6sRgOB/pSjFUmCiPBO2OcdV1szqYPJ0dGNkGw3pHUfE3P3gAHYuHL/GdYyhCi74/BiyK5C2g==",
"resolved": "3.0.10",
"contentHash": "b+Y1CFId1ucl33OvNITID0eEVesOKiw/dmrcDJ4Z/P9KEuMTeE8aKu5/iSjwtPVV+SagaMx9XzZe7kVYyRqhuQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.pl": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "JMUCLMrAEzRR9b+4BY4+b8D+ymQIUAp74b7+zWY/ZLOnwc8Yw01sFx51hbJCgohEVXAitOAyHjzvrhUoijG32Q==",
"resolved": "3.0.10",
"contentHash": "LJCt3+dGUQhopQ/1w/zzBNlUHtjkdrt57zS+llMQeFm+1u0e3R+lWJx4zP+hqZcpRA7armR38KghvRSz+bYBbg==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.pt": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "Xb9XhdAQKG9qkbR/SPO5oYPiUtDaueTwJPRnUoBBIbdDTzpQlSUVjJ/DFez+/X8d8GsANWjof3g/5YnOWZ16+g==",
"resolved": "3.0.10",
"contentHash": "+cggEy6W08xD8GU45/EaMfvNQMCd7K1G7hprSPty/9zIoM3OzaajdMA/Oxrb0mHIKpARxCNbQ29ltOtip3MTNA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.pt-BR": {
"type": "Transitive",
"resolved": "3.0.10",
"contentHash": "kbhwn4G+STHUT9cLDdrP60+ZFla4lTg6def+FTnhpMS4JHf8ll0TL1kz9Nj5DTJFdqnvs8/eYfmVhnEegyBjBw==",
"dependencies": {
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ro": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "SSaZzeQmrbmDh2/CHSUF3jbU2V0ilcVHPa32/i7g0TXeywzP35CIcZ7vfKlJcfLSnempMg1bdm703hfeUdoLTw==",
"resolved": "3.0.10",
"contentHash": "JwdY+SywNIDrFcWKyiZsgsSJkIleoAXAtCLYiLn+CNCk+135VwhchZHm5B9nNvpLjGVaP5Y0ouZQ8xTx1oeZag==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.ru": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "+uTlQsIs4blTP6f0mnQ7lnn8wyOLmZFMWHqqTh3+Ul960FLlo0UnxYScV1sgNLHudv/04IiLsuj3bGxWJKW0FQ==",
"resolved": "3.0.10",
"contentHash": "1vXr7pkV0zSZF6ENJUadsAagubrBAvTPHPgdgttHlMhUQ1Zz9ZWTJaQdmf2RCen4YywMmfsQQZiGqJl52LVT8Q==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.sk": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "dcSN1GYFUNTQFlLljGZLarLe1e258wtNK+k0Mj2sghN+ffvlK1aRhUvanmqAaURfkK5ume1Q4e0A4/ATMNNFsw==",
"resolved": "3.0.10",
"contentHash": "LD93nJw1zwXuC+LSw3+02Jk02bdAoENHzb/qrMoGBf70XeV2rBeoxSyJBVLHQ00RZfwNSy6TCTXQyagESVmN+Q==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.sl": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "H05Tl8RQs8RoJ+UWYPga087x4Be57EpRk6iYwo4absTlFyPeISv8whUqayJBvDpNGS94QHennrnfembrCnhJXg==",
"resolved": "3.0.10",
"contentHash": "utURJ8K/tnbA5KXlKcT8T+35MEVdZrXUvu2tH+T6M9xxJ1MhLGuFWnc687pcsLdpaLnGB7ZVwrAITlueAE+KGA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.sr": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "FLH10qbsgL5NJJ/moB1iSnmO1wkVDloJYwq1CwIKp0PePb4bViO0z4nTXKCERWn3zl3WXeCJ5gOUyrvplr6bSQ==",
"resolved": "3.0.10",
"contentHash": "e2bVzUAjEpxjFdLVmXZL9EsdVojlV/8GMwZzwDS6OByfG+PP5JxemKylK6FoUK5X2IBPNg68LOvn72WgwaruIQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.sr-Latn": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "aTIigE/P5ahO5RO88igcz5V8Fo0MP8+Cu/NFVFXQJv+Yw39dQ1fOscLpVXenOg22sy2Tz2pi/JNfK42SDEiV6Q==",
"resolved": "3.0.10",
"contentHash": "VXPlm+eg5sraDhZ50WGiMZMvj4I2d+0VAy8cNaLLXNt+EgkUz646dRfg5HrQPPjtBbegj6o7gcg8Xl6z0ke+hA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.sv": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "k3URfcuKrOfL64Eg5mE2uV8YotKnhObFsJaIiyGhRPyl2QrrPU6VXKM2DmLa6Cfa1RN3zHYLv8PzBo1g6jVBYA==",
"resolved": "3.0.10",
"contentHash": "COq4bbUKk7F1jfE2sC/ZmomoQqX0Herwe1iXZyDl5zyw21mUY9wL96S1qbRmRp15UmGzFJdqBPdnd58P7kVMyg==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.th-TH": {
"Humanizer.Core.th": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "9vaBFcdcXvE4uXr4VLbhPQ/Hv7lKf4Cb1HzThrnWklTk6uzZkmYYRs/yat+apk7of0VAlY8rthsMiNIwwSWW+A==",
"resolved": "3.0.10",
"contentHash": "8A4lZCLE6bEPYdQKeGfPBHDgAsY30TWWdjoWA1+vAfLA9Ip1l2Ons+Ngw4npkcoI3qdCidU6hWOsFnPfJ8ZufQ==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.tr": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "d5xUVe/1NrmAeBTL3KamGtZJ/3ja7O8OOWV/ICmNeOEHPMg5wZTWnQ3DOOrJAdrnf5D6kNcngKxYwKZYoRS19g==",
"resolved": "3.0.10",
"contentHash": "aRAxq1VyuIVoFdx8TZyVsRoDk0yqe9NATjhGgWHPsdR8TVHnuiyXeEbiXf846870VteyLJL9tVy3jaj2rC45tw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.uk": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "3i9xEIoqoWUtNKaihYqr9EfQw7ilWiDIaDwrXDrADldO1HHFAcc+F2vNEB1sZ81/F/1FBCthyODTFDBD1Ma4Kg==",
"resolved": "3.0.10",
"contentHash": "NPnGZ1jdoMDYGrsHt5aLlj7Ambp7i5ER/5BbtjBSegn3+PliMbd4Eftr1Ind4qvCU6rTP2a9lUqBoRd+hSVA+g==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.uz-Cyrl-UZ": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "LZK6cDgU0fQ0zURLYwk4qlodc0ZGcXkxV6zfHJbHXBsow12cJ2p7NhL495ihWLnDrHfD20+J1y9rTvC9vIWnFA==",
"resolved": "3.0.10",
"contentHash": "8NSyuiTlCfse1ki9QeO35IpZLcxoMlSMGvEY892aw9CNGaDMROQjFzf1iKJkWTyB9atrHHPuWlasBAFk2QkT9w==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.uz-Latn-UZ": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "AmoGiYjAjoi7htH6Vimd266rGRfyU0Jsa9BjALP7EAsWILjAzba3aMmChK33A6apFzC9fv2XT0amZsgIqBvR/Q==",
"resolved": "3.0.10",
"contentHash": "FaSETkwfUiDU/RHlD54xbBKEZXnFIUnTNTnA5ZYXgDOiMmX6cY7vyRDmI1YpQtO+QR1irSettpLFpHeysslnpA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.vi": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "PZnFdIJF54ODyMazZDSe30NuRuVx4/c/3KBXzwFX3tR+yz+gJkYlYpTI8ozRLF2/mlcFr9iFAU4Pau6jJFevFw==",
"resolved": "3.0.10",
"contentHash": "Uz5gph//0eNP0Ta1tqdKDyghiUnZdW0aVW4MK/poD5hLEH1qLoDTWuTb8gxj3F8GvSFjgVmF9HZGQx5cO06N8Q==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.zh-CN": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "GX8JvHC9/ghN8ZU/MadCnRrLB6kJXQEuJz3i0hS03VVrTZlHMRQfXEC3NhSktSJ9FCyYq+WZaw7jhmSaQ/S2pA==",
"resolved": "3.0.10",
"contentHash": "7veG4JdZ4OeYNlgci/6I/Xf9G/iCljBcwsbLVyGaqdia+2UMAp1iGu+MCs3zKOmEvcb4ipHGFkwT5d/2WfEevA==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.zh-Hans": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "S0nzvET+5M188jPN2cUiMguIszKZQ6tVyvAem3xBjvF0LjodAlAmZNlMjw5zaPMACuWMkOgvpKjuA8dhFa2Gqg==",
"resolved": "3.0.10",
"contentHash": "z8E2R0QL43NB6m8ntQaL1cONMjudoIJXvMQUFXG4OaKp5iyYqWs6FMC2B7x2rLxOynpsxoq5AQ/8BO0pMdfZrw==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Humanizer.Core.zh-Hant": {
"type": "Transitive",
"resolved": "3.0.0-beta.96",
"contentHash": "nzWjsEb7JLHtBOuWuKBI1b0rm31C4Q8DSJHZy6UPhjO1HVra7GcMVeNyxXsljaVtdjR5kjLaPuhrr0yDNEUtYA==",
"resolved": "3.0.10",
"contentHash": "KI5Nv82v9diPBxcBAwIAvZQHjSrDy7He0atnzdZghS+QchHwlkPKhTyEzVSbOZdD42RxCX1ArMiL864A2iw34Q==",
"dependencies": {
"Humanizer.Core": "[3.0.0-beta.96]"
"Humanizer.Core": "[3.0.10]"
}
},
"Microsoft.AspNetCore.Authorization": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "WgLlLBlMczb2+QLNG6sM95OUZ0EBztz60k/N75tjIgpyu0SdpIfYytAmX/7JJAjRTZF0c/CrWaQV+SH9FuGsrA==",
"dependencies": {
"Microsoft.AspNetCore.Metadata": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Extensions.Options": "9.0.1"
}
},
"Microsoft.AspNetCore.Components": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "6pwfbQKNtvPkbF4tCGiAKGyt6BVpu58xAXz7u2YXcUKTNmNxrymbG1mEyMc0EPzVdnquDDqTyfXM3mC1EJycxQ==",
"dependencies": {
"Microsoft.AspNetCore.Authorization": "9.0.1",
"Microsoft.AspNetCore.Components.Analyzers": "9.0.1"
}
},
"Microsoft.AspNetCore.Components.Analyzers": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "I8Rs4LXT5UQxM5Nin2+Oj8aSY2heszSZ3EyTLgt3mxmfiRPrVO7D8NNSsf1voI2Gb0qFJceof/J5c9E+nfNuHw=="
},
"Microsoft.AspNetCore.Components.Forms": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "KyULVU32bLz74LWDwPEwNUEllTehzWJuM7YAsz80rMKEzvR0K8cRjRzO0fnN/nfydMeLRRlbI0xj8wnEAymLVw==",
"dependencies": {
"Microsoft.AspNetCore.Components": "9.0.1"
}
},
"Microsoft.AspNetCore.Components.Web": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "LI0vjYEd9MaDZPDQxPCn4gGYDkEC5U9rp1nWZo7rPozJxgTG2zU3WERujxTi2LeAC2ZzdXlOVCrUyPQ55LZV2A==",
"dependencies": {
"Microsoft.AspNetCore.Components": "9.0.1",
"Microsoft.AspNetCore.Components.Forms": "9.0.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.Primitives": "9.0.1",
"Microsoft.JSInterop": "9.0.1"
}
},
"Microsoft.AspNetCore.Metadata": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "EZnHifamF7IFEIyjAKMtJM3I/94OIe72i3P09v5oL0twmsmfQwal6Ni3m8lbB5mge3jWFhMozeW+rUdRSqnXRQ=="
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "v5R638eNMxksfXb7MFnkPwLPp+Ym4W/SIGNuoe8qFVVyvygQD5DdLusybmYSJEr9zc1UzWzim/ATKeIOVvOFDg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "4EK93Jcd2lQG4GY6PAw8jGss0ZzFP0vPc1J85mES5fKNuDTqgFXHba9onBw2s18fs3I4vdo2AWyfD1mPAxWSQQ==",
"dependencies": {
"Microsoft.Extensions.Configuration": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.0",
"Microsoft.Extensions.FileProviders.Physical": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "WiTK0LrnsqmedrbzwL7f4ZUo+/wByqy2eKab39I380i2rd8ImfCRMrtkqJVGDmfqlkP/YzhckVOwPc5MPrSNpg==",
"dependencies": {
"Microsoft.Extensions.Configuration": "9.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
"Microsoft.Extensions.Configuration.FileExtensions": "9.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA=="
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "uK439QzYR0q2emLVtYzwyK3x+T5bTY4yWsd/k/ZUS9LR6Sflp8MIdhGXW8kQCd86dQD4tLqvcbLkku8qHY263Q==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "3+ZUSpOSmie+o8NnLIRqCxSh65XL/ExU7JYnFOg58awDRlY3lVpZ9A369jkoZL1rpsq7LDhEfkn2ghhGaY1y5Q==",
"dependencies": {
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.0",
"Microsoft.Extensions.FileSystemGlobbing": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "jGFKZiXs2HNseK3NK/rfwHNNovER71jSj4BD1a/649ml9+h6oEtYd0GSALZDNW8jZ2Rh+oAeadOa6sagYW1F2A=="
},
"Microsoft.Extensions.Localization": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "UgvX4Yb2T3tEsKT30ktZr0H7kTRPapCgEH0bdTwxiEGSdA39/hAQMvvb+vgHpqmevDU5+puyI9ujRkmmbF946w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Localization.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Extensions.Options": "9.0.1"
}
},
"Microsoft.Extensions.Localization.Abstractions": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "CABog43lyaZQMjmlktuImCy6zmAzRBaXqN81uPaMQjlp//ISDVYItZPh6KWpWRF4MY/B67X5oDc3JTUpfdocZw=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Primitives": "9.0.1"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g=="
},
"Microsoft.JSInterop": {
"type": "Transitive",
"resolved": "9.0.1",
"contentHash": "/xBwIfb0YoC2Muv6EsHjxpqZw2aKv94+i0g0FWZvqvGv3DeAy+8wipAuECVvKYEs2EIclRD41bjajHLoD6mTtw=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.1",
"contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A=="
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.5.1",
"contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg=="
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.5.4",
"contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg=="
},
"caddymanager.contracts": {
"type": "Project"
},
@@ -714,14 +541,11 @@
"dependencies": {
"CaddyManager.Contracts": "[1.0.0, )",
"Docker.DotNet": "[3.125.15, )",
"Humanizer": "[3.0.0-beta.96, )",
"Microsoft.Extensions.Configuration": "[9.0.0, )",
"Microsoft.Extensions.Configuration.Binder": "[9.0.0, )",
"Microsoft.Extensions.Configuration.EnvironmentVariables": "[9.0.0, )",
"Microsoft.Extensions.Configuration.Json": "[9.0.0, )",
"Humanizer": "[3.0.10, )",
"NetCore.AutoRegisterDi": "[2.2.1, )"
}
}
}
},
"net10.0/linux-musl-x64": {}
}
}

View File

@@ -161,8 +161,14 @@ services:
# Path to the Caddyfile as seen from inside the caddy container. Must match the container side of the
# caddy container's config volume. Defaults to /etc/caddy/Caddyfile, so this line is optional above.
DockerService__CaddyConfigPathInContainer: "/etc/caddy/Caddyfile"
# Shared key for the HTTP API (see "HTTP API" below). Leave it out to keep the API closed.
Api__Key: "change-me"
# To have the access to the caddy config file
user: "1000:1000"
# The .NET GC sizes its heap against the cgroup limit, so this both caps the worst
# case and makes the runtime self-tune downward. Raise it if you run many configs.
mem_limit: 256m
memswap_limit: 256m
ports:
- "8080:8080"
volumes:
@@ -186,6 +192,49 @@ Currently, the Caddy Manager is able to:
configuration first, so a broken file is reported back instead of taking the proxy down)
- Restart caddy container on demand
- Parse simple information from the caddy configurations
- Do all of the above over HTTP, for scripts and other services (see below)
### HTTP API
The same operations are exposed as a JSON API, documented with OpenAPI:
- Interactive documentation: `/scalar`
- OpenAPI document: `/openapi/v1.json`
Every request needs the shared key in the `X-Api-Key` header. The key comes from `Api:Key`
(environment variable `Api__Key`). **While no key is configured the API is disabled and every
endpoint answers `503`** — nothing is exposed by accident.
| Method | Endpoint | Description |
| --- | --- | --- |
| `GET` | `/api/configurations` | List the reverse proxy configurations |
| `GET` | `/api/configurations/{name}` | Get one configuration with its raw content |
| `POST` | `/api/configurations` | Create a configuration (`{ "fileName": "...", "content": "..." }`) |
| `PUT` | `/api/configurations/{name}` | Replace a configuration's content (`{ "content": "..." }`) |
| `POST` | `/api/configurations/{name}/rename` | Rename a configuration (`{ "newFileName": "..." }`) |
| `DELETE` | `/api/configurations/{name}` | Delete a configuration |
| `GET` | `/api/caddyfile` | Get the global Caddyfile |
| `PUT` | `/api/caddyfile` | Replace the global Caddyfile (`{ "content": "..." }`) |
| `POST` | `/api/caddy/reload` | Graceful `caddy reload` |
| `POST` | `/api/caddy/restart` | Restart the Caddy container |
`{name}` is the file name without the `.caddy` extension, as shown in the UI.
```shell
curl -H "X-Api-Key: change-me" http://localhost:8080/api/configurations
curl -X POST http://localhost:8080/api/configurations \
-H "X-Api-Key: change-me" -H "Content-Type: application/json" \
-d '{"fileName":"example","content":"example.com {\n\treverse_proxy 10.0.0.2:8080\n}"}'
curl -X POST -H "X-Api-Key: change-me" http://localhost:8080/api/caddy/reload
```
Renaming moves the file only; if the global Caddyfile imports the old name, update that import
yourself (the UI warns about this too).
> Note: the app redirects HTTP to HTTPS, so a direct `curl http://...` against the container port
> gets a `307`. Add `-L`, call it over HTTPS, or go through your reverse proxy.
<p align="right">(<a href="#readme-top">back to top</a>)</p>

View File

@@ -18,7 +18,13 @@
ASPNETCORE_ENVIRONMENT: "Production"
CaddyService__ConfigDir: "/config"
DockerService__CaddyContainerName: "caddy"
# Shared key for the HTTP API. While unset the /api endpoints return 503 and stay closed.
# Api__Key: "change-me"
user: "1000:1000"
# The .NET GC sizes its heap against the cgroup limit, so this both caps the
# worst case and makes the runtime self-tune downward.
mem_limit: 256m
memswap_limit: 256m
ports:
- "8080:8080"
volumes:

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:10.0 \
dotnet "$@"