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] }