Skip to content

Backlog/v12 forwarder collector fixes - #2507

Merged
yllada merged 8 commits into
release/v12.0.0from
backlog/v12_forwarder_collector_fixes
Aug 24, 2026
Merged

Backlog/v12 forwarder collector fixes#2507
yllada merged 8 commits into
release/v12.0.0from
backlog/v12_forwarder_collector_fixes

Conversation

@yllada

@yllada yllada commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Started from one bogus error on disable and ended up fixing the pattern behind it.

What it fixes

Before Now
Disable logged error closing tcp listener: use of closed network connection Clean close, no error
Every client disconnect logged 2× ERROR 1× INFO
Config changes on an enabled HTTP integration were silently ignored Applied without disabling first
A typo in an integration path killed the whole HTTP collector That integration is skipped, the rest keep running
Disable with traffic leaked a goroutine + fd per connection Connections released
Stop could leave ports bound → address already in use All ports released, immediate restart works
Listener/context fields read without the mutex (syslog, netflow, file) Captured as locals under the lock

Root cause

A long-running goroutine read shared struct fields without the mutex the reconcile
path writes under, and no one owned the socket. Same mistake in four files.

Validation

Built and installed against a live backend. TCP, UDP and HTTP: enable, send logs,
disable. Logs arrived, ports released, immediate restart clean.

yllada added 8 commits August 24, 2026 10:04
The HTTP/HTTPS collector lived in a top-level listeners package while the
syslog, netflow and file collectors lived under collector/. It implements the
same Collector interface and already depended on collector/configwatcher and
collector/schema, so it belongs beside its siblings.

The package name also claimed more than it held: it contained one collector,
while syslog and netflow have listeners too.

No behavior change. Only the package clause and the two import sites move; the
redundant import alias in collector.go and config.go is gone as a result.
…eld reads

Disabling an integration logged a bogus error:

  error closing tcp listener: close tcp [::]:1470: use of closed network connection

disableTCP closes the listener and the accept goroutine's defer closed it a
second time. The owner's close had already succeeded, so the message was a
lie about a failure that never happened.

The deferred close is removed and ownership is now explicit: enableTCP and
disableTCP open and close, the goroutine only accepts. The panic path is the
one exception and calls disableTCP, because a listener nobody is accepting on
must go down too; leaving it open would keep accepting peers whose logs are
never read, losing data silently instead of failing loudly.

The more dangerous defect was the goroutine reading inst.TCPListener.Listener,
.CTX and .TLSEnabled with no mutex while enableTCP writes them under one. On
the port-change path (disable, 200ms sleep, enable) a delayed goroutine could
close the newly installed listener, leaving IsEnabled true on a dead socket
with nothing in the log. The goroutine now captures those values as locals
while the mutex is still held, so it can only ever touch its own listener.

Connections receive that context as a parameter instead of re-reading the
field, so a connection accepted by a retired listener can no longer latch onto
the context of its replacement and keep forwarding after the change.

Verified against a live backend: enable/disable on TCP and UDP now log only
the listening and closed lines, with the port released and the process alive.
Disabling an integration with traffic in flight leaked a goroutine and a file
descriptor per connection, permanently and with nothing in the log.

msgChannel is unbuffered and its only reader, handleMessage, returns as soon
as the context is cancelled. An unguarded send therefore parked readLoop
forever on any message still in flight, so it never reached its ctx.Done
check, handleConnectionTCP never returned, and its deferred Close never ran.
The connection stayed established from the peer's side and the descriptor
stayed counted against the process.

The send now selects on ctx.Done, the same idiom handleMessage already used
for its own send to the queue.

That covers a reader parked on the send. A reader parked on the read had the
same outcome: the plain-TCP path clears its read deadline after the TLS probe,
so cancellation could not wake it and it waited until the peer spoke or hung
up. A watchdog bounded by the handler's own lifetime now moves the read
deadline into the past on cancellation, which makes the in-flight read fail
immediately so the handler returns and its defer closes the connection. It
only moves the deadline: the handler's defer remains the sole owner of Close.

The TLS path is deliberately left without a watchdog. readLoop resets a 30s
deadline at the top of every iteration there, so a watchdog would be
best-effort rather than a guarantee; a TLS connection parked on a read is
already bounded to 30s. Making it a guarantee means reworking that idle
timeout, which is observable behavior and belongs in its own change.
…errors

Every normal client disconnect produced two ERROR lines. On a forwarder whose
clients reconnect routinely this was the dominant source of error volume, and
it hid the failures that mattered.

Two causes. framing.go both logged and returned at every failure point, so the
caller logged the same event again. And it wrapped its cause with %w, which
defeats the caller's classification: a direct == comparison against io.EOF and
a type assertion to net.Error never match a wrapped error, so both Info
branches in readLoop were unreachable and every disconnect fell through to
ErrorF.

framing.go is a parsing helper and no longer logs at all; it returns the
wrapped error and readLoop decides the level, which is the only place that
knows the peer and whether the context is cancelled. Classification now uses
errors.Is and errors.As, which walk the wrap chain.

readLoop also checks ctx.Err() before classifying. A read that fails because
we are shutting down is an orderly release, not a transport fault, and
checking it first keeps it out of the error branch.

io.ErrUnexpectedEOF deliberately stays at ERROR. A peer that announced N
octets, sent fewer and vanished broke its own framing and the partial message
is discarded; on a SIEM that is data loss and the operator needs to see it.

Malformed frames, invalid lengths and unknown framing bytes keep ERROR and
lose only the duplicate.

Verified against a live backend: three clean disconnects produced three Info
lines where the previous build produced six errors.
…asing the mutex

enablePort wrote nc.listener, nc.ctx and nc.cancel under the mutex and the
read goroutine then read those same fields with no lock at all, which is a
data race the race detector flags.

The window is reachable through the port-change path, which disables, sleeps
200ms and enables again. A delayed goroutine reads the newly installed
listener and the new, uncancelled context, so its return path becomes
unreachable and two goroutines end up reading the same UDP socket with
datagrams split nondeterministically between them.

It is less severe than the equivalent defect in syslog because nothing closes
the new listener, so there is no dead socket, and the next disable cancels the
new context and stops both. It is the same mistake all the same.

The goroutine now uses locals captured while the mutex is held. The fields are
still assigned because disablePort needs them to cancel and close.

Note for anyone searching production logs: disablePort discards the error from
Close, so netflow never emitted a close error either way. Absence of error
lines here was never evidence of health.
…s file handle

The file handle lived in a shared struct field that four places closed: Stop,
reconcile and stopWatchersForDataType under fc.mu, and tailFile with no lock,
so the mutex protected nothing.

tailFile also had no deferred close, so both of its returns abandoned the
handle. A reconcile that observed w.file as nil, because tailFile sat between
nilling it and reopening it, deleted the watcher from the map and left the
goroutine to open a descriptor nothing could ever reach again.

That handle is not leaked forever: os.newFile installs a runtime finalizer, so
the runtime closes it once it becomes unreachable. The real defect is a close
that depends on the garbage collector with no bound on when it happens. A
mostly idle forwarder allocates almost nothing, so the descriptor and the
unlinked inode behind it stay pinned, and under descriptor pressure os.Open
fails long before any sweep.

The handle and the offset are now locals of tailFile with a deferred close, so
there is exactly one owner and every exit path releases it, panic included.
The external closers only cancel the context now.

Cancellation alone is sufficient: the loop never blocks in a read, and it
checks ctx.Done at the top of the outer loop and before every ReadString. The
old external close never accelerated anything, it only made the next Stat fail.
Worst-case release moves from immediate to one pollInterval, one second.

Tailing, rotation detection and truncation detection are unchanged.
…g collectors

StopAll called Stop on every collector and returned without waiting for
anything. A reconcile already in flight could therefore reopen a socket after
Stop had passed, and the process exited with the port still bound, which
surfaces on the next start as "address already in use".

Shutdown is now three strictly ordered phases: cancel the watch context, wait
for every collector to report that its Start returned, and only then call
Stop. The wait is what makes the ordering hold, and it is meaningful because
every collector's Start ends in configwatcher.Watch, which blocks while it
watches. The return of Start is therefore the signal that no reconcile is
still running. That contract is now documented on Watch, since the shutdown
path depends on it from another package.

The wait is bounded at 10s for the whole phase, shared across collectors
rather than per collector, so several wedged collectors cost one timeout
instead of several. It covers the slowest legitimate reconcile: netflow's
200ms sleep, syslog's 200ms per protocol, and http's 5s graceful shutdown. On
expiry the collectors are stopped anyway, because leaving one alive is worse
than the race, and the final log line says the shutdown was degraded instead
of reporting it as clean.

A collector that already returned is checked without blocking first, so it can
never be reported as late; select picks at random when several cases are ready,
and the timer being ready must not misattribute the delay.

Watch also checks for cancellation before its initial reconcile. That reconcile
opens sockets, and a collector whose goroutine had not been scheduled yet when
StopAll ran would otherwise open them after shutdown had already passed.

Verified against a live backend: four collectors quiesced in 0.35ms, every port
released, and an immediate restart rebound with no error.
…ration paths

reconcile keyed only on name and protocol and restarted an instance only when
the key was absent. Changing port, path, bind or auth on an integration that
stayed enabled produced the same key, so the freshly built config was silently
discarded and the old server kept serving with the old settings, with nothing
in the log. The workaround was to disable and re-enable the integration.

The config fields now live in a separate comparable struct embedded in the
instance, so a plain == covers all of them and adding a field to that struct
is picked up for free. On a difference the instance is restarted and the
transition is logged so an operator can see the edit was applied.

Everything that must change goes down before anything comes up. Restarting one
key at a time cannot handle two integrations swapping ports: whichever is
processed first fails to bind because the other still holds the port, and stays
down until the next reconcile, up to the five minute fallback interval.

mux.Handle panics on a malformed pattern, and the path comes straight from
operator config with no validation. A trailing space, a missing leading slash
or a stray brace killed the whole HTTP collector: the panic unwound out of
reconcile and Watch, so it never reconciled again and no later config change
was applied. Which integrations had already started when it fired depended on
Go's random map iteration order, so the same configuration produced different
outcomes on different boots.

Paths are now validated before use. Whitespace and a missing leading slash are
repaired and logged, since intent is unambiguous there and it also fixes a
trailing newline silently producing an unreachable route. Anything else is
rejected with the reason, that integration alone is skipped, and the collector
keeps reconciling. Validation trial-registers the pattern on a throwaway mux
under recover, so it matches what ServeMux accepts by construction instead of
duplicating its grammar.

shutdownInstance now force-closes after a failed graceful shutdown, so a
request still in flight cannot keep being served under the previous auth
settings for the remainder of its read timeout.

Verified against a live backend: an in-place path change moved the endpoint
without disabling the integration, and a malformed path was rejected by name
with no panic while the collector applied the next change normally.
@github-actions

Copy link
Copy Markdown

✅ AI review — Clean

No issues detected in this diff.

architecture (gemini-3-flash-lite) — clean

Summary: Refactoring of collector lifecycle management, HTTP listeners package structure, and connection handling in forwarder.

No findings.

bugs (gemini-3-flash-lite) — clean

Summary: Refactored collector lifecycle, stopping sequences, and moved http package; no concrete bugs found.

No findings.

security (gemini-3-flash-lite) — clean

Summary: Refactored collectors and listeners in forwarder package with improved lifecycle handling, zero vulnerabilities introduced.

No findings.

🔴 go-deps — pending updates

🔍 Discovered 30 Go projects

📦 Dependencies with updates available:

  📁 ./plugins/crowdstrike:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/azure:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/soc-ai:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/events:
     - github.com/threatwinds/go-sdk: v1.1.27-0.20260811073440-251cb9d842cd → v1.1.31

  📁 ./plugins/gcp:
     - cloud.google.com/go/pubsub: v1.51.0 → v1.51.1
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/bitdefender:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/o365:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/rule-flood-guard:
     - github.com/threatwinds/go-sdk: v1.1.27-0.20260811073440-251cb9d842cd → v1.1.31

  📁 ./plugins/stats:
     - github.com/threatwinds/go-sdk: v1.1.27-0.20260811073440-251cb9d842cd → v1.1.31

  📁 ./plugins/feeds:
     - github.com/threatwinds/go-sdk: v1.1.27-0.20260811073440-251cb9d842cd → v1.1.31

  📁 ./plugins/geolocation:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/playground:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/soar:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/sophos:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./plugins/alerts:
     - github.com/threatwinds/go-sdk: v1.1.27-0.20260811073440-251cb9d842cd → v1.1.31

  📁 ./plugins/aws:
     - github.com/aws/aws-sdk-go-v2: v1.43.6 → v1.43.7
     - github.com/aws/aws-sdk-go-v2/config: v1.32.37 → v1.32.38
     - github.com/aws/aws-sdk-go-v2/credentials: v1.19.36 → v1.19.37
     - github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs: v1.82.2 → v1.82.3
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./log-input:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31
     - google.golang.org/grpc: v1.83.0 → v1.83.1

  📁 ./backend:
     - cloud.google.com/go/pubsub: v1.51.0 → v1.51.1
     - github.com/aws/aws-sdk-go-v2/config: v1.32.36 → v1.32.38
     - github.com/aws/aws-sdk-go-v2/credentials: v1.19.35 → v1.19.37
     - github.com/aws/aws-sdk-go-v2/service/sts: v1.45.5 → v1.45.7
     - google.golang.org/grpc: v1.83.0 → v1.83.1

  📁 ./collectors/collector:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31
     - google.golang.org/grpc: v1.83.0 → v1.83.1

  📁 ./collectors/forwarder:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31
     - google.golang.org/grpc: v1.83.0 → v1.83.1

  📁 ./collectors/as400:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31
     - google.golang.org/grpc: v1.83.0 → v1.83.1

  📁 ./collectors/utmstack:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31
     - google.golang.org/grpc: v1.83.0 → v1.83.1

  📁 ./tools/rulecheck:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31

  📁 ./agent-manager:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31
     - google.golang.org/grpc: v1.83.0 → v1.83.1

  📁 ./agent:
     - github.com/threatwinds/go-sdk: v1.1.28 → v1.1.31
     - google.golang.org/grpc: v1.83.0 → v1.83.1

❌ Please update dependencies before merging.

@yllada
yllada merged commit 384cdd9 into release/v12.0.0 Aug 24, 2026
1 check passed
@yllada
yllada deleted the backlog/v12_forwarder_collector_fixes branch August 24, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant