diff --git a/flakehashes.json b/flakehashes.json index 052c527ee..a09a7fc76 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -1,6 +1,6 @@ { "vendor": { - "goModSum": "sha256-aq3f+UFdqP7KURd92D2IvlViCkYouZtK/6UzRyNTBgE=", - "sri": "sha256-EZ6+6LYzxcc48FwNddi46h6kaHur108O1qv1TIXfRzY=" + "goModSum": "sha256-oSB3v9cn+i6QxfOQGzsjCFjVgSOTrpp9mjR7mJmzQYs=", + "sri": "sha256-KpiRQbHBAPp2BB+NodeSih2/WIlg52dfoOpYxbrVmBI=" } } diff --git a/go.mod b/go.mod index d8055c88b..4dc87cc9b 100644 --- a/go.mod +++ b/go.mod @@ -19,9 +19,16 @@ require ( github.com/go-chi/metrics v0.1.1 github.com/go-gormigrate/gormigrate/v2 v2.1.7 github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/go-cmp v0.7.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jagottsicher/termcolor v1.0.2 + github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974 + github.com/libdns/cloudflare v0.2.2 + github.com/libdns/hetzner/v2 v2.0.1 + github.com/libdns/libdns v1.1.1 + github.com/libdns/powerdns v0.1.4 + github.com/libdns/rfc2136 v1.0.1 github.com/oapi-codegen/runtime v1.7.0 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 github.com/ory/dockertest/v3 v3.12.0 @@ -135,7 +142,6 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect @@ -151,6 +157,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/hetznercloud/hcloud-go/v2 v2.27.0 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -163,6 +170,7 @@ require ( github.com/kamstrup/intmap v0.5.2 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.20.1 // indirect + github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed // indirect github.com/lib/pq v1.12.3 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/mattn/go-colorable v0.1.15 // indirect @@ -170,7 +178,9 @@ require ( github.com/mattn/go-runewidth v0.0.31 // indirect github.com/mdlayher/netlink v1.11.2 // indirect github.com/mdlayher/socket v0.7.0 // indirect + github.com/miekg/dns v1.1.73 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/mittwald/go-powerdns v0.6.6 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/moby/api v1.55.0 // indirect github.com/moby/moby/client v0.5.1 // indirect diff --git a/go.sum b/go.sum index 47beb9f0a..ef7c9f9a7 100644 --- a/go.sum +++ b/go.sum @@ -259,12 +259,16 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/hetznercloud/hcloud-go/v2 v2.27.0 h1:SOGpAP3kQ6+aevB4Hxr63ukNsdYJjHhuWNB1C3NsiJo= +github.com/hetznercloud/hcloud-go/v2 v2.27.0/go.mod h1:OVlbjfoEuvNPI8ji3Sm/jPkjOxO7MKEiPyfctZ0R8jw= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= @@ -311,12 +315,27 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed h1:Jc+HE4/e+TK1H40RzQSa6ZR7Yu2Mx2brH+O4/uWsCgo= +github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed/go.mod h1:sZmT6pTI0BarSbOc1CvUxwpxLl5SSIXhitioezuVmD0= +github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974 h1:G3F633EXxBI7ls+F8Jet7geawmCsqZMzyLEp+ZmS4aI= +github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974/go.mod h1:cTFSzETMrxz9nLduiEMrI1VuLLohAg8AA56kJjw25Vw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/hetzner/v2 v2.0.1 h1:phsNc9er8JbVOCsytLG7VuMbCdTPsv+WWwNX1WCbk4Q= +github.com/libdns/hetzner/v2 v2.0.1/go.mod h1:IkEcr62KG0rXYhs2K5VWCG4SFPUQWuI1SzlpXBz9amk= +github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/powerdns v0.1.4 h1:QdQ+FL2t5ky2UYiWSodbz2HOwQkCWpd+WGa4OoWGpBk= +github.com/libdns/powerdns v0.1.4/go.mod h1:kTRi2e4sCcValWW6iWwyfcvFXvlxl1mn83vDAgq8bhA= +github.com/libdns/rfc2136 v1.0.1 h1:aiztZgzI2cd9FAtBNPILz01mQcZs1jMqJ467KKI4UQ0= +github.com/libdns/rfc2136 v1.0.1/go.mod h1:Uf4niCfXVgiMgwUrkPdIa5/sqLFdjVhkZj1ZfFAuSq4= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -340,6 +359,8 @@ github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mittwald/go-powerdns v0.6.6 h1:yQcuszhl98+jJgELjD5ecfxCQWoshhnArexpwrwQxLY= +github.com/mittwald/go-powerdns v0.6.6/go.mod h1:adWJ860laOgm14afg+7V0nCa5NQT37oEYe2HRhoS/CA= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= @@ -358,6 +379,7 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= @@ -627,6 +649,8 @@ google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/h2non/gock.v1 v1.0.14 h1:fTeu9fcUvSnLNacYvYI54h+1/XEteDyHvrVCZEEEYNM= +gopkg.in/h2non/gock.v1 v1.0.14/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hscontrol/dns/provider.go b/hscontrol/dns/provider.go new file mode 100644 index 000000000..b87fdfc0e --- /dev/null +++ b/hscontrol/dns/provider.go @@ -0,0 +1,157 @@ +package dns + +import ( + "errors" + "fmt" + "maps" + "os" + "reflect" + "slices" + "strings" + + "github.com/go-viper/mapstructure/v2" + gigahost "github.com/kradalby/libdns-gigahost" + "github.com/libdns/cloudflare" + "github.com/libdns/hetzner/v2" + "github.com/libdns/libdns" + "github.com/libdns/powerdns" + "github.com/libdns/rfc2136" +) + +// Provider writes ACME DNS-01 challenge records into a zone. +// +// It is append and delete only: a wildcard order puts two values on the +// same name, so SetRecords would replace the value the CA is validating. +type Provider interface { + libdns.RecordAppender + libdns.RecordDeleter +} + +// providers is the compiled-in provider registry. Providers are decoded +// from configuration through their json tags, so adding one is one line +// plus its module; the set is curated because each provider is a +// dependency to keep current and its quirks become ours. +var providers = map[string]func() Provider{ + "cloudflare": func() Provider { return new(cloudflare.Provider) }, + "gigahost": func() Provider { return new(gigahost.Provider) }, + "hetzner": func() Provider { return new(hetzner.Provider) }, + "powerdns": func() Provider { return new(powerdns.Provider) }, + "rfc2136": func() Provider { return new(rfc2136.Provider) }, +} + +var ( + ErrUnknownProvider = errors.New("unknown DNS provider") + ErrProviderConfig = errors.New("invalid DNS provider configuration") +) + +// pathSuffix marks a setting whose value is read from a file, keeping +// secrets out of the configuration file. +const pathSuffix = "_path" + +// ProviderNames returns the names of the compiled-in providers. +func ProviderNames() []string { + return slices.Sorted(maps.Keys(providers)) +} + +// NewProvider builds the named provider from its settings. +// +// settings holds the provider's keys as written in the configuration +// file. get looks up a single key and is consulted for every field of +// the provider, because environment variables only reach individual +// keys, never a map. Any string field can instead be read from a file +// named by "_path". Unknown keys are an error so a typo does not +// silently leave a credential empty. +func NewProvider( + name string, + settings map[string]any, + get func(key string) (any, bool), +) (Provider, error) { + newProvider, ok := providers[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownProvider, name) + } + + provider := newProvider() + + input := make(map[string]any, len(settings)) + for k, v := range settings { + input[strings.ToLower(k)] = v + } + + delete(input, "name") + + fields := jsonFields(provider) + for tag, kind := range fields { + if v, ok := get(tag); ok { + input[tag] = v + } + + pathKey := tag + pathSuffix + if _, isField := fields[pathKey]; isField || kind != reflect.String { + continue + } + + if v, ok := get(pathKey); ok { + input[pathKey] = v + } + + path, ok := input[pathKey] + if !ok { + continue + } + + if _, both := input[tag]; both { + return nil, fmt.Errorf("%w: %s and %s are mutually exclusive", ErrProviderConfig, tag, pathKey) + } + + value, err := os.ReadFile(os.ExpandEnv(fmt.Sprint(path))) + if err != nil { + return nil, fmt.Errorf("%w: reading %s: %w", ErrProviderConfig, pathKey, err) + } + + input[tag] = strings.TrimSpace(string(value)) + delete(input, pathKey) + } + + // Every provider needs a credential or a server: a block with only a + // name is a secret that was not set, and would fail at the first + // certificate instead of at startup. + if len(input) == 0 { + return nil, fmt.Errorf("%w for %s: no settings", ErrProviderConfig, name) + } + + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + TagName: "json", + WeaklyTypedInput: true, + ErrorUnused: true, + Result: provider, + }) + if err != nil { + return nil, fmt.Errorf("creating decoder: %w", err) + } + + err = decoder.Decode(input) + if err != nil { + return nil, fmt.Errorf("%w for %s: %w", ErrProviderConfig, name, err) + } + + return provider, nil +} + +// jsonFields returns the json key and kind of each configurable field of +// the provider struct. +func jsonFields(provider Provider) map[string]reflect.Kind { + t := reflect.TypeOf(provider).Elem() + fields := make(map[string]reflect.Kind, t.NumField()) + + for f := range t.Fields() { + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if !f.IsExported() || name == "" || name == "-" { + continue + } + + fields[name] = f.Type.Kind() + } + + return fields +} diff --git a/hscontrol/dns/provider_test.go b/hscontrol/dns/provider_test.go new file mode 100644 index 000000000..665fabd93 --- /dev/null +++ b/hscontrol/dns/provider_test.go @@ -0,0 +1,126 @@ +package dns + +import ( + "os" + "path/filepath" + "testing" + + gigahost "github.com/kradalby/libdns-gigahost" + "github.com/libdns/cloudflare" + "github.com/libdns/hetzner/v2" + "github.com/libdns/powerdns" + "github.com/libdns/rfc2136" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func noEnv(string) (any, bool) { return nil, false } + +func TestNewProvider(t *testing.T) { + secret := filepath.Join(t.TempDir(), "token") + require.NoError(t, os.WriteFile(secret, []byte("from-file\n"), 0o600)) + + tests := []struct { + name string + provider string + settings map[string]any + want Provider + wantErr error + }{ + { + name: "cloudflare from settings", + provider: "cloudflare", + settings: map[string]any{"name": "cloudflare", "api_token": "tok"}, + want: &cloudflare.Provider{APIToken: "tok"}, + }, + { + name: "name is case insensitive", + provider: "CloudFlare", + settings: map[string]any{"api_token": "tok"}, + want: &cloudflare.Provider{APIToken: "tok"}, + }, + { + name: "rfc2136", + provider: "rfc2136", + settings: map[string]any{ + "server": "ns1.example.com:53", + "key_name": "headscale", + "key_alg": "hmac-sha256", + "key": "c2VjcmV0", + }, + want: &rfc2136.Provider{ + Server: "ns1.example.com:53", + KeyName: "headscale", + KeyAlg: "hmac-sha256", + Key: "c2VjcmV0", + }, + }, + { + name: "secret from path", + provider: "cloudflare", + settings: map[string]any{"api_token_path": secret}, + want: &cloudflare.Provider{APIToken: "from-file"}, + }, + { + name: "value and path both set", + provider: "cloudflare", + settings: map[string]any{"api_token": "tok", "api_token_path": secret}, + wantErr: ErrProviderConfig, + }, + { + name: "unreadable path", + provider: "cloudflare", + settings: map[string]any{"api_token_path": filepath.Join(t.TempDir(), "missing")}, + wantErr: ErrProviderConfig, + }, + { + name: "unknown key", + provider: "cloudflare", + settings: map[string]any{"auth_token": "tok"}, + wantErr: ErrProviderConfig, + }, + { + name: "unknown provider", + provider: "route53", + wantErr: ErrUnknownProvider, + }, + { + name: "gigahost", + provider: "gigahost", + settings: map[string]any{"api_key_path": secret}, + want: &gigahost.Provider{APIKey: "from-file"}, + }, + { + name: "hetzner", + provider: "hetzner", + settings: map[string]any{"api_token_path": secret}, + want: &hetzner.Provider{APIToken: "from-file"}, + }, + { + name: "powerdns", + provider: "powerdns", + settings: map[string]any{"server_url": "https://pdns.example.com", "api_token": "tok"}, + want: &powerdns.Provider{ServerURL: "https://pdns.example.com", APIToken: "tok"}, + }, + { + name: "only a name", + provider: "gigahost", + settings: map[string]any{"name": "gigahost"}, + wantErr: ErrProviderConfig, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewProvider(tt.provider, tt.settings, noEnv) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}