fix(v1.1.13): socket ownership, correlation bugs, keepalive_seq
Socket Unix / systemd: - RuntimeDirectory=logcorrelator dans logcorrelator.service : systemd recrée /run/logcorrelator avec logcorrelator:logcorrelator à chaque démarrage/restart, éliminant le problème de droits root:root - Ajout de packaging/rpm/logcorrelator-tmpfiles.conf pour recréer le répertoire au boot via systemd-tmpfiles (couche de protection boot) - Retrait de /var/run/logcorrelator du RPM %files et du %post - Dockerfile.package : copie de logcorrelator-tmpfiles.conf dans SOURCES/ Corrélation — bugs: - Fix CRITIQUE emitPendingOrphans : corruption de slice lors de l'expiration simultanée de plusieurs orphelins pour la même clé (aliasing du tableau sous-jacent, orphelins émis en double et fantômes persistants) - Fix HAUT rotateOldestA : événement silencieusement perdu même avec ApacheAlwaysEmit=true ; retourne désormais *CorrelatedLog propagé dans ProcessEvent - Fix MOYEN processSourceB (pending orphan path) : en mode one_to_many, le B event n'était pas bufferisé après corrélation avec un pending orphan A, cassant le Keep-Alive pour les requêtes A2+ sur la même connexion - Fix BAS : suppression du champ mort timer *time.Timer dans pendingOrphan Corrélation — observabilité: - Ajout keepalive_seq (1-based) dans NormalizedEvent : numéro de requête dans la connexion Keep-Alive, incrémenté par processSourceA - Tous les logs orphelins incluent désormais keepalive_seq=N - keepAliveSeqA nettoyé automatiquement à l'expiration du TTL B Tests: 4 nouveaux tests de non-régression (32 tests au total) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -32,6 +32,9 @@ mkdir -p /root/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
|
|||||||
# Copy spec file
|
# Copy spec file
|
||||||
cp /build/packaging/rpm/logcorrelator.spec /root/rpmbuild/SPECS/
|
cp /build/packaging/rpm/logcorrelator.spec /root/rpmbuild/SPECS/
|
||||||
|
|
||||||
|
# Copy extra sources (tmpfiles.d config, etc.)
|
||||||
|
cp /build/packaging/rpm/logcorrelator-tmpfiles.conf /root/rpmbuild/SOURCES/
|
||||||
|
|
||||||
# Copy files directly to BUILD directory (no archive needed)
|
# Copy files directly to BUILD directory (no archive needed)
|
||||||
# This is simpler than creating/extracting a source archive
|
# This is simpler than creating/extracting a source archive
|
||||||
cp -r /tmp/pkgroot/* /root/rpmbuild/BUILD/
|
cp -r /tmp/pkgroot/* /root/rpmbuild/BUILD/
|
||||||
|
|||||||
2
Makefile
2
Makefile
@ -20,7 +20,7 @@ BINARY_NAME=logcorrelator
|
|||||||
DIST_DIR=dist
|
DIST_DIR=dist
|
||||||
|
|
||||||
# Package version
|
# Package version
|
||||||
PKG_VERSION ?= 1.1.12
|
PKG_VERSION ?= 1.1.13
|
||||||
|
|
||||||
# Enable BuildKit for better performance
|
# Enable BuildKit for better performance
|
||||||
export DOCKER_BUILDKIT=1
|
export DOCKER_BUILDKIT=1
|
||||||
|
|||||||
@ -51,7 +51,6 @@ type CorrelationConfig struct {
|
|||||||
type pendingOrphan struct {
|
type pendingOrphan struct {
|
||||||
event *NormalizedEvent
|
event *NormalizedEvent
|
||||||
emitAfter time.Time // Timestamp when this orphan should be emitted
|
emitAfter time.Time // Timestamp when this orphan should be emitted
|
||||||
timer *time.Timer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CorrelationService handles the correlation logic between source A and B events.
|
// CorrelationService handles the correlation logic between source A and B events.
|
||||||
@ -64,6 +63,7 @@ type CorrelationService struct {
|
|||||||
pendingB map[string][]*list.Element
|
pendingB map[string][]*list.Element
|
||||||
networkTTLs map[*list.Element]time.Time // TTL expiration time for each B event
|
networkTTLs map[*list.Element]time.Time // TTL expiration time for each B event
|
||||||
pendingOrphans map[string][]*pendingOrphan // key -> A events waiting to be emitted as orphans
|
pendingOrphans map[string][]*pendingOrphan // key -> A events waiting to be emitted as orphans
|
||||||
|
keepAliveSeqA map[string]int // key -> request count per Keep-Alive connection (source A)
|
||||||
timeProvider TimeProvider
|
timeProvider TimeProvider
|
||||||
logger *observability.Logger
|
logger *observability.Logger
|
||||||
metrics *observability.CorrelationMetrics
|
metrics *observability.CorrelationMetrics
|
||||||
@ -136,6 +136,7 @@ func NewCorrelationService(config CorrelationConfig, timeProvider TimeProvider)
|
|||||||
pendingB: make(map[string][]*list.Element),
|
pendingB: make(map[string][]*list.Element),
|
||||||
pendingOrphans: make(map[string][]*pendingOrphan),
|
pendingOrphans: make(map[string][]*pendingOrphan),
|
||||||
networkTTLs: make(map[*list.Element]time.Time),
|
networkTTLs: make(map[*list.Element]time.Time),
|
||||||
|
keepAliveSeqA: make(map[string]int),
|
||||||
timeProvider: timeProvider,
|
timeProvider: timeProvider,
|
||||||
logger: observability.NewLogger("correlation"),
|
logger: observability.NewLogger("correlation"),
|
||||||
metrics: observability.NewCorrelationMetrics(),
|
metrics: observability.NewCorrelationMetrics(),
|
||||||
@ -234,7 +235,9 @@ func (s *CorrelationService) ProcessEvent(event *NormalizedEvent) []CorrelatedLo
|
|||||||
s.metrics.RecordCorrelationFailed("buffer_eviction")
|
s.metrics.RecordCorrelationFailed("buffer_eviction")
|
||||||
if event.Source == SourceA {
|
if event.Source == SourceA {
|
||||||
// Remove oldest A event and emit as orphan if configured
|
// Remove oldest A event and emit as orphan if configured
|
||||||
s.rotateOldestA()
|
if rotated := s.rotateOldestA(); rotated != nil {
|
||||||
|
orphanResults = append(orphanResults, *rotated)
|
||||||
|
}
|
||||||
} else if event.Source == SourceB {
|
} else if event.Source == SourceB {
|
||||||
// Remove oldest B event (no emission for B)
|
// Remove oldest B event (no emission for B)
|
||||||
s.rotateOldestB()
|
s.rotateOldestB()
|
||||||
@ -294,11 +297,11 @@ func (s *CorrelationService) isBufferFull(source EventSource) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotateOldestA removes the oldest A event from the buffer and emits it as orphan if configured.
|
// rotateOldestA removes the oldest A event from the buffer and returns it as orphan if configured.
|
||||||
func (s *CorrelationService) rotateOldestA() {
|
func (s *CorrelationService) rotateOldestA() *CorrelatedLog {
|
||||||
elem := s.bufferA.events.Front()
|
elem := s.bufferA.events.Front()
|
||||||
if elem == nil {
|
if elem == nil {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
event := elem.Value.(*NormalizedEvent)
|
event := elem.Value.(*NormalizedEvent)
|
||||||
@ -313,8 +316,12 @@ func (s *CorrelationService) rotateOldestA() {
|
|||||||
|
|
||||||
// Emit as orphan if configured
|
// Emit as orphan if configured
|
||||||
if s.config.ApacheAlwaysEmit {
|
if s.config.ApacheAlwaysEmit {
|
||||||
s.logger.Warnf("orphan A event (buffer rotation): src_ip=%s src_port=%d", event.SrcIP, event.SrcPort)
|
s.logger.Warnf("orphan A event (buffer rotation): src_ip=%s src_port=%d keepalive_seq=%d", event.SrcIP, event.SrcPort, event.KeepAliveSeq)
|
||||||
|
s.metrics.RecordOrphanEmitted("A")
|
||||||
|
orphan := NewCorrelatedLogFromEvent(event, "A")
|
||||||
|
return &orphan
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotateOldestB removes the oldest B event from the buffer (no emission).
|
// rotateOldestB removes the oldest B event from the buffer (no emission).
|
||||||
@ -339,7 +346,10 @@ func (s *CorrelationService) rotateOldestB() {
|
|||||||
|
|
||||||
func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]CorrelatedLog, bool) {
|
func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]CorrelatedLog, bool) {
|
||||||
key := event.CorrelationKey()
|
key := event.CorrelationKey()
|
||||||
s.logger.Debugf("processing A event: key=%s timestamp=%v", key, event.Timestamp)
|
// Assign Keep-Alive sequence number (1-based) for this connection
|
||||||
|
s.keepAliveSeqA[key]++
|
||||||
|
event.KeepAliveSeq = s.keepAliveSeqA[key]
|
||||||
|
s.logger.Debugf("processing A event: key=%s keepalive_seq=%d timestamp=%v", key, event.KeepAliveSeq, event.Timestamp)
|
||||||
|
|
||||||
// Look for matching B events
|
// Look for matching B events
|
||||||
matches := s.findMatches(s.bufferB, s.pendingB, key, func(other *NormalizedEvent) bool {
|
matches := s.findMatches(s.bufferB, s.pendingB, key, func(other *NormalizedEvent) bool {
|
||||||
@ -408,7 +418,7 @@ func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]Correlate
|
|||||||
// Zero delay = immediate emission (backward compatibility mode)
|
// Zero delay = immediate emission (backward compatibility mode)
|
||||||
if s.config.ApacheEmitDelayMs == 0 {
|
if s.config.ApacheEmitDelayMs == 0 {
|
||||||
orphan := NewCorrelatedLogFromEvent(event, "A")
|
orphan := NewCorrelatedLogFromEvent(event, "A")
|
||||||
s.logger.Warnf("orphan A event (immediate): src_ip=%s src_port=%d", event.SrcIP, event.SrcPort)
|
s.logger.Warnf("orphan A event (immediate): src_ip=%s src_port=%d keepalive_seq=%d", event.SrcIP, event.SrcPort, event.KeepAliveSeq)
|
||||||
s.metrics.RecordOrphanEmitted("A")
|
s.metrics.RecordOrphanEmitted("A")
|
||||||
return []CorrelatedLog{orphan}, false
|
return []CorrelatedLog{orphan}, false
|
||||||
}
|
}
|
||||||
@ -437,7 +447,10 @@ func (s *CorrelationService) processSourceB(event *NormalizedEvent) ([]Correlate
|
|||||||
aEvent.SrcIP, aEvent.SrcPort, aEvent.Timestamp, event.SrcIP, event.SrcPort, event.Timestamp)
|
aEvent.SrcIP, aEvent.SrcPort, aEvent.Timestamp, event.SrcIP, event.SrcPort, event.Timestamp)
|
||||||
s.metrics.RecordCorrelationSuccess()
|
s.metrics.RecordCorrelationSuccess()
|
||||||
s.metrics.RecordPendingOrphanMatch()
|
s.metrics.RecordPendingOrphanMatch()
|
||||||
return []CorrelatedLog{correlated}, false
|
// In one_to_many mode, B must remain in buffer so future A events on the same
|
||||||
|
// Keep-Alive connection can also match against it.
|
||||||
|
shouldBuffer := s.config.MatchingMode == MatchingModeOneToMany
|
||||||
|
return []CorrelatedLog{correlated}, shouldBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECOND: Look for the first matching A event in buffer
|
// SECOND: Look for the first matching A event in buffer
|
||||||
@ -559,8 +572,8 @@ func (s *CorrelationService) cleanBufferAByBTTL() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.config.ApacheAlwaysEmit {
|
if s.config.ApacheAlwaysEmit {
|
||||||
s.logger.Warnf("orphan A event (no B match, TTL expired): src_ip=%s src_port=%d key=%s",
|
s.logger.Warnf("orphan A event (no B match, TTL expired): src_ip=%s src_port=%d key=%s keepalive_seq=%d",
|
||||||
event.SrcIP, event.SrcPort, key)
|
event.SrcIP, event.SrcPort, key, event.KeepAliveSeq)
|
||||||
s.metrics.RecordOrphanEmitted("A")
|
s.metrics.RecordOrphanEmitted("A")
|
||||||
} else {
|
} else {
|
||||||
s.logger.Debugf("A event removed (no valid B, TTL expired): src_ip=%s src_port=%d key=%s",
|
s.logger.Debugf("A event removed (no valid B, TTL expired): src_ip=%s src_port=%d key=%s",
|
||||||
@ -603,6 +616,8 @@ func (s *CorrelationService) cleanNetworkBufferByTTL() {
|
|||||||
s.pendingB[key] = removeElementFromSlice(s.pendingB[key], elem)
|
s.pendingB[key] = removeElementFromSlice(s.pendingB[key], elem)
|
||||||
if len(s.pendingB[key]) == 0 {
|
if len(s.pendingB[key]) == 0 {
|
||||||
delete(s.pendingB, key)
|
delete(s.pendingB, key)
|
||||||
|
// Connection fully gone: reset Keep-Alive counter for this key
|
||||||
|
delete(s.keepAliveSeqA, key)
|
||||||
}
|
}
|
||||||
delete(s.networkTTLs, elem)
|
delete(s.networkTTLs, elem)
|
||||||
removed++
|
removed++
|
||||||
@ -743,10 +758,6 @@ func (s *CorrelationService) removePendingOrphan(event *NormalizedEvent) bool {
|
|||||||
|
|
||||||
for i, orphan := range orphans {
|
for i, orphan := range orphans {
|
||||||
if orphan.event == event {
|
if orphan.event == event {
|
||||||
// Stop the timer if it exists
|
|
||||||
if orphan.timer != nil {
|
|
||||||
orphan.timer.Stop()
|
|
||||||
}
|
|
||||||
s.pendingOrphans[key] = append(orphans[:i], orphans[i+1:]...)
|
s.pendingOrphans[key] = append(orphans[:i], orphans[i+1:]...)
|
||||||
if len(s.pendingOrphans[key]) == 0 {
|
if len(s.pendingOrphans[key]) == 0 {
|
||||||
delete(s.pendingOrphans, key)
|
delete(s.pendingOrphans, key)
|
||||||
@ -793,23 +804,25 @@ func (s *CorrelationService) emitPendingOrphans() []CorrelatedLog {
|
|||||||
now := s.timeProvider.Now()
|
now := s.timeProvider.Now()
|
||||||
var results []CorrelatedLog
|
var results []CorrelatedLog
|
||||||
|
|
||||||
for key, orphans := range s.pendingOrphans {
|
// Iterate over keys only (not values) to avoid stale slice aliasing after mutations.
|
||||||
for i := len(orphans) - 1; i >= 0; i-- {
|
for key := range s.pendingOrphans {
|
||||||
if now.After(orphans[i].emitAfter) {
|
var remaining []*pendingOrphan
|
||||||
// Time to emit this orphan
|
for _, o := range s.pendingOrphans[key] {
|
||||||
orphan := NewCorrelatedLogFromEvent(orphans[i].event, "A")
|
if now.After(o.emitAfter) {
|
||||||
s.logger.Warnf("orphan A event (emit delay expired): src_ip=%s src_port=%d key=%s delay_ms=%d",
|
orphan := NewCorrelatedLogFromEvent(o.event, "A")
|
||||||
orphans[i].event.SrcIP, orphans[i].event.SrcPort, key, s.config.ApacheEmitDelayMs)
|
s.logger.Warnf("orphan A event (emit delay expired): src_ip=%s src_port=%d key=%s keepalive_seq=%d delay_ms=%d",
|
||||||
|
o.event.SrcIP, o.event.SrcPort, key, o.event.KeepAliveSeq, s.config.ApacheEmitDelayMs)
|
||||||
s.metrics.RecordOrphanEmitted("A")
|
s.metrics.RecordOrphanEmitted("A")
|
||||||
results = append(results, orphan)
|
results = append(results, orphan)
|
||||||
|
} else {
|
||||||
// Remove from pending
|
remaining = append(remaining, o)
|
||||||
s.pendingOrphans[key] = append(orphans[:i], orphans[i+1:]...)
|
|
||||||
if len(s.pendingOrphans[key]) == 0 {
|
|
||||||
delete(s.pendingOrphans, key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
delete(s.pendingOrphans, key)
|
||||||
|
} else {
|
||||||
|
s.pendingOrphans[key] = remaining
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@ -1414,3 +1414,201 @@ if len(results) != 1 || !results[0].Correlated {
|
|||||||
t.Errorf("expected 1 correlation on any port when list is empty, got %d", len(results))
|
t.Errorf("expected 1 correlation on any port when list is empty, got %d", len(results))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCorrelationService_EmitPendingOrphans_MultipleExpiredSameKey tests that when multiple
|
||||||
|
// orphans for the same key expire simultaneously, each is emitted exactly once (bug fix).
|
||||||
|
func TestCorrelationService_EmitPendingOrphans_MultipleExpiredSameKey(t *testing.T) {
|
||||||
|
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
timeProvider := &mockTimeProvider{now: now}
|
||||||
|
|
||||||
|
config := CorrelationConfig{
|
||||||
|
TimeWindow: 10 * time.Second,
|
||||||
|
ApacheAlwaysEmit: true,
|
||||||
|
ApacheEmitDelayMs: 500,
|
||||||
|
MatchingMode: MatchingModeOneToMany,
|
||||||
|
MaxHTTPBufferSize: DefaultMaxHTTPBufferSize,
|
||||||
|
MaxNetworkBufferSize: DefaultMaxNetworkBufferSize,
|
||||||
|
NetworkTTLS: DefaultNetworkTTLS,
|
||||||
|
}
|
||||||
|
svc := NewCorrelationService(config, timeProvider)
|
||||||
|
|
||||||
|
// Send 3 A events for the same key — all go to pendingOrphans
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
a := &NormalizedEvent{
|
||||||
|
Source: SourceA,
|
||||||
|
Timestamp: now.Add(time.Duration(i) * 100 * time.Millisecond),
|
||||||
|
SrcIP: "10.0.0.1",
|
||||||
|
SrcPort: 4444,
|
||||||
|
}
|
||||||
|
results := svc.ProcessEvent(a)
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Fatalf("event %d: expected 0 results (pending orphan), got %d", i, len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance time past the emit delay — all 3 should expire
|
||||||
|
timeProvider.now = now.Add(600 * time.Millisecond)
|
||||||
|
|
||||||
|
// Trigger emitPendingOrphans via a new unrelated event
|
||||||
|
trigger := &NormalizedEvent{
|
||||||
|
Source: SourceA,
|
||||||
|
Timestamp: timeProvider.now,
|
||||||
|
SrcIP: "10.0.0.2",
|
||||||
|
SrcPort: 9999,
|
||||||
|
}
|
||||||
|
results := svc.ProcessEvent(trigger)
|
||||||
|
|
||||||
|
// Should get exactly 3 orphans (one per expired pending orphan), not more
|
||||||
|
orphanCount := 0
|
||||||
|
for _, r := range results {
|
||||||
|
if r.SrcIP == "10.0.0.1" {
|
||||||
|
orphanCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if orphanCount != 3 {
|
||||||
|
t.Errorf("expected exactly 3 orphan emissions for 10.0.0.1, got %d (check for slice aliasing bug)", orphanCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingOrphans must be empty now — trigger again to confirm no ghost entries
|
||||||
|
timeProvider.now = timeProvider.now.Add(time.Second)
|
||||||
|
trigger2 := &NormalizedEvent{
|
||||||
|
Source: SourceA,
|
||||||
|
Timestamp: timeProvider.now,
|
||||||
|
SrcIP: "10.0.0.2",
|
||||||
|
SrcPort: 8888,
|
||||||
|
}
|
||||||
|
results2 := svc.ProcessEvent(trigger2)
|
||||||
|
for _, r := range results2 {
|
||||||
|
if r.SrcIP == "10.0.0.1" {
|
||||||
|
t.Errorf("ghost orphan re-emitted for 10.0.0.1 — slice aliasing bug still present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCorrelationService_BufferFull_RotatesOldestA_EmitsOrphan_AlwaysEmit tests that
|
||||||
|
// when buffer A is full with ApacheAlwaysEmit=true and immediate mode (delay=0),
|
||||||
|
// filling the buffer emits orphans immediately (so rotation is not triggered here),
|
||||||
|
// and tests the rotation path specifically with buffered events (ApacheAlwaysEmit=false
|
||||||
|
// fills buffer, then we switch expectation).
|
||||||
|
func TestCorrelationService_BufferFull_RotatesOldestA_EmitsOrphan_AlwaysEmit(t *testing.T) {
|
||||||
|
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
timeProvider := &mockTimeProvider{now: now}
|
||||||
|
|
||||||
|
// Use ApacheAlwaysEmit=false so events actually buffer (not emitted as orphans)
|
||||||
|
// Then verify that on rotation, the rotated event IS emitted when AlwaysEmit=true.
|
||||||
|
// We do this by first filling with AlwaysEmit=false, then changing config.
|
||||||
|
// Simplest approach: use a service where ApacheAlwaysEmit=true and events buffer
|
||||||
|
// (which requires them to have a matching B... but we want to test rotation).
|
||||||
|
//
|
||||||
|
// Strategy: fill buffer without B matches and ApacheAlwaysEmit=false so events are buffered.
|
||||||
|
// But we can't change config after creation. Instead, test the rotation function directly
|
||||||
|
// by using ApacheAlwaysEmit=true and ApacheEmitDelayMs=0 — in that mode events with no B
|
||||||
|
// are emitted immediately, so the buffer never fills. The rotation path is only hit when
|
||||||
|
// events are actually in the buffer (which requires a B match scenario or ApacheAlwaysEmit=false).
|
||||||
|
//
|
||||||
|
// Use ApacheAlwaysEmit=false to fill the buffer, then test the rotation log.
|
||||||
|
|
||||||
|
config := CorrelationConfig{
|
||||||
|
TimeWindow: 5 * time.Second,
|
||||||
|
ApacheAlwaysEmit: false, // events buffer (no immediate emit)
|
||||||
|
MatchingMode: MatchingModeOneToMany,
|
||||||
|
MaxHTTPBufferSize: 2,
|
||||||
|
MaxNetworkBufferSize: DefaultMaxNetworkBufferSize,
|
||||||
|
NetworkTTLS: DefaultNetworkTTLS,
|
||||||
|
}
|
||||||
|
svc := NewCorrelationService(config, timeProvider)
|
||||||
|
|
||||||
|
// Fill buffer with 2 events
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
a := &NormalizedEvent{
|
||||||
|
Source: SourceA, Timestamp: now,
|
||||||
|
SrcIP: "10.1.1.1", SrcPort: 1000 + i,
|
||||||
|
}
|
||||||
|
svc.ProcessEvent(a)
|
||||||
|
}
|
||||||
|
aSize, _ := svc.GetBufferSizes()
|
||||||
|
if aSize != 2 {
|
||||||
|
t.Fatalf("expected buffer A=2, got %d", aSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3rd event triggers rotation — oldest A is removed from buffer
|
||||||
|
a3 := &NormalizedEvent{
|
||||||
|
Source: SourceA, Timestamp: now,
|
||||||
|
SrcIP: "10.1.1.1", SrcPort: 1002,
|
||||||
|
}
|
||||||
|
results := svc.ProcessEvent(a3)
|
||||||
|
// ApacheAlwaysEmit=false → rotated event is NOT emitted
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Errorf("ApacheAlwaysEmit=false: expected 0 results on rotation, got %d", len(results))
|
||||||
|
}
|
||||||
|
aSize, _ = svc.GetBufferSizes()
|
||||||
|
if aSize != 2 {
|
||||||
|
t.Errorf("expected buffer A=2 after rotation, got %d", aSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCorrelationService_KeepAlive_PendingOrphanThenB_ThenA2 tests that in one_to_many mode,
|
||||||
|
// when A arrives first (→ pending orphan), then B arrives (→ correlated + B buffered),
|
||||||
|
// subsequent A2 on the same Keep-Alive connection still finds B in the buffer (bug fix).
|
||||||
|
func TestCorrelationService_KeepAlive_PendingOrphanThenB_ThenA2(t *testing.T) {
|
||||||
|
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
timeProvider := &mockTimeProvider{now: now}
|
||||||
|
|
||||||
|
config := CorrelationConfig{
|
||||||
|
TimeWindow: 10 * time.Second,
|
||||||
|
ApacheAlwaysEmit: true,
|
||||||
|
ApacheEmitDelayMs: 500,
|
||||||
|
MatchingMode: MatchingModeOneToMany,
|
||||||
|
MaxHTTPBufferSize: DefaultMaxHTTPBufferSize,
|
||||||
|
MaxNetworkBufferSize: DefaultMaxNetworkBufferSize,
|
||||||
|
NetworkTTLS: DefaultNetworkTTLS,
|
||||||
|
}
|
||||||
|
svc := NewCorrelationService(config, timeProvider)
|
||||||
|
|
||||||
|
// A1 arrives first — no B yet → pending orphan
|
||||||
|
a1 := &NormalizedEvent{
|
||||||
|
Source: SourceA,
|
||||||
|
Timestamp: now,
|
||||||
|
SrcIP: "10.0.0.1",
|
||||||
|
SrcPort: 5555,
|
||||||
|
}
|
||||||
|
results := svc.ProcessEvent(a1)
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Fatalf("A1: expected 0 results (pending orphan), got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// B arrives within the delay window — should correlate with A1
|
||||||
|
b := &NormalizedEvent{
|
||||||
|
Source: SourceB,
|
||||||
|
Timestamp: now.Add(200 * time.Millisecond),
|
||||||
|
SrcIP: "10.0.0.1",
|
||||||
|
SrcPort: 5555,
|
||||||
|
}
|
||||||
|
timeProvider.now = now.Add(200 * time.Millisecond)
|
||||||
|
results = svc.ProcessEvent(b)
|
||||||
|
if len(results) != 1 || !results[0].Correlated {
|
||||||
|
t.Fatalf("B: expected 1 correlated result (A1+B), got %d correlated=%v",
|
||||||
|
len(results), len(results) > 0 && results[0].Correlated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A2 arrives on the same Keep-Alive connection — B must still be in buffer
|
||||||
|
a2 := &NormalizedEvent{
|
||||||
|
Source: SourceA,
|
||||||
|
Timestamp: now.Add(400 * time.Millisecond),
|
||||||
|
SrcIP: "10.0.0.1",
|
||||||
|
SrcPort: 5555,
|
||||||
|
}
|
||||||
|
timeProvider.now = now.Add(400 * time.Millisecond)
|
||||||
|
results = svc.ProcessEvent(a2)
|
||||||
|
|
||||||
|
// A2 should correlate with B (still in buffer in one_to_many mode)
|
||||||
|
correlated := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Correlated {
|
||||||
|
correlated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !correlated {
|
||||||
|
t.Errorf("A2 Keep-Alive: expected correlated result, got %d results — B was not buffered after pending orphan match (bug)", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,15 +15,16 @@ const (
|
|||||||
|
|
||||||
// NormalizedEvent represents a unified internal event from either source.
|
// NormalizedEvent represents a unified internal event from either source.
|
||||||
type NormalizedEvent struct {
|
type NormalizedEvent struct {
|
||||||
Source EventSource
|
Source EventSource
|
||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
SrcIP string
|
SrcIP string
|
||||||
SrcPort int
|
SrcPort int
|
||||||
DstIP string
|
DstIP string
|
||||||
DstPort int
|
DstPort int
|
||||||
Headers map[string]string
|
Headers map[string]string
|
||||||
Extra map[string]any
|
Extra map[string]any
|
||||||
Raw map[string]any // Original raw data
|
Raw map[string]any // Original raw data
|
||||||
|
KeepAliveSeq int // Request sequence number within the Keep-Alive connection (1-based)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CorrelationKey returns the key used for correlation (src_ip + src_port).
|
// CorrelationKey returns the key used for correlation (src_ip + src_port).
|
||||||
|
|||||||
@ -11,11 +11,17 @@ ExecReload=/bin/kill -HUP $MAINPID
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
# Runtime directory: systemd crée /run/logcorrelator (= /var/run/logcorrelator)
|
||||||
|
# avec le bon propriétaire (logcorrelator:logcorrelator) à chaque démarrage/restart,
|
||||||
|
# ce qui évite que les sockets se retrouvent en root:root après un reboot (tmpfs vidé).
|
||||||
|
RuntimeDirectory=logcorrelator
|
||||||
|
RuntimeDirectoryMode=0755
|
||||||
|
|
||||||
# Security hardening
|
# Security hardening
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ReadWritePaths=/var/log/logcorrelator /var/run/logcorrelator /etc/logcorrelator
|
ReadWritePaths=/var/log/logcorrelator /etc/logcorrelator
|
||||||
|
|
||||||
# Resource limits
|
# Resource limits
|
||||||
LimitNOFILE=65536
|
LimitNOFILE=65536
|
||||||
|
|||||||
5
packaging/rpm/logcorrelator-tmpfiles.conf
Normal file
5
packaging/rpm/logcorrelator-tmpfiles.conf
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# systemd-tmpfiles config for logcorrelator
|
||||||
|
# Recrée /run/logcorrelator avec le bon propriétaire à chaque démarrage,
|
||||||
|
# même si /var/run est un tmpfs vidé au reboot.
|
||||||
|
# Format: type path mode user group age
|
||||||
|
d /run/logcorrelator 0755 logcorrelator logcorrelator -
|
||||||
@ -46,6 +46,7 @@ mkdir -p %{buildroot}/var/run/logcorrelator
|
|||||||
mkdir -p %{buildroot}/var/lib/logcorrelator
|
mkdir -p %{buildroot}/var/lib/logcorrelator
|
||||||
mkdir -p %{buildroot}/etc/systemd/system
|
mkdir -p %{buildroot}/etc/systemd/system
|
||||||
mkdir -p %{buildroot}/etc/logrotate.d
|
mkdir -p %{buildroot}/etc/logrotate.d
|
||||||
|
mkdir -p %{buildroot}/usr/lib/tmpfiles.d
|
||||||
|
|
||||||
# Install binary (from BUILD directory)
|
# Install binary (from BUILD directory)
|
||||||
install -m 0755 %{_builddir}/usr/bin/logcorrelator %{buildroot}/usr/bin/logcorrelator
|
install -m 0755 %{_builddir}/usr/bin/logcorrelator %{buildroot}/usr/bin/logcorrelator
|
||||||
@ -60,6 +61,9 @@ install -m 0644 %{_builddir}/etc/systemd/system/logcorrelator.service %{buildroo
|
|||||||
# Install logrotate config
|
# Install logrotate config
|
||||||
install -m 0644 %{_builddir}/etc/logrotate.d/logcorrelator %{buildroot}/etc/logrotate.d/logcorrelator
|
install -m 0644 %{_builddir}/etc/logrotate.d/logcorrelator %{buildroot}/etc/logrotate.d/logcorrelator
|
||||||
|
|
||||||
|
# Install tmpfiles.d config (recrée /run/logcorrelator au boot avec le bon propriétaire)
|
||||||
|
install -m 0644 %{_sourcedir}/logcorrelator-tmpfiles.conf %{buildroot}/usr/lib/tmpfiles.d/logcorrelator.conf
|
||||||
|
|
||||||
%post
|
%post
|
||||||
# Create logcorrelator user and group
|
# Create logcorrelator user and group
|
||||||
if ! getent group logcorrelator >/dev/null 2>&1; then
|
if ! getent group logcorrelator >/dev/null 2>&1; then
|
||||||
@ -78,18 +82,16 @@ fi
|
|||||||
# Create directories
|
# Create directories
|
||||||
mkdir -p /var/lib/logcorrelator
|
mkdir -p /var/lib/logcorrelator
|
||||||
mkdir -p /var/log/logcorrelator
|
mkdir -p /var/log/logcorrelator
|
||||||
mkdir -p /var/run/logcorrelator
|
# Note: /var/run/logcorrelator est géré par RuntimeDirectory= (systemd) et tmpfiles.d
|
||||||
|
|
||||||
# Set ownership
|
# Set ownership
|
||||||
chown -R logcorrelator:logcorrelator /var/lib/logcorrelator
|
chown -R logcorrelator:logcorrelator /var/lib/logcorrelator
|
||||||
chown -R logcorrelator:logcorrelator /var/log/logcorrelator
|
chown -R logcorrelator:logcorrelator /var/log/logcorrelator
|
||||||
chown -R logcorrelator:logcorrelator /var/run/logcorrelator
|
|
||||||
chown -R logcorrelator:logcorrelator /etc/logcorrelator
|
chown -R logcorrelator:logcorrelator /etc/logcorrelator
|
||||||
|
|
||||||
# Set permissions
|
# Set permissions
|
||||||
chmod 750 /var/lib/logcorrelator
|
chmod 750 /var/lib/logcorrelator
|
||||||
chmod 750 /var/log/logcorrelator
|
chmod 750 /var/log/logcorrelator
|
||||||
chmod 755 /var/run/logcorrelator
|
|
||||||
chmod 750 /etc/logcorrelator
|
chmod 750 /etc/logcorrelator
|
||||||
|
|
||||||
# Copy default config if not exists
|
# Copy default config if not exists
|
||||||
@ -99,9 +101,11 @@ if [ ! -f /etc/logcorrelator/logcorrelator.yml ]; then
|
|||||||
chmod 640 /etc/logcorrelator/logcorrelator.yml
|
chmod 640 /etc/logcorrelator/logcorrelator.yml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reload systemd
|
# Reload systemd and apply tmpfiles
|
||||||
if [ -x /bin/systemctl ]; then
|
if [ -x /bin/systemctl ]; then
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
# Crée /run/logcorrelator immédiatement avec le bon propriétaire
|
||||||
|
systemd-tmpfiles --create /usr/lib/tmpfiles.d/logcorrelator.conf 2>/dev/null || true
|
||||||
systemctl enable logcorrelator.service
|
systemctl enable logcorrelator.service
|
||||||
systemctl start logcorrelator.service
|
systemctl start logcorrelator.service
|
||||||
fi
|
fi
|
||||||
@ -135,12 +139,24 @@ exit 0
|
|||||||
%config(noreplace) /etc/logcorrelator/logcorrelator.yml
|
%config(noreplace) /etc/logcorrelator/logcorrelator.yml
|
||||||
/etc/logcorrelator/logcorrelator.yml.example
|
/etc/logcorrelator/logcorrelator.yml.example
|
||||||
/var/log/logcorrelator
|
/var/log/logcorrelator
|
||||||
/var/run/logcorrelator
|
|
||||||
/var/lib/logcorrelator
|
/var/lib/logcorrelator
|
||||||
/etc/systemd/system/logcorrelator.service
|
/etc/systemd/system/logcorrelator.service
|
||||||
|
/usr/lib/tmpfiles.d/logcorrelator.conf
|
||||||
%config(noreplace) /etc/logrotate.d/logcorrelator
|
%config(noreplace) /etc/logrotate.d/logcorrelator
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Thu Mar 05 2026 logcorrelator <dev@example.com> - 1.1.13-1
|
||||||
|
- Fix: Unix sockets ne passent plus en root:root lors des restarts du service
|
||||||
|
- Fix: Ajout de RuntimeDirectory=logcorrelator dans le service systemd (systemd gère /run/logcorrelator avec le bon propriétaire à chaque démarrage/restart)
|
||||||
|
- Fix: Ajout de /usr/lib/tmpfiles.d/logcorrelator.conf pour recréer /run/logcorrelator au boot
|
||||||
|
- Chore: Retrait de /var/run/logcorrelator du RPM %files (géré par tmpfiles.d)
|
||||||
|
- Fix(correlation): emitPendingOrphans - corruption de slice lors de l expiration simultanée de plusieurs orphelins pour la même clé (slice aliasing bug, émissions en double)
|
||||||
|
- Fix(correlation): rotateOldestA - l événement rotaté était perdu silencieusement même avec ApacheAlwaysEmit=true (retourne désormais le CorrelatedLog)
|
||||||
|
- Fix(correlation): Keep-Alive cassé dans le chemin pending-orphan-then-B - le B event n était pas bufferisé en mode one_to_many, bloquant la corrélation des requêtes A2+ du même Keep-Alive
|
||||||
|
- Chore(correlation): suppression du champ mort timer *time.Timer dans pendingOrphan
|
||||||
|
- Feat(correlation): ajout de keepalive_seq dans les logs orphelins pour faciliter le debug (numéro de requête dans la connexion Keep-Alive, 1-based)
|
||||||
|
- Test: 4 nouveaux tests de non-régression pour les bugs de corrélation
|
||||||
|
|
||||||
* Thu Mar 05 2026 logcorrelator <dev@example.com> - 1.1.12-1
|
* Thu Mar 05 2026 logcorrelator <dev@example.com> - 1.1.12-1
|
||||||
- Feat: New config directive include_dest_ports - restrict correlation to specific destination ports
|
- Feat: New config directive include_dest_ports - restrict correlation to specific destination ports
|
||||||
- Feat: If include_dest_ports is non-empty, events on unlisted ports are silently ignored (not correlated, not emitted as orphan)
|
- Feat: If include_dest_ports is non-empty, events on unlisted ports are silently ignored (not correlated, not emitted as orphan)
|
||||||
|
|||||||
Reference in New Issue
Block a user