Files
mysql-tunnel/internal/logger/logger.go
T
2026-09-21 16:46:55 +08:00

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