263 lines
5.5 KiB
Go
263 lines
5.5 KiB
Go
package logger
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
RetainDays = 7
|
|
dateLayout = "2006-01-02"
|
|
)
|
|
|
|
type Level int
|
|
|
|
const (
|
|
LevelDebug Level = iota
|
|
LevelInfo
|
|
LevelWarn
|
|
LevelError
|
|
)
|
|
|
|
func ParseLevel(s string) Level {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "debug":
|
|
return LevelDebug
|
|
case "warn", "warning":
|
|
return LevelWarn
|
|
case "error":
|
|
return LevelError
|
|
default:
|
|
return LevelInfo
|
|
}
|
|
}
|
|
|
|
func (l Level) String() string {
|
|
switch l {
|
|
case LevelDebug:
|
|
return "DEBUG"
|
|
case LevelWarn:
|
|
return "WARN"
|
|
case LevelError:
|
|
return "ERROR"
|
|
default:
|
|
return "INFO"
|
|
}
|
|
}
|
|
|
|
type Logger struct {
|
|
mu sync.Mutex
|
|
level Level
|
|
out io.Writer
|
|
file *os.File
|
|
basePath string // e.g. .../logs/tunnel.log → files tunnel-YYYY-MM-DD.log
|
|
day string // current file date YYYY-MM-DD
|
|
logSQL bool
|
|
}
|
|
|
|
func New(path string, level string, logSQL bool) (*Logger, error) {
|
|
l := &Logger{
|
|
level: ParseLevel(level),
|
|
logSQL: logSQL,
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
l.out = os.Stdout
|
|
return l, nil
|
|
}
|
|
l.basePath = path
|
|
if err := l.rotateLocked(time.Now()); err != nil {
|
|
return nil, err
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func (l *Logger) Close() error {
|
|
if l == nil {
|
|
return nil
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.file == nil {
|
|
return nil
|
|
}
|
|
err := l.file.Close()
|
|
l.file = nil
|
|
l.out = os.Stdout
|
|
return err
|
|
}
|
|
|
|
func (l *Logger) LogSQLEnabled() bool {
|
|
return l.logSQL
|
|
}
|
|
|
|
func (l *Logger) Debug(msg string, args ...any) { l.log(LevelDebug, msg, args...) }
|
|
func (l *Logger) Info(msg string, args ...any) { l.log(LevelInfo, msg, args...) }
|
|
func (l *Logger) Warn(msg string, args ...any) { l.log(LevelWarn, msg, args...) }
|
|
func (l *Logger) Error(msg string, args ...any) { l.log(LevelError, msg, args...) }
|
|
|
|
func (l *Logger) log(level Level, msg string, args ...any) {
|
|
if l == nil {
|
|
return
|
|
}
|
|
if level < l.level {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
line := fmt.Sprintf("%s %s %s", now.Format("2006-01-02 15:04:05"), level.String(), format(msg, args...))
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.basePath != "" {
|
|
_ = l.rotateLocked(now)
|
|
}
|
|
fmt.Fprintln(l.out, line)
|
|
}
|
|
|
|
// rotateLocked opens today's file when the calendar day changes. Caller holds mu.
|
|
func (l *Logger) rotateLocked(now time.Time) error {
|
|
day := now.Format(dateLayout)
|
|
if l.file != nil && l.day == day {
|
|
return nil
|
|
}
|
|
path := DatedPath(l.basePath, now)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|
return fmt.Errorf("create log dir: %w", err)
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
|
|
if err != nil {
|
|
return fmt.Errorf("open log file: %w", err)
|
|
}
|
|
if l.file != nil {
|
|
_ = l.file.Close()
|
|
}
|
|
l.file = f
|
|
l.day = day
|
|
l.out = io.MultiWriter(os.Stdout, f)
|
|
_ = purgeOld(l.basePath, now, RetainDays)
|
|
return nil
|
|
}
|
|
|
|
// DatedPath turns logs/tunnel.log into logs/tunnel-2006-01-02.log.
|
|
func DatedPath(base string, day time.Time) string {
|
|
dir := filepath.Dir(base)
|
|
ext := filepath.Ext(base)
|
|
stem := strings.TrimSuffix(filepath.Base(base), ext)
|
|
if stem == "" || stem == "." {
|
|
stem = "tunnel"
|
|
}
|
|
if ext == "" {
|
|
ext = ".log"
|
|
}
|
|
return filepath.Join(dir, stem+"-"+day.Format(dateLayout)+ext)
|
|
}
|
|
|
|
func purgeOld(base string, now time.Time, retainDays int) error {
|
|
if retainDays < 1 {
|
|
retainDays = RetainDays
|
|
}
|
|
dir := filepath.Dir(base)
|
|
ext := filepath.Ext(base)
|
|
stem := strings.TrimSuffix(filepath.Base(base), ext)
|
|
if stem == "" || stem == "." {
|
|
stem = "tunnel"
|
|
}
|
|
if ext == "" {
|
|
ext = ".log"
|
|
}
|
|
re := regexp.MustCompile("^" + regexp.QuoteMeta(stem) + `-(\d{4}-\d{2}-\d{2})` + regexp.QuoteMeta(ext) + `$`)
|
|
// Keep retainDays calendar days including today (e.g. 7 → today back through today-6).
|
|
oldest := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
|
|
AddDate(0, 0, -(retainDays - 1))
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
m := re.FindStringSubmatch(e.Name())
|
|
if m == nil {
|
|
continue
|
|
}
|
|
d, err := time.ParseInLocation(dateLayout, m[1], now.Location())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if d.Before(oldest) {
|
|
_ = os.Remove(filepath.Join(dir, e.Name()))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func format(msg string, args ...any) string {
|
|
if len(args) == 0 {
|
|
return msg
|
|
}
|
|
return msg + " " + strings.TrimSpace(fmt.Sprint(args...))
|
|
}
|
|
|
|
// RedactURL removes userinfo and known secret query keys.
|
|
func RedactURL(raw string) string {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || raw == "" {
|
|
return "(redacted-url)"
|
|
}
|
|
u.User = nil
|
|
q := u.Query()
|
|
changed := false
|
|
for key := range q {
|
|
lk := strings.ToLower(key)
|
|
if lk == "token" || lk == "password" || lk == "pass" || strings.Contains(lk, "secret") || strings.Contains(lk, "access") {
|
|
q.Set(key, "***")
|
|
changed = true
|
|
}
|
|
}
|
|
if changed {
|
|
u.RawQuery = q.Encode()
|
|
}
|
|
u.Fragment = ""
|
|
return u.String()
|
|
}
|
|
|
|
var secretKV = regexp.MustCompile(`(?i)(token|password|passwd|authorization)\s*[=:]\s*[^\s&;]+`)
|
|
|
|
func RedactError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return RedactText(err.Error())
|
|
}
|
|
|
|
func RedactText(s string) string {
|
|
out := secretKV.ReplaceAllString(s, "${1}=***")
|
|
if u := extractURL(out); u != "" {
|
|
out = strings.ReplaceAll(out, u, RedactURL(u))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractURL(s string) string {
|
|
lower := strings.ToLower(s)
|
|
start := strings.Index(lower, "https://")
|
|
if start < 0 {
|
|
start = strings.Index(lower, "http://")
|
|
}
|
|
if start < 0 {
|
|
return ""
|
|
}
|
|
end := start
|
|
for end < len(s) && !strings.ContainsRune(" \t\r\n<>\"'", rune(s[end])) {
|
|
end++
|
|
}
|
|
return s[start:end]
|
|
}
|