556 lines
14 KiB
Go
556 lines
14 KiB
Go
// FILE: logwisp/src/internal/sink/tcp.go
|
|
package sink
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"logwisp/src/internal/config"
|
|
"logwisp/src/internal/core"
|
|
"logwisp/src/internal/format"
|
|
"logwisp/src/internal/network"
|
|
"logwisp/src/internal/session"
|
|
|
|
"github.com/lixenwraith/log"
|
|
"github.com/lixenwraith/log/compat"
|
|
"github.com/panjf2000/gnet/v2"
|
|
)
|
|
|
|
// TCPSink streams log entries to connected TCP clients.
|
|
type TCPSink struct {
|
|
// Configuration
|
|
config *config.TCPSinkOptions
|
|
|
|
// Network
|
|
server *tcpServer
|
|
engine *gnet.Engine
|
|
engineMu sync.Mutex
|
|
netLimiter *network.NetLimiter
|
|
|
|
// Application
|
|
input chan core.LogEntry
|
|
formatter format.Formatter
|
|
logger *log.Logger
|
|
|
|
// Runtime
|
|
done chan struct{}
|
|
wg sync.WaitGroup
|
|
startTime time.Time
|
|
|
|
// Security & Session
|
|
sessionManager *session.Manager
|
|
|
|
// Statistics
|
|
activeConns atomic.Int64
|
|
totalProcessed atomic.Uint64
|
|
lastProcessed atomic.Value // time.Time
|
|
|
|
// Error tracking
|
|
writeErrors atomic.Uint64
|
|
consecutiveWriteErrors map[gnet.Conn]int
|
|
errorMu sync.Mutex
|
|
}
|
|
|
|
// TCPConfig holds configuration for the TCPSink.
|
|
type TCPConfig struct {
|
|
Host string
|
|
Port int64
|
|
BufferSize int64
|
|
Heartbeat *config.HeartbeatConfig
|
|
ACL *config.ACLConfig
|
|
}
|
|
|
|
// NewTCPSink creates a new TCP streaming sink.
|
|
func NewTCPSink(opts *config.TCPSinkOptions, logger *log.Logger, formatter format.Formatter) (*TCPSink, error) {
|
|
if opts == nil {
|
|
return nil, fmt.Errorf("TCP sink options cannot be nil")
|
|
}
|
|
|
|
t := &TCPSink{
|
|
config: opts,
|
|
input: make(chan core.LogEntry, opts.BufferSize),
|
|
done: make(chan struct{}),
|
|
startTime: time.Now(),
|
|
logger: logger,
|
|
formatter: formatter,
|
|
consecutiveWriteErrors: make(map[gnet.Conn]int),
|
|
sessionManager: session.NewManager(30 * time.Minute),
|
|
}
|
|
t.lastProcessed.Store(time.Time{})
|
|
|
|
// Initialize net limiter with pointer
|
|
if opts.ACL != nil && (opts.ACL.Enabled ||
|
|
len(opts.ACL.IPWhitelist) > 0 ||
|
|
len(opts.ACL.IPBlacklist) > 0) {
|
|
t.netLimiter = network.NewNetLimiter(opts.ACL, logger)
|
|
}
|
|
|
|
return t, nil
|
|
}
|
|
|
|
// Input returns the channel for sending log entries.
|
|
func (t *TCPSink) Input() chan<- core.LogEntry {
|
|
return t.input
|
|
}
|
|
|
|
// Start initializes the TCP server and begins the broadcast loop.
|
|
func (t *TCPSink) Start(ctx context.Context) error {
|
|
t.server = &tcpServer{
|
|
sink: t,
|
|
clients: make(map[gnet.Conn]*tcpClient),
|
|
}
|
|
|
|
// Register expiry callback
|
|
t.sessionManager.RegisterExpiryCallback("tcp_sink", func(sessionID, remoteAddr string) {
|
|
t.handleSessionExpiry(sessionID, remoteAddr)
|
|
})
|
|
|
|
// Start log broadcast loop
|
|
t.wg.Add(1)
|
|
go func() {
|
|
defer t.wg.Done()
|
|
t.broadcastLoop(ctx)
|
|
}()
|
|
|
|
// Configure gnet options
|
|
addr := fmt.Sprintf("tcp://%s:%d", t.config.Host, t.config.Port)
|
|
|
|
// Create a gnet adapter using the existing logger instance
|
|
gnetLogger := compat.NewGnetAdapter(t.logger)
|
|
|
|
var opts []gnet.Option
|
|
opts = append(opts,
|
|
gnet.WithLogger(gnetLogger),
|
|
gnet.WithMulticore(true),
|
|
gnet.WithReusePort(true),
|
|
)
|
|
|
|
// Start gnet server
|
|
errChan := make(chan error, 1)
|
|
go func() {
|
|
t.logger.Info("msg", "Starting TCP server",
|
|
"component", "tcp_sink",
|
|
"port", t.config.Port)
|
|
|
|
err := gnet.Run(t.server, addr, opts...)
|
|
if err != nil {
|
|
t.logger.Error("msg", "TCP server failed",
|
|
"component", "tcp_sink",
|
|
"port", t.config.Port,
|
|
"error", err)
|
|
}
|
|
errChan <- err
|
|
}()
|
|
|
|
// Monitor context for shutdown
|
|
go func() {
|
|
<-ctx.Done()
|
|
t.engineMu.Lock()
|
|
if t.engine != nil {
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
(*t.engine).Stop(shutdownCtx)
|
|
}
|
|
t.engineMu.Unlock()
|
|
}()
|
|
|
|
// Wait briefly for server to start or fail
|
|
select {
|
|
case err := <-errChan:
|
|
// Server failed immediately
|
|
close(t.done)
|
|
t.wg.Wait()
|
|
return err
|
|
case <-time.After(100 * time.Millisecond):
|
|
// Server started successfully
|
|
t.logger.Info("msg", "TCP server started", "port", t.config.Port)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Stop gracefully shuts down the TCP server.
|
|
func (t *TCPSink) Stop() {
|
|
t.logger.Info("msg", "Stopping TCP sink")
|
|
|
|
// Unregister callback
|
|
t.sessionManager.UnregisterExpiryCallback("tcp_sink")
|
|
|
|
// Signal broadcast loop to stop
|
|
close(t.done)
|
|
|
|
// Stop gnet engine if running
|
|
t.engineMu.Lock()
|
|
engine := t.engine
|
|
t.engineMu.Unlock()
|
|
|
|
if engine != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
(*engine).Stop(ctx) // Dereference the pointer
|
|
}
|
|
|
|
// Wait for broadcast loop to finish
|
|
t.wg.Wait()
|
|
|
|
// Stop session manager
|
|
if t.sessionManager != nil {
|
|
t.sessionManager.Stop()
|
|
}
|
|
|
|
t.logger.Info("msg", "TCP sink stopped")
|
|
}
|
|
|
|
// GetStats returns the sink's statistics.
|
|
func (t *TCPSink) GetStats() SinkStats {
|
|
lastProc, _ := t.lastProcessed.Load().(time.Time)
|
|
|
|
var netLimitStats map[string]any
|
|
if t.netLimiter != nil {
|
|
netLimitStats = t.netLimiter.GetStats()
|
|
}
|
|
|
|
var sessionStats map[string]any
|
|
if t.sessionManager != nil {
|
|
sessionStats = t.sessionManager.GetStats()
|
|
}
|
|
|
|
return SinkStats{
|
|
Type: "tcp",
|
|
TotalProcessed: t.totalProcessed.Load(),
|
|
ActiveConnections: t.activeConns.Load(),
|
|
StartTime: t.startTime,
|
|
LastProcessed: lastProc,
|
|
Details: map[string]any{
|
|
"port": t.config.Port,
|
|
"buffer_size": t.config.BufferSize,
|
|
"net_limit": netLimitStats,
|
|
"sessions": sessionStats,
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetActiveConnections returns the current number of active connections.
|
|
func (t *TCPSink) GetActiveConnections() int64 {
|
|
return t.activeConns.Load()
|
|
}
|
|
|
|
// tcpServer implements the gnet.EventHandler interface for the TCP sink.
|
|
type tcpServer struct {
|
|
gnet.BuiltinEventEngine
|
|
sink *TCPSink
|
|
clients map[gnet.Conn]*tcpClient
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// tcpClient represents a connected TCP client.
|
|
type tcpClient struct {
|
|
conn gnet.Conn
|
|
buffer bytes.Buffer
|
|
sessionID string
|
|
}
|
|
|
|
// broadcastLoop manages the central broadcasting of log entries to all clients.
|
|
func (t *TCPSink) broadcastLoop(ctx context.Context) {
|
|
var ticker *time.Ticker
|
|
var tickerChan <-chan time.Time
|
|
|
|
if t.config.Heartbeat != nil && t.config.Heartbeat.Enabled {
|
|
ticker = time.NewTicker(time.Duration(t.config.Heartbeat.IntervalMS) * time.Millisecond)
|
|
tickerChan = ticker.C
|
|
defer ticker.Stop()
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case entry, ok := <-t.input:
|
|
if !ok {
|
|
return
|
|
}
|
|
t.totalProcessed.Add(1)
|
|
t.lastProcessed.Store(time.Now())
|
|
|
|
data, err := t.formatter.Format(entry)
|
|
if err != nil {
|
|
t.logger.Error("msg", "Failed to format log entry",
|
|
"component", "tcp_sink",
|
|
"error", err,
|
|
"entry_source", entry.Source)
|
|
continue
|
|
}
|
|
t.broadcastData(data)
|
|
|
|
case <-tickerChan:
|
|
heartbeatEntry := t.createHeartbeatEntry()
|
|
data, err := t.formatter.Format(heartbeatEntry)
|
|
if err != nil {
|
|
t.logger.Error("msg", "Failed to format heartbeat",
|
|
"component", "tcp_sink",
|
|
"error", err)
|
|
continue
|
|
}
|
|
t.broadcastData(data)
|
|
|
|
case <-t.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// OnBoot is called when the server starts.
|
|
func (s *tcpServer) OnBoot(eng gnet.Engine) gnet.Action {
|
|
// Store engine reference for shutdown
|
|
s.sink.engineMu.Lock()
|
|
s.sink.engine = &eng
|
|
s.sink.engineMu.Unlock()
|
|
|
|
s.sink.logger.Debug("msg", "TCP server booted",
|
|
"component", "tcp_sink",
|
|
"port", s.sink.config.Port)
|
|
return gnet.None
|
|
}
|
|
|
|
// OnOpen is called when a new connection is established.
|
|
func (s *tcpServer) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) {
|
|
remoteAddr := c.RemoteAddr()
|
|
remoteAddrStr := remoteAddr.String()
|
|
s.sink.logger.Debug("msg", "TCP connection attempt", "remote_addr", remoteAddrStr)
|
|
|
|
// Reject IPv6 connections
|
|
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
|
|
if tcpAddr.IP.To4() == nil {
|
|
return []byte("IPv4-only (IPv6 not supported)\n"), gnet.Close
|
|
}
|
|
}
|
|
|
|
// Check net limit
|
|
if s.sink.netLimiter != nil {
|
|
tcpAddr, err := net.ResolveTCPAddr("tcp", remoteAddrStr)
|
|
if err != nil {
|
|
s.sink.logger.Warn("msg", "Failed to parse TCP address",
|
|
"remote_addr", remoteAddrStr,
|
|
"error", err)
|
|
return nil, gnet.Close
|
|
}
|
|
|
|
if !s.sink.netLimiter.CheckTCP(tcpAddr) {
|
|
s.sink.logger.Warn("msg", "TCP connection net limited",
|
|
"remote_addr", remoteAddrStr)
|
|
return nil, gnet.Close
|
|
}
|
|
|
|
// Register connection post-establishment
|
|
s.sink.netLimiter.RegisterConnection(remoteAddrStr)
|
|
}
|
|
|
|
// Create session for tracking
|
|
sess := s.sink.sessionManager.CreateSession(remoteAddrStr, "tcp_sink", nil)
|
|
|
|
// TCP Sink accepts all connections without authentication
|
|
client := &tcpClient{
|
|
conn: c,
|
|
buffer: bytes.Buffer{},
|
|
sessionID: sess.ID,
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.clients[c] = client
|
|
s.mu.Unlock()
|
|
|
|
newCount := s.sink.activeConns.Add(1)
|
|
s.sink.logger.Debug("msg", "TCP connection opened",
|
|
"remote_addr", remoteAddr,
|
|
"session_id", sess.ID,
|
|
"active_connections", newCount)
|
|
|
|
return nil, gnet.None
|
|
}
|
|
|
|
// OnClose is called when a connection is closed.
|
|
func (s *tcpServer) OnClose(c gnet.Conn, err error) gnet.Action {
|
|
remoteAddrStr := c.RemoteAddr().String()
|
|
|
|
// Get client to retrieve session ID
|
|
s.mu.RLock()
|
|
client, exists := s.clients[c]
|
|
s.mu.RUnlock()
|
|
|
|
if exists && client.sessionID != "" {
|
|
// Remove session
|
|
s.sink.sessionManager.RemoveSession(client.sessionID)
|
|
s.sink.logger.Debug("msg", "Session removed",
|
|
"component", "tcp_sink",
|
|
"session_id", client.sessionID,
|
|
"remote_addr", remoteAddrStr)
|
|
}
|
|
|
|
// Remove client state
|
|
s.mu.Lock()
|
|
delete(s.clients, c)
|
|
s.mu.Unlock()
|
|
|
|
// Clean up write error tracking
|
|
s.sink.errorMu.Lock()
|
|
delete(s.sink.consecutiveWriteErrors, c)
|
|
s.sink.errorMu.Unlock()
|
|
|
|
// Release connection
|
|
if s.sink.netLimiter != nil {
|
|
s.sink.netLimiter.ReleaseConnection(remoteAddrStr)
|
|
}
|
|
|
|
newCount := s.sink.activeConns.Add(-1)
|
|
s.sink.logger.Debug("msg", "TCP connection closed",
|
|
"remote_addr", remoteAddrStr,
|
|
"active_connections", newCount,
|
|
"error", err)
|
|
return gnet.None
|
|
}
|
|
|
|
// OnTraffic is called when data is received from a connection.
|
|
func (s *tcpServer) OnTraffic(c gnet.Conn) gnet.Action {
|
|
s.mu.RLock()
|
|
client, exists := s.clients[c]
|
|
s.mu.RUnlock()
|
|
|
|
// Update session activity when client sends data
|
|
if exists && client.sessionID != "" {
|
|
s.sink.sessionManager.UpdateActivity(client.sessionID)
|
|
}
|
|
|
|
// TCP Sink doesn't expect any data from clients, discard all
|
|
c.Discard(-1)
|
|
return gnet.None
|
|
}
|
|
|
|
// handleSessionExpiry is the callback for cleaning up expired sessions.
|
|
func (t *TCPSink) handleSessionExpiry(sessionID, remoteAddr string) {
|
|
t.server.mu.RLock()
|
|
defer t.server.mu.RUnlock()
|
|
|
|
// Find connection by session ID
|
|
for conn, client := range t.server.clients {
|
|
if client.sessionID == sessionID {
|
|
t.logger.Info("msg", "Closing expired session connection",
|
|
"component", "tcp_sink",
|
|
"session_id", sessionID,
|
|
"remote_addr", remoteAddr)
|
|
|
|
// Close connection
|
|
conn.Close()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// broadcastData sends a formatted byte slice to all connected clients.
|
|
func (t *TCPSink) broadcastData(data []byte) {
|
|
t.server.mu.RLock()
|
|
defer t.server.mu.RUnlock()
|
|
|
|
// Track clients to remove after iteration
|
|
var staleClients []gnet.Conn
|
|
|
|
for conn, client := range t.server.clients {
|
|
// Update session activity before sending data
|
|
if client.sessionID != "" {
|
|
if !t.sessionManager.IsSessionActive(client.sessionID) {
|
|
// Session expired, mark for cleanup
|
|
staleClients = append(staleClients, conn)
|
|
continue
|
|
}
|
|
t.sessionManager.UpdateActivity(client.sessionID)
|
|
}
|
|
|
|
conn.AsyncWrite(data, func(c gnet.Conn, err error) error {
|
|
if err != nil {
|
|
t.writeErrors.Add(1)
|
|
t.handleWriteError(c, err)
|
|
} else {
|
|
// Reset consecutive error count on success
|
|
t.errorMu.Lock()
|
|
delete(t.consecutiveWriteErrors, c)
|
|
t.errorMu.Unlock()
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Clean up stale connections outside the read lock
|
|
if len(staleClients) > 0 {
|
|
go t.cleanupStaleConnections(staleClients)
|
|
}
|
|
}
|
|
|
|
// handleWriteError manages errors during async writes, closing faulty connections.
|
|
func (t *TCPSink) handleWriteError(c gnet.Conn, err error) {
|
|
remoteAddrStr := c.RemoteAddr().String()
|
|
|
|
t.errorMu.Lock()
|
|
defer t.errorMu.Unlock()
|
|
|
|
// Track consecutive errors per connection
|
|
if t.consecutiveWriteErrors == nil {
|
|
t.consecutiveWriteErrors = make(map[gnet.Conn]int)
|
|
}
|
|
|
|
t.consecutiveWriteErrors[c]++
|
|
errorCount := t.consecutiveWriteErrors[c]
|
|
|
|
t.logger.Debug("msg", "AsyncWrite error",
|
|
"component", "tcp_sink",
|
|
"remote_addr", remoteAddrStr,
|
|
"error", err,
|
|
"consecutive_errors", errorCount)
|
|
|
|
// Close connection after 3 consecutive write errors
|
|
if errorCount >= 3 {
|
|
t.logger.Warn("msg", "Closing connection due to repeated write errors",
|
|
"component", "tcp_sink",
|
|
"remote_addr", remoteAddrStr,
|
|
"error_count", errorCount)
|
|
delete(t.consecutiveWriteErrors, c)
|
|
c.Close()
|
|
}
|
|
}
|
|
|
|
// createHeartbeatEntry generates a new heartbeat log entry.
|
|
func (t *TCPSink) createHeartbeatEntry() core.LogEntry {
|
|
message := "heartbeat"
|
|
|
|
// Build fields for heartbeat metadata
|
|
fields := make(map[string]any)
|
|
fields["type"] = "heartbeat"
|
|
|
|
if t.config.Heartbeat.IncludeStats {
|
|
fields["active_connections"] = t.activeConns.Load()
|
|
fields["uptime_seconds"] = int64(time.Since(t.startTime).Seconds())
|
|
}
|
|
|
|
fieldsJSON, _ := json.Marshal(fields)
|
|
|
|
return core.LogEntry{
|
|
Time: time.Now(),
|
|
Source: "logwisp-tcp",
|
|
Level: "INFO",
|
|
Message: message,
|
|
Fields: fieldsJSON,
|
|
}
|
|
}
|
|
|
|
// cleanupStaleConnections closes connections associated with expired sessions.
|
|
func (t *TCPSink) cleanupStaleConnections(staleConns []gnet.Conn) {
|
|
for _, conn := range staleConns {
|
|
t.logger.Info("msg", "Closing stale connection",
|
|
"component", "tcp_sink",
|
|
"remote_addr", conn.RemoteAddr().String())
|
|
conn.Close()
|
|
}
|
|
} |