tcp stream (und damit: tls passthrough) ist implementiert
This commit is contained in:
1 parent
6a5cb95f92
commit
fdcd06c93f
16 files changed
+1008
-75
No files matched your search
@@ -18,6 +18,7 @@ import (
|
||||
// Configuration ist die Wurzel der dynamischen Traefik-Konfiguration.
|
||||
type Configuration struct {
|
||||
HTTP *HTTPConfiguration `json:"http,omitempty" yaml:"http,omitempty"`
|
||||
TCP *TCPConfiguration `json:"tcp,omitempty" yaml:"tcp,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPConfiguration enthält Router, Services und Middlewares der HTTP-Ebene.
|
||||
@@ -60,6 +61,43 @@ type Server struct {
|
||||
URL string `json:"url" yaml:"url"`
|
||||
}
|
||||
|
||||
// TCPConfiguration enthält Router und Services der TCP-Ebene. Sultan nutzt
|
||||
// sie nur für TLS-Passthrough (Routing per SNI, TLS endet beim Backend).
|
||||
type TCPConfiguration struct {
|
||||
Routers map[string]*TCPRouter `json:"routers,omitempty" yaml:"routers,omitempty"`
|
||||
Services map[string]*TCPService `json:"services,omitempty" yaml:"services,omitempty"`
|
||||
}
|
||||
|
||||
// TCPRouter entspricht einem Traefik-TCP-Router.
|
||||
type TCPRouter struct {
|
||||
EntryPoints []string `json:"entryPoints,omitempty" yaml:"entryPoints,omitempty"`
|
||||
Service string `json:"service" yaml:"service"`
|
||||
Rule string `json:"rule" yaml:"rule"`
|
||||
Priority int `json:"priority,omitempty" yaml:"priority,omitempty"`
|
||||
TLS *TCPRouterTLS `json:"tls,omitempty" yaml:"tls,omitempty"`
|
||||
}
|
||||
|
||||
// TCPRouterTLS aktiviert TLS für einen TCP-Router; Passthrough reicht die
|
||||
// Verbindung unverändert an das Backend durch.
|
||||
type TCPRouterTLS struct {
|
||||
Passthrough bool `json:"passthrough,omitempty" yaml:"passthrough,omitempty"`
|
||||
}
|
||||
|
||||
// TCPService entspricht einem Traefik-TCP-Service.
|
||||
type TCPService struct {
|
||||
LoadBalancer *TCPServersLoadBalancer `json:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty"`
|
||||
}
|
||||
|
||||
// TCPServersLoadBalancer verteilt Verbindungen auf die angegebenen Server.
|
||||
type TCPServersLoadBalancer struct {
|
||||
Servers []TCPServer `json:"servers" yaml:"servers"`
|
||||
}
|
||||
|
||||
// TCPServer ist ein einzelnes TCP-Backend (host:port).
|
||||
type TCPServer struct {
|
||||
Address string `json:"address" yaml:"address"`
|
||||
}
|
||||
|
||||
// Middleware ist eine Traefik-Middleware als verschachtelte Map, z. B.
|
||||
// {"redirectRegex": {"regex": "...", "permanent": "true"}}.
|
||||
//
|
||||
|
||||
+123
-17
@@ -46,6 +46,8 @@ func FromLabels(host string, labels map[string]string) (*Configuration, error) {
|
||||
routers: map[string]*Router{},
|
||||
services: map[string]*serviceSpec{},
|
||||
middlewares: map[string]Middleware{},
|
||||
tcpRouters: map[string]*TCPRouter{},
|
||||
tcpServices: map[string]*serviceSpec{},
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(labels))
|
||||
@@ -93,6 +95,8 @@ type builder struct {
|
||||
routers map[string]*Router
|
||||
services map[string]*serviceSpec
|
||||
middlewares map[string]Middleware
|
||||
tcpRouters map[string]*TCPRouter
|
||||
tcpServices map[string]*serviceSpec
|
||||
errs []error
|
||||
}
|
||||
|
||||
@@ -101,7 +105,7 @@ func (b *builder) apply(key, value string) error {
|
||||
switch strings.ToLower(parts[0]) {
|
||||
case "enable", "docker":
|
||||
return nil
|
||||
case "http":
|
||||
case "http", "tcp":
|
||||
default:
|
||||
return ErrUnsupportedLabel
|
||||
}
|
||||
@@ -112,6 +116,9 @@ func (b *builder) apply(key, value string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: leerer Name", ErrInvalidLabel)
|
||||
}
|
||||
if strings.EqualFold(parts[0], "tcp") {
|
||||
return b.applyTCP(kind, name, strings.ToLower(strings.Join(attr, ".")), value)
|
||||
}
|
||||
switch kind {
|
||||
case "routers":
|
||||
r, ok := b.routers[name]
|
||||
@@ -173,6 +180,65 @@ func setRouterAttr(r *Router, attr, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyTCP übersetzt traefik.tcp.*. Unterstützt wird nur, was für
|
||||
// TLS-Passthrough nötig ist; TCP-Middlewares und TLS-Terminierung nicht.
|
||||
func (b *builder) applyTCP(kind, name, attr, value string) error {
|
||||
switch kind {
|
||||
case "routers":
|
||||
r, ok := b.tcpRouters[name]
|
||||
if !ok {
|
||||
r = &TCPRouter{}
|
||||
b.tcpRouters[name] = r
|
||||
}
|
||||
return setTCPRouterAttr(r, attr, value)
|
||||
case "services":
|
||||
if attr != "loadbalancer.server.port" {
|
||||
return ErrUnsupportedLabel
|
||||
}
|
||||
s, ok := b.tcpServices[name]
|
||||
if !ok {
|
||||
s = &serviceSpec{}
|
||||
b.tcpServices[name] = s
|
||||
}
|
||||
return setServiceAttr(s, attr, value)
|
||||
default:
|
||||
return ErrUnsupportedLabel
|
||||
}
|
||||
}
|
||||
|
||||
func setTCPRouterAttr(r *TCPRouter, attr, value string) error {
|
||||
switch attr {
|
||||
case "rule":
|
||||
r.Rule = strings.TrimSpace(value)
|
||||
case "entrypoints":
|
||||
r.EntryPoints = splitList(value)
|
||||
case "service":
|
||||
r.Service = strings.TrimSpace(value)
|
||||
case "priority":
|
||||
p, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %q ist keine Zahl", ErrInvalidLabel, value)
|
||||
}
|
||||
r.Priority = p
|
||||
case "tls", "tls.passthrough":
|
||||
on, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %q ist kein Bool", ErrInvalidLabel, value)
|
||||
}
|
||||
if attr == "tls.passthrough" {
|
||||
if r.TLS == nil {
|
||||
r.TLS = &TCPRouterTLS{}
|
||||
}
|
||||
r.TLS.Passthrough = on
|
||||
} else if on && r.TLS == nil {
|
||||
r.TLS = &TCPRouterTLS{}
|
||||
}
|
||||
default:
|
||||
return ErrUnsupportedLabel
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureTLS(r *Router) *RouterTLS {
|
||||
if r.TLS == nil {
|
||||
r.TLS = &RouterTLS{}
|
||||
@@ -239,15 +305,20 @@ func setMiddlewareAttr(m Middleware, attr []string, value string) error {
|
||||
}
|
||||
|
||||
func (b *builder) build(host string) *Configuration {
|
||||
cfg := &Configuration{HTTP: b.buildHTTP(host), TCP: b.buildTCP(host)}
|
||||
if (len(b.services) > 0 || len(b.tcpServices) > 0) && strings.TrimSpace(host) == "" {
|
||||
b.errs = append(b.errs, fmt.Errorf("%w: keine Backend-Adresse (host) angegeben", ErrIncomplete))
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (b *builder) buildHTTP(host string) *HTTPConfiguration {
|
||||
if len(b.routers) == 0 && len(b.services) == 0 && len(b.middlewares) == 0 {
|
||||
return &Configuration{}
|
||||
return nil
|
||||
}
|
||||
http := &HTTPConfiguration{}
|
||||
|
||||
if len(b.services) > 0 {
|
||||
if strings.TrimSpace(host) == "" {
|
||||
b.errs = append(b.errs, fmt.Errorf("%w: keine Backend-Adresse (host) angegeben", ErrIncomplete))
|
||||
}
|
||||
http.Services = map[string]*Service{}
|
||||
for name, s := range b.services {
|
||||
if s.port == "" {
|
||||
@@ -271,17 +342,8 @@ func (b *builder) build(host string) *Configuration {
|
||||
if r.Rule == "" {
|
||||
b.errs = append(b.errs, fmt.Errorf("router %q: %w: rule fehlt", name, ErrIncomplete))
|
||||
}
|
||||
if r.Service != "" {
|
||||
continue
|
||||
}
|
||||
// Wie Traefiks Docker-Provider: ohne explizite Angabe wird der
|
||||
// Router an den einzigen Service des Containers gebunden.
|
||||
if len(b.services) != 1 {
|
||||
b.errs = append(b.errs, fmt.Errorf("router %q: %w: service fehlt und ist bei %d Services nicht eindeutig", name, ErrIncomplete, len(b.services)))
|
||||
continue
|
||||
}
|
||||
for svc := range b.services {
|
||||
r.Service = svc
|
||||
if r.Service == "" {
|
||||
r.Service = b.defaultService("router", name, b.services)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,7 +351,51 @@ func (b *builder) build(host string) *Configuration {
|
||||
if len(b.middlewares) > 0 {
|
||||
http.Middlewares = b.middlewares
|
||||
}
|
||||
return &Configuration{HTTP: http}
|
||||
return http
|
||||
}
|
||||
|
||||
func (b *builder) buildTCP(host string) *TCPConfiguration {
|
||||
if len(b.tcpRouters) == 0 && len(b.tcpServices) == 0 {
|
||||
return nil
|
||||
}
|
||||
tcp := &TCPConfiguration{}
|
||||
if len(b.tcpServices) > 0 {
|
||||
tcp.Services = map[string]*TCPService{}
|
||||
for name, s := range b.tcpServices {
|
||||
if s.port == "" {
|
||||
b.errs = append(b.errs, fmt.Errorf("tcp service %q: %w: loadbalancer.server.port fehlt", name, ErrIncomplete))
|
||||
continue
|
||||
}
|
||||
tcp.Services[name] = &TCPService{LoadBalancer: &TCPServersLoadBalancer{
|
||||
Servers: []TCPServer{{Address: net.JoinHostPort(host, s.port)}},
|
||||
}}
|
||||
}
|
||||
}
|
||||
if len(b.tcpRouters) > 0 {
|
||||
tcp.Routers = b.tcpRouters
|
||||
for name, r := range b.tcpRouters {
|
||||
if r.Rule == "" {
|
||||
b.errs = append(b.errs, fmt.Errorf("tcp router %q: %w: rule fehlt", name, ErrIncomplete))
|
||||
}
|
||||
if r.Service == "" {
|
||||
r.Service = b.defaultService("tcp router", name, b.tcpServices)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tcp
|
||||
}
|
||||
|
||||
// defaultService bindet wie Traefiks Docker-Provider einen Router ohne
|
||||
// explizite Angabe an den einzigen Service des Containers.
|
||||
func (b *builder) defaultService(kind, router string, services map[string]*serviceSpec) string {
|
||||
if len(services) != 1 {
|
||||
b.errs = append(b.errs, fmt.Errorf("%s %q: %w: service fehlt und ist bei %d Services nicht eindeutig", kind, router, ErrIncomplete, len(services)))
|
||||
return ""
|
||||
}
|
||||
for svc := range services {
|
||||
return svc
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitList(value string) []string {
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestFromLabels_Errors(t *testing.T) {
|
||||
labels map[string]string
|
||||
want error
|
||||
}{
|
||||
{"TCP nicht unterstützt", "h", map[string]string{"traefik.tcp.routers.db.rule": "HostSNI(`*`)"}, ErrUnsupportedLabel},
|
||||
{"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.routers.dns.entrypoints": "dns"}, ErrUnsupportedLabel},
|
||||
{"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.services.dns.loadbalancer.server.port": "53"}, ErrUnsupportedLabel},
|
||||
{"unbekannte HTTP-Art", "h", map[string]string{"traefik.http.serverstransports.x.insecureskipverify": "true"}, ErrUnsupportedLabel},
|
||||
{"Schlüssel zu kurz", "h", map[string]string{"traefik.http.routers.r": "x"}, ErrUnsupportedLabel},
|
||||
@@ -333,7 +333,7 @@ func TestFromLabels_Middlewares_ObjectThenValue(t *testing.T) {
|
||||
func TestFromLabels_CollectsAllErrors(t *testing.T) {
|
||||
_, err := FromLabels("h", map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.tcp.routers.db.rule": "HostSNI(`*`)",
|
||||
"traefik.udp.routers.dns.entrypoints": "dns",
|
||||
"traefik.http.services.s.loadbalancer.server.port": "nope",
|
||||
"traefik.http.routers.r.service": "s",
|
||||
})
|
||||
|
||||
+21
-7
@@ -21,22 +21,36 @@ func Merge(configs ...*Configuration) (*Configuration, error) {
|
||||
Services: map[string]*Service{},
|
||||
Middlewares: map[string]Middleware{},
|
||||
}
|
||||
tcp := &TCPConfiguration{
|
||||
Routers: map[string]*TCPRouter{},
|
||||
Services: map[string]*TCPService{},
|
||||
}
|
||||
var errs []error
|
||||
for _, c := range configs {
|
||||
if c == nil || c.HTTP == nil {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, mergeInto(out.Routers, c.HTTP.Routers, "router")...)
|
||||
errs = append(errs, mergeInto(out.Services, c.HTTP.Services, "service")...)
|
||||
errs = append(errs, mergeInto(out.Middlewares, c.HTTP.Middlewares, "middleware")...)
|
||||
if c.HTTP != nil {
|
||||
errs = append(errs, mergeInto(out.Routers, c.HTTP.Routers, "router")...)
|
||||
errs = append(errs, mergeInto(out.Services, c.HTTP.Services, "service")...)
|
||||
errs = append(errs, mergeInto(out.Middlewares, c.HTTP.Middlewares, "middleware")...)
|
||||
}
|
||||
if c.TCP != nil {
|
||||
errs = append(errs, mergeInto(tcp.Routers, c.TCP.Routers, "tcp router")...)
|
||||
errs = append(errs, mergeInto(tcp.Services, c.TCP.Services, "tcp service")...)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
if len(out.Routers) == 0 && len(out.Services) == 0 && len(out.Middlewares) == 0 {
|
||||
return &Configuration{}, nil
|
||||
res := &Configuration{}
|
||||
if len(out.Routers) > 0 || len(out.Services) > 0 || len(out.Middlewares) > 0 {
|
||||
res.HTTP = out
|
||||
}
|
||||
return &Configuration{HTTP: out}, nil
|
||||
if len(tcp.Routers) > 0 || len(tcp.Services) > 0 {
|
||||
res.TCP = tcp
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func mergeInto[V any](dst, src map[string]V, kind string) []error {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package traefik
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TLS-Passthrough für eigene CAs (z. B. tinyCA im Kundenlabor).
|
||||
func caLabels() map[string]string {
|
||||
return map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.tcp.routers.ca.rule": "HostSNI(`ca.lab.kunde.de`)",
|
||||
"traefik.tcp.routers.ca.entrypoints": "websecure",
|
||||
"traefik.tcp.routers.ca.tls.passthrough": "true",
|
||||
"traefik.tcp.services.ca.loadbalancer.server.port": "9000",
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromLabels_TCPPassthrough(t *testing.T) {
|
||||
got, err := FromLabels("100.64.0.42", caLabels())
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
want := &Configuration{TCP: &TCPConfiguration{
|
||||
Routers: map[string]*TCPRouter{
|
||||
"ca": {
|
||||
EntryPoints: []string{"websecure"},
|
||||
Service: "ca",
|
||||
Rule: "HostSNI(`ca.lab.kunde.de`)",
|
||||
TLS: &TCPRouterTLS{Passthrough: true},
|
||||
},
|
||||
},
|
||||
Services: map[string]*TCPService{
|
||||
"ca": {LoadBalancer: &TCPServersLoadBalancer{Servers: []TCPServer{{Address: "100.64.0.42:9000"}}}},
|
||||
},
|
||||
}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Konfiguration weicht ab\n got: %s\nwant: %s", mustJSON(t, got), mustJSON(t, want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromLabels_TCPAttributes(t *testing.T) {
|
||||
l := caLabels()
|
||||
delete(l, "traefik.tcp.routers.ca.tls.passthrough")
|
||||
l["traefik.TCP.Routers.ca.TLS"] = "true"
|
||||
l["traefik.tcp.routers.ca.priority"] = "7"
|
||||
l["traefik.tcp.routers.ca.service"] = "ca"
|
||||
got, err := FromLabels("2001:db8::1", l)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
r := got.TCP.Routers["ca"]
|
||||
if r.Priority != 7 || r.TLS == nil || r.TLS.Passthrough {
|
||||
t.Errorf("Router = %+v", r)
|
||||
}
|
||||
if addr := got.TCP.Services["ca"].LoadBalancer.Servers[0].Address; addr != "[2001:db8::1]:9000" {
|
||||
t.Errorf("Adresse = %q", addr)
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne service-Label bindet sich der Router an den einzigen TCP-Service.
|
||||
func TestFromLabels_TCPDefaultServiceBinding(t *testing.T) {
|
||||
got, err := FromLabels("10.0.0.1", caLabels())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TCP.Routers["ca"].Service != "ca" {
|
||||
t.Errorf("Service = %q", got.TCP.Routers["ca"].Service)
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP und TCP im selben Container: getrennte Namensräume, wie bei Traefik.
|
||||
func TestFromLabels_HTTPAndTCPTogether(t *testing.T) {
|
||||
l := caLabels()
|
||||
l["traefik.http.routers.ca.rule"] = "Host(`ca.lab.kunde.de`)"
|
||||
l["traefik.http.routers.ca.entrypoints"] = "web"
|
||||
l["traefik.http.services.ca.loadbalancer.server.port"] = "80"
|
||||
got, err := FromLabels("10.0.0.1", l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.HTTP.Routers["ca"].Service != "ca" || got.TCP.Routers["ca"].Service != "ca" {
|
||||
t.Errorf("Bindung falsch: %s", mustJSON(t, got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromLabels_TCPErrors(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
key, value string
|
||||
want error
|
||||
}{
|
||||
"certresolver": {"traefik.tcp.routers.ca.tls.certresolver", "le", ErrUnsupportedLabel},
|
||||
"middlewares": {"traefik.tcp.routers.ca.middlewares", "x", ErrUnsupportedLabel},
|
||||
"tcp middlewares": {"traefik.tcp.middlewares.x.ipallowlist.sourcerange", "10.0.0.0/8", ErrUnsupportedLabel},
|
||||
"proxyprotocol": {"traefik.tcp.services.ca.loadbalancer.proxyprotocol.version", "2", ErrUnsupportedLabel},
|
||||
"udp": {"traefik.udp.routers.dns.entrypoints", "dns", ErrUnsupportedLabel},
|
||||
"passthrough kaputt": {"traefik.tcp.routers.ca.tls.passthrough", "vielleicht", ErrInvalidLabel},
|
||||
"port kaputt": {"traefik.tcp.services.ca.loadbalancer.server.port", "0", ErrInvalidLabel},
|
||||
}
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
l := caLabels()
|
||||
l[tt.key] = tt.value
|
||||
if _, err := FromLabels("10.0.0.1", l); !errors.Is(err, tt.want) {
|
||||
t.Errorf("erwartet %v, bekommen %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
l := caLabels()
|
||||
delete(l, "traefik.tcp.routers.ca.rule")
|
||||
if _, err := FromLabels("10.0.0.1", l); !errors.Is(err, ErrIncomplete) {
|
||||
t.Errorf("fehlende Regel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMerge_TCP(t *testing.T) {
|
||||
a, err := FromLabels("10.0.0.1", caLabels())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := FromLabels("10.0.0.2", map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.tcp.routers.db.rule": "HostSNI(`db.kunde.de`)",
|
||||
"traefik.tcp.routers.db.tls.passthrough": "true",
|
||||
"traefik.tcp.services.db.loadbalancer.server.port": "5432",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Merge(a, b, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Merge: %v", err)
|
||||
}
|
||||
if len(got.TCP.Routers) != 2 || len(got.TCP.Services) != 2 || got.HTTP != nil {
|
||||
t.Errorf("Ergebnis: %s", mustJSON(t, got))
|
||||
}
|
||||
|
||||
c, _ := FromLabels("10.0.0.3", caLabels()) // gleicher Name, andere Adresse
|
||||
if _, err := Merge(a, c); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("erwartet ErrConflict, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshal_TCP(t *testing.T) {
|
||||
c, err := FromLabels("10.0.0.1", caLabels())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := c.Marshal(FormatJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"tcp":{"routers":{"ca":{"entryPoints":["websecure"],"service":"ca","rule":"HostSNI(` + "`ca.lab.kunde.de`" + `)","tls":{"passthrough":true}}},"services":{"ca":{"loadBalancer":{"servers":[{"address":"10.0.0.1:9000"}]}}}}}`
|
||||
if string(got) != want {
|
||||
t.Errorf("JSON\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user