Sfoglia il codice sorgente

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 settimane fa
parent
commit
f372866982

+ 25
- 0
example.config.toml Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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
+}

Loading…
Annulla
Salva