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.