433090effd
- DiagnosticsController: replaced raw [Authorize(Roles = "SuperAdmin,Administrator")] with [Authorize(Policy = SuperAdminOnly)] to match every other platform-admin controller; added PowderCoating.Shared.Constants using directive - DataPurge, DataExport, StorageMigration, SeedData: added "← Maintenance" breadcrumb link at the top of each page so operators know they are in the guarded maintenance area and can navigate back to the hub Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
399 lines
15 KiB
C#
399 lines
15 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PowderCoating.Shared.Constants;
|
|
using System.Reflection;
|
|
using System.Security.Principal;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public class DiagnosticsController : Controller
|
|
{
|
|
private readonly ILogger<DiagnosticsController> _logger;
|
|
private readonly IWebHostEnvironment _environment;
|
|
|
|
public DiagnosticsController(ILogger<DiagnosticsController> logger, IWebHostEnvironment environment)
|
|
{
|
|
_logger = logger;
|
|
_environment = environment;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the diagnostics overview page with environment, path, logging, and
|
|
/// log-file metadata (restricted to SuperAdmin and Administrator roles).
|
|
/// <para>
|
|
/// Probes write access to both the application root and the <c>logs/</c>
|
|
/// subdirectory by creating and immediately deleting a temporary file via
|
|
/// <see cref="CanWriteToDirectory"/>. This catches permission problems (e.g. the
|
|
/// process identity has read-only access) before they silently break Serilog.
|
|
/// A live <c>LogInformation</c> call is also made and the success/failure is
|
|
/// surfaced in the view so administrators can verify the logging pipeline end-to-end.
|
|
/// </para>
|
|
/// </summary>
|
|
public IActionResult Index()
|
|
{
|
|
var diagnostics = new DiagnosticsInfo
|
|
{
|
|
CurrentTime = DateTime.Now,
|
|
ApplicationPath = _environment.ContentRootPath,
|
|
LogsPath = Path.Combine(_environment.ContentRootPath, "logs"),
|
|
LogsDirectoryExists = Directory.Exists(Path.Combine(_environment.ContentRootPath, "logs")),
|
|
EnvironmentName = _environment.EnvironmentName,
|
|
UserIdentity = OperatingSystem.IsWindows() ? WindowsIdentity.GetCurrent().Name : Environment.UserName,
|
|
CanWriteToAppPath = CanWriteToDirectory(_environment.ContentRootPath),
|
|
CanWriteToLogsPath = CanWriteToDirectory(Path.Combine(_environment.ContentRootPath, "logs"))
|
|
};
|
|
|
|
// Try to test logging
|
|
try
|
|
{
|
|
_logger.LogInformation("Diagnostics page accessed by {User} at {Time}", User.Identity?.Name, DateTime.Now);
|
|
diagnostics.LoggingTestSuccess = true;
|
|
diagnostics.LoggingTestMessage = "Successfully called logger.LogInformation";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
diagnostics.LoggingTestSuccess = false;
|
|
diagnostics.LoggingTestMessage = $"Logging failed: {ex.Message}";
|
|
}
|
|
|
|
// Get log files if they exist
|
|
var logsPath = Path.Combine(_environment.ContentRootPath, "logs");
|
|
if (Directory.Exists(logsPath))
|
|
{
|
|
try
|
|
{
|
|
diagnostics.LogFiles = Directory.GetFiles(logsPath, "*.txt")
|
|
.Select(f => new FileInfo(f))
|
|
.OrderByDescending(f => f.LastWriteTime)
|
|
.Select(f => new LogFileInfo
|
|
{
|
|
Name = f.Name,
|
|
Size = f.Length,
|
|
LastModified = f.LastWriteTime,
|
|
FullPath = f.FullName
|
|
})
|
|
.ToList();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
diagnostics.LogFilesError = ex.Message;
|
|
}
|
|
}
|
|
|
|
return View(diagnostics);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Displays the contents of a specific Serilog log file (or the most recent error
|
|
/// log by default), with optional keyword search and line-count trimming.
|
|
/// <para>
|
|
/// Security hardening applied in this action:
|
|
/// <list type="bullet">
|
|
/// <item>Filename is validated against a strict regex (alphanumeric, hyphens,
|
|
/// underscores, <c>.txt</c> extension only) to block directory-traversal
|
|
/// payloads like <c>../../appsettings.json</c>.</item>
|
|
/// <item>The resolved full path is checked with <c>StartsWith(basePath)</c>
|
|
/// after calling <c>Path.GetFullPath</c> so OS path-normalisation cannot
|
|
/// be abused to escape the logs directory.</item>
|
|
/// <item>Files are read with <c>FileShare.ReadWrite</c> so that Serilog, which
|
|
/// holds the active log file open, is not blocked.</item>
|
|
/// </list>
|
|
/// Only the last <paramref name="lines"/> lines are returned to avoid sending
|
|
/// megabytes of text to the browser; if a search filter is active it is applied
|
|
/// first and then the tail is taken from the filtered set.
|
|
/// </para>
|
|
/// </summary>
|
|
public IActionResult ViewLogs(string? fileName = null, int lines = 500, string? search = null)
|
|
{
|
|
var logsPath = Path.Combine(_environment.ContentRootPath, "logs");
|
|
|
|
if (!Directory.Exists(logsPath))
|
|
{
|
|
return View(new LogViewerModel { Error = "Logs directory does not exist." });
|
|
}
|
|
|
|
var model = new LogViewerModel
|
|
{
|
|
LogsPath = logsPath,
|
|
SelectedLines = lines
|
|
};
|
|
|
|
// Get all log files
|
|
try
|
|
{
|
|
model.AvailableLogFiles = Directory.GetFiles(logsPath, "*.txt")
|
|
.Select(f => new FileInfo(f))
|
|
.OrderByDescending(f => f.LastWriteTime)
|
|
.Select(f => new LogFileInfo
|
|
{
|
|
Name = f.Name,
|
|
Size = f.Length,
|
|
LastModified = f.LastWriteTime,
|
|
FullPath = f.FullName
|
|
})
|
|
.ToList();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
model.Error = $"Error loading log files: {ex.Message}";
|
|
return View(model);
|
|
}
|
|
|
|
// If no file specified, use the most recent error log
|
|
if (string.IsNullOrEmpty(fileName))
|
|
{
|
|
fileName = model.AvailableLogFiles
|
|
.FirstOrDefault(f => f.Name.StartsWith("errors-"))?.Name
|
|
?? model.AvailableLogFiles.FirstOrDefault()?.Name;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(fileName))
|
|
{
|
|
model.Error = "No log files found.";
|
|
return View(model);
|
|
}
|
|
|
|
model.SelectedFileName = fileName;
|
|
|
|
// SECURITY: Sanitize filename - only allow alphanumeric, hyphens, underscores, and .txt extension
|
|
if (!System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9\-_]+\.txt$"))
|
|
{
|
|
_logger.LogWarning("SECURITY: Invalid log filename requested: {FileName} by {User}", fileName, User.Identity?.Name);
|
|
model.Error = "Invalid file name. Only .txt log files are allowed.";
|
|
return View(model);
|
|
}
|
|
|
|
var filePath = Path.Combine(logsPath, fileName);
|
|
|
|
// SECURITY: Enhanced path traversal protection
|
|
var fullPath = Path.GetFullPath(filePath);
|
|
var basePath = Path.GetFullPath(logsPath);
|
|
|
|
// Check if resolved path starts with base path AND ensure no path traversal
|
|
if (!fullPath.StartsWith(basePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
|
|
fullPath != basePath)
|
|
{
|
|
_logger.LogWarning("SECURITY: Path traversal attempt detected: {FilePath} by {User}", fullPath, User.Identity?.Name);
|
|
model.Error = "Invalid file path.";
|
|
return View(model);
|
|
}
|
|
|
|
// Verify file extension
|
|
if (Path.GetExtension(fullPath) != ".txt")
|
|
{
|
|
_logger.LogWarning("SECURITY: Non-txt file access attempted: {FilePath} by {User}", fullPath, User.Identity?.Name);
|
|
model.Error = "Only .txt files are allowed.";
|
|
return View(model);
|
|
}
|
|
|
|
if (!System.IO.File.Exists(filePath))
|
|
{
|
|
model.Error = $"Log file '{fileName}' not found.";
|
|
return View(model);
|
|
}
|
|
|
|
try
|
|
{
|
|
// Read the file with shared access to handle active log files
|
|
string[] allLines;
|
|
using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
|
using (var streamReader = new StreamReader(fileStream))
|
|
{
|
|
var content = streamReader.ReadToEnd();
|
|
allLines = content.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
|
|
}
|
|
|
|
model.TotalLines = allLines.Length;
|
|
|
|
// Apply search filter if provided
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
allLines = allLines.Where(line =>
|
|
line.Contains(search, StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
model.SearchTerm = search;
|
|
model.FilteredLines = allLines.Length;
|
|
}
|
|
|
|
// Take last N lines (most recent)
|
|
model.LogContent = string.Join(Environment.NewLine,
|
|
allLines.TakeLast(lines));
|
|
|
|
model.DisplayedLines = Math.Min(lines, allLines.Length);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
model.Error = $"Error reading log file: {ex.Message}";
|
|
}
|
|
|
|
return View(model);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serves a Serilog log file as a downloadable <c>text/plain</c> response so
|
|
/// administrators can pull log archives from the server without direct file-system
|
|
/// access.
|
|
/// <para>
|
|
/// Applies the same path-containment security check as <see cref="ViewLogs"/>
|
|
/// (resolved full path must start with the logs directory path) to prevent
|
|
/// arbitrary file downloads. The file is read into a byte array with
|
|
/// <c>FileShare.ReadWrite</c> to avoid locking Serilog's active sink.
|
|
/// </para>
|
|
/// </summary>
|
|
public IActionResult DownloadLog(string fileName)
|
|
{
|
|
var logsPath = Path.Combine(_environment.ContentRootPath, "logs");
|
|
var filePath = Path.Combine(logsPath, fileName);
|
|
|
|
// Security check - ensure the file is in the logs directory
|
|
var fullPath = Path.GetFullPath(filePath);
|
|
if (!fullPath.StartsWith(Path.GetFullPath(logsPath)))
|
|
{
|
|
return BadRequest("Invalid file path.");
|
|
}
|
|
|
|
if (!System.IO.File.Exists(filePath))
|
|
{
|
|
return NotFound($"Log file '{fileName}' not found.");
|
|
}
|
|
|
|
try
|
|
{
|
|
// Read file with shared access to handle active log files
|
|
byte[] fileBytes;
|
|
using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
|
using (var memoryStream = new MemoryStream())
|
|
{
|
|
fileStream.CopyTo(memoryStream);
|
|
fileBytes = memoryStream.ToArray();
|
|
}
|
|
|
|
return File(fileBytes, "text/plain", fileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error downloading log file {FileName}", fileName);
|
|
return StatusCode(500, $"Error downloading log file: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Permanently deletes Serilog <c>.txt</c> log files whose last-write timestamp
|
|
/// is older than <paramref name="daysToKeep"/> days (defaults to 30).
|
|
/// <para>
|
|
/// This is a destructive, irreversible file-system operation. Only the
|
|
/// <c>Administrator</c> / <c>SuperAdmin</c> roles can reach this action (enforced
|
|
/// at controller level). The deletion is logged at Warning level so there is an
|
|
/// audit trail in any newer log files that survive the purge.
|
|
/// </para>
|
|
/// </summary>
|
|
public IActionResult ClearOldLogs(int daysToKeep = 30)
|
|
{
|
|
var logsPath = Path.Combine(_environment.ContentRootPath, "logs");
|
|
|
|
if (!Directory.Exists(logsPath))
|
|
{
|
|
TempData["ErrorMessage"] = "Logs directory does not exist.";
|
|
return RedirectToAction(nameof(ViewLogs));
|
|
}
|
|
|
|
try
|
|
{
|
|
var cutoffDate = DateTime.Now.AddDays(-daysToKeep);
|
|
var files = Directory.GetFiles(logsPath, "*.txt")
|
|
.Select(f => new FileInfo(f))
|
|
.Where(f => f.LastWriteTime < cutoffDate)
|
|
.ToList();
|
|
|
|
var deletedCount = 0;
|
|
foreach (var file in files)
|
|
{
|
|
file.Delete();
|
|
deletedCount++;
|
|
}
|
|
|
|
_logger.LogInformation("Cleared {Count} old log files (older than {Days} days) by {User}",
|
|
deletedCount, daysToKeep, User.Identity?.Name);
|
|
|
|
TempData["SuccessMessage"] = $"Deleted {deletedCount} old log file(s).";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error clearing old logs");
|
|
TempData["ErrorMessage"] = $"Error clearing logs: {ex.Message}";
|
|
}
|
|
|
|
return RedirectToAction(nameof(ViewLogs));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests whether the current process identity can write files to
|
|
/// <paramref name="path"/> by creating and immediately deleting a uniquely-named
|
|
/// temporary file.
|
|
/// <para>
|
|
/// Creates the directory if it does not yet exist so that a missing <c>logs/</c>
|
|
/// folder does not produce a false negative on fresh deployments.
|
|
/// Any exception (UnauthorizedAccess, IOException, etc.) is swallowed and returns
|
|
/// <c>false</c> — this is intentional because the method is purely diagnostic and
|
|
/// must not throw.
|
|
/// </para>
|
|
/// </summary>
|
|
private bool CanWriteToDirectory(string path)
|
|
{
|
|
try
|
|
{
|
|
if (!Directory.Exists(path))
|
|
{
|
|
Directory.CreateDirectory(path);
|
|
}
|
|
|
|
var testFile = Path.Combine(path, $"write_test_{Guid.NewGuid()}.tmp");
|
|
System.IO.File.WriteAllText(testFile, "test");
|
|
System.IO.File.Delete(testFile);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
public class DiagnosticsInfo
|
|
{
|
|
public DateTime CurrentTime { get; set; }
|
|
public string ApplicationPath { get; set; } = string.Empty;
|
|
public string LogsPath { get; set; } = string.Empty;
|
|
public bool LogsDirectoryExists { get; set; }
|
|
public string EnvironmentName { get; set; } = string.Empty;
|
|
public string UserIdentity { get; set; } = string.Empty;
|
|
public bool CanWriteToAppPath { get; set; }
|
|
public bool CanWriteToLogsPath { get; set; }
|
|
public bool LoggingTestSuccess { get; set; }
|
|
public string LoggingTestMessage { get; set; } = string.Empty;
|
|
public List<LogFileInfo> LogFiles { get; set; } = new();
|
|
public string? LogFilesError { get; set; }
|
|
}
|
|
|
|
public class LogFileInfo
|
|
{
|
|
public string Name { get; set; } = string.Empty;
|
|
public long Size { get; set; }
|
|
public DateTime LastModified { get; set; }
|
|
public string FullPath { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class LogViewerModel
|
|
{
|
|
public string LogsPath { get; set; } = string.Empty;
|
|
public List<LogFileInfo> AvailableLogFiles { get; set; } = new();
|
|
public string? SelectedFileName { get; set; }
|
|
public string LogContent { get; set; } = string.Empty;
|
|
public int TotalLines { get; set; }
|
|
public int DisplayedLines { get; set; }
|
|
public int FilteredLines { get; set; }
|
|
public int SelectedLines { get; set; } = 500;
|
|
public string? SearchTerm { get; set; }
|
|
public string? Error { get; set; }
|
|
}
|