/*
Technitium DNS Server
Copyright (C) 2024 Shreyas Zare (shreyas@technitium.com)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TechnitiumLibrary.IO;
using TechnitiumLibrary.Net.Dns;
using TechnitiumLibrary.Net.Dns.EDnsOptions;
using TechnitiumLibrary.Net.Dns.ResourceRecords;
namespace DnsServerCore
{
public sealed class LogManager : IDisposable
{
#region variables
static readonly char[] commaSeparator = new char[] { ',' };
readonly string _configFolder;
bool _enableLogging;
string _logFolder;
int _maxLogFileDays;
bool _useLocalTime;
const string LOG_ENTRY_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
const string LOG_FILE_DATE_TIME_FORMAT = "yyyy-MM-dd";
string _logFile;
StreamWriter _logOut;
DateTime _logDate;
readonly BlockingCollection _queue = new BlockingCollection();
Thread _consumerThread;
readonly object _logFileLock = new object();
readonly object _queueLock = new object();
readonly AutoResetEvent _queueWait = new AutoResetEvent(false);
CancellationTokenSource _queueCancellationTokenSource = new CancellationTokenSource();
readonly Timer _logCleanupTimer;
const int LOG_CLEANUP_TIMER_INITIAL_INTERVAL = 60 * 1000;
const int LOG_CLEANUP_TIMER_PERIODIC_INTERVAL = 60 * 60 * 1000;
readonly object _saveLock = new object();
bool _pendingSave;
readonly Timer _saveTimer;
const int SAVE_TIMER_INITIAL_INTERVAL = 10000;
#endregion
#region constructor
public LogManager(string configFolder)
{
_configFolder = configFolder;
AppDomain.CurrentDomain.UnhandledException += delegate (object sender, UnhandledExceptionEventArgs e)
{
if (!_enableLogging)
{
Console.WriteLine(e.ExceptionObject.ToString());
return;
}
lock (_queueLock)
{
try
{
_queueCancellationTokenSource.Cancel();
lock (_logFileLock)
{
if (_logOut != null)
WriteLog(DateTime.UtcNow, e.ExceptionObject.ToString());
}
}
catch (ObjectDisposedException)
{ }
catch (Exception ex)
{
Console.WriteLine(e.ExceptionObject.ToString());
Console.WriteLine(ex.ToString());
}
finally
{
_queueWait.Set();
}
}
};
_logCleanupTimer = new Timer(delegate (object state)
{
try
{
if (_maxLogFileDays < 1)
return;
DateTime cutoffDate = DateTime.UtcNow.AddDays(_maxLogFileDays * -1).Date;
DateTimeStyles dateTimeStyles;
if (_useLocalTime)
dateTimeStyles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
else
dateTimeStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;
foreach (string logFile in ListLogFiles())
{
string logFileName = Path.GetFileNameWithoutExtension(logFile);
if (!DateTime.TryParseExact(logFileName, LOG_FILE_DATE_TIME_FORMAT, CultureInfo.InvariantCulture, dateTimeStyles, out DateTime logFileDate))
continue;
if (logFileDate < cutoffDate)
{
try
{
File.Delete(logFile);
Write("LogManager cleanup deleted the log file: " + logFile);
}
catch (Exception ex)
{
Write(ex);
}
}
}
}
catch (Exception ex)
{
Write(ex);
}
});
LoadConfig();
if (_enableLogging)
StartLogging();
_saveTimer = new Timer(delegate (object state)
{
lock (_saveLock)
{
if (_pendingSave)
{
try
{
SaveConfigInternal();
_pendingSave = false;
}
catch (Exception ex)
{
Write(ex);
//set timer to retry again
_saveTimer.Change(SAVE_TIMER_INITIAL_INTERVAL, Timeout.Infinite);
}
}
}
});
}
#endregion
#region IDisposable
bool _disposed;
private void Dispose(bool disposing)
{
lock (_saveLock)
{
_saveTimer?.Dispose();
if (_pendingSave)
{
try
{
SaveConfigFileInternal();
}
catch (Exception ex)
{
Write(ex);
}
finally
{
_pendingSave = false;
}
}
}
lock (_queueLock)
{
try
{
_queueCancellationTokenSource.Cancel();
lock (_logFileLock)
{
if (_disposed)
return;
if (disposing)
{
if (_logOut != null)
{
WriteLog(DateTime.UtcNow, "Logging stopped.");
_logOut.Dispose();
}
_logCleanupTimer.Dispose();
}
_disposed = true;
}
}
finally
{
_queueWait.Set();
}
}
}
public void Dispose()
{
Dispose(true);
}
#endregion
#region private
internal void StartLogging()
{
StartNewLog();
_queueWait.Set();
//start consumer thread
_consumerThread = new Thread(delegate ()
{
while (true)
{
_queueWait.WaitOne();
Monitor.Enter(_logFileLock);
try
{
if (_disposed || (_logOut == null))
break;
foreach (LogQueueItem item in _queue.GetConsumingEnumerable(_queueCancellationTokenSource.Token))
{
if (_useLocalTime)
{
DateTime messageLocalDateTime = item._dateTime.ToLocalTime();
if (messageLocalDateTime.Date > _logDate)
{
WriteLog(DateTime.UtcNow, "Logging stopped.");
StartNewLog();
}
WriteLog(messageLocalDateTime, item._message);
}
else
{
if (item._dateTime.Date > _logDate)
{
WriteLog(DateTime.UtcNow, "Logging stopped.");
StartNewLog();
}
WriteLog(item._dateTime, item._message);
}
}
}
catch (ObjectDisposedException)
{ }
catch (OperationCanceledException)
{ }
finally
{
Monitor.Exit(_logFileLock);
}
_queueCancellationTokenSource = new CancellationTokenSource();
}
});
_consumerThread.Name = "Log";
_consumerThread.IsBackground = true;
_consumerThread.Start();
}
internal void StopLogging()
{
lock (_queueLock)
{
try
{
if (_logOut != null)
_queueCancellationTokenSource.Cancel();
lock (_logFileLock)
{
if (_logOut != null)
{
WriteLog(DateTime.UtcNow, "Logging stopped.");
_logOut.Dispose();
_logOut = null; //to stop consumer thread
}
}
}
finally
{
_queueWait.Set();
}
}
}
internal void LoadConfig()
{
string logConfigFile = Path.Combine(_configFolder, "log.config");
try
{
using (FileStream fS = new FileStream(logConfigFile, FileMode.Open, FileAccess.Read))
{
BinaryReader bR = new BinaryReader(fS);
if (Encoding.ASCII.GetString(bR.ReadBytes(2)) != "LS") //format
throw new InvalidDataException("DnsServer log config file format is invalid.");
byte version = bR.ReadByte();
switch (version)
{
case 1:
_enableLogging = bR.ReadBoolean();
_logFolder = bR.ReadShortString();
_maxLogFileDays = bR.ReadInt32();
_useLocalTime = bR.ReadBoolean();
break;
default:
throw new InvalidDataException("DnsServer log config version not supported.");
}
}
}
catch (FileNotFoundException)
{
_enableLogging = true;
_logFolder = "logs";
_maxLogFileDays = 365;
_useLocalTime = false;
SaveConfigFileInternal();
}
catch (Exception ex)
{
Console.Write(ex.ToString());
SaveConfigFileInternal();
}
if (_maxLogFileDays == 0)
_logCleanupTimer.Change(Timeout.Infinite, Timeout.Infinite);
else
_logCleanupTimer.Change(LOG_CLEANUP_TIMER_INITIAL_INTERVAL, LOG_CLEANUP_TIMER_PERIODIC_INTERVAL);
}
private string ConvertToRelativePath(string path)
{
if (path.StartsWith(_configFolder, Environment.OSVersion.Platform == PlatformID.Win32NT ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))
path = path.Substring(_configFolder.Length).TrimStart(Path.DirectorySeparatorChar);
return path;
}
private string ConvertToAbsolutePath(string path)
{
if (Path.IsPathRooted(path))
return path;
return Path.Combine(_configFolder, path);
}
private void SaveConfigFileInternal()
{
string logConfigFile = Path.Combine(_configFolder, "log.config");
using (MemoryStream mS = new MemoryStream())
{
//serialize config
BinaryWriter bW = new BinaryWriter(mS);
bW.Write(Encoding.ASCII.GetBytes("LS")); //format
bW.Write((byte)1); //version
bW.Write(_enableLogging);
bW.WriteShortString(_logFolder);
bW.Write(_maxLogFileDays);
bW.Write(_useLocalTime);
//write config
mS.Position = 0;
using (FileStream fS = new FileStream(logConfigFile, FileMode.Create, FileAccess.Write))
{
mS.CopyTo(fS);
}
}
}
private void SaveConfigInternal()
{
SaveConfigFileInternal();
if (_logOut is null)
{
//stopped
if (_enableLogging)
StartLogging();
}
else
{
//running
if (!_enableLogging)
{
StopLogging();
}
else if (!_logFile.StartsWith(ConvertToAbsolutePath(_logFolder)))
{
//log folder changed; restart logging to new folder
StopLogging();
StartLogging();
}
}
}
private void StartNewLog()
{
if (_logOut != null)
_logOut.Dispose();
string logFolder = ConvertToAbsolutePath(_logFolder);
if (!Directory.Exists(logFolder))
Directory.CreateDirectory(logFolder);
DateTime logStartDateTime;
if (_useLocalTime)
logStartDateTime = DateTime.Now;
else
logStartDateTime = DateTime.UtcNow;
_logFile = Path.Combine(logFolder, logStartDateTime.ToString(LOG_FILE_DATE_TIME_FORMAT) + ".log");
_logOut = new StreamWriter(new FileStream(_logFile, FileMode.Append, FileAccess.Write, FileShare.Read));
_logDate = logStartDateTime.Date;
WriteLog(logStartDateTime, "Logging started.");
}
private void WriteLog(DateTime dateTime, string message)
{
if (_useLocalTime)
{
if (dateTime.Kind == DateTimeKind.Local)
_logOut.WriteLine("[" + dateTime.ToString(LOG_ENTRY_DATE_TIME_FORMAT) + " Local] " + message);
else
_logOut.WriteLine("[" + dateTime.ToLocalTime().ToString(LOG_ENTRY_DATE_TIME_FORMAT) + " Local] " + message);
}
else
{
if (dateTime.Kind == DateTimeKind.Utc)
_logOut.WriteLine("[" + dateTime.ToString(LOG_ENTRY_DATE_TIME_FORMAT) + " UTC] " + message);
else
_logOut.WriteLine("[" + dateTime.ToUniversalTime().ToString(LOG_ENTRY_DATE_TIME_FORMAT) + " UTC] " + message);
}
_logOut.Flush();
}
#endregion
#region public
public string[] ListLogFiles()
{
return Directory.GetFiles(ConvertToAbsolutePath(_logFolder), "*.log", SearchOption.TopDirectoryOnly);
}
public async Task DownloadLogAsync(HttpContext context, string logName, long limit)
{
string logFileName = logName + ".log";
using (FileStream fS = new FileStream(Path.Combine(ConvertToAbsolutePath(_logFolder), logFileName), FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 64 * 1024, true))
{
HttpResponse response = context.Response;
response.ContentType = "text/plain";
response.Headers.ContentDisposition = "attachment;filename=" + logFileName;
if ((limit > fS.Length) || (limit < 1))
limit = fS.Length;
OffsetStream oFS = new OffsetStream(fS, 0, limit);
HttpRequest request = context.Request;
Stream s;
string acceptEncoding = request.Headers.AcceptEncoding;
if (string.IsNullOrEmpty(acceptEncoding))
{
s = response.Body;
}
else
{
string[] acceptEncodingParts = acceptEncoding.Split(commaSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (acceptEncodingParts.Contains("br"))
{
response.Headers.ContentEncoding = "br";
s = new BrotliStream(response.Body, CompressionMode.Compress);
}
else if (acceptEncodingParts.Contains("gzip"))
{
response.Headers.ContentEncoding = "gzip";
s = new GZipStream(response.Body, CompressionMode.Compress);
}
else if (acceptEncodingParts.Contains("deflate"))
{
response.Headers.ContentEncoding = "deflate";
s = new DeflateStream(response.Body, CompressionMode.Compress);
}
else
{
s = response.Body;
}
}
await using (s)
{
await oFS.CopyToAsync(s);
if (fS.Length > limit)
await s.WriteAsync(Encoding.UTF8.GetBytes("\r\n####___TRUNCATED___####"));
}
}
}
public void DeleteLog(string logName)
{
string logFile = Path.Combine(ConvertToAbsolutePath(_logFolder), logName + ".log");
if (logFile.Equals(_logFile, StringComparison.OrdinalIgnoreCase))
DeleteCurrentLogFile();
else
File.Delete(logFile);
}
public void DeleteAllLogs()
{
string[] logFiles = ListLogFiles();
foreach (string logFile in logFiles)
{
if (logFile.Equals(_logFile, StringComparison.OrdinalIgnoreCase))
DeleteCurrentLogFile();
else
File.Delete(logFile);
}
}
public void Write(Exception ex)
{
Write(ex.ToString());
}
public void Write(IPEndPoint ep, Exception ex)
{
Write(ep, ex.ToString());
}
public void Write(IPEndPoint ep, string message)
{
string ipInfo;
if (ep == null)
ipInfo = "";
else if (ep.Address.IsIPv4MappedToIPv6)
ipInfo = "[" + ep.Address.MapToIPv4().ToString() + ":" + ep.Port + "] ";
else
ipInfo = "[" + ep.ToString() + "] ";
Write(ipInfo + message);
}
public void Write(IPEndPoint ep, DnsTransportProtocol protocol, Exception ex)
{
Write(ep, protocol, ex.ToString());
}
public void Write(IPEndPoint ep, DnsTransportProtocol protocol, DnsDatagram request, DnsDatagram response)
{
DnsQuestionRecord q = null;
if (request.Question.Count > 0)
q = request.Question[0];
string requestInfo;
if (q is null)
requestInfo = "MISSING QUESTION!";
else
requestInfo = "QNAME: " + q.Name + "; QTYPE: " + q.Type.ToString() + "; QCLASS: " + q.Class;
if (request.Additional.Count > 0)
{
DnsResourceRecord lastRR = request.Additional[request.Additional.Count - 1];
if ((lastRR.Type == DnsResourceRecordType.TSIG) && (lastRR.RDATA is DnsTSIGRecordData tsig))
requestInfo += "; TSIG KeyName: " + lastRR.Name.ToLowerInvariant() + "; TSIG Algo: " + tsig.AlgorithmName + "; TSIG Error: " + tsig.Error.ToString();
}
string responseInfo;
if (response is null)
{
responseInfo = "; NO RESPONSE FROM SERVER!";
}
else
{
responseInfo = "; RCODE: " + response.RCODE.ToString();
string answer;
if (response.Answer.Count == 0)
{
if (response.Truncation)
answer = "[TRUNCATED]";
else
answer = "[]";
}
else if ((response.Answer.Count > 2) && response.IsZoneTransfer)
{
answer = "[ZONE TRANSFER]";
}
else
{
answer = "[";
for (int i = 0; i < response.Answer.Count; i++)
{
if (i > 0)
answer += ", ";
answer += response.Answer[i].RDATA.ToString();
}
answer += "]";
if (response.Additional.Count > 0)
{
switch (q.Type)
{
case DnsResourceRecordType.NS:
case DnsResourceRecordType.MX:
case DnsResourceRecordType.SRV:
answer += "; ADDITIONAL: [";
for (int i = 0; i < response.Additional.Count; i++)
{
DnsResourceRecord additional = response.Additional[i];
switch (additional.Type)
{
case DnsResourceRecordType.A:
case DnsResourceRecordType.AAAA:
if (i > 0)
answer += ", ";
answer += additional.Name + " (" + additional.RDATA.ToString() + ")";
break;
}
}
answer += "]";
break;
}
}
}
EDnsClientSubnetOptionData responseECS = response.GetEDnsClientSubnetOption();
if (responseECS is not null)
answer += "; ECS: " + responseECS.Address.ToString() + "/" + responseECS.ScopePrefixLength;
responseInfo += "; ANSWER: " + answer;
}
Write(ep, protocol, requestInfo + responseInfo);
}
public void Write(IPEndPoint ep, DnsTransportProtocol protocol, string message)
{
Write(ep, protocol.ToString(), message);
}
public void Write(IPEndPoint ep, string protocol, string message)
{
string ipInfo;
if (ep == null)
ipInfo = "";
else if (ep.Address.IsIPv4MappedToIPv6)
ipInfo = "[" + ep.Address.MapToIPv4().ToString() + ":" + ep.Port + "] ";
else
ipInfo = "[" + ep.ToString() + "] ";
Write(ipInfo + "[" + protocol.ToUpper() + "] " + message);
}
public void Write(string message)
{
if (_enableLogging)
_queue.Add(new LogQueueItem(message));
}
public void DeleteCurrentLogFile()
{
lock (_queueLock)
{
try
{
if (_logOut != null)
_queueCancellationTokenSource.Cancel();
lock (_logFileLock)
{
if (_logOut != null)
_logOut.Dispose();
File.Delete(_logFile);
if (_enableLogging)
StartNewLog();
}
}
finally
{
_queueWait.Set();
}
}
}
public void SaveConfig()
{
lock (_saveLock)
{
if (_pendingSave)
return;
_pendingSave = true;
_saveTimer.Change(SAVE_TIMER_INITIAL_INTERVAL, Timeout.Infinite);
}
}
#endregion
#region properties
public bool EnableLogging
{
get { return _enableLogging; }
set { _enableLogging = value; }
}
public string LogFolder
{
get { return _logFolder; }
set
{
string logFolder;
if (string.IsNullOrEmpty(value))
logFolder = "logs";
else if (value.Length > 255)
throw new ArgumentException("Log folder path length cannot exceed 255 characters.", nameof(LogFolder));
else
logFolder = value;
Directory.CreateDirectory(ConvertToAbsolutePath(logFolder));
_logFolder = ConvertToRelativePath(logFolder);
}
}
public int MaxLogFileDays
{
get { return _maxLogFileDays; }
set
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(MaxLogFileDays), "MaxLogFileDays must be greater than or equal to 0.");
_maxLogFileDays = value;
if (_maxLogFileDays == 0)
_logCleanupTimer.Change(Timeout.Infinite, Timeout.Infinite);
else
_logCleanupTimer.Change(LOG_CLEANUP_TIMER_INITIAL_INTERVAL, LOG_CLEANUP_TIMER_PERIODIC_INTERVAL);
}
}
public bool UseLocalTime
{
get { return _useLocalTime; }
set { _useLocalTime = value; }
}
public string CurrentLogFile
{ get { return _logFile; } }
public string LogFolderAbsolutePath
{ get { return ConvertToAbsolutePath(_logFolder); } }
#endregion
class LogQueueItem
{
#region variables
public readonly DateTime _dateTime;
public readonly string _message;
#endregion
#region constructor
public LogQueueItem(string message)
{
_dateTime = DateTime.UtcNow;
_message = message;
}
#endregion
}
}
}