Initial version
This commit is contained in:
241
EonaCat.LogStack.Status/Services/SyslogService.cs
Normal file
241
EonaCat.LogStack.Status/Services/SyslogService.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
public class SyslogUdpService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<SyslogUdpService> _logger;
|
||||
private readonly IngestionService _ingestionService;
|
||||
private readonly int _port;
|
||||
|
||||
private readonly Channel<LogEntry> _channel;
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public static bool IsRunning { get; private set; }
|
||||
|
||||
public SyslogUdpService(ILogger<SyslogUdpService> logger, IServiceScopeFactory scopeFactory, IConfiguration config)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_port = config.GetValue("Syslog:Port", 514);
|
||||
|
||||
_channel = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(10_000)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var receiverTask = ReceiveLoop(stoppingToken);
|
||||
var processorTask = ProcessLoop(stoppingToken);
|
||||
|
||||
await Task.WhenAll(receiverTask, processorTask);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoop(CancellationToken token)
|
||||
{
|
||||
using var udpClient = new UdpClient(_port);
|
||||
udpClient.Client.ReceiveBufferSize = 4 * 1024 * 1024;
|
||||
|
||||
_logger.LogInformation("Syslog UDP server listening on port {Port}", _port);
|
||||
IsRunning = true;
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await udpClient.ReceiveAsync(token);
|
||||
|
||||
var message = Encoding.UTF8.GetString(result.Buffer);
|
||||
var remoteIp = result.RemoteEndPoint.Address.ToString();
|
||||
|
||||
var entry = ParseMessage(message, remoteIp);
|
||||
|
||||
if (entry != null)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(entry, token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Receive loop error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessLoop(CancellationToken token)
|
||||
{
|
||||
var batch = new List<LogEntry>(100);
|
||||
|
||||
while (await _channel.Reader.WaitToReadAsync(token))
|
||||
{
|
||||
while (_channel.Reader.TryRead(out var entry))
|
||||
{
|
||||
batch.Add(entry);
|
||||
|
||||
if (batch.Count >= 100)
|
||||
{
|
||||
await Flush(batch);
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await Flush(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Flush(List<LogEntry> batch)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var ingestionService = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
|
||||
await ingestionService.IngestBatchAsync(batch.ToArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Batch ingestion failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private LogEntry ParseMessage(string rawMessage, string remoteIp)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsJson(rawMessage))
|
||||
return ParseJson(rawMessage, remoteIp);
|
||||
|
||||
return ParseSyslogAdvanced(rawMessage, remoteIp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Parsing failed");
|
||||
|
||||
return new LogEntry
|
||||
{
|
||||
Source = "Syslog.Unknown",
|
||||
Level = "Info",
|
||||
Message = rawMessage,
|
||||
Host = remoteIp,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsJson(string input)
|
||||
{
|
||||
input = input.TrimStart();
|
||||
return input.StartsWith("{") || input.StartsWith("[");
|
||||
}
|
||||
|
||||
private LogEntry ParseJson(string json, string remoteIp)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string Get(string name) =>
|
||||
root.TryGetProperty(name, out var val) ? val.ToString() : null;
|
||||
|
||||
return new LogEntry
|
||||
{
|
||||
Source = Get("source") ?? "Custom.Json",
|
||||
Level = MapLevel(Get("level")),
|
||||
Message = Get("message"),
|
||||
Exception = Get("exception"),
|
||||
Host = Get("host") ?? remoteIp,
|
||||
TraceId = Get("traceId"),
|
||||
Properties = json,
|
||||
Timestamp = DateTime.TryParse(Get("timestamp"), out var dt)
|
||||
? dt : DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private LogEntry ParseSyslogAdvanced(string message, string remoteIp)
|
||||
{
|
||||
var entry = new LogEntry
|
||||
{
|
||||
Source = "Syslog",
|
||||
Host = remoteIp,
|
||||
Message = message,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Level = "Info"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// PRI parsing
|
||||
if (message.StartsWith("<"))
|
||||
{
|
||||
var end = message.IndexOf('>');
|
||||
if (end > 0)
|
||||
{
|
||||
var pri = int.Parse(message[1..end]);
|
||||
var severity = pri % 8;
|
||||
entry.Level = MapSyslogSeverity(severity);
|
||||
|
||||
message = message[(end + 1)..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Try RFC5424 parsing
|
||||
var parts = message.Split(' ', 7, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length >= 7 && int.TryParse(parts[0], out _))
|
||||
{
|
||||
entry.Timestamp = DateTime.TryParse(parts[1], out var ts)
|
||||
? ts : entry.Timestamp;
|
||||
|
||||
entry.Host = parts[2];
|
||||
entry.Source = parts[3];
|
||||
entry.Message = parts[6];
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.Message = message;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fallback
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private string MapSyslogSeverity(int severity) => severity switch
|
||||
{
|
||||
0 or 1 => "Fatal",
|
||||
2 or 3 => "Error",
|
||||
4 => "Warning",
|
||||
5 or 6 => "Info",
|
||||
7 => "Debug",
|
||||
_ => "Info"
|
||||
};
|
||||
|
||||
private string MapLevel(string level) => level?.ToLower() switch
|
||||
{
|
||||
"trace" => "Debug",
|
||||
"debug" => "Debug",
|
||||
"info" => "Info",
|
||||
"warn" or "warning" => "Warning",
|
||||
"error" => "Error",
|
||||
"fatal" => "Fatal",
|
||||
_ => "Info"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user