From 9f7cb79bad88e403e0033a42244805d7f5fa8296 Mon Sep 17 00:00:00 2001 From: ebolo Date: Sun, 26 Jul 2026 18:34:02 +0700 Subject: [PATCH] feat(api): Add authenticated Caddy management API --- CaddyManager.Services/Caddy/CaddyService.cs | 11 + .../Services/Caddy/CaddyServiceTests.cs | 62 +++++ CaddyManager/Api/CaddyApi.cs | 252 ++++++++++++++++++ CaddyManager/CaddyManager.csproj | 4 + CaddyManager/Program.cs | 30 +++ CaddyManager/appsettings.json | 3 + CaddyManager/packages.lock.json | 21 ++ README.md | 45 ++++ compose.yaml | 2 + 9 files changed, 430 insertions(+) create mode 100644 CaddyManager/Api/CaddyApi.cs diff --git a/CaddyManager.Services/Caddy/CaddyService.cs b/CaddyManager.Services/Caddy/CaddyService.cs index 9dfbb3c..bb4755e 100644 --- a/CaddyManager.Services/Caddy/CaddyService.cs +++ b/CaddyManager.Services/Caddy/CaddyService.cs @@ -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 @@ -198,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"); diff --git a/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs b/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs index 21323fb..8b250af 100644 --- a/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs +++ b/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs @@ -436,6 +436,37 @@ public class CaddyServiceTests : IDisposable File.Exists(filePath).Should().BeTrue(); } + /// + /// 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. + /// + [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 @@ -755,6 +786,37 @@ public class CaddyServiceTests : IDisposable result.DeletedConfigurations.Should().BeEmpty(); } + /// + /// 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. + /// + [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 diff --git a/CaddyManager/Api/CaddyApi.cs b/CaddyManager/Api/CaddyApi.cs new file mode 100644 index 0000000..0220a0f --- /dev/null +++ b/CaddyManager/Api/CaddyApi.cs @@ -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; + +/// +/// HTTP surface over the same services the Blazor UI uses, so scripts can manage reverse proxy +/// configurations and reload Caddy without a browser +/// +public static class CaddyApi +{ + /// + /// Header carrying the shared API key + /// + public const string ApiKeyHeader = "X-Api-Key"; + + private const string ReverseProxiesTag = "Reverse proxies"; + private const string CaddyTag = "Caddy"; + + /// + /// Maps every /api endpoint behind the shared key check + /// + 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() + .Produces(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(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(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() + .Produces(StatusCodes.Status400BadRequest) + .Produces(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() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(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() + .Produces(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() + .Produces(StatusCodes.Status400BadRequest); + + group.MapPost("/caddy/reload", async Task (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() + .Produces(StatusCodes.Status502BadGateway); + + group.MapPost("/caddy/restart", async Task (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(StatusCodes.Status502BadGateway); + } + + /// + /// 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 + /// + 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, + }; + + /// + /// 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 + /// + 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, + }); +} + +/// +/// A reverse proxy configuration together with its raw Caddyfile content +/// +/// Parsed summary of the configuration +/// Raw content of the .caddy file +public record ConfigurationResponse(CaddyConfigurationInfo Info, string Content); + +/// +/// Raw content of a configuration file +/// +/// Raw Caddyfile content +public record ContentResponse(string Content); + +/// +/// Request to create a new reverse proxy configuration +/// +/// File name without the .caddy extension +/// Raw Caddyfile content +public record CreateConfigurationRequest(string FileName, string Content); + +/// +/// Request to replace the content of an existing configuration +/// +/// Raw Caddyfile content +public record SaveContentRequest(string Content); + +/// +/// Request to rename a configuration file +/// +/// New file name without the .caddy extension +public record RenameConfigurationRequest(string NewFileName); diff --git a/CaddyManager/CaddyManager.csproj b/CaddyManager/CaddyManager.csproj index 18a41b0..a82d07e 100644 --- a/CaddyManager/CaddyManager.csproj +++ b/CaddyManager/CaddyManager.csproj @@ -43,8 +43,12 @@ + + + + diff --git a/CaddyManager/Program.cs b/CaddyManager/Program.cs index e0fcf48..cf7e6f6 100644 --- a/CaddyManager/Program.cs +++ b/CaddyManager/Program.cs @@ -1,7 +1,10 @@ +using CaddyManager.Api; using CaddyManager.Components; using Microsoft.AspNetCore.Components.Server; +using Microsoft.OpenApi; using MudBlazor.Services; using NetCore.AutoRegisterDi; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -30,6 +33,29 @@ builder.Services.Configure(o => 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(); + 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; + }); +}); + builder.Services.AddMudServices(config => { config.SnackbarConfiguration.VisibleStateDuration = 4000; @@ -56,4 +82,8 @@ app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); +app.MapCaddyApi(); +app.MapOpenApi(); +app.MapScalarApiReference(); + app.Run(); diff --git a/CaddyManager/appsettings.json b/CaddyManager/appsettings.json index 54ec6ac..3dbf9cd 100644 --- a/CaddyManager/appsettings.json +++ b/CaddyManager/appsettings.json @@ -6,6 +6,9 @@ } }, "AllowedHosts": "*", + "Api": { + "Key": "" + }, "CaddyService": { "ConfigDir": "/root/compose/caddy/config" }, diff --git a/CaddyManager/packages.lock.json b/CaddyManager/packages.lock.json index c4348b6..4ea7fd2 100644 --- a/CaddyManager/packages.lock.json +++ b/CaddyManager/packages.lock.json @@ -82,6 +82,21 @@ "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": "[9.7.0, )", @@ -94,6 +109,12 @@ "resolved": "2.2.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.10", diff --git a/README.md b/README.md index 2d36efc..535ead6 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,8 @@ 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 @@ -190,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.

(back to top)

diff --git a/compose.yaml b/compose.yaml index e32b4f6..1d29d23 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,6 +18,8 @@ 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.