feat(api): Add authenticated Caddy management API
All checks were successful
Caddy Manager CI build / docker (push) Successful in 4m24s

This commit is contained in:
2026-07-26 18:34:02 +07:00
parent a94abca127
commit 9f7cb79bad
9 changed files with 430 additions and 0 deletions

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
@@ -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");

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
@@ -755,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

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

@@ -43,8 +43,12 @@
<PackageReference Include="BlazorMonaco" Version="3.5.0" />
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
<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

@@ -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<CircuitOptions>(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<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;
});
});
builder.Services.AddMudServices(config =>
{
config.SnackbarConfiguration.VisibleStateDuration = 4000;
@@ -56,4 +82,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

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

View File

@@ -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.
<p align="right">(<a href="#readme-top">back to top</a>)</p>

View File

@@ -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.