// Package output provides writers for ja4sentinel log records package output import ( "encoding/json" "fmt" "io" "net" "os" "sync" "ja4sentinel/api" ) // StdoutWriter writes log records to stdout type StdoutWriter struct { encoder *json.Encoder mutex sync.Mutex } // NewStdoutWriter creates a new stdout writer func NewStdoutWriter() *StdoutWriter { return &StdoutWriter{ encoder: json.NewEncoder(os.Stdout), } } // Write writes a log record to stdout func (w *StdoutWriter) Write(rec api.LogRecord) error { w.mutex.Lock() defer w.mutex.Unlock() return w.encoder.Encode(rec) } // Close closes the writer (no-op for stdout) func (w *StdoutWriter) Close() error { return nil } // FileWriter writes log records to a file type FileWriter struct { file *os.File encoder *json.Encoder mutex sync.Mutex } // NewFileWriter creates a new file writer func NewFileWriter(path string) (*FileWriter, error) { file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return nil, fmt.Errorf("failed to open file %s: %w", path, err) } return &FileWriter{ file: file, encoder: json.NewEncoder(file), }, nil } // Write writes a log record to the file func (w *FileWriter) Write(rec api.LogRecord) error { w.mutex.Lock() defer w.mutex.Unlock() return w.encoder.Encode(rec) } // Close closes the file func (w *FileWriter) Close() error { w.mutex.Lock() defer w.mutex.Unlock() if w.file != nil { return w.file.Close() } return nil } // UnixSocketWriter writes log records to a UNIX socket type UnixSocketWriter struct { socketPath string conn net.Conn mutex sync.Mutex } // NewUnixSocketWriter creates a new UNIX socket writer func NewUnixSocketWriter(socketPath string) (*UnixSocketWriter, error) { w := &UnixSocketWriter{ socketPath: socketPath, } // Try to connect (socket may not exist yet) conn, err := net.Dial("unix", socketPath) if err != nil { // Socket doesn't exist yet, we'll try to connect on first write return w, nil } w.conn = conn return w, nil } // Write writes a log record to the UNIX socket func (w *UnixSocketWriter) Write(rec api.LogRecord) error { w.mutex.Lock() defer w.mutex.Unlock() ensureConn := func() error { if w.conn != nil { return nil } conn, err := net.Dial("unix", w.socketPath) if err != nil { return fmt.Errorf("failed to connect to socket %s: %w", w.socketPath, err) } w.conn = conn return nil } if err := ensureConn(); err != nil { return err } data, err := json.Marshal(rec) if err != nil { return fmt.Errorf("failed to marshal record: %w", err) } // Add newline for line-based protocols data = append(data, '\n') if _, err = w.conn.Write(data); err != nil { _ = w.conn.Close() w.conn = nil if err2 := ensureConn(); err2 != nil { return fmt.Errorf("failed to write to socket and reconnect failed: %w", err2) } if _, err2 := w.conn.Write(data); err2 != nil { _ = w.conn.Close() w.conn = nil return fmt.Errorf("failed to write to socket after reconnect: %w", err2) } } return nil } // Close closes the UNIX socket connection func (w *UnixSocketWriter) Close() error { w.mutex.Lock() defer w.mutex.Unlock() if w.conn != nil { return w.conn.Close() } return nil } // MultiWriter combines multiple writers type MultiWriter struct { writers []api.Writer mutex sync.Mutex } // NewMultiWriter creates a new multi-writer func NewMultiWriter() *MultiWriter { return &MultiWriter{ writers: make([]api.Writer, 0), } } // Write writes a log record to all writers func (mw *MultiWriter) Write(rec api.LogRecord) error { mw.mutex.Lock() defer mw.mutex.Unlock() var lastErr error for _, w := range mw.writers { if err := w.Write(rec); err != nil { lastErr = err } } return lastErr } // Add adds a writer to the multi-writer func (mw *MultiWriter) Add(writer api.Writer) { mw.mutex.Lock() defer mw.mutex.Unlock() mw.writers = append(mw.writers, writer) } // CloseAll closes all writers func (mw *MultiWriter) CloseAll() error { mw.mutex.Lock() defer mw.mutex.Unlock() var lastErr error for _, w := range mw.writers { if closer, ok := w.(io.Closer); ok { if err := closer.Close(); err != nil { lastErr = err } } } return lastErr } // BuilderImpl implements the api.Builder interface type BuilderImpl struct{} // NewBuilder creates a new output builder func NewBuilder() *BuilderImpl { return &BuilderImpl{} } // NewFromConfig constructs writers from AppConfig func (b *BuilderImpl) NewFromConfig(cfg api.AppConfig) (api.Writer, error) { multiWriter := NewMultiWriter() for _, outputCfg := range cfg.Outputs { if !outputCfg.Enabled { continue } var writer api.Writer var err error switch outputCfg.Type { case "stdout": writer = NewStdoutWriter() case "file": path := outputCfg.Params["path"] if path == "" { return nil, fmt.Errorf("file output requires 'path' parameter") } writer, err = NewFileWriter(path) if err != nil { return nil, err } case "unix_socket": socketPath := outputCfg.Params["socket_path"] if socketPath == "" { return nil, fmt.Errorf("unix_socket output requires 'socket_path' parameter") } writer, err = NewUnixSocketWriter(socketPath) if err != nil { return nil, err } default: return nil, fmt.Errorf("unknown output type: %s", outputCfg.Type) } multiWriter.Add(writer) } // If no outputs configured, default to stdout if len(multiWriter.writers) == 0 { multiWriter.Add(NewStdoutWriter()) } return multiWriter, nil }