feat(api): Add authenticated Caddy management API
All checks were successful
Caddy Manager CI build / docker (push) Successful in 4m24s
All checks were successful
Caddy Manager CI build / docker (push) Successful in 4m24s
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
252
CaddyManager/Api/CaddyApi.cs
Normal file
252
CaddyManager/Api/CaddyApi.cs
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Api": {
|
||||
"Key": ""
|
||||
},
|
||||
"CaddyService": {
|
||||
"ConfigDir": "/root/compose/caddy/config"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
45
README.md
45
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.
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user