1// Package accesslog provides a dedicated HTTP access log writer for security
2// and observability tooling. It supports JSON, CLF, and Combined formats with
3// stdout, file, and journald output backends.
4package accesslog
5
6import (
7 "fmt"
8 "os"
9 "path/filepath"
10 "sync"
11
12 "gopkg.in/natefinch/lumberjack.v2"
13
14 out "github.com/bnema/gordon/internal/boundaries/out"
15)
16
17// Config holds configuration for the access log writer.
18type Config struct {
19 // Format is the output format: "json", "clf", or "combined".
20 Format string
21 // Output is the output backend: "stdout", "file", or "journald".
22 Output string
23 // FilePath is the path to the log file (only used when Output == "file").
24 // If empty, defaults to {data_dir}/logs/access.log (caller must resolve).
25 FilePath string
26 // MaxSize is the maximum size in MB before rotation (only used when Output == "file").
27 MaxSize int
28 // MaxBackups is the number of old log files to retain (only used when Output == "file").
29 MaxBackups int
30 // MaxAge is the maximum number of days to retain old log files (only used when Output == "file").
31 MaxAge int
32 // SyslogIdentifier is the journald SYSLOG_IDENTIFIER (only used when Output == "journald").
33 SyslogIdentifier string
34}
35
36// sink is the internal interface for output backends.
37type sink interface {
38 WriteLine(line string) error
39 Close() error
40}
41
42// Writer serializes access log entries and writes them to a configured sink.
43// It is safe for concurrent use. It implements out.AccessLogWriter.
44type Writer struct {
45 formatter func(out.AccessLogEntry) (string, error)
46 sink sink
47 mu sync.Mutex
48}
49
50// New creates a new Writer from cfg. Returns an error if the configuration is
51// invalid or the output backend cannot be initialized.
52func New(cfg Config) (*Writer, error) {
53 formatter, err := newFormatter(cfg.Format)
54 if err != nil {
55 return nil, fmt.Errorf("failed to create formatter: %w", err)
56 }
57
58 s, err := newSink(cfg)
59 if err != nil {
60 return nil, fmt.Errorf("failed to create sink: %w", err)
61 }
62
63 return &Writer{
64 formatter: formatter,
65 sink: s,
66 }, nil
67}
68
69// Write serializes entry and writes it to the configured output. It is safe
70// for concurrent use. A write failure is returned as an error; callers should
71// log the error and continue — Write never panics.
72func (w *Writer) Write(entry out.AccessLogEntry) error {
73 line, err := w.formatter(entry)
74 if err != nil {
75 return fmt.Errorf("accesslog: format entry: %w", err)
76 }
77
78 w.mu.Lock()
79 defer w.mu.Unlock()
80
81 if err := w.sink.WriteLine(line); err != nil {
82 return fmt.Errorf("accesslog: write line: %w", err)
83 }
84 return nil
85}
86
87// Close releases any resources held by the writer (e.g. open file handles).
88func (w *Writer) Close() error {
89 w.mu.Lock()
90 defer w.mu.Unlock()
91 return w.sink.Close()
92}
93
94// newFormatter returns the format function for the given format name.
95func newFormatter(format string) (func(out.AccessLogEntry) (string, error), error) {
96 switch format {
97 case "json":
98 return formatJSON, nil
99 case "clf":
100 return formatCLF, nil
101 case "combined":
102 return formatCombined, nil
103 default:
104 return nil, fmt.Errorf("invalid logging.access_log.format: %q (must be json, clf, or combined)", format)
105 }
106}
107
108// newSink creates the output sink for the given config.
109func newSink(cfg Config) (sink, error) {
110 switch cfg.Output {
111 case "stdout", "":
112 return &stdoutSink{}, nil
113 case "file":
114 return newFileSink(cfg)
115 case "journald":
116 return newJournaldSink(cfg)
117 default:
118 return nil, fmt.Errorf("invalid logging.access_log.output: %q (must be stdout, file, or journald)", cfg.Output)
119 }
120}
121
122// stdoutSink writes lines to os.Stdout.
123type stdoutSink struct{}
124
125func (s *stdoutSink) WriteLine(line string) error {
126 _, err := fmt.Fprintln(os.Stdout, line)
127 return err
128}
129
130func (s *stdoutSink) Close() error { return nil }
131
132// fileSink writes lines to a rotating file via lumberjack.
133type fileSink struct {
134 logger *lumberjack.Logger
135}
136
137func newFileSink(cfg Config) (*fileSink, error) {
138 if cfg.FilePath == "" {
139 return nil, fmt.Errorf("accesslog: file output requires a non-empty file_path")
140 }
141
142 // Ensure parent directory exists.
143 if err := os.MkdirAll(filepath.Dir(cfg.FilePath), 0o700); err != nil {
144 return nil, fmt.Errorf("accesslog: create log directory: %w", err)
145 }
146
147 // lumberjack defaults when zero: MaxSize=100MB, MaxBackups=keep all, MaxAge=no limit.
148 // Config defaults in run.go set these to sensible values; zero here is valid.
149 return &fileSink{
150 logger: &lumberjack.Logger{
151 Filename: cfg.FilePath,
152 MaxSize: cfg.MaxSize,
153 MaxBackups: cfg.MaxBackups,
154 MaxAge: cfg.MaxAge,
155 Compress: true,
156 },
157 }, nil
158}
159
160func (s *fileSink) WriteLine(line string) error {
161 _, err := fmt.Fprintln(s.logger, line)
162 return err
163}
164
165func (s *fileSink) Close() error {
166 return s.logger.Close()
167}