From 162a69c2315255ac63a35146b0fa89a54f0677aa Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sun, 25 Jan 2026 12:46:57 +0100 Subject: [PATCH 1/2] Adds detached support. Closes #1511 --- DevProxy/ApiControllers/ProxyController.cs | 272 +++++++++++++++++- DevProxy/Commands/DevProxyCommand.cs | 70 ++++- DevProxy/Commands/DevProxyConfigOptions.cs | 3 + DevProxy/Commands/LogsCommand.cs | 227 +++++++++++++++ DevProxy/Commands/StatusCommand.cs | 82 ++++++ DevProxy/Commands/StopCommand.cs | 139 +++++++++ .../Extensions/ILoggingBuilderExtensions.cs | 3 +- DevProxy/Program.cs | 187 +++++++++++- DevProxy/Proxy/ProxyEngine.cs | 2 + DevProxy/State/ProxyInstanceState.cs | 32 +++ DevProxy/State/StateManager.cs | 232 +++++++++++++++ 11 files changed, 1241 insertions(+), 8 deletions(-) create mode 100644 DevProxy/Commands/LogsCommand.cs create mode 100644 DevProxy/Commands/StatusCommand.cs create mode 100644 DevProxy/Commands/StopCommand.cs create mode 100644 DevProxy/State/ProxyInstanceState.cs create mode 100644 DevProxy/State/StateManager.cs diff --git a/DevProxy/ApiControllers/ProxyController.cs b/DevProxy/ApiControllers/ProxyController.cs index 01aa9c5e..d5dd36ee 100644 --- a/DevProxy/ApiControllers/ProxyController.cs +++ b/DevProxy/ApiControllers/ProxyController.cs @@ -2,12 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.AspNetCore.Mvc; +using DevProxy.Abstractions.Proxy; +using DevProxy.Commands; using DevProxy.Jwt; -using System.Security.Cryptography.X509Certificates; -using System.ComponentModel.DataAnnotations; using DevProxy.Proxy; -using DevProxy.Abstractions.Proxy; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography.X509Certificates; namespace DevProxy.ApiControllers; @@ -137,4 +138,265 @@ public IActionResult GetRootCertificate([FromQuery][Required] string format) return File(pemBytes, "application/x-x509-ca-cert", "devProxy.pem"); } -} + + [HttpGet("logs")] + public async Task GetLogsAsync( + [FromQuery] int? lines, + [FromQuery] bool follow = false, + [FromQuery] string? since = null, + CancellationToken cancellationToken = default) + { + // Only available in detached/daemon mode + if (!DevProxyCommand.IsInternalDaemon) + { + Response.StatusCode = StatusCodes.Status404NotFound; + await Response.WriteAsync("Logs endpoint is only available in detached mode.", cancellationToken); + return; + } + + var logFile = DevProxyCommand.DetachedLogFilePath; + if (string.IsNullOrEmpty(logFile) || !System.IO.File.Exists(logFile)) + { + Response.StatusCode = StatusCodes.Status404NotFound; + await Response.WriteAsync("Log file not found.", cancellationToken); + return; + } + + var acceptHeader = Request.Headers.Accept.ToString(); + var useJson = acceptHeader.Contains("application/json", StringComparison.OrdinalIgnoreCase); + var useSse = acceptHeader.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) || follow; + + if (useSse) + { + Response.ContentType = "text/event-stream"; + Response.Headers.CacheControl = "no-cache"; + Response.Headers.Connection = "keep-alive"; + + await StreamLogsAsync(logFile, lines ?? 50, since, useJson, cancellationToken); + } + else + { + Response.ContentType = useJson ? "application/json" : "text/plain"; + await WriteLogsAsync(logFile, lines ?? 50, since, useJson, cancellationToken); + } + } + + private async Task WriteLogsAsync(string logFile, int lineCount, string? since, bool useJson, CancellationToken cancellationToken) + { + var allLines = await ReadAllLinesAsync(logFile, cancellationToken); + var filteredLines = FilterLines(allLines, since).TakeLast(lineCount).ToList(); + + if (useJson) + { + var logEntries = filteredLines.Select(ParseLogLine).ToList(); + var json = System.Text.Json.JsonSerializer.Serialize(logEntries); + await Response.WriteAsync(json, cancellationToken); + } + else + { + foreach (var line in filteredLines) + { + await Response.WriteAsync(line + Environment.NewLine, cancellationToken); + } + } + } + + private async Task StreamLogsAsync(string logFile, int initialLines, string? since, bool useJson, CancellationToken cancellationToken) + { + // Write initial lines + var allLines = await ReadAllLinesAsync(logFile, cancellationToken); + var filteredLines = FilterLines(allLines, since).TakeLast(initialLines).ToList(); + + foreach (var line in filteredLines) + { + await WriteSseEventAsync(line, useJson, cancellationToken); + } + + // Follow new lines + var lastPosition = new FileInfo(logFile).Length; + + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(500, cancellationToken); + + try + { + using var fs = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + if (fs.Length > lastPosition) + { + _ = fs.Seek(lastPosition, SeekOrigin.Begin); + using var reader = new StreamReader(fs); + + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) + { + await WriteSseEventAsync(line, useJson, cancellationToken); + } + + lastPosition = fs.Length; + } + } + catch (IOException) + { + // File might be temporarily locked + } + } + } + + private async Task WriteSseEventAsync(string line, bool useJson, CancellationToken cancellationToken) + { + if (useJson) + { + var logEntry = ParseLogLine(line); + var json = System.Text.Json.JsonSerializer.Serialize(logEntry); + await Response.WriteAsync($"event: log\ndata: {json}\n\n", cancellationToken); + } + else + { + await Response.WriteAsync($"data: {line}\n\n", cancellationToken); + } + + await Response.Body.FlushAsync(cancellationToken); + } + + private static IEnumerable FilterLines(IList lines, string? since) + { + if (string.IsNullOrEmpty(since)) + { + return lines; + } + + var sinceTime = ParseSinceOption(since); + if (sinceTime == null) + { + return lines; + } + + return lines.Where(line => LineMatchesSince(line, sinceTime.Value)); + } + + private static DateTime? ParseSinceOption(string? since) + { + if (string.IsNullOrEmpty(since)) + { + return null; + } + + // Try parsing as a duration (e.g., "5m", "1h", "30s") + if (since.Length >= 2) + { + var unit = since[^1]; + if (int.TryParse(since[..^1], out var value)) + { + return unit switch + { + 's' => DateTime.Now.AddSeconds(-value), + 'm' => DateTime.Now.AddMinutes(-value), + 'h' => DateTime.Now.AddHours(-value), + 'd' => DateTime.Now.AddDays(-value), + _ => null + }; + } + } + + // Try parsing as a datetime + if (DateTime.TryParse(since, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dateTime)) + { + return dateTime; + } + + return null; + } + + private static bool LineMatchesSince(string line, DateTime sinceTime) + { + // Parse timestamp from line format: [HH:mm:ss.fff] ... + if (line.Length < 14 || line[0] != '[' || line[13] != ']') + { + return true; + } + + var timestampStr = line[1..13]; + if (TimeSpan.TryParseExact(timestampStr, "hh\\:mm\\:ss\\.fff", System.Globalization.CultureInfo.InvariantCulture, out var timeOfDay)) + { + var lineTime = DateTime.Today.Add(timeOfDay); + if (lineTime > DateTime.Now) + { + lineTime = lineTime.AddDays(-1); + } + + return lineTime >= sinceTime; + } + + return true; + } + + private static LogEntryDto ParseLogLine(string line) + { + // Parse: [HH:mm:ss.fff] level: category: message + var entry = new LogEntryDto { Raw = line }; + + if (line.Length < 14 || line[0] != '[' || line[13] != ']') + { + entry.Message = line; + return entry; + } + + entry.Time = line[1..13]; + + if (line.Length < 16) + { + entry.Message = line; + return entry; + } + + var rest = line[15..]; // Skip "] " + var colonIndex = rest.IndexOf(':', StringComparison.Ordinal); + if (colonIndex > 0) + { + entry.Level = rest[..colonIndex].Trim(); + rest = rest[(colonIndex + 1)..].TrimStart(); + + colonIndex = rest.IndexOf(':', StringComparison.Ordinal); + if (colonIndex > 0) + { + entry.Category = rest[..colonIndex].Trim(); + entry.Message = rest[(colonIndex + 1)..].TrimStart(); + } + else + { + entry.Message = rest; + } + } + else + { + entry.Message = rest; + } + + return entry; + } + + private sealed class LogEntryDto + { + public string? Time { get; set; } + public string? Level { get; set; } + public string? Category { get; set; } + public string? Message { get; set; } + public string? Raw { get; set; } + } + + private static async Task> ReadAllLinesAsync(string filePath, CancellationToken cancellationToken) + { + var lines = new List(); + using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(fs); + + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) + { + lines.Add(line); + } + + return lines; + } +} \ No newline at end of file diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index c5c4b8b3..1740df9f 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -38,6 +38,8 @@ sealed class DevProxyCommand : RootCommand internal const string DiscoverOptionName = "--discover"; internal const string EnvOptionName = "--env"; internal const string LogForOptionName = "--log-for"; + internal const string DetachedOptionName = "--detach"; + internal const string InternalDaemonOptionName = "--_internal-daemon"; private static readonly string[] globalOptions = ["--version"]; private static readonly string[] helpOptions = ["--help", "-h", "/h", "-?", "/?"]; @@ -46,7 +48,10 @@ sealed class DevProxyCommand : RootCommand private static bool _isStdioCommandResolved; private static bool _isJwtCommandResolved; private static bool _isRootCommandResolved; + private static bool _isDetachedModeResolved; + private static bool _isInternalDaemonResolved; private static bool _stdioLogFilePathResolved; + private static bool _detachedLogFilePathResolved; public static bool HasGlobalOptions { @@ -97,6 +102,22 @@ public static bool IsJwtCommand } } + public static bool IsDetachedMode + { + get + { + if (_isDetachedModeResolved) + { + return field; + } + + var args = Environment.GetCommandLineArgs(); + field = args.Contains("--detach") || args.Contains("-d"); + _isDetachedModeResolved = true; + return field; + } + } + /// /// Determines if the root command (proxy itself) is being invoked. /// Returns true when no subcommand is specified (only options or no args). @@ -122,6 +143,22 @@ public static bool IsRootCommand } } + public static bool IsInternalDaemon + { + get + { + if (_isInternalDaemonResolved) + { + return field; + } + + var args = Environment.GetCommandLineArgs(); + field = args.Contains("--_internal-daemon"); + _isInternalDaemonResolved = true; + return field; + } + } + public static string StdioLogFilePath { get @@ -139,6 +176,21 @@ public static string StdioLogFilePath } } + public static string DetachedLogFilePath + { + get + { + if (_detachedLogFilePathResolved) + { + return field ?? string.Empty; + } + + field = State.StateManager.GenerateLogFilePath(); + _detachedLogFilePathResolved = true; + return field; + } + } + public DevProxyCommand( IEnumerable plugins, ISet urlsToWatch, @@ -415,14 +467,27 @@ private void ConfigureCommand() } }); + var detachedOption = new Option(DetachedOptionName, "-d") + { + Description = "Run Dev Proxy in the background" + }; + + var internalDaemonOption = new Option(InternalDaemonOptionName) + { + Description = "Internal use only - do not use directly", + Hidden = true + }; + var options = new List