Ver código fonte

Add configurable logTimeFormat option

Logs hardcoded timestamps as Unix milliseconds, which carries no
timezone and reads as UTC in plain log viewers (podman logs, logread).
This adds a logTimeFormat config field so operators can pick a
TZ-aware format, while keeping the existing numeric output as default.

- New TypeLogTimeFormat wrapper (typed like every other config field):
  presets unix / unix-ms / unix-micro / unix-nano / rfc3339 /
  rfc3339-nano, and any other value is passed through to zerolog as a
  Go reference-time layout. Set() rejects an empty value.
- Default stays "unix-ms" -> zerolog.TimeFormatUnixMs, so output is
  byte-for-byte unchanged unless the option is set.
- run_proxy.go resolves the configured value instead of hardcoding it.
- example.config.toml documents the presets with a single consistent
  example instant.

Based on the approach of #531 by @resolvicomai. Resolves #427.
log-time-format
Alexey Dolotov 3 semanas atrás
pai
commit
f372866982

+ 25
- 0
example.config.toml Ver arquivo

@@ -15,6 +15,31 @@
15 15
 # you have any issue.
16 16
 debug = true
17 17
 
18
+# logTimeFormat configures the timestamp format in logs. The default is
19
+# "unix-ms", which keeps the historical integer Unix-milliseconds output,
20
+# so nothing that parses the current logs breaks.
21
+#
22
+# All example values below render the same instant:
23
+# 2026-05-20T12:23:45.123Z, i.e. 2026-05-20T15:23:45.123 at +03:00.
24
+#
25
+# Numeric presets (good for log aggregators, always UTC by definition):
26
+#   "unix-ms"      Unix milliseconds.  DEFAULT. 1779279825123
27
+#   "unix"         Unix seconds.       1779279825
28
+#   "unix-micro"   Unix microseconds.  1779279825123456
29
+#   "unix-nano"    Unix nanoseconds.   1779279825123456789
30
+#
31
+# Human-readable presets (honor the host timezone):
32
+#   "rfc3339"      2026-05-20T15:23:45+03:00
33
+#   "rfc3339-nano" 2026-05-20T15:23:45.123456789+03:00
34
+#
35
+# Or any Go time layout. Reference moment: Mon Jan 2 15:04:05 MST 2006.
36
+# Examples:
37
+#   log-time-format = "2006-01-02 15:04:05"            # 2026-05-20 15:23:45
38
+#   log-time-format = "2006-01-02 15:04:05.000 -0700"  # with ms and offset
39
+#   log-time-format = "02 Jan 2006 15:04:05 MST"       # 20 May 2026 15:23:45 MSK
40
+#
41
+# log-time-format = "unix-ms"
42
+
18 43
 # A secret (required). Please remember that mtg supports only FakeTLS
19 44
 # mode, legacy simple and secured mode are prohibited. For you it means
20 45
 # that secret should either be base64-encoded or starts with ee.

+ 7
- 1
internal/cli/run_proxy.go Ver arquivo

@@ -24,7 +24,13 @@ import (
24 24
 )
25 25
 
26 26
 func makeLogger(conf *config.Config) mtglib.Logger {
27
-	zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs
27
+	// An unset log time format keeps mtg's historical default
28
+	// (Unix milliseconds), so the existing output does not change.
29
+	logTimeFormat := config.TypeLogTimeFormat{
30
+		Value: conf.LogTimeFormat.Get(config.TypeLogTimeFormatUnixMs),
31
+	}
32
+
33
+	zerolog.TimeFieldFormat = logTimeFormat.ZerologFormat()
28 34
 	zerolog.TimestampFieldName = "timestamp"
29 35
 	zerolog.LevelFieldName = "level"
30 36
 

+ 44
- 0
internal/cli/run_proxy_test.go Ver arquivo

@@ -0,0 +1,44 @@
1
+package cli
2
+
3
+import (
4
+	"testing"
5
+	"time"
6
+
7
+	"github.com/9seconds/mtg/v2/internal/config"
8
+	"github.com/rs/zerolog"
9
+	"github.com/stretchr/testify/assert"
10
+)
11
+
12
+// TestMakeLoggerTimeFieldFormat verifies that the configured logTimeFormat
13
+// is what ends up in zerolog's global TimeFieldFormat — including the
14
+// historical default when the field is unset.
15
+func TestMakeLoggerTimeFieldFormat(t *testing.T) {
16
+	cases := []struct {
17
+		name  string
18
+		value string
19
+		want  string
20
+	}{
21
+		{name: "unset-default", value: "", want: zerolog.TimeFormatUnixMs},
22
+		{name: "unix", value: "unix", want: zerolog.TimeFormatUnix},
23
+		{name: "unix-ms", value: "unix-ms", want: zerolog.TimeFormatUnixMs},
24
+		{name: "unix-micro", value: "unix-micro", want: zerolog.TimeFormatUnixMicro},
25
+		{name: "unix-nano", value: "unix-nano", want: zerolog.TimeFormatUnixNano},
26
+		{name: "rfc3339", value: "rfc3339", want: time.RFC3339},
27
+		{name: "rfc3339-nano", value: "rfc3339-nano", want: time.RFC3339Nano},
28
+		{name: "go-layout", value: "2006-01-02 15:04:05", want: "2006-01-02 15:04:05"},
29
+	}
30
+
31
+	for _, c := range cases {
32
+		c := c
33
+
34
+		t.Run(c.name, func(t *testing.T) {
35
+			conf := &config.Config{}
36
+			if c.value != "" {
37
+				assert.NoError(t, conf.LogTimeFormat.Set(c.value))
38
+			}
39
+
40
+			makeLogger(conf)
41
+			assert.Equal(t, c.want, zerolog.TimeFieldFormat)
42
+		})
43
+	}
44
+}

+ 19
- 18
internal/config/config.go Ver arquivo

@@ -22,20 +22,21 @@ type ListConfig struct {
22 22
 }
23 23
 
24 24
 type Config struct {
25
-	Debug                       TypeBool        `json:"debug"`
26
-	AllowFallbackOnUnknownDC    TypeBool        `json:"allowFallbackOnUnknownDc"`
27
-	Secret                      mtglib.Secret   `json:"secret"`
28
-	BindTo                      TypeHostPort    `json:"bindTo"`
29
-	ProxyProtocolListener       TypeBool        `json:"proxyProtocolListener"`
30
-	PreferIP                    TypePreferIP    `json:"preferIp"`
31
-	AutoUpdate                  TypeBool        `json:"autoUpdate"`
32
-	DomainFrontingPort          TypePort        `json:"domainFrontingPort"`
33
-	DomainFrontingIP            TypeIP          `json:"domainFrontingIp"`
34
-	DomainFrontingProxyProtocol TypeBool        `json:"domainFrontingProxyProtocol"`
35
-	TolerateTimeSkewness        TypeDuration    `json:"tolerateTimeSkewness"`
36
-	Concurrency                 TypeConcurrency `json:"concurrency"`
37
-	PublicIPv4                  TypeIP          `json:"publicIpv4"`
38
-	PublicIPv6                  TypeIP          `json:"publicIpv6"`
25
+	Debug                       TypeBool          `json:"debug"`
26
+	LogTimeFormat               TypeLogTimeFormat `json:"logTimeFormat"`
27
+	AllowFallbackOnUnknownDC    TypeBool          `json:"allowFallbackOnUnknownDc"`
28
+	Secret                      mtglib.Secret     `json:"secret"`
29
+	BindTo                      TypeHostPort      `json:"bindTo"`
30
+	ProxyProtocolListener       TypeBool          `json:"proxyProtocolListener"`
31
+	PreferIP                    TypePreferIP      `json:"preferIp"`
32
+	AutoUpdate                  TypeBool          `json:"autoUpdate"`
33
+	DomainFrontingPort          TypePort          `json:"domainFrontingPort"`
34
+	DomainFrontingIP            TypeIP            `json:"domainFrontingIp"`
35
+	DomainFrontingProxyProtocol TypeBool          `json:"domainFrontingProxyProtocol"`
36
+	TolerateTimeSkewness        TypeDuration      `json:"tolerateTimeSkewness"`
37
+	Concurrency                 TypeConcurrency   `json:"concurrency"`
38
+	PublicIPv4                  TypeIP            `json:"publicIpv4"`
39
+	PublicIPv6                  TypeIP            `json:"publicIpv6"`
39 40
 	DomainFronting              struct {
40 41
 		Host          TypeHost `json:"host"`
41 42
 		IP            TypeIP   `json:"ip"`
@@ -71,10 +72,10 @@ type Config struct {
71 72
 			Interval TypeDuration    `json:"interval"`
72 73
 			Count    TypeConcurrency `json:"count"`
73 74
 		} `json:"keepAlive"`
74
-		DOHIP            TypeIP         `json:"dohIp"`
75
-		DNS              TypeDNSURI     `json:"dns"`
76
-		Proxies          []TypeProxyURL `json:"proxies"`
77
-		TCPNotSentLowat  TypeBytes      `json:"tcpNotSentLowat"`
75
+		DOHIP           TypeIP         `json:"dohIp"`
76
+		DNS             TypeDNSURI     `json:"dns"`
77
+		Proxies         []TypeProxyURL `json:"proxies"`
78
+		TCPNotSentLowat TypeBytes      `json:"tcpNotSentLowat"`
78 79
 	} `json:"network"`
79 80
 	Stats struct {
80 81
 		StatsD struct {

+ 43
- 0
internal/config/config_test.go Ver arquivo

@@ -42,6 +42,49 @@ func (suite *ConfigTestSuite) TestParseMinimalConfig() {
42 42
 	suite.Equal("0.0.0.0:3128", conf.BindTo.String())
43 43
 }
44 44
 
45
+func (suite *ConfigTestSuite) TestParseLogTimeFormatDefault() {
46
+	conf, err := config.Parse(suite.ReadConfig("minimal.toml"))
47
+	suite.NoError(err)
48
+	suite.Equal("unix-ms", conf.LogTimeFormat.Get("unix-ms"))
49
+}
50
+
51
+func (suite *ConfigTestSuite) TestParseLogTimeFormatPresets() {
52
+	presets := []string{
53
+		"unix", "unix-ms", "unix-micro", "unix-nano",
54
+		"rfc3339", "rfc3339-nano",
55
+		"2006-01-02 15:04:05",
56
+	}
57
+
58
+	for _, preset := range presets {
59
+		preset := preset
60
+
61
+		suite.Run(preset, func() {
62
+			conf, err := config.Parse([]byte(`
63
+secret = "7oe1GqLy6TBc38CV3jx7q09nb29nbGUuY29t"
64
+bind-to = "0.0.0.0:3128"
65
+log-time-format = "` + preset + `"
66
+`))
67
+			suite.NoError(err)
68
+			suite.Equal(preset, conf.LogTimeFormat.Get("unix-ms"))
69
+		})
70
+	}
71
+}
72
+
73
+func (suite *ConfigTestSuite) TestParseLogTimeFormatEmptyIsUnset() {
74
+	// An explicitly empty log-time-format is dropped by the TOML->JSON
75
+	// omitempty step (the same as prefer-ip and every other optional
76
+	// string field), so it is treated as unset and falls back to the
77
+	// default. Rejection of a genuinely empty value is enforced by
78
+	// TypeLogTimeFormat.Set, exercised in type_log_time_format_test.go.
79
+	conf, err := config.Parse([]byte(`
80
+secret = "7oe1GqLy6TBc38CV3jx7q09nb29nbGUuY29t"
81
+bind-to = "0.0.0.0:3128"
82
+log-time-format = ""
83
+`))
84
+	suite.NoError(err)
85
+	suite.Equal("unix-ms", conf.LogTimeFormat.Get("unix-ms"))
86
+}
87
+
45 88
 func (suite *ConfigTestSuite) TestParsePublicIP() {
46 89
 	conf, err := config.Parse(suite.ReadConfig("public_ip.toml"))
47 90
 	suite.NoError(err)

+ 1
- 0
internal/config/parse.go Ver arquivo

@@ -10,6 +10,7 @@ import (
10 10
 
11 11
 type tomlConfig struct {
12 12
 	Debug                       bool   `toml:"debug" json:"debug,omitempty"`
13
+	LogTimeFormat               string `toml:"log-time-format" json:"logTimeFormat,omitempty"`
13 14
 	AllowFallbackOnUnknownDC    bool   `toml:"allow-fallback-on-unknown-dc" json:"allowFallbackOnUnknownDc,omitempty"`
14 15
 	Secret                      string `toml:"secret" json:"secret"`
15 16
 	BindTo                      string `toml:"bind-to" json:"bindTo"`

+ 105
- 0
internal/config/type_log_time_format.go Ver arquivo

@@ -0,0 +1,105 @@
1
+package config
2
+
3
+import (
4
+	"fmt"
5
+	"strings"
6
+	"time"
7
+
8
+	"github.com/rs/zerolog"
9
+)
10
+
11
+const (
12
+	// TypeLogTimeFormatUnix renders log timestamps as integer Unix
13
+	// seconds.
14
+	TypeLogTimeFormatUnix = "unix"
15
+
16
+	// TypeLogTimeFormatUnixMs renders log timestamps as integer Unix
17
+	// milliseconds. This is the default and matches mtg's historical
18
+	// behavior.
19
+	TypeLogTimeFormatUnixMs = "unix-ms"
20
+
21
+	// TypeLogTimeFormatUnixMicro renders log timestamps as integer Unix
22
+	// microseconds.
23
+	TypeLogTimeFormatUnixMicro = "unix-micro"
24
+
25
+	// TypeLogTimeFormatUnixNano renders log timestamps as integer Unix
26
+	// nanoseconds.
27
+	TypeLogTimeFormatUnixNano = "unix-nano"
28
+
29
+	// TypeLogTimeFormatRFC3339 renders log timestamps as an RFC3339
30
+	// string, honoring the host timezone.
31
+	TypeLogTimeFormatRFC3339 = "rfc3339"
32
+
33
+	// TypeLogTimeFormatRFC3339Nano renders log timestamps as an
34
+	// RFC3339Nano string, honoring the host timezone.
35
+	TypeLogTimeFormatRFC3339Nano = "rfc3339-nano"
36
+)
37
+
38
+// TypeLogTimeFormat configures the timestamp format used by the logger.
39
+//
40
+// A value is either one of the presets above or any Go reference-time
41
+// layout (see the time package). Presets map to zerolog's Unix magic
42
+// constants or to time.RFC3339/time.RFC3339Nano; everything else is
43
+// handed to zerolog verbatim as a layout string.
44
+//
45
+// Validation is intentionally shallow: a Go layout is just a string, so
46
+// there is no way to reject a malformed one up front without rendering a
47
+// sample timestamp (and even that has false negatives). Set therefore
48
+// rejects only what is unambiguously wrong — an empty value — and accepts
49
+// any non-empty string, leaving genuine layout typos to surface in the
50
+// log output.
51
+type TypeLogTimeFormat struct {
52
+	Value string
53
+}
54
+
55
+func (t *TypeLogTimeFormat) Set(value string) error {
56
+	if strings.TrimSpace(value) == "" {
57
+		return fmt.Errorf("log time format cannot be empty")
58
+	}
59
+
60
+	t.Value = value
61
+
62
+	return nil
63
+}
64
+
65
+func (t TypeLogTimeFormat) Get(defaultValue string) string {
66
+	if t.Value == "" {
67
+		return defaultValue
68
+	}
69
+
70
+	return t.Value
71
+}
72
+
73
+// ZerologFormat resolves the configured value to the string that
74
+// zerolog.TimeFieldFormat expects: a Unix magic constant, an RFC3339
75
+// layout, or the raw Go layout for a free-form value.
76
+func (t TypeLogTimeFormat) ZerologFormat() string {
77
+	switch strings.ToLower(strings.TrimSpace(t.Value)) {
78
+	case TypeLogTimeFormatUnix:
79
+		return zerolog.TimeFormatUnix
80
+	case TypeLogTimeFormatUnixMs:
81
+		return zerolog.TimeFormatUnixMs
82
+	case TypeLogTimeFormatUnixMicro:
83
+		return zerolog.TimeFormatUnixMicro
84
+	case TypeLogTimeFormatUnixNano:
85
+		return zerolog.TimeFormatUnixNano
86
+	case TypeLogTimeFormatRFC3339:
87
+		return time.RFC3339
88
+	case TypeLogTimeFormatRFC3339Nano:
89
+		return time.RFC3339Nano
90
+	default:
91
+		return t.Value
92
+	}
93
+}
94
+
95
+func (t *TypeLogTimeFormat) UnmarshalText(data []byte) error {
96
+	return t.Set(string(data))
97
+}
98
+
99
+func (t TypeLogTimeFormat) MarshalText() ([]byte, error) {
100
+	return []byte(t.String()), nil
101
+}
102
+
103
+func (t TypeLogTimeFormat) String() string {
104
+	return t.Value
105
+}

+ 126
- 0
internal/config/type_log_time_format_test.go Ver arquivo

@@ -0,0 +1,126 @@
1
+package config_test
2
+
3
+import (
4
+	"encoding/json"
5
+	"testing"
6
+	"time"
7
+
8
+	"github.com/9seconds/mtg/v2/internal/config"
9
+	"github.com/rs/zerolog"
10
+	"github.com/stretchr/testify/assert"
11
+	"github.com/stretchr/testify/suite"
12
+)
13
+
14
+type typeLogTimeFormatTestStruct struct {
15
+	Value config.TypeLogTimeFormat `json:"value"`
16
+}
17
+
18
+type TypeLogTimeFormatTestSuite struct {
19
+	suite.Suite
20
+}
21
+
22
+func (suite *TypeLogTimeFormatTestSuite) TestUnmarshalFail() {
23
+	testData := []string{
24
+		"",
25
+		"   ",
26
+	}
27
+
28
+	for _, v := range testData {
29
+		data, err := json.Marshal(map[string]string{
30
+			"value": v,
31
+		})
32
+		suite.NoError(err)
33
+
34
+		suite.T().Run(v, func(t *testing.T) {
35
+			assert.Error(t, json.Unmarshal(data, &typeLogTimeFormatTestStruct{}))
36
+		})
37
+	}
38
+}
39
+
40
+func (suite *TypeLogTimeFormatTestSuite) TestUnmarshalOk() {
41
+	testData := []string{
42
+		"unix",
43
+		"unix-ms",
44
+		"unix-micro",
45
+		"unix-nano",
46
+		"rfc3339",
47
+		"rfc3339-nano",
48
+		"UNIX-MS",
49
+		"RFC3339",
50
+		"2006-01-02 15:04:05",
51
+		"02 Jan 2006 15:04:05 MST",
52
+	}
53
+
54
+	for _, v := range testData {
55
+		data, err := json.Marshal(map[string]string{
56
+			"value": v,
57
+		})
58
+		suite.NoError(err)
59
+
60
+		suite.T().Run(v, func(t *testing.T) {
61
+			assert.NoError(t, json.Unmarshal(data, &typeLogTimeFormatTestStruct{}))
62
+		})
63
+	}
64
+}
65
+
66
+func (suite *TypeLogTimeFormatTestSuite) TestZerologFormatPresets() {
67
+	testData := []struct {
68
+		value string
69
+		want  string
70
+	}{
71
+		{value: "unix", want: zerolog.TimeFormatUnix},
72
+		{value: "unix-ms", want: zerolog.TimeFormatUnixMs},
73
+		{value: "unix-micro", want: zerolog.TimeFormatUnixMicro},
74
+		{value: "unix-nano", want: zerolog.TimeFormatUnixNano},
75
+		{value: "rfc3339", want: time.RFC3339},
76
+		{value: "rfc3339-nano", want: time.RFC3339Nano},
77
+		// Case-insensitive on presets.
78
+		{value: "UNIX-NANO", want: zerolog.TimeFormatUnixNano},
79
+		{value: "RFC3339-Nano", want: time.RFC3339Nano},
80
+		// Surrounding whitespace is trimmed on presets.
81
+		{value: "  unix-ms  ", want: zerolog.TimeFormatUnixMs},
82
+		// Anything else is a Go layout passed through verbatim.
83
+		{value: "2006-01-02 15:04:05", want: "2006-01-02 15:04:05"},
84
+		{value: "02 Jan 2006 15:04:05 MST", want: "02 Jan 2006 15:04:05 MST"},
85
+	}
86
+
87
+	for _, c := range testData {
88
+		c := c
89
+
90
+		suite.T().Run(c.value, func(t *testing.T) {
91
+			v := config.TypeLogTimeFormat{}
92
+			assert.NoError(t, v.Set(c.value))
93
+			assert.Equal(t, c.want, v.ZerologFormat())
94
+		})
95
+	}
96
+}
97
+
98
+func (suite *TypeLogTimeFormatTestSuite) TestSetRejectsEmpty() {
99
+	v := config.TypeLogTimeFormat{}
100
+
101
+	suite.Error(v.Set(""))
102
+	suite.Error(v.Set("   "))
103
+}
104
+
105
+func (suite *TypeLogTimeFormatTestSuite) TestGetDefault() {
106
+	// A zero value carries no format; Get must fall back to the default.
107
+	v := config.TypeLogTimeFormat{}
108
+	suite.Equal("unix-ms", v.Get("unix-ms"))
109
+
110
+	suite.NoError(v.Set("rfc3339"))
111
+	suite.Equal("rfc3339", v.Get("unix-ms"))
112
+}
113
+
114
+func (suite *TypeLogTimeFormatTestSuite) TestMarshalRoundTrip() {
115
+	testStruct := &typeLogTimeFormatTestStruct{}
116
+	suite.NoError(testStruct.Value.Set("rfc3339"))
117
+
118
+	encoded, err := json.Marshal(testStruct)
119
+	suite.NoError(err)
120
+	suite.Contains(string(encoded), "rfc3339")
121
+}
122
+
123
+func TestTypeLogTimeFormat(t *testing.T) {
124
+	t.Parallel()
125
+	suite.Run(t, &TypeLogTimeFormatTestSuite{})
126
+}

Carregando…
Cancelar
Salvar