This commit is contained in:
1 parent
4081b5949d
commit
2909500bb8
14 files changed
+342
-114
No files matched your search
@@ -36,7 +36,7 @@ namespace EonaCat.LogStack.Server
|
|||||||
_maxLogDirectorySize = maxLogDirectorySize;
|
_maxLogDirectorySize = maxLogDirectorySize;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected virtual Task ProcessLogAsync(string logData)
|
protected virtual async Task ProcessLogAsync(string logData)
|
||||||
{
|
{
|
||||||
string logsRootDirectory = "logs";
|
string logsRootDirectory = "logs";
|
||||||
|
|
||||||
@@ -67,7 +67,12 @@ namespace EonaCat.LogStack.Server
|
|||||||
// After processing log, check directory size and clean up if needed
|
// After processing log, check directory size and clean up if needed
|
||||||
CleanUpOldLogs();
|
CleanUpOldLogs();
|
||||||
|
|
||||||
return File.AppendAllTextAsync(logFilePath, logData + Environment.NewLine);
|
using (var stream = new FileStream(logFilePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete, 4096, FileOptions.Asynchronous))
|
||||||
|
using (var writer = new StreamWriter(stream))
|
||||||
|
{
|
||||||
|
await writer.WriteLineAsync(logData).ConfigureAwait(false);
|
||||||
|
await writer.FlushAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CleanUpOldLogs()
|
private void CleanUpOldLogs()
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.Status", "EonaCat.LogStack.Status\EonaCat.LogStack.Status.csproj", "{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.Status", "EonaCat.LogStack.Status\EonaCat.LogStack.Status.csproj", "{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testers", "Testers", "{A930DAA9-4165-776A-E33D-6691CB934F50}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.FileDeletionTests", "Testers\EonaCat.LogStack.FileDeletionTests\EonaCat.LogStack.FileDeletionTests.csproj", "{25A49D02-5463-4662-B064-55293976DF0A}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -130,10 +134,25 @@ Global
|
|||||||
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x64.Build.0 = Release|Any CPU
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x86.ActiveCfg = Release|Any CPU
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x86.Build.0 = Release|Any CPU
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{25A49D02-5463-4662-B064-55293976DF0A} = {A930DAA9-4165-776A-E33D-6691CB934F50}
|
||||||
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {B01183F3-D85E-45FB-9749-DA281F465A0F}
|
SolutionGuid = {B01183F3-D85E-45FB-9749-DA281F465A0F}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|||||||
@@ -134,7 +134,11 @@ namespace EonaCat.LogStack.Features
|
|||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
});
|
});
|
||||||
|
|
||||||
File.WriteAllText(filePath, json);
|
using (var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read | FileShare.Delete))
|
||||||
|
using (var writer = new StreamWriter(stream))
|
||||||
|
{
|
||||||
|
writer.Write(json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +333,11 @@ namespace EonaCat.LogStack.Features
|
|||||||
{
|
{
|
||||||
WriteIndented = true
|
WriteIndented = true
|
||||||
});
|
});
|
||||||
File.WriteAllText(filePath, json);
|
using (var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read | FileShare.Delete))
|
||||||
|
using (var writer = new StreamWriter(stream))
|
||||||
|
{
|
||||||
|
writer.Write(json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static LogReplayScenario Load(string filePath)
|
public static LogReplayScenario Load(string filePath)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
/// This creates a hash-chain so any deletion or modification of a past
|
/// This creates a hash-chain so any deletion or modification of a past
|
||||||
/// entry invalidates all subsequent hashes, making tampering detectable.
|
/// entry invalidates all subsequent hashes, making tampering detectable.
|
||||||
///
|
///
|
||||||
/// The file is opened with FileShare.Read only (no concurrent writers).
|
/// The file permits external readers and deletion, but no concurrent writers.
|
||||||
/// The flow is synchronous-by-design: audit entries must land on disk
|
/// The flow is synchronous-by-design: audit entries must land on disk
|
||||||
/// before the method returns, so <see cref="BlastAsync"/> blocks until
|
/// before the method returns, so <see cref="BlastAsync"/> blocks until
|
||||||
/// the entry is flushed.
|
/// the entry is flushed.
|
||||||
@@ -53,8 +53,8 @@ namespace EonaCat.LogStack.Flows
|
|||||||
private readonly bool _includeProperties;
|
private readonly bool _includeProperties;
|
||||||
|
|
||||||
private readonly object _writeLock = new object();
|
private readonly object _writeLock = new object();
|
||||||
private readonly FileStream _stream;
|
private FileStream _stream;
|
||||||
private readonly StreamWriter _writer;
|
private StreamWriter _writer;
|
||||||
|
|
||||||
private long _sequence;
|
private long _sequence;
|
||||||
private string _previousHash;
|
private string _previousHash;
|
||||||
@@ -117,19 +117,23 @@ namespace EonaCat.LogStack.Flows
|
|||||||
string date = DateTime.UtcNow.ToString("yyyyMMdd");
|
string date = DateTime.UtcNow.ToString("yyyyMMdd");
|
||||||
_filePath = Path.Combine(directory, $"{filePrefix}_{Environment.MachineName}_{date}.audit");
|
_filePath = Path.Combine(directory, $"{filePrefix}_{Environment.MachineName}_{date}.audit");
|
||||||
|
|
||||||
// Exclusive write access
|
OpenFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFile()
|
||||||
|
{
|
||||||
_stream = new FileStream(
|
_stream = new FileStream(
|
||||||
_filePath,
|
_filePath,
|
||||||
FileMode.Append,
|
FileMode.Append,
|
||||||
FileAccess.Write,
|
FileAccess.Write,
|
||||||
FileShare.Read, // allow external readers, but no other writers
|
FileShare.Read | FileShare.Delete,
|
||||||
bufferSize: 4096,
|
bufferSize: 4096,
|
||||||
FileOptions.WriteThrough); // WriteThrough = no OS cache, hits disk immediately
|
FileOptions.WriteThrough); // WriteThrough = no OS cache, hits disk immediately
|
||||||
|
|
||||||
_writer = new StreamWriter(_stream, Encoding.UTF8) { AutoFlush = true };
|
_writer = new StreamWriter(_stream, Encoding.UTF8) { AutoFlush = true };
|
||||||
|
|
||||||
// Derive starting hash from the last line already in the file (for continuity)
|
// Derive starting hash from the last line already in the file (for continuity)
|
||||||
_previousHash = ReadLastHash(directory, filePrefix, date);
|
_previousHash = ReadLastHash(_filePath);
|
||||||
_sequence = CountExistingLines(_filePath);
|
_sequence = CountExistingLines(_filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +157,10 @@ namespace EonaCat.LogStack.Flows
|
|||||||
string previousHash = new string('0', HashLength);
|
string previousHash = new string('0', HashLength);
|
||||||
long expectedSeq = 1;
|
long expectedSeq = 1;
|
||||||
|
|
||||||
foreach (string raw in File.ReadLines(filePath, Encoding.UTF8))
|
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
string raw;
|
||||||
|
while ((raw = reader.ReadLine()) != null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#"))
|
if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#"))
|
||||||
{
|
{
|
||||||
@@ -252,6 +259,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
IsEnabled = false;
|
IsEnabled = false;
|
||||||
|
await base.DisposeAsync().ConfigureAwait(false);
|
||||||
lock (_writeLock)
|
lock (_writeLock)
|
||||||
{
|
{
|
||||||
try { _writer.Flush(); } catch { }
|
try { _writer.Flush(); } catch { }
|
||||||
@@ -259,13 +267,19 @@ namespace EonaCat.LogStack.Flows
|
|||||||
try { _writer.Dispose(); } catch { }
|
try { _writer.Dispose(); } catch { }
|
||||||
try { _stream.Dispose(); } catch { }
|
try { _stream.Dispose(); } catch { }
|
||||||
}
|
}
|
||||||
await base.DisposeAsync().ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteEntry(LogEvent log)
|
private void WriteEntry(LogEvent log)
|
||||||
{
|
{
|
||||||
lock (_writeLock)
|
lock (_writeLock)
|
||||||
{
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
{
|
||||||
|
_writer.Dispose();
|
||||||
|
_stream.Dispose();
|
||||||
|
OpenFile();
|
||||||
|
}
|
||||||
|
|
||||||
long seq = Interlocked.Increment(ref _sequence);
|
long seq = Interlocked.Increment(ref _sequence);
|
||||||
|
|
||||||
var sb = new StringBuilder(256);
|
var sb = new StringBuilder(256);
|
||||||
@@ -356,10 +370,8 @@ namespace EonaCat.LogStack.Flows
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ReadLastHash(string directory, string prefix, string date)
|
private static string ReadLastHash(string path)
|
||||||
{
|
{
|
||||||
string path = Path.Combine(directory, $"{prefix}_{Environment.MachineName}_{date}.audit");
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
return new string('0', HashLength);
|
return new string('0', HashLength);
|
||||||
@@ -368,7 +380,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
string lastLine = null;
|
string lastLine = null;
|
||||||
|
|
||||||
// Open file with FileShare.ReadWrite to allow reading while it's being written to
|
// Open file with FileShare.ReadWrite to allow reading while it's being written to
|
||||||
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||||
using (var reader = new StreamReader(fileStream, Encoding.UTF8))
|
using (var reader = new StreamReader(fileStream, Encoding.UTF8))
|
||||||
{
|
{
|
||||||
// Read lines from the file
|
// Read lines from the file
|
||||||
@@ -402,7 +414,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
long count = 0;
|
long count = 0;
|
||||||
|
|
||||||
// Open the file with FileShare.ReadWrite to allow concurrent read/write access
|
// Open the file with FileShare.ReadWrite to allow concurrent read/write access
|
||||||
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||||
using (var reader = new StreamReader(fileStream, Encoding.UTF8))
|
using (var reader = new StreamReader(fileStream, Encoding.UTF8))
|
||||||
{
|
{
|
||||||
while (!reader.EndOfStream)
|
while (!reader.EndOfStream)
|
||||||
|
|||||||
@@ -571,7 +571,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (FileStream source = File.OpenRead(encryptedPath))
|
using (FileStream source = new FileStream(encryptedPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||||
{
|
{
|
||||||
byte[] magic = new byte[4];
|
byte[] magic = new byte[4];
|
||||||
ReadExact(source, magic, 4);
|
ReadExact(source, magic, 4);
|
||||||
@@ -604,7 +604,8 @@ namespace EonaCat.LogStack.Flows
|
|||||||
}
|
}
|
||||||
|
|
||||||
using (ICryptoTransform dec = aes.CreateDecryptor())
|
using (ICryptoTransform dec = aes.CreateDecryptor())
|
||||||
using (StreamWriter out_ = new StreamWriter(outputPath, false, Encoding.UTF8))
|
using (FileStream output = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.Read | FileShare.Delete))
|
||||||
|
using (StreamWriter out_ = new StreamWriter(output, Encoding.UTF8))
|
||||||
{
|
{
|
||||||
byte[] buffer = new byte[4];
|
byte[] buffer = new byte[4];
|
||||||
while (source.Position < source.Length)
|
while (source.Position < source.Length)
|
||||||
@@ -847,7 +848,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
? DateTime.Now.Date
|
? DateTime.Now.Date
|
||||||
: DateTime.UtcNow.Date;
|
: DateTime.UtcNow.Date;
|
||||||
|
|
||||||
if (_currentStream == null || _currentDate != today || _currentSize > _maxFileSize)
|
if (_currentStream == null || _currentDate != today || _currentSize > _maxFileSize || !File.Exists(_currentPath))
|
||||||
{
|
{
|
||||||
if (_currentStream != null)
|
if (_currentStream != null)
|
||||||
{
|
{
|
||||||
@@ -934,7 +935,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
|
|
||||||
bool isNew = !File.Exists(_currentPath) || new FileInfo(_currentPath).Length == 0;
|
bool isNew = !File.Exists(_currentPath) || new FileInfo(_currentPath).Length == 0;
|
||||||
|
|
||||||
_currentStream = new FileStream(_currentPath, FileMode.Append, FileAccess.Write, FileShare.Read, 65536, _durableWrites ? FileOptions.WriteThrough : FileOptions.None);
|
_currentStream = new FileStream(_currentPath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete, 65536, _durableWrites ? FileOptions.WriteThrough : FileOptions.None);
|
||||||
|
|
||||||
byte[] salt = new byte[SaltSize];
|
byte[] salt = new byte[SaltSize];
|
||||||
byte[] iv = new byte[IvSize];
|
byte[] iv = new byte[IvSize];
|
||||||
@@ -956,7 +957,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Re-read header so we continue the same key/IV session
|
// Re-read header so we continue the same key/IV session
|
||||||
using (FileStream hdr = File.OpenRead(_currentPath))
|
using (FileStream hdr = new FileStream(_currentPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||||
{
|
{
|
||||||
hdr.Seek(4, SeekOrigin.Begin);
|
hdr.Seek(4, SeekOrigin.Begin);
|
||||||
ReadExact(hdr, salt, SaltSize);
|
ReadExact(hdr, salt, SaltSize);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
|
|
||||||
private readonly BlockingCollection<LogEvent> _queue;
|
private readonly BlockingCollection<LogEvent> _queue;
|
||||||
private readonly ConcurrentQueue<string> _compressionQueue = new ConcurrentQueue<string>();
|
private readonly ConcurrentQueue<string> _compressionQueue = new ConcurrentQueue<string>();
|
||||||
|
private readonly object _archiveCompressionLock = new object();
|
||||||
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
||||||
private volatile bool _isDisposing; // Track disposal state to prevent accessing disposed objects
|
private volatile bool _isDisposing; // Track disposal state to prevent accessing disposed objects
|
||||||
private readonly Thread _writerThread;
|
private readonly Thread _writerThread;
|
||||||
@@ -1385,7 +1386,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
const string footer = "</logs>";
|
const string footer = "</logs>";
|
||||||
const int tailSize = 512;
|
const int tailSize = 512;
|
||||||
|
|
||||||
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite))
|
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete))
|
||||||
{
|
{
|
||||||
if (fs.Length < footer.Length)
|
if (fs.Length < footer.Length)
|
||||||
{
|
{
|
||||||
@@ -1801,62 +1802,66 @@ namespace EonaCat.LogStack.Flows
|
|||||||
|
|
||||||
private string ArchiveFile(string filePath)
|
private string ArchiveFile(string filePath)
|
||||||
{
|
{
|
||||||
try
|
lock (_archiveCompressionLock)
|
||||||
{
|
{
|
||||||
string dir = Path.GetDirectoryName(filePath);
|
DrainCompressionQueue();
|
||||||
string fileName = Path.GetFileName(filePath);
|
try
|
||||||
int extIdx = fileName.LastIndexOf(_fileExtension, StringComparison.OrdinalIgnoreCase);
|
|
||||||
string baseName = extIdx >= 0 ? fileName.Substring(0, extIdx) : fileName;
|
|
||||||
int maxFiles = _retention.MaxRolledFiles > 0 ? _retention.MaxRolledFiles : 999;
|
|
||||||
|
|
||||||
for (int i = maxFiles - 1; i >= 1; i--)
|
|
||||||
{
|
{
|
||||||
string src = Path.Combine(dir, baseName + "_" + i + _fileExtension);
|
string dir = Path.GetDirectoryName(filePath);
|
||||||
string srcGz = src + ".gz";
|
string fileName = Path.GetFileName(filePath);
|
||||||
string dst = Path.Combine(dir, baseName + "_" + (i + 1) + _fileExtension);
|
int extIdx = fileName.LastIndexOf(_fileExtension, StringComparison.OrdinalIgnoreCase);
|
||||||
string dstGz = dst + ".gz";
|
string baseName = extIdx >= 0 ? fileName.Substring(0, extIdx) : fileName;
|
||||||
|
int maxFiles = _retention.MaxRolledFiles > 0 ? _retention.MaxRolledFiles : 999;
|
||||||
|
|
||||||
if (File.Exists(srcGz))
|
for (int i = maxFiles - 1; i >= 1; i--)
|
||||||
{
|
{
|
||||||
if (File.Exists(dstGz))
|
string src = Path.Combine(dir, baseName + "_" + i + _fileExtension);
|
||||||
|
string srcGz = src + ".gz";
|
||||||
|
string dst = Path.Combine(dir, baseName + "_" + (i + 1) + _fileExtension);
|
||||||
|
string dstGz = dst + ".gz";
|
||||||
|
|
||||||
|
if (File.Exists(srcGz))
|
||||||
{
|
{
|
||||||
File.Delete(dstGz);
|
if (File.Exists(dstGz))
|
||||||
|
{
|
||||||
|
File.Delete(dstGz);
|
||||||
|
}
|
||||||
|
File.Move(srcGz, dstGz);
|
||||||
}
|
}
|
||||||
File.Move(srcGz, dstGz);
|
|
||||||
|
if (!File.Exists(src))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(dst))
|
||||||
|
{
|
||||||
|
File.Delete(dst);
|
||||||
|
}
|
||||||
|
File.Move(src, dst);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(src))
|
string archive = Path.Combine(dir, baseName + "_1" + _fileExtension);
|
||||||
|
if (File.Exists(archive))
|
||||||
{
|
{
|
||||||
continue;
|
File.Delete(archive);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File.Exists(dst))
|
File.Move(filePath, archive);
|
||||||
|
|
||||||
|
if (_compressionFormat != CompressionFormat.None)
|
||||||
{
|
{
|
||||||
File.Delete(dst);
|
_compressionQueue.Enqueue(archive);
|
||||||
|
_compressionSignal.Release(1);
|
||||||
}
|
}
|
||||||
File.Move(src, dst);
|
|
||||||
}
|
|
||||||
|
|
||||||
string archive = Path.Combine(dir, baseName + "_1" + _fileExtension);
|
return archive;
|
||||||
if (File.Exists(archive))
|
}
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
File.Delete(archive);
|
OnException?.Invoke(this, "[FileFlow] Archive error '" + filePath + "': " + ex.Message);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
File.Move(filePath, archive);
|
|
||||||
|
|
||||||
if (_compressionFormat != CompressionFormat.None)
|
|
||||||
{
|
|
||||||
_compressionQueue.Enqueue(archive);
|
|
||||||
_compressionSignal.Release(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return archive;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
OnException?.Invoke(this, "[FileFlow] Archive error '" + filePath + "': " + ex.Message);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1891,12 +1896,15 @@ namespace EonaCat.LogStack.Flows
|
|||||||
|
|
||||||
private void DrainCompressionQueue()
|
private void DrainCompressionQueue()
|
||||||
{
|
{
|
||||||
while (_compressionQueue.TryDequeue(out string path))
|
lock (_archiveCompressionLock)
|
||||||
{
|
{
|
||||||
try { CompressFile(path); }
|
while (_compressionQueue.TryDequeue(out string path))
|
||||||
catch (Exception ex)
|
|
||||||
{
|
{
|
||||||
OnException?.Invoke(this, "[FileFlow] Compress error '" + path + "': " + ex.Message);
|
try { CompressFile(path); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnException?.Invoke(this, "[FileFlow] Compress error '" + path + "': " + ex.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1919,8 +1927,8 @@ namespace EonaCat.LogStack.Flows
|
|||||||
const int bufSize = 65536;
|
const int bufSize = 65536;
|
||||||
byte[] buffer = new byte[bufSize];
|
byte[] buffer = new byte[bufSize];
|
||||||
|
|
||||||
using (FileStream src = File.OpenRead(path))
|
using (FileStream src = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete))
|
||||||
using (FileStream dst = File.Create(outPath))
|
using (FileStream dst = new FileStream(outPath, FileMode.Create, FileAccess.Write, FileShare.Delete))
|
||||||
using (GZipStream gz = new GZipStream(dst, CompressionLevel.Optimal))
|
using (GZipStream gz = new GZipStream(dst, CompressionLevel.Optimal))
|
||||||
{
|
{
|
||||||
int read;
|
int read;
|
||||||
@@ -2063,54 +2071,58 @@ namespace EonaCat.LogStack.Flows
|
|||||||
|
|
||||||
private void ApplyRetention()
|
private void ApplyRetention()
|
||||||
{
|
{
|
||||||
try
|
lock (_archiveCompressionLock)
|
||||||
{
|
{
|
||||||
DirectoryInfo dir = new DirectoryInfo(_directory);
|
DrainCompressionQueue();
|
||||||
if (!dir.Exists)
|
try
|
||||||
{
|
{
|
||||||
return;
|
DirectoryInfo dir = new DirectoryInfo(_directory);
|
||||||
}
|
if (!dir.Exists)
|
||||||
|
|
||||||
FileInfo[] files = dir.GetFiles("*" + _fileExtension)
|
|
||||||
.Concat(dir.GetFiles("*" + _fileExtension + ".gz"))
|
|
||||||
.OrderByDescending(f => f.LastWriteTimeUtc)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
long totalBytes = 0;
|
|
||||||
int kept = 0;
|
|
||||||
int keptCompressed = 0;
|
|
||||||
|
|
||||||
foreach (FileInfo f in files)
|
|
||||||
{
|
|
||||||
bool isCompressed = f.Extension.Equals(".gz", StringComparison.OrdinalIgnoreCase);
|
|
||||||
bool tooOld = _retention.MaxAgeDays > 0
|
|
||||||
&& (DateTime.UtcNow - f.LastWriteTimeUtc).TotalDays > _retention.MaxAgeDays;
|
|
||||||
bool tooMany = _retention.MaxRolledFiles > 0 && kept >= _retention.MaxRolledFiles;
|
|
||||||
bool tooManyCompressed = isCompressed
|
|
||||||
&& _retention.MaxCompressedFiles > 0
|
|
||||||
&& keptCompressed >= _retention.MaxCompressedFiles;
|
|
||||||
bool tooLarge = _retention.MaxTotalArchiveBytes > 0
|
|
||||||
&& totalBytes + f.Length > _retention.MaxTotalArchiveBytes;
|
|
||||||
bool directoryTooLarge = totalBytes + f.Length > _maxDirectorySize;
|
|
||||||
|
|
||||||
if (tooOld || tooMany || tooManyCompressed || tooLarge || directoryTooLarge)
|
|
||||||
{
|
{
|
||||||
try { f.Delete(); } catch { /* ignore */ }
|
return;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
FileInfo[] files = dir.GetFiles("*" + _fileExtension)
|
||||||
|
.Concat(dir.GetFiles("*" + _fileExtension + ".gz"))
|
||||||
|
.OrderByDescending(f => f.LastWriteTimeUtc)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
long totalBytes = 0;
|
||||||
|
int kept = 0;
|
||||||
|
int keptCompressed = 0;
|
||||||
|
|
||||||
|
foreach (FileInfo f in files)
|
||||||
{
|
{
|
||||||
totalBytes += f.Length;
|
bool isCompressed = f.Extension.Equals(".gz", StringComparison.OrdinalIgnoreCase);
|
||||||
kept++;
|
bool tooOld = _retention.MaxAgeDays > 0
|
||||||
if (isCompressed)
|
&& (DateTime.UtcNow - f.LastWriteTimeUtc).TotalDays > _retention.MaxAgeDays;
|
||||||
|
bool tooMany = _retention.MaxRolledFiles > 0 && kept >= _retention.MaxRolledFiles;
|
||||||
|
bool tooManyCompressed = isCompressed
|
||||||
|
&& _retention.MaxCompressedFiles > 0
|
||||||
|
&& keptCompressed >= _retention.MaxCompressedFiles;
|
||||||
|
bool tooLarge = _retention.MaxTotalArchiveBytes > 0
|
||||||
|
&& totalBytes + f.Length > _retention.MaxTotalArchiveBytes;
|
||||||
|
bool directoryTooLarge = totalBytes + f.Length > _maxDirectorySize;
|
||||||
|
|
||||||
|
if (tooOld || tooMany || tooManyCompressed || tooLarge || directoryTooLarge)
|
||||||
{
|
{
|
||||||
keptCompressed++;
|
try { f.Delete(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
totalBytes += f.Length;
|
||||||
|
kept++;
|
||||||
|
if (isCompressed)
|
||||||
|
{
|
||||||
|
keptCompressed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
catch (Exception ex)
|
{
|
||||||
{
|
OnException?.Invoke(this, "[FileFlow] Retention error: " + ex.Message);
|
||||||
OnException?.Invoke(this, "[FileFlow] Retention error: " + ex.Message);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
{
|
{
|
||||||
var targetFileName = GetCurrentFileName();
|
var targetFileName = GetCurrentFileName();
|
||||||
|
|
||||||
if (_currentWriter != null && _currentFileName == targetFileName && !ShouldRotate())
|
if (_currentWriter != null && _currentFileName == targetFileName && !ShouldRotate() && File.Exists(targetFileName))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ namespace EonaCat.LogStack.Flows
|
|||||||
_currentFileName,
|
_currentFileName,
|
||||||
FileMode.Append,
|
FileMode.Append,
|
||||||
FileAccess.Write,
|
FileAccess.Write,
|
||||||
FileShare.Read,
|
FileShare.Read | FileShare.Delete,
|
||||||
4096,
|
4096,
|
||||||
FileOptions.SequentialScan);
|
FileOptions.SequentialScan);
|
||||||
|
|
||||||
|
|||||||
@@ -294,7 +294,8 @@ namespace ServiceMonitoring
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
string logFilePath = Path.Combine(_statusDirectory, "status_log.txt");
|
string logFilePath = Path.Combine(_statusDirectory, "status_log.txt");
|
||||||
using (var writer = new StreamWriter(logFilePath, append: true))
|
using (var stream = new FileStream(logFilePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete))
|
||||||
|
using (var writer = new StreamWriter(stream))
|
||||||
{
|
{
|
||||||
writer.WriteLine(statusMessage);
|
writer.WriteLine(statusMessage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public sealed class TcpFlow : FlowBase
|
|||||||
await EnsureConnectedAsync(cancellationToken);
|
await EnsureConnectedAsync(cancellationToken);
|
||||||
|
|
||||||
// Send file in chunks
|
// Send file in chunks
|
||||||
using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
|
using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||||
{
|
{
|
||||||
byte[] buffer = new byte[4096];
|
byte[] buffer = new byte[4096];
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ namespace EonaCat.LogStack.Helpers
|
|||||||
var testFile = Path.Combine(dirPath, ".permission_test_" + Guid.NewGuid().ToString("N"));
|
var testFile = Path.Combine(dirPath, ".permission_test_" + Guid.NewGuid().ToString("N"));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (var fs = File.Create(testFile, 1, FileOptions.DeleteOnClose))
|
using (var fs = new FileStream(testFile, FileMode.Create, FileAccess.Write, FileShare.Delete, 1, FileOptions.DeleteOnClose))
|
||||||
{
|
{
|
||||||
fs.WriteByte(0);
|
fs.WriteByte(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -698,7 +698,11 @@ namespace EonaCat.LogStack.Server
|
|||||||
filePath = $"{basePath}_{idx++}.log";
|
filePath = $"{basePath}_{idx++}.log";
|
||||||
}
|
}
|
||||||
|
|
||||||
File.AppendAllText(filePath, logData + Environment.NewLine);
|
using (var stream = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete))
|
||||||
|
using (var writer = new StreamWriter(stream))
|
||||||
|
{
|
||||||
|
writer.WriteLine(logData);
|
||||||
|
}
|
||||||
Interlocked.Increment(ref Metrics.TotalWritten);
|
Interlocked.Increment(ref Metrics.TotalWritten);
|
||||||
LogWritten?.Invoke(logData);
|
LogWritten?.Invoke(logData);
|
||||||
|
|
||||||
|
|||||||
@@ -1083,6 +1083,9 @@ await using var logger = new LogBuilder("Production")
|
|||||||
|
|
||||||
Encrypt sensitive logs with AES encryption and password protection.
|
Encrypt sensitive logs with AES encryption and password protection.
|
||||||
|
|
||||||
|
Encrypted log files can be deleted while logging is active, including on Windows.
|
||||||
|
The next log entry recreates the file with a fresh encryption header; deleted entries are not restored.
|
||||||
|
|
||||||
### Writing Encrypted Logs
|
### Writing Encrypted Logs
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" PrivateAssets="all" />
|
||||||
|
<ProjectReference Include="..\..\EonaCat.LogStack\EonaCat.LogStack.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using EonaCat.LogStack.Flows;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.FileDeletionTests;
|
||||||
|
|
||||||
|
public sealed class FileDeletionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), "EonaCat.FileDeletionTests", Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public FileDeletionTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EncryptedLogCanBeDeletedAndRecreatedWithAValidNewHeader()
|
||||||
|
{
|
||||||
|
await using var flow = new EncryptedFileFlow(_directory, "test-password", template: "{message}").WithDurableWrites();
|
||||||
|
await WriteEncryptedAsync(flow, "before-deletion");
|
||||||
|
string path = Assert.Single(Directory.GetFiles(_directory, "*.eona"));
|
||||||
|
byte[] originalHeader = ReadHeader(path);
|
||||||
|
|
||||||
|
Assert.Throws<IOException>(() => File.Open(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete));
|
||||||
|
File.Delete(path);
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
|
||||||
|
await WriteEncryptedAsync(flow, "after-deletion");
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
Assert.False(originalHeader.SequenceEqual(ReadHeader(path)));
|
||||||
|
string outputPath = Path.Combine(_directory, "decrypted.txt");
|
||||||
|
Assert.True(EncryptedFileFlow.DecryptToFile(path, outputPath, "test-password"));
|
||||||
|
string contents = File.ReadAllText(outputPath);
|
||||||
|
Assert.Contains("after-deletion", contents);
|
||||||
|
Assert.DoesNotContain("before-deletion", contents);
|
||||||
|
Assert.Equal(0, flow.GetTotalErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EncryptedLogCanResumeAnExistingFile()
|
||||||
|
{
|
||||||
|
byte[] originalHeader;
|
||||||
|
string path;
|
||||||
|
await using (var first = new EncryptedFileFlow(_directory, "test-password", template: "{message}").WithDurableWrites())
|
||||||
|
{
|
||||||
|
await WriteEncryptedAsync(first, "first-session");
|
||||||
|
path = Assert.Single(Directory.GetFiles(_directory, "*.eona"));
|
||||||
|
originalHeader = ReadHeader(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var second = new EncryptedFileFlow(_directory, "test-password", template: "{message}").WithDurableWrites();
|
||||||
|
await WriteEncryptedAsync(second, "second-session");
|
||||||
|
Assert.Equal(originalHeader, ReadHeader(path));
|
||||||
|
string outputPath = Path.Combine(_directory, "decrypted.txt");
|
||||||
|
Assert.True(EncryptedFileFlow.DecryptToFile(path, outputPath, "test-password"));
|
||||||
|
string contents = File.ReadAllText(outputPath);
|
||||||
|
Assert.Contains("first-session", contents);
|
||||||
|
Assert.Contains("second-session", contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RotatingLogCanBeDeletedAndRecreated()
|
||||||
|
{
|
||||||
|
await using var flow = new RotatingFileFlow(_directory);
|
||||||
|
Assert.Equal(WriteResult.Success, await flow.BlastAsync(CreateEvent("before-deletion")));
|
||||||
|
string path = Assert.Single(Directory.GetFiles(_directory));
|
||||||
|
File.Delete(path);
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
|
||||||
|
Assert.Equal(WriteResult.Success, await flow.BlastAsync(CreateEvent("after-deletion")));
|
||||||
|
string contents = ReadSharedText(path);
|
||||||
|
Assert.Contains("after-deletion", contents);
|
||||||
|
Assert.DoesNotContain("before-deletion", contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuditLogCanBeDeletedAndRecreatedWithAValidHashChain()
|
||||||
|
{
|
||||||
|
var flow = new AuditFlow(_directory);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await flow.BlastAsync(CreateEvent("before-deletion"));
|
||||||
|
Assert.Throws<IOException>(() => File.Open(flow.FilePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete));
|
||||||
|
File.Delete(flow.FilePath);
|
||||||
|
Assert.False(File.Exists(flow.FilePath));
|
||||||
|
|
||||||
|
await flow.BlastAsync(CreateEvent("after-deletion"));
|
||||||
|
await flow.BlastAsync(CreateEvent("next-entry"));
|
||||||
|
var liveVerification = AuditFlow.Verify(flow.FilePath);
|
||||||
|
Assert.True(liveVerification.ok, liveVerification.reason);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await flow.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var verification = AuditFlow.Verify(flow.FilePath);
|
||||||
|
Assert.True(verification.ok, verification.reason);
|
||||||
|
string contents = File.ReadAllText(flow.FilePath);
|
||||||
|
Assert.Contains("after-deletion", contents);
|
||||||
|
Assert.Contains("next-entry", contents);
|
||||||
|
Assert.DoesNotContain("before-deletion", contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlainLogCanBeDeletedWhileOpen()
|
||||||
|
{
|
||||||
|
await using var flow = new FileFlow(_directory);
|
||||||
|
Assert.Equal(WriteResult.Success, await flow.BlastAsync(CreateEvent("before-deletion")));
|
||||||
|
Assert.True(SpinWait.SpinUntil(() => flow.GetStats().BytesWritten > 0 || flow.GetTotalErrors() > 0, TimeSpan.FromSeconds(10)), "Plain writer did not complete.");
|
||||||
|
Assert.Equal(0, flow.GetTotalErrors());
|
||||||
|
await flow.FlushAsync();
|
||||||
|
string path = Assert.Single(Directory.GetFiles(_directory, "*.log"));
|
||||||
|
File.Delete(path);
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LogEvent CreateEvent(string message) => new LogEventBuilder().WithMessage(message).Build();
|
||||||
|
|
||||||
|
private static async Task WriteEncryptedAsync(EncryptedFileFlow flow, string message)
|
||||||
|
{
|
||||||
|
long previousBytes = flow.GetStats().BytesWritten;
|
||||||
|
Assert.Equal(WriteResult.Success, await flow.BlastAsync(CreateEvent(message)));
|
||||||
|
Assert.True(SpinWait.SpinUntil(() => flow.GetStats().BytesWritten > previousBytes || flow.GetTotalErrors() > 0, TimeSpan.FromSeconds(10)), "Encrypted writer did not complete.");
|
||||||
|
Assert.Equal(0, flow.GetTotalErrors());
|
||||||
|
await flow.FlushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ReadHeader(string path)
|
||||||
|
{
|
||||||
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
byte[] header = new byte[52];
|
||||||
|
stream.ReadExactly(header);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadSharedText(string path)
|
||||||
|
{
|
||||||
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
return reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user