diff --git a/README.md b/README.md index 01012e2..ab3d835 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,7 @@ The proxy can be configured via: -database-url string PostgreSQL connection URL -log-level string Log level: debug, info, warn, error (default "info") -log-format string Log format: text, json (default "text") +-access-log string Path to the JSONL access log -version Print version and exit ``` @@ -438,6 +439,7 @@ PROXY_DATABASE_PATH=./cache/proxy.db PROXY_DATABASE_URL=postgres://user:pass@localhost/proxy?sslmode=disable PROXY_LOG_LEVEL=info PROXY_LOG_FORMAT=text +PROXY_ACCESS_LOG_PATH=/var/log/proxy/access.jsonl ``` ### Configuration File @@ -458,6 +460,9 @@ log: level: "info" format: "text" +access_log: + path: "/var/log/proxy/access.jsonl" # Optional JSONL activity log + # Optional: override upstream URLs upstream: npm: "https://registry.npmjs.org" diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index 15a71c0..9054eee 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -40,6 +40,8 @@ // Log level: debug, info, warn, error (default "info") // -log-format string // Log format: text, json (default "text") +// -access-log string +// Path to the JSONL access log (disabled by default) // // Stats Flags: // @@ -72,6 +74,7 @@ // PROXY_DATABASE_URL - PostgreSQL connection URL // PROXY_LOG_LEVEL - Log level // PROXY_LOG_FORMAT - Log format +// PROXY_ACCESS_LOG_PATH - JSONL access log path // PROXY_UPSTREAM_MAVEN - Maven repository upstream URL // PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL - Gradle Plugin Portal upstream URL // PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Disable Gradle PUT uploads @@ -184,6 +187,7 @@ func runServe() { databaseURL := fs.String("database-url", "", "PostgreSQL connection URL") logLevel := fs.String("log-level", "", "Log level: debug, info, warn, error") logFormat := fs.String("log-format", "", "Log format: text, json") + accessLogPath := fs.String("access-log", "", "Path to the JSONL access log") version := fs.Bool("version", false, "Print version and exit") fs.Usage = func() { @@ -201,6 +205,7 @@ func runServe() { fmt.Fprintf(os.Stderr, " PROXY_DATABASE_URL PostgreSQL connection URL\n") fmt.Fprintf(os.Stderr, " PROXY_LOG_LEVEL Log level\n") fmt.Fprintf(os.Stderr, " PROXY_LOG_FORMAT Log format\n") + fmt.Fprintf(os.Stderr, " PROXY_ACCESS_LOG_PATH JSONL access log path\n") fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_MAVEN Maven repository upstream URL\n") fmt.Fprintf(os.Stderr, " PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL Gradle Plugin Portal upstream URL\n") fmt.Fprintf(os.Stderr, " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Disable Gradle PUT uploads\n") @@ -256,6 +261,9 @@ func runServe() { if *logFormat != "" { cfg.Log.Format = *logFormat } + if *accessLogPath != "" { + cfg.AccessLog.Path = *accessLogPath + } // Validate configuration if err := cfg.Validate(); err != nil { diff --git a/config.example.yaml b/config.example.yaml index 7ada017..f44178a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -78,6 +78,10 @@ log: # Log format: "text" or "json" format: "text" +# JSONL access log. Leave path empty to disable it. +access_log: + path: "" + # Upstream registry URLs and authentication upstream: # npm registry URL diff --git a/docs/configuration.md b/docs/configuration.md index 1e5a1c7..a4f45de 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,6 +108,30 @@ log: | `log.level` | `PROXY_LOG_LEVEL` | `-log-level` | `debug`, `info`, `warn`, `error` | | `log.format` | `PROXY_LOG_FORMAT` | `-log-format` | `text`, `json` | +## Access Log + +The optional access log records client requests and each HTTP exchange with an upstream registry. It is always written as JSONL, with one JSON object per line. Records for the same client request share a `request_id`. + +```yaml +access_log: + path: "/var/log/proxy/access.jsonl" +``` + +| Config | Environment | Flag | Description | +|--------|-------------|------|-------------| +| `access_log.path` | `PROXY_ACCESS_LOG_PATH` | `-access-log` | File to append JSONL records to; empty disables the log | + +The parent directory must exist and be writable when the proxy starts. A newly created log file is readable and writable only by the proxy process owner. + +A request that receives a rate limit response from an upstream can produce records like these: + +```json +{"time":"2026-08-16T12:00:00Z","event":"upstream","request_id":"host/example-000001","method":"GET","url":"https://registry.example/packages/example","status_code":429,"duration_ms":42} +{"time":"2026-08-16T12:00:00Z","event":"request","request_id":"host/example-000001","method":"GET","path":"/npm/example","status_code":502,"duration_ms":43,"remote_addr":"192.0.2.10:41234"} +``` + +Upstream retries and OCI authentication calls are separate `upstream` records, so the log preserves every status returned over the wire. Network failures have an `error` field and no `status_code`. URL credentials, query strings, and fragments are omitted from both upstream URLs and client paths. + ## Upstream Registries Override default upstream registry URLs: diff --git a/internal/accesslog/accesslog.go b/internal/accesslog/accesslog.go new file mode 100644 index 0000000..6a3cb01 --- /dev/null +++ b/internal/accesslog/accesslog.go @@ -0,0 +1,109 @@ +// Package accesslog writes proxy activity as JSON Lines. +package accesslog + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "sync" + "time" +) + +const ( + accessLogFileMode os.FileMode = 0o600 + + // EventRequest identifies the response sent by the proxy to a client. + EventRequest = "request" + // EventUpstream identifies one HTTP exchange with an upstream service. + EventUpstream = "upstream" +) + +type requestIDKey struct{} + +// Entry is one proxy activity record. +type Entry struct { + Time time.Time `json:"time"` + Event string `json:"event"` + RequestID string `json:"request_id,omitempty"` + Method string `json:"method"` + Path string `json:"path,omitempty"` + URL string `json:"url,omitempty"` + StatusCode int `json:"status_code,omitempty"` + DurationMS int64 `json:"duration_ms"` + RemoteAddr string `json:"remote_addr,omitempty"` + Error string `json:"error,omitempty"` +} + +// Logger appends complete JSON objects to a file, one per line. +type Logger struct { + mu sync.Mutex + file *os.File + encoder *json.Encoder +} + +// Open opens path for append, creating it with owner-only permissions when needed. +func Open(path string) (*Logger, error) { + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, accessLogFileMode) + if err != nil { + return nil, fmt.Errorf("opening access log: %w", err) + } + + return &Logger{ + file: file, + encoder: json.NewEncoder(file), + }, nil +} + +// Write appends an entry to the log. +func (l *Logger) Write(entry Entry) error { + if entry.Time.IsZero() { + entry.Time = time.Now().UTC() + } + + l.mu.Lock() + defer l.mu.Unlock() + + if err := l.encoder.Encode(entry); err != nil { + return fmt.Errorf("writing access log: %w", err) + } + return nil +} + +// Close closes the log file after any active writer finishes. +func (l *Logger) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + + if err := l.file.Close(); err != nil { + return fmt.Errorf("closing access log: %w", err) + } + return nil +} + +// WithRequestID stores a proxy request ID in ctx. +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDKey{}, requestID) +} + +// RequestID returns the proxy request ID stored in ctx. +func RequestID(ctx context.Context) string { + requestID, _ := ctx.Value(requestIDKey{}).(string) + return requestID +} + +// URLWithoutSecrets returns a URL without user information, query values, or fragments. +func URLWithoutSecrets(value *url.URL) string { + if value == nil { + return "" + } + + clean := *value + clean.User = nil + clean.RawQuery = "" + clean.ForceQuery = false + clean.Fragment = "" + clean.RawFragment = "" + return clean.String() +} diff --git a/internal/accesslog/accesslog_test.go b/internal/accesslog/accesslog_test.go new file mode 100644 index 0000000..4e53fa8 --- /dev/null +++ b/internal/accesslog/accesslog_test.go @@ -0,0 +1,91 @@ +package accesslog + +import ( + "bufio" + "context" + "encoding/json" + "net/url" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestLoggerWritesJSONLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.jsonl") + logger, err := Open(path) + if err != nil { + t.Fatal(err) + } + + const entries = 20 + var wg sync.WaitGroup + for range entries { + wg.Add(1) + go func() { + defer wg.Done() + if err := logger.Write(Entry{ + Event: EventUpstream, + RequestID: "request-id", + Method: "GET", + URL: "https://registry.example/packages/example", + StatusCode: 429, + }); err != nil { + t.Errorf("Write: %v", err) + } + }() + } + wg.Wait() + + if err := logger.Close(); err != nil { + t.Fatal(err) + } + + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + count := 0 + for scanner.Scan() { + var entry Entry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + t.Fatalf("line %d is not JSON: %v", count+1, err) + } + if entry.Time.IsZero() { + t.Errorf("line %d has no time", count+1) + } + if entry.StatusCode != 429 { + t.Errorf("line %d status_code = %d, want 429", count+1, entry.StatusCode) + } + count++ + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + if count != entries { + t.Errorf("lines = %d, want %d", count, entries) + } +} + +func TestRequestID(t *testing.T) { + ctx := WithRequestID(context.Background(), "abc-123") + if got := RequestID(ctx); got != "abc-123" { + t.Errorf("RequestID = %q, want %q", got, "abc-123") + } +} + +func TestURLWithoutSecrets(t *testing.T) { + value, err := url.Parse("https://user:password@registry.example/package.tgz?token=secret#fragment") + if err != nil { + t.Fatal(err) + } + + got := URLWithoutSecrets(value) + want := "https://registry.example/package.tgz" + if got != want { + t.Errorf("URLWithoutSecrets = %q, want %q", got, want) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 31f8c26..7d87234 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -91,6 +91,9 @@ type Config struct { // Log configures logging. Log LogConfig `json:"log" yaml:"log"` + // AccessLog configures the JSONL activity log. + AccessLog AccessLogConfig `json:"access_log" yaml:"access_log"` + // Upstream configures upstream registry URLs (optional overrides). Upstream UpstreamConfig `json:"upstream" yaml:"upstream"` @@ -280,6 +283,12 @@ type LogConfig struct { Format string `json:"format" yaml:"format"` } +// AccessLogConfig configures the JSONL activity log. +type AccessLogConfig struct { + // Path is the file to append activity records to. Empty disables the access log. + Path string `json:"path" yaml:"path"` +} + // UpstreamConfig configures upstream registry URLs and authentication. // Leave empty to use defaults. type UpstreamConfig struct { @@ -508,6 +517,7 @@ func setEnvBool(dst *bool, key string) { // - PROXY_DATABASE_PATH // - PROXY_LOG_LEVEL // - PROXY_LOG_FORMAT +// - PROXY_ACCESS_LOG_PATH // - PROXY_HEALTH_STORAGE_PROBE_INTERVAL func (c *Config) LoadFromEnv() { setEnvString(&c.Listen, "PROXY_LISTEN") @@ -524,6 +534,7 @@ func (c *Config) LoadFromEnv() { setEnvString(&c.Database.URL, "PROXY_DATABASE_URL") setEnvString(&c.Log.Level, "PROXY_LOG_LEVEL") setEnvString(&c.Log.Format, "PROXY_LOG_FORMAT") + setEnvString(&c.AccessLog.Path, "PROXY_ACCESS_LOG_PATH") setEnvString(&c.Upstream.Maven, "PROXY_UPSTREAM_MAVEN") setEnvString(&c.Upstream.GradlePluginPortal, "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL") setEnvString(&c.Upstream.Debian, "PROXY_UPSTREAM_DEBIAN") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e4677c3..e374c9e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,6 +26,9 @@ func TestDefault(t *testing.T) { if cfg.Database.Path == "" { t.Error("Database.Path should not be empty") } + if cfg.AccessLog.Path != "" { + t.Errorf("AccessLog.Path = %q, want disabled by default", cfg.AccessLog.Path) + } if cfg.Gradle.BuildCache.MaxUploadSize != "100MB" { t.Errorf("Gradle.BuildCache.MaxUploadSize = %q, want %q", cfg.Gradle.BuildCache.MaxUploadSize, "100MB") } @@ -212,6 +215,8 @@ database: log: level: "debug" format: "json" +access_log: + path: "/var/log/proxy/access.jsonl" ` if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("writing config file: %v", err) @@ -240,6 +245,9 @@ log: if cfg.Log.Format != "json" { t.Errorf("Log.Format = %q, want %q", cfg.Log.Format, "json") } + if cfg.AccessLog.Path != "/var/log/proxy/access.jsonl" { + t.Errorf("AccessLog.Path = %q, want %q", cfg.AccessLog.Path, "/var/log/proxy/access.jsonl") + } } func TestLoadJSON(t *testing.T) { @@ -275,6 +283,7 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("PROXY_UI_URL", "https://ui.env.example.com/ui") t.Setenv("PROXY_STORAGE_PATH", "/env/cache") t.Setenv("PROXY_LOG_LEVEL", testLevelDebug) + t.Setenv("PROXY_ACCESS_LOG_PATH", "/tmp/proxy-access.jsonl") t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public") t.Setenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL", "https://plugins.example.com/m2") t.Setenv("PROXY_UPSTREAM_DEBIAN", "http://archive.ubuntu.com/ubuntu") @@ -301,6 +310,9 @@ func TestLoadFromEnv(t *testing.T) { if cfg.Log.Level != testLevelDebug { t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, testLevelDebug) } + if cfg.AccessLog.Path != "/tmp/proxy-access.jsonl" { + t.Errorf("AccessLog.Path = %q, want %q", cfg.AccessLog.Path, "/tmp/proxy-access.jsonl") + } if cfg.Upstream.Maven != "https://maven.example.com/repository/maven-public" { t.Errorf("Upstream.Maven = %q, want %q", cfg.Upstream.Maven, "https://maven.example.com/repository/maven-public") } diff --git a/internal/httpclient/access_log.go b/internal/httpclient/access_log.go new file mode 100644 index 0000000..8e13f23 --- /dev/null +++ b/internal/httpclient/access_log.go @@ -0,0 +1,74 @@ +package httpclient + +import ( + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/git-pkgs/proxy/internal/accesslog" +) + +type accessLogTransport struct { + base http.RoundTripper + accessLog *accesslog.Logger + logger *slog.Logger +} + +// NewAccessLogTransport records each upstream HTTP exchange around base. +func NewAccessLogTransport(base http.RoundTripper, log *accesslog.Logger, logger *slog.Logger) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + if logger == nil { + logger = slog.Default() + } + if log == nil { + return base + } + return &accessLogTransport{ + base: base, + accessLog: log, + logger: logger, + } +} + +func (t *accessLogTransport) RoundTrip(req *http.Request) (*http.Response, error) { + start := time.Now() + resp, err := t.base.RoundTrip(req) + + entry := accesslog.Entry{ + Event: accesslog.EventUpstream, + RequestID: accesslog.RequestID(req.Context()), + Method: req.Method, + URL: accesslog.URLWithoutSecrets(req.URL), + DurationMS: time.Since(start).Milliseconds(), + } + if resp != nil { + entry.StatusCode = resp.StatusCode + } + if err != nil { + entry.Error = errorWithoutSecrets(err, req.URL) + } + if writeErr := t.accessLog.Write(entry); writeErr != nil { + t.logger.Error("failed to write access log", "error", writeErr) + } + + return resp, err +} + +func errorWithoutSecrets(err error, requestURL *url.URL) string { + message := err.Error() + if requestURL == nil { + return message + } + + cleanURL := accesslog.URLWithoutSecrets(requestURL) + for _, value := range []string{requestURL.String(), requestURL.Redacted()} { + if value != "" { + message = strings.ReplaceAll(message, value, cleanURL) + } + } + return message +} diff --git a/internal/httpclient/access_log_test.go b/internal/httpclient/access_log_test.go new file mode 100644 index 0000000..aa3a43c --- /dev/null +++ b/internal/httpclient/access_log_test.go @@ -0,0 +1,121 @@ +package httpclient + +import ( + "bufio" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/git-pkgs/proxy/internal/accesslog" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestAccessLogTransportRecordsUpstreamStatus(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.jsonl") + accessLogger, err := accesslog.Open(path) + if err != nil { + t.Fatal(err) + } + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Body: io.NopCloser(strings.NewReader("rate limited")), + Request: req, + }, nil + }) + client := &http.Client{Transport: NewAccessLogTransport(base, accessLogger, slog.Default())} + req, err := http.NewRequest(http.MethodGet, "https://user:password@registry.example/package.tgz?token=secret", nil) + if err != nil { + t.Fatal(err) + } + req = req.WithContext(accesslog.WithRequestID(req.Context(), "request-123")) + + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if err := accessLogger.Close(); err != nil { + t.Fatal(err) + } + + entry := readAccessLogEntry(t, path) + if entry.Event != accesslog.EventUpstream { + t.Errorf("event = %q, want %q", entry.Event, accesslog.EventUpstream) + } + if entry.RequestID != "request-123" { + t.Errorf("request_id = %q, want %q", entry.RequestID, "request-123") + } + if entry.StatusCode != http.StatusTooManyRequests { + t.Errorf("status_code = %d, want %d", entry.StatusCode, http.StatusTooManyRequests) + } + if entry.URL != "https://registry.example/package.tgz" { + t.Errorf("url = %q, want URL without credentials or query", entry.URL) + } +} + +func TestAccessLogTransportRecordsUpstreamError(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.jsonl") + accessLogger, err := accesslog.Open(path) + if err != nil { + t.Fatal(err) + } + + wantErr := errors.New("GET https://user:password@registry.example/package.tgz?token=secret: connection refused") + base := roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, wantErr + }) + client := &http.Client{Transport: NewAccessLogTransport(base, accessLogger, slog.Default())} + + _, err = client.Get("https://user:password@registry.example/package.tgz?token=secret") + if !errors.Is(err, wantErr) { + t.Fatalf("GET error = %v, want %v", err, wantErr) + } + if err := accessLogger.Close(); err != nil { + t.Fatal(err) + } + + entry := readAccessLogEntry(t, path) + if entry.StatusCode != 0 { + t.Errorf("status_code = %d, want 0", entry.StatusCode) + } + if strings.Contains(entry.Error, "password") || strings.Contains(entry.Error, "secret") { + t.Errorf("error contains URL credentials or query: %q", entry.Error) + } + if !strings.Contains(entry.Error, "connection refused") { + t.Errorf("error = %q, want connection failure", entry.Error) + } +} + +func readAccessLogEntry(t *testing.T, path string) accesslog.Entry { + t.Helper() + + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + if !scanner.Scan() { + t.Fatalf("access log is empty: %v", scanner.Err()) + } + + var entry accesslog.Entry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + t.Fatalf("decoding access log: %v", err) + } + return entry +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 9b81254..4332718 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -6,13 +6,10 @@ import ( "sync/atomic" "time" + "github.com/git-pkgs/proxy/internal/accesslog" "github.com/go-chi/chi/v5/middleware" ) -type contextKey string - -const requestIDKey contextKey = "request_id" - var requestCounter atomic.Uint64 // RequestIDMiddleware adds a sequential request ID to the context and response headers. @@ -23,7 +20,7 @@ func RequestIDMiddleware(next http.Handler) http.Handler { requestID := middleware.GetReqID(r.Context()) // Store formatted ID in context - ctx := context.WithValue(r.Context(), requestIDKey, requestID) + ctx := accesslog.WithRequestID(r.Context(), requestID) // Add to response header for client tracking w.Header().Set("X-Request-ID", requestID) @@ -34,10 +31,7 @@ func RequestIDMiddleware(next http.Handler) http.Handler { // GetRequestID retrieves the request ID from context. func GetRequestID(ctx context.Context) string { - if id, ok := ctx.Value(requestIDKey).(string); ok { - return id - } - return "" + return accesslog.RequestID(ctx) } // LoggerMiddleware logs HTTP requests with request ID correlation. @@ -56,6 +50,20 @@ func (s *Server) LoggerMiddleware(next http.Handler) http.Handler { "status", rw.status, "duration", time.Since(start), "remote", r.RemoteAddr) + + if s.accessLog != nil { + if err := s.accessLog.Write(accesslog.Entry{ + Event: accesslog.EventRequest, + RequestID: requestID, + Method: r.Method, + Path: r.URL.EscapedPath(), + StatusCode: rw.status, + DurationMS: time.Since(start).Milliseconds(), + RemoteAddr: r.RemoteAddr, + }); err != nil { + s.logger.Error("failed to write access log", "error", err) + } + } }) } diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go index 75c6ccd..1923461 100644 --- a/internal/server/middleware_test.go +++ b/internal/server/middleware_test.go @@ -2,12 +2,16 @@ package server import ( "context" + "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" + "github.com/git-pkgs/proxy/internal/accesslog" "github.com/go-chi/chi/v5/middleware" ) @@ -45,7 +49,7 @@ func TestGetRequestID(t *testing.T) { }{ { name: "with request ID", - ctx: context.WithValue(context.Background(), requestIDKey, "test-123"), + ctx: accesslog.WithRequestID(context.Background(), "test-123"), expected: "test-123", }, { @@ -121,6 +125,50 @@ func TestLoggerMiddleware(t *testing.T) { } } +func TestLoggerMiddlewareWritesAccessLog(t *testing.T) { + path := filepath.Join(t.TempDir(), "access.jsonl") + activityLog, err := accesslog.Open(path) + if err != nil { + t.Fatal(err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + s := &Server{logger: logger, accessLog: activityLog} + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + handler := middleware.RequestID(RequestIDMiddleware(s.LoggerMiddleware(next))) + + req := httptest.NewRequest(http.MethodGet, "/packages/example?token=secret", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if err := activityLog.Close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + var entry accesslog.Entry + if err := json.Unmarshal(data, &entry); err != nil { + t.Fatalf("decoding access log: %v", err) + } + if entry.Event != accesslog.EventRequest { + t.Errorf("event = %q, want %q", entry.Event, accesslog.EventRequest) + } + if entry.RequestID == "" { + t.Error("request_id is empty") + } + if entry.Path != "/packages/example" { + t.Errorf("path = %q, want query string omitted", entry.Path) + } + if entry.StatusCode != http.StatusNotFound { + t.Errorf("status_code = %d, want %d", entry.StatusCode, http.StatusNotFound) + } +} + func TestResponseWriter_WriteHeader(t *testing.T) { tests := []struct { name string diff --git a/internal/server/server.go b/internal/server/server.go index e677bc9..bbd7288 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -61,6 +61,7 @@ import ( "github.com/git-pkgs/cooldown" swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" + "github.com/git-pkgs/proxy/internal/accesslog" "github.com/git-pkgs/proxy/internal/config" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/enrichment" @@ -95,10 +96,26 @@ type Server struct { templates *Templates cancel context.CancelFunc healthCache *healthCache + accessLog *accesslog.Logger } // New creates a new Server with the given configuration. func New(cfg *config.Config, logger *slog.Logger) (*Server, error) { + var activityLog *accesslog.Logger + if cfg.AccessLog.Path != "" { + var err error + activityLog, err = accesslog.Open(cfg.AccessLog.Path) + if err != nil { + return nil, fmt.Errorf("initializing access log: %w", err) + } + } + closeAccessLog := true + defer func() { + if closeAccessLog && activityLog != nil { + _ = activityLog.Close() + } + }() + // Initialize database var db *database.DB var err error @@ -147,14 +164,17 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) { return nil, fmt.Errorf("initializing health cache: %w", err) } - return &Server{ + server := &Server{ cfg: cfg, db: db, storage: store, logger: logger, templates: &Templates{}, healthCache: hc, - }, nil + accessLog: activityLog, + } + closeAccessLog = false + return server, nil } // Start starts the HTTP server. @@ -162,7 +182,11 @@ func (s *Server) Start() error { // Use one authentication-aware transport for metadata and artifacts so // configured credentials and cached OCI challenges apply consistently. safeClient := safehttp.New(nil, safehttp.Options{}) - authTransport := upstreamhttp.NewTransport(safeClient.Transport, upstreamhttp.AuthFunc(s.authForURL)) + baseTransport := safeClient.Transport + if s.accessLog != nil { + baseTransport = upstreamhttp.NewAccessLogTransport(baseTransport, s.accessLog, s.logger) + } + authTransport := upstreamhttp.NewTransport(baseTransport, upstreamhttp.AuthFunc(s.authForURL)) metadataClient := *safeClient metadataClient.Timeout = s.cfg.ParseHTTPTimeout() metadataClient.Transport = authTransport @@ -373,6 +397,12 @@ func (s *Server) Shutdown(ctx context.Context) error { } } + if s.accessLog != nil { + if err := s.accessLog.Close(); err != nil { + errs = append(errs, fmt.Errorf("access log close: %w", err)) + } + } + if s.db != nil { if err := s.db.Close(); err != nil { errs = append(errs, fmt.Errorf("database close: %w", err)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 98b58cc..a870106 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1344,6 +1344,27 @@ func TestNewServer_StorageConnectivityCheck(t *testing.T) { _ = srv.db.Close() } +func TestNewServer_InvalidAccessLogFailsBeforeDatabaseInit(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + cfg := &config.Config{ + Storage: config.StorageConfig{Path: filepath.Join(tempDir, "artifacts")}, + Database: config.DatabaseConfig{Path: dbPath}, + AccessLog: config.AccessLogConfig{Path: filepath.Join(tempDir, "missing", "access.jsonl")}, + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := New(cfg, logger); err == nil { + t.Fatal("New() succeeded with invalid access log path") + } else if !strings.Contains(err.Error(), "initializing access log") { + t.Fatalf("New() error = %v, want access log initialization error", err) + } + + if _, err := os.Stat(dbPath); !os.IsNotExist(err) { + t.Errorf("database initialized before access log validation: %v", err) + } +} + func TestStatsEndpoint_StorageURL(t *testing.T) { ts := newTestServer(t) defer ts.close()