Initial commit
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Reflection;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Roles = "SuperAdmin,Administrator")]
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user