package httpchain import ( "bytes" "context" "encoding/json" "fmt" "io" "maps" "net" "net/http" "net/url" "os" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/lixenwraith/logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/sink" "github.com/lixenwraith/logwisp/internal/tlsx" lconfig "github.com/lixenwraith/config" "github.com/lixenwraith/log" ) func init() { if err := plugin.RegisterSink("http_chain", NewHTTPChainSinkPlugin); err != nil { panic(fmt.Sprintf("failed to register http_chain sink: %v", err)) } } const ( DefaultHTTPChainSinkBufferSize = 1000 DefaultHTTPChainSinkIngestPath = "/ingest" DefaultHTTPChainSinkMaxBatchCount = 100 DefaultHTTPChainSinkMaxBatchBytes = 1024 * 1024 DefaultHTTPChainSinkFlushIntervalMS = 1000 DefaultHTTPChainSinkRequestTimeoutMS = 10000 DefaultHTTPChainSinkBackoffMinMS = 500 DefaultHTTPChainSinkBackoffMaxMS = 30000 maxResponseDrain = 64 * 1024 ) // HTTPChainSink batches structured entries and posts NDJSON to a downstream // http_chain source. Delivery is at-least-once per batch. type HTTPChainSink struct { id string proxy *session.Proxy session *session.Session config *config.HTTPChainSinkOptions node string baseURL string // scheme://host:port, where /auth lives url string tlsEnabled bool mtls bool // Authorization: pins the downstream server's identity auth *authz.Policy client *http.Client input chan core.TransportEvent logger *log.Logger // Batch state owned exclusively by run loop goroutine batch bytes.Buffer batchCount int64 reqTimeout time.Duration done chan struct{} wg sync.WaitGroup startTime time.Time totalProcessed atomic.Uint64 batchesSent atomic.Uint64 requestErrors atomic.Uint64 droppedBatches atomic.Uint64 synthesized atomic.Uint64 lastProcessed atomic.Value // time.Time } // NewHTTPChainSinkPlugin creates an http_chain sink through plugin factory func NewHTTPChainSinkPlugin( id string, configMap map[string]any, logger *log.Logger, proxy *session.Proxy, ) (sink.Sink, error) { opts := &config.HTTPChainSinkOptions{} if err := config.Scan(configMap, opts); err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) } if err := lconfig.NonEmpty(opts.Host); err != nil { return nil, fmt.Errorf("host: %w", err) } if err := lconfig.Port(opts.Port); err != nil { return nil, fmt.Errorf("port: %w", err) } network, err := core.Network(opts.Host) if err != nil { return nil, fmt.Errorf("host: %w", err) } if opts.IngestPath == "" { opts.IngestPath = DefaultHTTPChainSinkIngestPath } else if !strings.HasPrefix(opts.IngestPath, "/") { return nil, fmt.Errorf("ingest_path: must start with '/'") } else if opts.IngestPath == chain.AuthPath { return nil, fmt.Errorf("ingest_path: %s is reserved for authentication", chain.AuthPath) } if opts.BufferSize <= 0 { opts.BufferSize = DefaultHTTPChainSinkBufferSize } if opts.MaxBatchCount <= 0 { opts.MaxBatchCount = DefaultHTTPChainSinkMaxBatchCount } if opts.MaxBatchBytes <= 0 { opts.MaxBatchBytes = DefaultHTTPChainSinkMaxBatchBytes } if opts.FlushIntervalMS <= 0 { opts.FlushIntervalMS = DefaultHTTPChainSinkFlushIntervalMS } if opts.RequestTimeoutMS <= 0 { opts.RequestTimeoutMS = DefaultHTTPChainSinkRequestTimeoutMS } if opts.BackoffMinMS <= 0 { opts.BackoffMinMS = DefaultHTTPChainSinkBackoffMinMS } if opts.BackoffMaxMS < opts.BackoffMinMS { opts.BackoffMaxMS = DefaultHTTPChainSinkBackoffMaxMS } node := opts.Node if node == "" { if hn, err := os.Hostname(); err == nil { node = hn } else { node = "unknown" } } tlsCfg, err := tlsx.Client(opts.TLS, opts.Host) if err != nil { return nil, err } authPolicy, err := authz.New(opts.Auth, tlsCfg, nil, authz.RoleDialer, authz.HTTP) if err != nil { return nil, err } if authPolicy.Enabled() { // Runs after the standard chain and hostname checks, so a server the // policy rejects fails the handshake instead of the first request tlsCfg.VerifyConnection = authPolicy.VerifyConnection } addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)) transport := &http.Transport{ DialContext: func(ctx context.Context, _, address string) (net.Conn, error) { d := net.Dialer{} return d.DialContext(ctx, network, address) }, MaxIdleConnsPerHost: 2, IdleConnTimeout: 90 * time.Second, DisableCompression: true, TLSClientConfig: tlsCfg, // nil = plaintext TLSHandshakeTimeout: tlsx.HandshakeTimeout, // h2 stays off: custom DialContext disables auto-ALPN and batched // NDJSON POSTs gain nothing from it } base := url.URL{Scheme: "http", Host: addr} // escapes an IPv6 zone if tlsCfg != nil { base.Scheme = "https" } t := &HTTPChainSink{ id: id, proxy: proxy, config: opts, node: node, tlsEnabled: tlsCfg != nil, mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0, auth: authPolicy, baseURL: base.String(), url: base.String() + opts.IngestPath, // A redirect would resend the batch, even from https to plaintext http client: &http.Client{ Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, input: make(chan core.TransportEvent, opts.BufferSize), done: make(chan struct{}), logger: logger, reqTimeout: time.Duration(opts.RequestTimeoutMS) * time.Millisecond, } t.lastProcessed.Store(time.Time{}) t.session = proxy.CreateSession( "http_chain://"+addr, map[string]any{ "instance_id": id, "type": "http_chain", "target": t.url, "node": node, }, ) logger.Info("msg", "HTTP chain sink initialized", "component", "http_chain_sink", "instance_id", id, "target", t.url, "node", node, "tls", t.tlsEnabled, "mtls", t.mtls, "auth", authPolicy.Describe()) tlsx.LogWarnings(logger, "http_chain_sink", id, opts.TLS, false) authPolicy.LogStartup(logger, "http_chain_sink", id, false) return t, nil } // Capabilities returns supported capabilities func (t *HTTPChainSink) Capabilities() []core.Capability { caps := []core.Capability{core.CapSessionAware} if t.tlsEnabled { caps = append(caps, core.CapTLS) } if t.auth.Enabled() { caps = append(caps, core.CapAuth) // pins the server identity } return caps } // Input returns the channel for sending transport events func (t *HTTPChainSink) Input() chan<- core.TransportEvent { return t.input } // Start launches the batching loop; downstream availability is not required func (t *HTTPChainSink) Start(ctx context.Context) error { t.startTime = time.Now() t.wg.Add(1) go t.runLoop(ctx) t.logger.Info("msg", "HTTP chain sink started", "component", "http_chain_sink", "instance_id", t.id, "target", t.url) return nil } // Stop delivers what is queued and batched within sink.FlushBound, then ends // the loop func (t *HTTPChainSink) Stop() { t.logger.Info("msg", "Stopping HTTP chain sink", "component", "http_chain_sink", "instance_id", t.id) close(t.done) t.wg.Wait() t.client.CloseIdleConnections() if t.session != nil { t.proxy.RemoveSession(t.session.ID) } t.logger.Info("msg", "HTTP chain sink stopped", "component", "http_chain_sink", "instance_id", t.id, "total_processed", t.totalProcessed.Load()) } // GetStats returns sink statistics func (t *HTTPChainSink) GetStats() sink.SinkStats { lastProc, _ := t.lastProcessed.Load().(time.Time) details := map[string]any{ "target": t.url, "node": t.node, "tls": t.tlsEnabled, "batches_sent": t.batchesSent.Load(), "request_errors": t.requestErrors.Load(), "dropped_batches": t.droppedBatches.Load(), "synthesized": t.synthesized.Load(), } maps.Copy(details, t.auth.Stats()) return sink.SinkStats{ ID: t.id, Type: "http_chain", TotalProcessed: t.totalProcessed.Load(), StartTime: t.startTime, LastProcessed: lastProc, Details: details, } } // runLoop batches events and flushes on size or interval func (t *HTTPChainSink) runLoop(ctx context.Context) { defer t.wg.Done() // Stop leaves sink.FlushBound to deliver the queue and the batch, so a // finite input arrives whole; a request or backoff in flight then ends, // and every later flush drops its batch at once ctx, cancel := sink.FlushContext(ctx, t.done, t.reqTimeout) defer cancel() ticker := time.NewTicker(time.Duration(t.config.FlushIntervalMS) * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: if t.batchCount > 0 { t.flush(ctx) } case event := <-t.input: if t.append(event) { t.flush(ctx) } case <-t.done: for len(t.input) > 0 { if t.append(<-t.input) { t.flush(ctx) } } if t.batchCount > 0 { t.flush(ctx) } return } } } // append serializes one event into the pending batch; true once it is full func (t *HTTPChainSink) append(event core.TransportEvent) bool { entry, synthesized := chain.EntryFromEvent(event, t.node, t.id) if synthesized { t.synthesized.Add(1) } line, err := json.Marshal(entry) if err != nil { // Non-transient: drop entry t.logger.Error("msg", "Failed to marshal chain entry", "component", "http_chain_sink", "error", err) return false } t.batch.Write(line) t.batch.WriteByte('\n') t.batchCount++ return t.batchCount >= t.config.MaxBatchCount || int64(t.batch.Len()) >= t.config.MaxBatchBytes } // flush delivers the pending batch, retrying transient failures with backoff // until shutdown drops it func (t *HTTPChainSink) flush(ctx context.Context) { body := bytes.Clone(t.batch.Bytes()) count := t.batchCount t.batch.Reset() t.batchCount = 0 failures := 0 for { if failures > 0 && !t.waitBackoff(ctx, failures) { break } transient, err := t.post(ctx, body) if err == nil { t.batchesSent.Add(1) t.totalProcessed.Add(uint64(count)) t.lastProcessed.Store(time.Now()) t.proxy.UpdateActivity(t.session.ID) return } t.requestErrors.Add(1) if !transient { t.droppedBatches.Add(1) t.logger.Error("msg", "Chain batch rejected, dropping", "component", "http_chain_sink", "target", t.url, "entries", count, "error", err) return } if ctx.Err() != nil { break } failures++ t.logger.Warn("msg", "Chain batch delivery failed", "component", "http_chain_sink", "target", t.url, "attempt", failures, "error", err) } t.droppedBatches.Add(1) t.logger.Warn("msg", "Chain batch dropped on shutdown", "component", "http_chain_sink", "target", t.url, "entries", count) } // post sends one NDJSON batch; transient=true marks retryable failures func (t *HTTPChainSink) post(ctx context.Context, body []byte) (transient bool, err error) { reqCtx, cancel := context.WithTimeout(ctx, t.reqTimeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, t.url, bytes.NewReader(body)) if err != nil { return false, err } req.Header.Set("Content-Type", chain.ContentTypeNDJSON) req.Header.Set(chain.HeaderProtocol, strconv.Itoa(chain.ProtocolVersion)) req.Header.Set(chain.HeaderNode, t.node) // Under scram: log in when no token is held; every failure is transient, // so the batch waits under backoff rather than being dropped if err := t.auth.Prepare(reqCtx, t.client, t.baseURL, req); err != nil { return true, err } resp, err := t.client.Do(req) if err != nil { t.auth.Invalidate(0, err) // another server certificate: log in anew return true, err } defer resp.Body.Close() // Drain for connection reuse, bounded so a hostile peer cannot stream forever io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseDrain)) switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: return false, nil case t.auth.Invalidate(resp.StatusCode, nil): // Token expired or the source reloaded; the retry logs in again return true, fmt.Errorf("status %s", resp.Status) case resp.StatusCode == http.StatusRequestTimeout, resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500: return true, fmt.Errorf("status %s", resp.Status) default: return false, fmt.Errorf("status %s", resp.Status) } } // waitBackoff sleeps for the computed delay, interruptible by shutdown func (t *HTTPChainSink) waitBackoff(ctx context.Context, failures int) bool { minD := time.Duration(t.config.BackoffMinMS) * time.Millisecond maxD := time.Duration(t.config.BackoffMaxMS) * time.Millisecond timer := time.NewTimer(chain.BackoffDelay(minD, maxD, failures)) defer timer.Stop() select { case <-timer.C: return true case <-ctx.Done(): return false } }