package internals import ( "encoding/json" "fmt" "log" "net" "net/http" "os" "path/filepath" "strings" "time" ) // authFileLog writes structured JSON-lines to a file; nil = file logging off. // Console logging always fires regardless of this setting. var authFileLog *log.Logger // authLogEntry is the structured format for each auth event. // One JSON object per line — compatible with CrowdSec, fail2ban, jq. type authLogEntry struct { Time string `json:"time"` // RFC3339 UTC RemoteIP string `json:"remote_ip"` // real client IP (proxy-aware) Username string `json:"username"` Success bool `json:"success"` Message string `json:"message"` // login_success | invalid_credentials | csrf_invalid | invalid_input } // initAuthLogger opens (or creates) the log file. // path "off" disables file logging; console output is always on. func initAuthLogger(path string) { if strings.EqualFold(path, "off") { fmt.Println("auth log: disabled (console only)") return } dir := filepath.Dir(path) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0750); err != nil { fmt.Fprintf(os.Stderr, "auth log: cannot create dir %q: %v\n", dir, err) return } } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) if err != nil { fmt.Fprintf(os.Stderr, "auth log: cannot open %q: %v\n", path, err) return } // log.New with empty flags → raw lines, no timestamp prefix (timestamp is in JSON) authFileLog = log.New(f, "", 0) fmt.Printf("auth log: %s\n", path) } // logAuthAttempt records one auth event. // Always prints to stdout; also writes to file if enabled. func logAuthAttempt(r *http.Request, username string, success bool, message string) { entry := authLogEntry{ Time: time.Now().UTC().Format(time.RFC3339), RemoteIP: realIP(r), Username: username, Success: success, Message: message, } b, _ := json.Marshal(entry) line := string(b) // Console — always visible fmt.Println(line) // File — if enabled if authFileLog != nil { authFileLog.Println(line) // log.Logger serialises concurrent writes } } // realIP returns the originating client IP, respecting common reverse-proxy // headers in priority order: Cloudflare → X-Forwarded-For → X-Real-IP → RemoteAddr. func realIP(r *http.Request) string { // Cloudflare sets CF-Connecting-IP to the unmodified client IP. if ip := r.Header.Get("CF-Connecting-IP"); ip != "" && net.ParseIP(ip) != nil { return ip } // X-Forwarded-For may be a comma-separated list; the leftmost entry is the // originating client (rightmost entries are added by each successive proxy). if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if idx := strings.IndexByte(xff, ','); idx != -1 { xff = xff[:idx] } xff = strings.TrimSpace(xff) if net.ParseIP(xff) != nil { return xff } } // Nginx / Traefik single-value header. if ip := r.Header.Get("X-Real-IP"); ip != "" && net.ParseIP(ip) != nil { return ip } // Direct connection. host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host }