app-init
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
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]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRedactURLToken(t *testing.T) {
|
||||
raw := "http://ntunnel.example.com/?token=secret-token-value"
|
||||
got := RedactURL(raw)
|
||||
if strings.Contains(got, "secret-token-value") {
|
||||
t.Fatalf("token leaked: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactTextPasswordAndToken(t *testing.T) {
|
||||
in := `login failed password=hunter2 token=abc123 url=https://x.example/?token=zzz`
|
||||
got := RedactText(in)
|
||||
for _, secret := range []string{"hunter2", "abc123", "zzz"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("secret %q leaked in %q", secret, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
if ParseLevel("DEBUG") != LevelDebug {
|
||||
t.Fatal("debug")
|
||||
}
|
||||
if ParseLevel("nope") != LevelInfo {
|
||||
t.Fatal("default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatedPath(t *testing.T) {
|
||||
day := time.Date(2026, 9, 21, 15, 0, 0, 0, time.Local)
|
||||
got := DatedPath(filepath.Join("logs", "tunnel.log"), day)
|
||||
want := filepath.Join("logs", "tunnel-2026-09-21.log")
|
||||
if got != want {
|
||||
t.Fatalf("got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyRotateAndPurge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := filepath.Join(dir, "tunnel.log")
|
||||
now := time.Date(2026, 9, 21, 10, 0, 0, 0, time.Local)
|
||||
|
||||
keepDay := now.AddDate(0, 0, -3)
|
||||
dropDay := now.AddDate(0, 0, -8)
|
||||
keepPath := DatedPath(base, keepDay)
|
||||
dropPath := DatedPath(base, dropDay)
|
||||
if err := os.WriteFile(keepPath, []byte("keep\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dropPath, []byte("drop\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
l, err := New(base, "info", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
l.mu.Lock()
|
||||
if err := l.rotateLocked(now); err != nil {
|
||||
l.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
l.Info("hello")
|
||||
|
||||
today := DatedPath(base, now)
|
||||
if _, err := os.Stat(today); err != nil {
|
||||
t.Fatalf("today log missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(keepPath); err != nil {
|
||||
t.Fatalf("recent log should remain: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dropPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old log should be purged, err=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user