537 lines
12 KiB
Go
537 lines
12 KiB
Go
package http
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"logwisp/src/internal/config"
|
|
"logwisp/src/internal/core"
|
|
"logwisp/src/internal/plugin"
|
|
"logwisp/src/internal/session"
|
|
"logwisp/src/internal/sink"
|
|
"logwisp/src/internal/version"
|
|
|
|
lconfig "github.com/lixenwraith/config"
|
|
"github.com/lixenwraith/log"
|
|
"github.com/lixenwraith/log/compat"
|
|
"github.com/valyala/fasthttp"
|
|
)
|
|
|
|
func init() {
|
|
if err := plugin.RegisterSink("http", NewHTTPSinkPlugin); err != nil {
|
|
panic(fmt.Sprintf("failed to register http sink: %v", err))
|
|
}
|
|
}
|
|
|
|
// HTTPSink streams log entries via Server-Sent Events (SSE)
|
|
type HTTPSink struct {
|
|
// Plugin identity and session management
|
|
id string
|
|
proxy *session.Proxy
|
|
|
|
// Configuration
|
|
config *config.HTTPSinkOptions
|
|
|
|
// Network
|
|
server *fasthttp.Server
|
|
|
|
// Application
|
|
input chan core.TransportEvent
|
|
logger *log.Logger
|
|
|
|
// Runtime
|
|
done chan struct{}
|
|
wg sync.WaitGroup
|
|
startTime time.Time
|
|
|
|
// Broker
|
|
clients map[uint64]chan []byte
|
|
clientsMu sync.RWMutex
|
|
unregister chan uint64
|
|
nextClientID atomic.Uint64
|
|
|
|
// Client session tracking
|
|
clientSessions map[uint64]string // clientID -> sessionID
|
|
sessionsMu sync.RWMutex
|
|
|
|
// Statistics
|
|
activeClients atomic.Int64
|
|
totalProcessed atomic.Uint64
|
|
lastProcessed atomic.Value // time.Time
|
|
}
|
|
|
|
// NewHTTPSinkPlugin creates an HTTP sink through plugin factory
|
|
func NewHTTPSinkPlugin(
|
|
id string,
|
|
configMap map[string]any,
|
|
logger *log.Logger,
|
|
proxy *session.Proxy,
|
|
) (sink.Sink, error) {
|
|
opts := &config.HTTPSinkOptions{
|
|
Host: DefaultHTTPHost,
|
|
Port: 0,
|
|
WriteTimeout: 0, // SSE indefinite streaming
|
|
}
|
|
|
|
if err := lconfig.ScanMap(configMap, opts); err != nil {
|
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Validate and apply defaults
|
|
if opts.Port <= 0 || opts.Port > HTTPMaxPort {
|
|
return nil, fmt.Errorf("port must be between 1 and %d", HTTPMaxPort)
|
|
}
|
|
if opts.BufferSize <= 0 {
|
|
opts.BufferSize = DefaultHTTPBufferSize
|
|
}
|
|
if opts.StreamPath == "" {
|
|
opts.StreamPath = DefaultHTTPStreamPath
|
|
}
|
|
if opts.StatusPath == "" {
|
|
opts.StatusPath = DefaultHTTPStatusPath
|
|
}
|
|
|
|
h := &HTTPSink{
|
|
id: id,
|
|
proxy: proxy,
|
|
config: opts,
|
|
input: make(chan core.TransportEvent, opts.BufferSize),
|
|
done: make(chan struct{}),
|
|
logger: logger,
|
|
clients: make(map[uint64]chan []byte),
|
|
unregister: make(chan uint64),
|
|
clientSessions: make(map[uint64]string),
|
|
}
|
|
h.lastProcessed.Store(time.Time{})
|
|
|
|
logger.Info("msg", "HTTP sink initialized",
|
|
"component", "http_sink",
|
|
"instance_id", id,
|
|
"host", opts.Host,
|
|
"port", opts.Port,
|
|
"stream_path", opts.StreamPath,
|
|
"status_path", opts.StatusPath)
|
|
|
|
return h, nil
|
|
}
|
|
|
|
// Capabilities returns supported capabilities
|
|
func (h *HTTPSink) Capabilities() []core.Capability {
|
|
return []core.Capability{
|
|
core.CapSessionAware,
|
|
core.CapMultiSession,
|
|
}
|
|
}
|
|
|
|
// Input returns the channel for sending transport events
|
|
func (h *HTTPSink) Input() chan<- core.TransportEvent {
|
|
return h.input
|
|
}
|
|
|
|
// Start initializes the HTTP server and begins the broker loop
|
|
func (h *HTTPSink) Start(ctx context.Context) error {
|
|
h.startTime = time.Now()
|
|
|
|
// Start central broker goroutine
|
|
h.wg.Add(1)
|
|
go h.brokerLoop(ctx)
|
|
|
|
fasthttpLogger := compat.NewFastHTTPAdapter(h.logger)
|
|
|
|
h.server = &fasthttp.Server{
|
|
Name: fmt.Sprintf("LogWisp/%s", version.Short()),
|
|
Handler: h.requestHandler,
|
|
DisableKeepalive: false,
|
|
StreamRequestBody: true,
|
|
Logger: fasthttpLogger,
|
|
WriteTimeout: time.Duration(h.config.WriteTimeout) * time.Millisecond,
|
|
}
|
|
|
|
addr := fmt.Sprintf("%s:%d", h.config.Host, h.config.Port)
|
|
|
|
errChan := make(chan error, 1)
|
|
go func() {
|
|
h.logger.Info("msg", "HTTP server starting",
|
|
"component", "http_sink",
|
|
"instance_id", h.id,
|
|
"address", addr)
|
|
|
|
err := h.server.ListenAndServe(addr)
|
|
if err != nil {
|
|
errChan <- err
|
|
}
|
|
}()
|
|
|
|
// Monitor context for shutdown
|
|
go func() {
|
|
<-ctx.Done()
|
|
if h.server != nil {
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), HttpServerShutdownTimeout)
|
|
defer cancel()
|
|
h.server.ShutdownWithContext(shutdownCtx)
|
|
}
|
|
}()
|
|
|
|
// Check if server started
|
|
select {
|
|
case err := <-errChan:
|
|
return err
|
|
case <-time.After(HttpServerStartTimeout):
|
|
h.logger.Info("msg", "HTTP server started",
|
|
"component", "http_sink",
|
|
"instance_id", h.id,
|
|
"host", h.config.Host,
|
|
"port", h.config.Port)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Stop gracefully shuts down the HTTP server and all client connections
|
|
func (h *HTTPSink) Stop() {
|
|
h.logger.Info("msg", "Stopping HTTP sink",
|
|
"component", "http_sink",
|
|
"instance_id", h.id)
|
|
|
|
close(h.done)
|
|
|
|
if h.server != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), HttpServerShutdownTimeout)
|
|
defer cancel()
|
|
h.server.ShutdownWithContext(ctx)
|
|
}
|
|
|
|
h.wg.Wait()
|
|
|
|
close(h.unregister)
|
|
|
|
h.clientsMu.Lock()
|
|
for _, ch := range h.clients {
|
|
close(ch)
|
|
}
|
|
h.clients = make(map[uint64]chan []byte)
|
|
h.clientsMu.Unlock()
|
|
|
|
h.logger.Info("msg", "HTTP sink stopped",
|
|
"component", "http_sink",
|
|
"instance_id", h.id,
|
|
"total_processed", h.totalProcessed.Load())
|
|
}
|
|
|
|
// GetStats returns sink statistics
|
|
func (h *HTTPSink) GetStats() sink.SinkStats {
|
|
lastProc, _ := h.lastProcessed.Load().(time.Time)
|
|
|
|
return sink.SinkStats{
|
|
ID: h.id,
|
|
Type: "http",
|
|
TotalProcessed: h.totalProcessed.Load(),
|
|
ActiveConnections: h.activeClients.Load(),
|
|
StartTime: h.startTime,
|
|
LastProcessed: lastProc,
|
|
Details: map[string]any{
|
|
"host": h.config.Host,
|
|
"port": h.config.Port,
|
|
"buffer_size": h.config.BufferSize,
|
|
"endpoints": map[string]string{
|
|
"stream": h.config.StreamPath,
|
|
"status": h.config.StatusPath,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// brokerLoop manages client connections and broadcasts transport events
|
|
func (h *HTTPSink) brokerLoop(ctx context.Context) {
|
|
defer h.wg.Done()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
h.logger.Debug("msg", "Broker loop stopping due to context cancellation",
|
|
"component", "http_sink")
|
|
return
|
|
|
|
case <-h.done:
|
|
h.logger.Debug("msg", "Broker loop stopping due to shutdown signal",
|
|
"component", "http_sink")
|
|
return
|
|
|
|
case clientID := <-h.unregister:
|
|
h.clientsMu.Lock()
|
|
if clientChan, exists := h.clients[clientID]; exists {
|
|
delete(h.clients, clientID)
|
|
close(clientChan)
|
|
h.logger.Debug("msg", "Unregistered client",
|
|
"component", "http_sink",
|
|
"client_id", clientID)
|
|
}
|
|
h.clientsMu.Unlock()
|
|
|
|
h.sessionsMu.Lock()
|
|
delete(h.clientSessions, clientID)
|
|
h.sessionsMu.Unlock()
|
|
|
|
case event, ok := <-h.input:
|
|
if !ok {
|
|
h.logger.Debug("msg", "Input channel closed, broker stopping",
|
|
"component", "http_sink")
|
|
return
|
|
}
|
|
|
|
h.totalProcessed.Add(1)
|
|
h.lastProcessed.Store(time.Now())
|
|
|
|
h.clientsMu.RLock()
|
|
clientCount := len(h.clients)
|
|
if clientCount > 0 {
|
|
var staleClients []uint64
|
|
|
|
for id, ch := range h.clients {
|
|
h.sessionsMu.RLock()
|
|
sessionID, hasSession := h.clientSessions[id]
|
|
h.sessionsMu.RUnlock()
|
|
|
|
if !hasSession {
|
|
staleClients = append(staleClients, id)
|
|
continue
|
|
}
|
|
|
|
// Check session still exists via proxy
|
|
if _, exists := h.proxy.GetSession(sessionID); !exists {
|
|
staleClients = append(staleClients, id)
|
|
continue
|
|
}
|
|
|
|
select {
|
|
case ch <- event.Payload:
|
|
h.proxy.UpdateActivity(sessionID)
|
|
default:
|
|
h.logger.Debug("msg", "Dropped event for slow client",
|
|
"component", "http_sink",
|
|
"client_id", id)
|
|
}
|
|
}
|
|
|
|
if len(staleClients) > 0 {
|
|
go func() {
|
|
for _, clientID := range staleClients {
|
|
select {
|
|
case h.unregister <- clientID:
|
|
case <-h.done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
h.clientsMu.RUnlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// requestHandler is the main entry point for all incoming HTTP requests
|
|
func (h *HTTPSink) requestHandler(ctx *fasthttp.RequestCtx) {
|
|
// IPv4-only enforcement - silent drop IPv6
|
|
remoteAddr := ctx.RemoteAddr()
|
|
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
|
|
if tcpAddr.IP.To4() == nil {
|
|
ctx.SetConnectionClose()
|
|
return
|
|
}
|
|
}
|
|
|
|
path := string(ctx.Path())
|
|
|
|
switch path {
|
|
case h.config.StatusPath:
|
|
h.handleStatus(ctx)
|
|
case h.config.StreamPath:
|
|
h.handleStream(ctx)
|
|
default:
|
|
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
|
ctx.SetContentType("application/json")
|
|
json.NewEncoder(ctx).Encode(map[string]any{
|
|
"error": "Not Found",
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleStream manages a client's Server-Sent Events (SSE) stream
|
|
func (h *HTTPSink) handleStream(ctx *fasthttp.RequestCtx) {
|
|
remoteAddrStr := ctx.RemoteAddr().String()
|
|
|
|
// Create session via proxy
|
|
sess := h.proxy.CreateSession(remoteAddrStr, map[string]any{
|
|
"type": "http_client",
|
|
})
|
|
|
|
// Set SSE headers
|
|
ctx.Response.Header.Set("Content-Type", "text/event-stream")
|
|
ctx.Response.Header.Set("Cache-Control", "no-cache")
|
|
ctx.Response.Header.Set("Connection", "keep-alive")
|
|
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
|
|
ctx.Response.Header.Set("X-Accel-Buffering", "no")
|
|
|
|
// Register client with broker
|
|
clientID := h.nextClientID.Add(1)
|
|
clientChan := make(chan []byte, h.config.BufferSize)
|
|
|
|
h.clientsMu.Lock()
|
|
h.clients[clientID] = clientChan
|
|
h.clientsMu.Unlock()
|
|
|
|
h.sessionsMu.Lock()
|
|
h.clientSessions[clientID] = sess.ID
|
|
h.sessionsMu.Unlock()
|
|
|
|
streamFunc := func(w *bufio.Writer) {
|
|
connectCount := h.activeClients.Add(1)
|
|
h.logger.Debug("msg", "HTTP client connected",
|
|
"component", "http_sink",
|
|
"remote_addr", remoteAddrStr,
|
|
"session_id", sess.ID,
|
|
"client_id", clientID,
|
|
"active_clients", connectCount)
|
|
|
|
h.wg.Add(1)
|
|
|
|
defer func() {
|
|
disconnectCount := h.activeClients.Add(-1)
|
|
h.logger.Debug("msg", "HTTP client disconnected",
|
|
"component", "http_sink",
|
|
"remote_addr", remoteAddrStr,
|
|
"session_id", sess.ID,
|
|
"client_id", clientID,
|
|
"active_clients", disconnectCount)
|
|
|
|
select {
|
|
case h.unregister <- clientID:
|
|
case <-h.done:
|
|
}
|
|
|
|
h.proxy.RemoveSession(sess.ID)
|
|
h.wg.Done()
|
|
}()
|
|
|
|
// Send connected event with metadata
|
|
connectionInfo := map[string]any{
|
|
"client_id": fmt.Sprintf("%d", clientID),
|
|
"session_id": sess.ID,
|
|
"instance_id": h.id,
|
|
"stream_path": h.config.StreamPath,
|
|
"status_path": h.config.StatusPath,
|
|
"buffer_size": h.config.BufferSize,
|
|
}
|
|
data, _ := json.Marshal(connectionInfo)
|
|
fmt.Fprintf(w, "event: connected\ndata: %s\n\n", data)
|
|
if err := w.Flush(); err != nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case payload, ok := <-clientChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := h.writeSSE(w, payload); err != nil {
|
|
return
|
|
}
|
|
|
|
if err := w.Flush(); err != nil {
|
|
return
|
|
}
|
|
|
|
h.proxy.UpdateActivity(sess.ID)
|
|
|
|
case <-h.done:
|
|
fmt.Fprintf(w, "event: disconnect\ndata: {\"reason\":\"server_shutdown\"}\n\n")
|
|
w.Flush()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.SetBodyStreamWriter(streamFunc)
|
|
}
|
|
|
|
// handleStatus provides a JSON status report
|
|
func (h *HTTPSink) handleStatus(ctx *fasthttp.RequestCtx) {
|
|
ctx.SetContentType("application/json")
|
|
|
|
status := map[string]any{
|
|
"service": "LogWisp",
|
|
"version": version.Short(),
|
|
"instance_id": h.id,
|
|
"server": map[string]any{
|
|
"type": "http",
|
|
"host": h.config.Host,
|
|
"port": h.config.Port,
|
|
"active_clients": h.activeClients.Load(),
|
|
"buffer_size": h.config.BufferSize,
|
|
"uptime_seconds": int(time.Since(h.startTime).Seconds()),
|
|
},
|
|
"endpoints": map[string]string{
|
|
"stream": h.config.StreamPath,
|
|
"status": h.config.StatusPath,
|
|
},
|
|
"statistics": map[string]any{
|
|
"total_processed": h.totalProcessed.Load(),
|
|
},
|
|
}
|
|
|
|
data, _ := json.Marshal(status)
|
|
ctx.SetBody(data)
|
|
}
|
|
|
|
// writeSSE formats payload into SSE data format
|
|
func (h *HTTPSink) writeSSE(w *bufio.Writer, payload []byte) error {
|
|
// Handle multi-line payloads per W3C SSE spec
|
|
lines := splitLines(payload)
|
|
for _, line := range lines {
|
|
if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Empty line terminates event
|
|
if _, err := w.WriteString("\n"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitLines splits payload by newlines, handling different line endings
|
|
func splitLines(data []byte) [][]byte {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Trim trailing newline if present
|
|
if data[len(data)-1] == '\n' {
|
|
data = data[:len(data)-1]
|
|
}
|
|
|
|
var lines [][]byte
|
|
start := 0
|
|
for i := 0; i < len(data); i++ {
|
|
if data[i] == '\n' {
|
|
lines = append(lines, data[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(data) {
|
|
lines = append(lines, data[start:])
|
|
}
|
|
|
|
if len(lines) == 0 {
|
|
return [][]byte{data}
|
|
}
|
|
return lines
|
|
} |