Skip to content

Commit 45dbbf8

Browse files
Adds detached support. Closes #1511 (#1533)
* Adds detached support. Closes #1511 * Fix system proxy cleanup on stop and timeout on toggle - Add 10s timeout to ToggleSystemProxy to prevent indefinite hangs in daemon context (Problem 1) - Add proxy cleanup in ForceStopAsync before killing the process to prevent orphaned system proxy settings (Problem 2)
1 parent c6eca35 commit 45dbbf8

11 files changed

Lines changed: 1289 additions & 9 deletions

File tree

DevProxy/ApiControllers/ProxyController.cs

Lines changed: 267 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5-
using Microsoft.AspNetCore.Mvc;
5+
using DevProxy.Abstractions.Proxy;
6+
using DevProxy.Commands;
67
using DevProxy.Jwt;
7-
using System.Security.Cryptography.X509Certificates;
8-
using System.ComponentModel.DataAnnotations;
98
using DevProxy.Proxy;
10-
using DevProxy.Abstractions.Proxy;
9+
using Microsoft.AspNetCore.Mvc;
10+
using System.ComponentModel.DataAnnotations;
11+
using System.Security.Cryptography.X509Certificates;
1112

1213
namespace DevProxy.ApiControllers;
1314

@@ -137,4 +138,265 @@ public IActionResult GetRootCertificate([FromQuery][Required] string format)
137138

138139
return File(pemBytes, "application/x-x509-ca-cert", "devProxy.pem");
139140
}
140-
}
141+
142+
[HttpGet("logs")]
143+
public async Task GetLogsAsync(
144+
[FromQuery] int? lines,
145+
[FromQuery] bool follow = false,
146+
[FromQuery] string? since = null,
147+
CancellationToken cancellationToken = default)
148+
{
149+
// Only available in detached/daemon mode
150+
if (!DevProxyCommand.IsInternalDaemon)
151+
{
152+
Response.StatusCode = StatusCodes.Status404NotFound;
153+
await Response.WriteAsync("Logs endpoint is only available in detached mode.", cancellationToken);
154+
return;
155+
}
156+
157+
var logFile = DevProxyCommand.DetachedLogFilePath;
158+
if (string.IsNullOrEmpty(logFile) || !System.IO.File.Exists(logFile))
159+
{
160+
Response.StatusCode = StatusCodes.Status404NotFound;
161+
await Response.WriteAsync("Log file not found.", cancellationToken);
162+
return;
163+
}
164+
165+
var acceptHeader = Request.Headers.Accept.ToString();
166+
var useJson = acceptHeader.Contains("application/json", StringComparison.OrdinalIgnoreCase);
167+
var useSse = acceptHeader.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) || follow;
168+
169+
if (useSse)
170+
{
171+
Response.ContentType = "text/event-stream";
172+
Response.Headers.CacheControl = "no-cache";
173+
Response.Headers.Connection = "keep-alive";
174+
175+
await StreamLogsAsync(logFile, lines ?? 50, since, useJson, cancellationToken);
176+
}
177+
else
178+
{
179+
Response.ContentType = useJson ? "application/json" : "text/plain";
180+
await WriteLogsAsync(logFile, lines ?? 50, since, useJson, cancellationToken);
181+
}
182+
}
183+
184+
private async Task WriteLogsAsync(string logFile, int lineCount, string? since, bool useJson, CancellationToken cancellationToken)
185+
{
186+
var allLines = await ReadAllLinesAsync(logFile, cancellationToken);
187+
var filteredLines = FilterLines(allLines, since).TakeLast(lineCount).ToList();
188+
189+
if (useJson)
190+
{
191+
var logEntries = filteredLines.Select(ParseLogLine).ToList();
192+
var json = System.Text.Json.JsonSerializer.Serialize(logEntries);
193+
await Response.WriteAsync(json, cancellationToken);
194+
}
195+
else
196+
{
197+
foreach (var line in filteredLines)
198+
{
199+
await Response.WriteAsync(line + Environment.NewLine, cancellationToken);
200+
}
201+
}
202+
}
203+
204+
private async Task StreamLogsAsync(string logFile, int initialLines, string? since, bool useJson, CancellationToken cancellationToken)
205+
{
206+
// Write initial lines
207+
var allLines = await ReadAllLinesAsync(logFile, cancellationToken);
208+
var filteredLines = FilterLines(allLines, since).TakeLast(initialLines).ToList();
209+
210+
foreach (var line in filteredLines)
211+
{
212+
await WriteSseEventAsync(line, useJson, cancellationToken);
213+
}
214+
215+
// Follow new lines
216+
var lastPosition = new FileInfo(logFile).Length;
217+
218+
while (!cancellationToken.IsCancellationRequested)
219+
{
220+
await Task.Delay(500, cancellationToken);
221+
222+
try
223+
{
224+
using var fs = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
225+
if (fs.Length > lastPosition)
226+
{
227+
_ = fs.Seek(lastPosition, SeekOrigin.Begin);
228+
using var reader = new StreamReader(fs);
229+
230+
string? line;
231+
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
232+
{
233+
await WriteSseEventAsync(line, useJson, cancellationToken);
234+
}
235+
236+
lastPosition = fs.Length;
237+
}
238+
}
239+
catch (IOException)
240+
{
241+
// File might be temporarily locked
242+
}
243+
}
244+
}
245+
246+
private async Task WriteSseEventAsync(string line, bool useJson, CancellationToken cancellationToken)
247+
{
248+
if (useJson)
249+
{
250+
var logEntry = ParseLogLine(line);
251+
var json = System.Text.Json.JsonSerializer.Serialize(logEntry);
252+
await Response.WriteAsync($"event: log\ndata: {json}\n\n", cancellationToken);
253+
}
254+
else
255+
{
256+
await Response.WriteAsync($"data: {line}\n\n", cancellationToken);
257+
}
258+
259+
await Response.Body.FlushAsync(cancellationToken);
260+
}
261+
262+
private static IEnumerable<string> FilterLines(IList<string> lines, string? since)
263+
{
264+
if (string.IsNullOrEmpty(since))
265+
{
266+
return lines;
267+
}
268+
269+
var sinceTime = ParseSinceOption(since);
270+
if (sinceTime == null)
271+
{
272+
return lines;
273+
}
274+
275+
return lines.Where(line => LineMatchesSince(line, sinceTime.Value));
276+
}
277+
278+
private static DateTime? ParseSinceOption(string? since)
279+
{
280+
if (string.IsNullOrEmpty(since))
281+
{
282+
return null;
283+
}
284+
285+
// Try parsing as a duration (e.g., "5m", "1h", "30s")
286+
if (since.Length >= 2)
287+
{
288+
var unit = since[^1];
289+
if (int.TryParse(since[..^1], out var value))
290+
{
291+
return unit switch
292+
{
293+
's' => DateTime.Now.AddSeconds(-value),
294+
'm' => DateTime.Now.AddMinutes(-value),
295+
'h' => DateTime.Now.AddHours(-value),
296+
'd' => DateTime.Now.AddDays(-value),
297+
_ => null
298+
};
299+
}
300+
}
301+
302+
// Try parsing as a datetime
303+
if (DateTime.TryParse(since, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dateTime))
304+
{
305+
return dateTime;
306+
}
307+
308+
return null;
309+
}
310+
311+
private static bool LineMatchesSince(string line, DateTime sinceTime)
312+
{
313+
// Parse timestamp from line format: [HH:mm:ss.fff] ...
314+
if (line.Length < 14 || line[0] != '[' || line[13] != ']')
315+
{
316+
return true;
317+
}
318+
319+
var timestampStr = line[1..13];
320+
if (TimeSpan.TryParseExact(timestampStr, "hh\\:mm\\:ss\\.fff", System.Globalization.CultureInfo.InvariantCulture, out var timeOfDay))
321+
{
322+
var lineTime = DateTime.Today.Add(timeOfDay);
323+
if (lineTime > DateTime.Now)
324+
{
325+
lineTime = lineTime.AddDays(-1);
326+
}
327+
328+
return lineTime >= sinceTime;
329+
}
330+
331+
return true;
332+
}
333+
334+
private static LogEntryDto ParseLogLine(string line)
335+
{
336+
// Parse: [HH:mm:ss.fff] level: category: message
337+
var entry = new LogEntryDto { Raw = line };
338+
339+
if (line.Length < 14 || line[0] != '[' || line[13] != ']')
340+
{
341+
entry.Message = line;
342+
return entry;
343+
}
344+
345+
entry.Time = line[1..13];
346+
347+
if (line.Length < 16)
348+
{
349+
entry.Message = line;
350+
return entry;
351+
}
352+
353+
var rest = line[15..]; // Skip "] "
354+
var colonIndex = rest.IndexOf(':', StringComparison.Ordinal);
355+
if (colonIndex > 0)
356+
{
357+
entry.Level = rest[..colonIndex].Trim();
358+
rest = rest[(colonIndex + 1)..].TrimStart();
359+
360+
colonIndex = rest.IndexOf(':', StringComparison.Ordinal);
361+
if (colonIndex > 0)
362+
{
363+
entry.Category = rest[..colonIndex].Trim();
364+
entry.Message = rest[(colonIndex + 1)..].TrimStart();
365+
}
366+
else
367+
{
368+
entry.Message = rest;
369+
}
370+
}
371+
else
372+
{
373+
entry.Message = rest;
374+
}
375+
376+
return entry;
377+
}
378+
379+
private sealed class LogEntryDto
380+
{
381+
public string? Time { get; set; }
382+
public string? Level { get; set; }
383+
public string? Category { get; set; }
384+
public string? Message { get; set; }
385+
public string? Raw { get; set; }
386+
}
387+
388+
private static async Task<List<string>> ReadAllLinesAsync(string filePath, CancellationToken cancellationToken)
389+
{
390+
var lines = new List<string>();
391+
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
392+
using var reader = new StreamReader(fs);
393+
394+
string? line;
395+
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
396+
{
397+
lines.Add(line);
398+
}
399+
400+
return lines;
401+
}
402+
}

0 commit comments

Comments
 (0)