Caddy Manager CI build / docker (push) Failing after 1m2s
Found while investigating an unrelated Gitea compromise: CaddyManager itself was not involved, but reviewing it turned up three things worth closing. Reading a configuration was the only file operation that did not validate the name. Saving, renaming and deleting all reject `..`, `/` and `\`, so the read path was the one way to leave the configuration directory and pull in any `*.caddy` file on the host. The HTTP API happened to be covered, because GET checks the name against the directory listing first, but the UI calls the service directly and nothing stopped it. Forwarded headers were trusted from any peer. That is correct only while the container port is unreachable except through the proxy; the moment it is published, a caller dictates the scheme, host and client address the app believes in. Loopback and private space cover a proxy on a Docker network or on the host, which is the documented deployment, and ignore everyone else. The README never said that the `X-Api-Key` check guards `/api/*` and nothing else, so the UI - which rewrites Caddyfiles and holds the Docker socket - reads as protected when it is not. It now says so, and warns about the specific shape that bit us: a second hostname added for machine callers whose only extra directive is a `tls` line, which serves the unauthenticated UI to anyone who can resolve it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
270 lines
9.0 KiB
C#
270 lines
9.0 KiB
C#
using CaddyManager.Contracts.Configurations.Caddy;
|
|
using CaddyManager.Contracts.Caddy;
|
|
using CaddyManager.Contracts.Configurations;
|
|
using CaddyManager.Contracts.Models.Caddy;
|
|
|
|
namespace CaddyManager.Services.Caddy;
|
|
|
|
/// <inheritdoc />
|
|
public class CaddyService(
|
|
IConfigurationsService configurationsService,
|
|
ICaddyConfigurationParsingService parsingService) : ICaddyService
|
|
{
|
|
/// <summary>
|
|
/// File name of the global configuration Caddyfile
|
|
/// </summary>
|
|
private const string CaddyGlobalConfigName = "Caddyfile";
|
|
|
|
private CaddyServiceConfigurations Configurations => configurationsService.Get<CaddyServiceConfigurations>();
|
|
|
|
/// <inheritdoc />
|
|
public List<CaddyConfigurationInfo> GetExistingCaddyConfigurations()
|
|
{
|
|
if (!Directory.Exists(Configurations.ConfigDir))
|
|
{
|
|
Directory.CreateDirectory(Configurations.ConfigDir);
|
|
}
|
|
|
|
return [.. Directory.GetFiles(Configurations.ConfigDir)
|
|
.Where(filePath => Path.GetFileName(filePath) != CaddyGlobalConfigName)
|
|
.Select(filePath =>
|
|
{
|
|
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
|
var info = GetCaddyConfigurationInfo(fileName);
|
|
info.FileName = fileName;
|
|
return info;
|
|
})
|
|
.OrderBy(info => info.FileName)];
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string GetCaddyConfigurationContent(string configurationName)
|
|
{
|
|
// Reading went unguarded while saving, renaming and deleting all validated the name, so a
|
|
// caller could walk out of the configuration directory and read any *.caddy file on disk
|
|
if (configurationName != CaddyGlobalConfigName && IsInvalidFileName(configurationName))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var path = configurationName == CaddyGlobalConfigName
|
|
? Path.Combine(Configurations.ConfigDir, CaddyGlobalConfigName)
|
|
: Path.Combine(Configurations.ConfigDir, $"{configurationName}.caddy");
|
|
|
|
if (File.Exists(path))
|
|
{
|
|
return File.ReadAllText(path);
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string GetCaddyGlobalConfigurationContent() => GetCaddyConfigurationContent(CaddyGlobalConfigName);
|
|
|
|
/// <inheritdoc />
|
|
public CaddyOperationResponse SaveCaddyConfiguration(CaddySaveConfigurationRequest request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.FileName))
|
|
{
|
|
return new CaddyOperationResponse
|
|
{
|
|
Success = false,
|
|
Message = "The configuration file name is required"
|
|
};
|
|
}
|
|
|
|
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
|
|
if (request.IsNew && File.Exists(filePath))
|
|
{
|
|
return new CaddyOperationResponse
|
|
{
|
|
Success = false,
|
|
Message = "The configuration file already exists"
|
|
};
|
|
}
|
|
|
|
try
|
|
{
|
|
File.WriteAllText(filePath, request.Content);
|
|
return new CaddyOperationResponse
|
|
{
|
|
Success = true,
|
|
Message = "Configuration file saved successfully"
|
|
};
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return new CaddyOperationResponse
|
|
{
|
|
Success = false,
|
|
Message = e.Message
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public CaddyOperationResponse SaveCaddyGlobalConfiguration(string content) => SaveCaddyConfiguration(
|
|
new CaddySaveConfigurationRequest
|
|
{
|
|
FileName = CaddyGlobalConfigName,
|
|
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)
|
|
{
|
|
var failed = new List<string>();
|
|
|
|
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");
|
|
|
|
if (File.Exists(filePath))
|
|
{
|
|
try
|
|
{
|
|
File.Delete(filePath);
|
|
}
|
|
catch
|
|
{
|
|
failed.Add(configurationName);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
failed.Add(configurationName);
|
|
}
|
|
}
|
|
|
|
return new CaddyDeleteOperationResponse
|
|
{
|
|
Success = failed.Count == 0,
|
|
Message = failed.Count == 0
|
|
? "Configuration(s) deleted successfully"
|
|
: $"Failed to delete the following configuration(s): {string.Join(", ", failed)}",
|
|
DeletedConfigurations = configurationNames.Except(failed).ToList()
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public CaddyConfigurationInfo GetCaddyConfigurationInfo(string configurationName)
|
|
{
|
|
var result = new CaddyConfigurationInfo
|
|
{
|
|
FileName = configurationName
|
|
};
|
|
|
|
var content = GetCaddyConfigurationContent(configurationName);
|
|
if (string.IsNullOrWhiteSpace(content))
|
|
{
|
|
return result;
|
|
}
|
|
|
|
result.Hostnames = parsingService.GetHostnamesFromCaddyfileContent(content);
|
|
result.ReverseProxyHostname = parsingService.GetReverseProxyTargetFromCaddyfileContent(content);
|
|
result.ReverseProxyPorts = parsingService.GetReverseProxyPortsFromCaddyfileContent(content);
|
|
result.Tags = parsingService.GetTagsFromCaddyfileContent(content);
|
|
|
|
return result;
|
|
}
|
|
} |