Quellcode durchsuchen

Add base dialers module

tags/v2.0.0-rc1
9seconds vor 5 Jahren
Ursprung
Commit
4689479745

+ 7
- 0
example.config.toml Datei anzeigen

@@ -44,6 +44,13 @@ prefer-ips = "prefer-ipv6"
44 44
 # access.
45 45
 cloak-port = 443
46 46
 
47
+# Path to access file. Each time when proxy starts up, it writes an
48
+# access file. This file contains a JSON with settings how to access
49
+# this proxy.
50
+#
51
+# Pass filepath here or '-' if you want to dump into stdout.
52
+access-file = "-"
53
+
47 54
 # FakeTLS can compare timestamps to prevent probes. Each message has
48 55
 # encrypted timestamp. So, mtg can compare this timestamp and decide if
49 56
 # we need to proceed with connection or not.

+ 8
- 0
go.mod Datei anzeigen

@@ -1,3 +1,11 @@
1 1
 module github.com/9seconds/mtg/v2
2 2
 
3 3
 go 1.16
4
+
5
+require (
6
+	github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6
7
+	github.com/libp2p/go-reuseport v0.0.2
8
+	github.com/pelletier/go-toml v1.8.1
9
+	github.com/shadowsocks/go-shadowsocks2 v0.1.4
10
+	golang.org/x/net v0.0.0-20210226172049-e18ecbb05110
11
+)

+ 36
- 0
go.sum Datei anzeigen

@@ -0,0 +1,36 @@
1
+github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0=
2
+github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg=
3
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
5
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6
+github.com/libp2p/go-reuseport v0.0.2 h1:XSG94b1FJfGA01BUrT82imejHQyTxO4jEWqheyCXYvU=
7
+github.com/libp2p/go-reuseport v0.0.2/go.mod h1:SPD+5RwGC7rcnzngoYC86GjPzjSywuQyMVAheVBD9nQ=
8
+github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
9
+github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
10
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
11
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
12
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
13
+github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
14
+github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
15
+github.com/shadowsocks/go-shadowsocks2 v0.1.4 h1:4VzajPL7RwwmImysBSvI+lm/UaegDGQq3hr42dYo3gs=
16
+github.com/shadowsocks/go-shadowsocks2 v0.1.4/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
17
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
18
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
19
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
20
+golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 h1:/ZScEX8SfEmUGRHs0gxpqteO5nfNW6axyZbBdw9A12g=
21
+golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
22
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
23
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
24
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
25
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
26
+golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
27
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
28
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
29
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
30
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
31
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
32
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
33
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
34
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
35
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
36
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

+ 19
- 0
main.go Datei anzeigen

@@ -1,12 +1,31 @@
1 1
 package main
2 2
 
3 3
 import (
4
+	"fmt"
5
+	"io/ioutil"
4 6
 	"math/rand"
7
+	"os"
5 8
 	"time"
9
+
10
+	"github.com/9seconds/mtg/v2/mtglib/dialers"
6 11
 )
7 12
 
8 13
 var version = "dev" // has to be set by ldflags
9 14
 
10 15
 func main() {
11 16
 	rand.Seed(time.Now().UTC().UnixNano())
17
+
18
+	f, _ := os.Open("example.config.toml")
19
+
20
+	fmt.Println(parseRawConfig(f))
21
+
22
+	bd, _ := dialers.NewDefaultBaseDialer(0, 0)
23
+	d, _ := dialers.MakeDialer(bd, "9.9.9.9", 0)
24
+
25
+	r, err := d.HTTP.Get("https://ifconfig.co")
26
+
27
+	fmt.Println(err)
28
+	body, _ := ioutil.ReadAll(r.Body)
29
+
30
+	fmt.Println(string(body))
12 31
 }

+ 9
- 0
mtglib/dialers/consts.go Datei anzeigen

@@ -0,0 +1,9 @@
1
+package dialers
2
+
3
+import "time"
4
+
5
+const (
6
+	DefaultTimeout     = 10 * time.Second
7
+	DefaultHTTPTimeout = DefaultTimeout
8
+	DefaultBufferSize  = 4096
9
+)

+ 82
- 0
mtglib/dialers/default.go Datei anzeigen

@@ -0,0 +1,82 @@
1
+package dialers
2
+
3
+import (
4
+	"context"
5
+	"fmt"
6
+	"net"
7
+	"time"
8
+
9
+	"github.com/libp2p/go-reuseport"
10
+)
11
+
12
+type defaultBaseDialer struct {
13
+	net.Dialer
14
+
15
+	bufferSize int
16
+}
17
+
18
+func (d *defaultBaseDialer) Dial(network, address string) (net.Conn, error) {
19
+	return d.DialContext(context.Background(), network, address)
20
+}
21
+
22
+func (d *defaultBaseDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
23
+	switch network {
24
+	case "tcp", "tcp4", "tcp6":
25
+	default:
26
+		return nil, fmt.Errorf("unsupported network %s", network)
27
+	}
28
+
29
+	conn, err := d.Dialer.DialContext(ctx, network, address)
30
+	if err != nil {
31
+		return nil, fmt.Errorf("cannot dial to %s: %w", address, err)
32
+	}
33
+
34
+	tcpConn := conn.(*net.TCPConn)
35
+
36
+	if err := tcpConn.SetNoDelay(true); err != nil {
37
+		conn.Close()
38
+		return nil, fmt.Errorf("cannot set TCP_NO_DELAY: %w", err)
39
+	}
40
+
41
+	if err := tcpConn.SetReadBuffer(d.bufferSize); err != nil {
42
+		tcpConn.Close()
43
+		return nil, fmt.Errorf("cannot set read buffer size: %w", err)
44
+	}
45
+
46
+	if err := tcpConn.SetWriteBuffer(d.bufferSize); err != nil {
47
+		tcpConn.Close()
48
+		return nil, fmt.Errorf("cannot set write buffer size: %w", err)
49
+	}
50
+
51
+	if err := tcpConn.SetKeepAlive(true); err != nil {
52
+		tcpConn.Close()
53
+		return nil, fmt.Errorf("cannot enable keep-alive: %w", err)
54
+	}
55
+
56
+	return tcpConn, nil
57
+}
58
+
59
+func NewDefaultBaseDialer(timeout time.Duration, bufferSize int) (BaseDialer, error) {
60
+	switch {
61
+	case timeout < 0:
62
+		return nil, fmt.Errorf("timeout %v should be positive number", timeout)
63
+	case bufferSize < 0:
64
+		return nil, fmt.Errorf("buffer size %s should be positive number", bufferSize)
65
+	}
66
+
67
+	if timeout == 0 {
68
+		timeout = DefaultTimeout
69
+	}
70
+
71
+	if bufferSize == 0 {
72
+		bufferSize = DefaultBufferSize
73
+	}
74
+
75
+	return &defaultBaseDialer{
76
+		Dialer: net.Dialer{
77
+			Timeout: timeout,
78
+			Control: reuseport.Control,
79
+		},
80
+		bufferSize: bufferSize,
81
+	}, nil
82
+}

+ 112
- 0
mtglib/dialers/dialer.go Datei anzeigen

@@ -0,0 +1,112 @@
1
+package dialers
2
+
3
+import (
4
+	"context"
5
+	"fmt"
6
+	"math/rand"
7
+	"net"
8
+	"net/http"
9
+	"time"
10
+
11
+	doh "github.com/babolivier/go-doh-client"
12
+)
13
+
14
+type Dialer struct {
15
+	HTTP http.Client
16
+	DNS  doh.Resolver
17
+
18
+	baseDialer BaseDialer
19
+}
20
+
21
+func (d *Dialer) Dial(network, address string) (net.Conn, error) {
22
+	return d.DialContext(context.Background(), network, address)
23
+}
24
+
25
+func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
26
+	host, port, _ := net.SplitHostPort(address)
27
+
28
+	ips, err := d.resolveIPs(network, host)
29
+	if err != nil {
30
+		return nil, fmt.Errorf("cannot resolve dns names: %w", err)
31
+	}
32
+
33
+	rand.Shuffle(len(ips), func(i, j int) {
34
+		ips[i], ips[j] = ips[j], ips[i]
35
+	})
36
+
37
+	for _, v := range ips {
38
+		if conn, err := d.baseDialer.DialContext(ctx, network, net.JoinHostPort(v, port)); err == nil {
39
+			return conn, nil
40
+		}
41
+	}
42
+
43
+	return nil, fmt.Errorf("cannot dial to %s:%s", network, address)
44
+}
45
+
46
+func (d *Dialer) resolveIPs(network, address string) ([]string, error) {
47
+	if net.ParseIP(address) != nil {
48
+		return []string{address}, nil
49
+	}
50
+
51
+	var ips []string
52
+
53
+	switch network {
54
+	case "tcp", "tcp4":
55
+		if recs, _, err := d.DNS.LookupA(address); err == nil {
56
+			for _, v := range recs {
57
+				ips = append(ips, v.IP4)
58
+			}
59
+		}
60
+	}
61
+
62
+	switch network {
63
+	case "tcp", "tcp6":
64
+		if recs, _, err := d.DNS.LookupAAAA(address); err == nil {
65
+			for _, v := range recs {
66
+				ips = append(ips, v.IP6)
67
+			}
68
+		}
69
+	}
70
+
71
+	if len(ips) == 0 {
72
+		return nil, fmt.Errorf("cannot find any ips for %s:%s", network, address)
73
+	}
74
+
75
+	return ips, nil
76
+}
77
+
78
+func MakeDialer(base BaseDialer, dohHostname string, httpTimeout time.Duration) (*Dialer, error) {
79
+	switch {
80
+	case httpTimeout < 0:
81
+		return nil, fmt.Errorf("timeout should be positive number %v", httpTimeout)
82
+	case httpTimeout == 0:
83
+		httpTimeout = DefaultHTTPTimeout
84
+	}
85
+
86
+	if net.ParseIP(dohHostname) == nil {
87
+		return nil, fmt.Errorf("hostname %s should be IP address", dohHostname)
88
+	}
89
+
90
+	dohHTTPClient := &http.Client{
91
+		Timeout: httpTimeout,
92
+		Transport: &http.Transport{
93
+			DialContext: base.DialContext,
94
+		},
95
+	}
96
+	rv := &Dialer{
97
+		baseDialer: base,
98
+		DNS: doh.Resolver{
99
+			Host:       dohHostname,
100
+			Class:      doh.IN,
101
+			HTTPClient: dohHTTPClient,
102
+		},
103
+	}
104
+	rv.HTTP = http.Client{
105
+		Timeout: httpTimeout,
106
+		Transport: &http.Transport{
107
+			DialContext: rv.DialContext,
108
+		},
109
+	}
110
+
111
+	return rv, nil
112
+}

+ 11
- 0
mtglib/dialers/interfaces.go Datei anzeigen

@@ -0,0 +1,11 @@
1
+package dialers
2
+
3
+import (
4
+	"context"
5
+	"net"
6
+)
7
+
8
+type BaseDialer interface {
9
+	Dial(network, address string) (net.Conn, error)
10
+	DialContext(ctx context.Context, network, address string) (net.Conn, error)
11
+}

+ 68
- 0
mtglib/dialers/shadowsocks.go Datei anzeigen

@@ -0,0 +1,68 @@
1
+package dialers
2
+
3
+import (
4
+	"context"
5
+	"encoding/base64"
6
+	"fmt"
7
+	"net"
8
+	"net/url"
9
+	"strings"
10
+	"time"
11
+
12
+	shadowsocks "github.com/shadowsocks/go-shadowsocks2/core"
13
+)
14
+
15
+type shadowsocksBaseDialer struct {
16
+	base   BaseDialer
17
+	cipher shadowsocks.StreamConnCipher
18
+}
19
+
20
+func (s *shadowsocksBaseDialer) Dial(network, address string) (net.Conn, error) {
21
+	conn, err := s.base.Dial(network, address)
22
+	if err != nil {
23
+		return nil, err
24
+	}
25
+
26
+	return s.cipher.StreamConn(conn), nil
27
+}
28
+
29
+func (s *shadowsocksBaseDialer) DialContext(ctx context.Context,
30
+	network, address string) (net.Conn, error) {
31
+	conn, err := s.base.DialContext(ctx, network, address)
32
+	if err != nil {
33
+		return nil, err
34
+	}
35
+
36
+	return s.cipher.StreamConn(conn), nil
37
+}
38
+
39
+func NewShadowsocksBaseDialer(proxyUrl *url.URL,
40
+	timeout time.Duration, bufferSize int) (BaseDialer, error) {
41
+	username := proxyUrl.User.Username()
42
+
43
+	decoded, err := base64.RawURLEncoding.DecodeString(username)
44
+	if err != nil {
45
+		return nil, fmt.Errorf("cannot decode payload: %w", err)
46
+	}
47
+
48
+	chunks := strings.SplitN(string(decoded), ":", 2)
49
+	if len(chunks) != 2 {
50
+		return nil, fmt.Errorf("incorrect payload %s", username)
51
+	}
52
+
53
+	cipher, err := shadowsocks.PickCipher(chunks[0], nil, chunks[1])
54
+	if err != nil {
55
+		return nil, fmt.Errorf("cannot initialize shadowsocks cipher: %w", err)
56
+	}
57
+
58
+	baseDialer, err := NewDefaultBaseDialer(timeout, bufferSize)
59
+	if err != nil {
60
+		return nil, fmt.Errorf("cannot initialize a base dialer: %w", err)
61
+
62
+	}
63
+
64
+	return &shadowsocksBaseDialer{
65
+		base:   baseDialer,
66
+		cipher: cipher,
67
+	}, nil
68
+}

+ 23
- 0
mtglib/dialers/socks5.go Datei anzeigen

@@ -0,0 +1,23 @@
1
+package dialers
2
+
3
+import (
4
+	"fmt"
5
+	"net/url"
6
+	"time"
7
+
8
+	"golang.org/x/net/proxy"
9
+)
10
+
11
+func NewSocks5BaseDialer(proxyUrl *url.URL, timeout time.Duration, bufferSize int) (BaseDialer, error) {
12
+	baseDialer, err := NewDefaultBaseDialer(timeout, bufferSize)
13
+	if err != nil {
14
+		return nil, fmt.Errorf("cannot initialize base dialer: %w", err)
15
+	}
16
+
17
+	rv, err := proxy.FromURL(proxyUrl, baseDialer.(*defaultBaseDialer))
18
+	if err != nil {
19
+		return nil, fmt.Errorf("cannot initialize socks5 proxy dialer: %w", err)
20
+	}
21
+
22
+	return rv.(BaseDialer), nil
23
+}

+ 71
- 0
mtglib/secret.go Datei anzeigen

@@ -0,0 +1,71 @@
1
+package mtglib
2
+
3
+import (
4
+	"encoding/base64"
5
+	"encoding/hex"
6
+	"errors"
7
+	"fmt"
8
+	"strings"
9
+)
10
+
11
+type Secret struct {
12
+	Key  []byte
13
+	Host string
14
+}
15
+
16
+func (s *Secret) MarshalText() ([]byte, error) {
17
+	if s == nil {
18
+		return nil, nil
19
+	}
20
+
21
+	return []byte(s.String()), nil
22
+}
23
+
24
+func (s *Secret) UnmarshalText(text []byte) error {
25
+	sc, err := ParseSecret(string(text))
26
+	if err != nil {
27
+		return err
28
+	}
29
+
30
+	*s = sc
31
+
32
+	return nil
33
+}
34
+
35
+func (s Secret) Base64() string {
36
+	return s.String()
37
+}
38
+
39
+func (s Secret) EE() string {
40
+	return "ee" + hex.EncodeToString(append(s.Key, s.Host...))
41
+}
42
+
43
+func (s Secret) String() string {
44
+	return base64.StdEncoding.EncodeToString(append(s.Key, s.Host...))
45
+}
46
+
47
+func ParseSecret(secret string) (Secret, error) {
48
+	rv := Secret{}
49
+
50
+	if secret == "" {
51
+		return rv, errors.New("secret cannot be empty")
52
+	}
53
+
54
+	decoded, err := base64.RawStdEncoding.DecodeString(secret)
55
+	if err != nil && strings.HasPrefix(secret, "ee") {
56
+		decoded, err = hex.DecodeString(strings.TrimPrefix(secret, "ee"))
57
+	}
58
+
59
+	if err != nil {
60
+		return rv, fmt.Errorf("incorrect secret format: %w", err)
61
+	}
62
+
63
+	if len(decoded) < 33 {
64
+		return rv, fmt.Errorf("secret %s has incorrect length", secret)
65
+	}
66
+
67
+	rv.Key = decoded[:32]
68
+	rv.Host = string(decoded[32:])
69
+
70
+	return rv, nil
71
+}

+ 60
- 0
raw_config.go Datei anzeigen

@@ -0,0 +1,60 @@
1
+package main
2
+
3
+import (
4
+	"fmt"
5
+	"io"
6
+
7
+	"github.com/pelletier/go-toml"
8
+)
9
+
10
+type rawConfig struct {
11
+	Debug      bool   `toml:"debug"`
12
+	Secret     string `toml:"secret"`
13
+	BindTo     string `toml:"bind-to"`
14
+	TCPBuffer  string `toml:"tcp-buffer"`
15
+	PreferIP   string `toml:"prefer-ip"`
16
+	CloakPort  uint   `toml:"cloak-port"`
17
+	AccessFile string `toml:"access-file"`
18
+	Probes     struct {
19
+		Time struct {
20
+			Enabled       bool   `toml:"enabled"`
21
+			AllowSkewness string `toml:"allow-skewness"`
22
+		} `toml:"time"`
23
+		AntiReplay struct {
24
+			Enabled bool   `toml:"enabled"`
25
+			MaxSize string `toml:"max-size"`
26
+			TTL     string `toml:"ttl"`
27
+		} `toml:"anti-replay"`
28
+	} `toml:"probes"`
29
+	PublicIP struct {
30
+		IPv4 string `toml:"ipv4"`
31
+		IPv6 string `toml:"ipv6"`
32
+	} `toml:"public-ip"`
33
+	Dialers struct {
34
+		Telegram string `toml:"telegram"`
35
+		Default  string `toml:"default"`
36
+	} `toml:"dialers"`
37
+	Stats struct {
38
+		StatsD struct {
39
+			Enabled      bool   `toml:"enabled"`
40
+			Address      string `toml:"address"`
41
+			MetricPrefix string `toml:"metric-prefix"`
42
+		} `toml:"statsd"`
43
+		Prometheus struct {
44
+			Enabled      bool   `toml:"enabled"`
45
+			BindTo       string `toml:"bind-to"`
46
+			HttpPath     string `toml:"http-path"`
47
+			MetricPrefix string `toml:"metric-prefix"`
48
+		} `toml:"prometheus"`
49
+	} `toml:"stats"`
50
+}
51
+
52
+func parseRawConfig(reader io.Reader) (*rawConfig, error) {
53
+	conf := &rawConfig{}
54
+
55
+	if err := toml.NewDecoder(reader).Decode(conf); err != nil {
56
+		return nil, fmt.Errorf("cannot parse config: %w", err)
57
+	}
58
+
59
+	return conf, nil
60
+}

Laden…
Abbrechen
Speichern