diff --git a/TODO.md b/TODO.md index a4138fb..c56810d 100644 --- a/TODO.md +++ b/TODO.md @@ -11,6 +11,9 @@ - [x] Add DOT support - [x] Add DNS protocol on TCP mode support. - [x] Major records supported +- [x] Support multiple resolvers + - [x] Take multiple transport options and initialise resolvers accordingly. +- [ ] Add timeout support ## CLI Features - [x] `ndots` support @@ -37,7 +40,9 @@ - [ ] Don't abuse Hub as global. Refactor methods to be independent of hub. - [ ] Add meaningful comments where required. - +- [ ] Meaningful error messages +- [ ] Better debug logs +- [ ] ## Tests - [ ] Add tests for Command Line Usage. @@ -55,9 +60,15 @@ - [ ] Homebrew - [ ] ARM - [ ] Docker + +--- ## Future Release - [ ] Support obscure protocal tweaks in `dig` - [ ] `digfile` - [ ] Support more DNS Record Types - [ ] Error on NXDomain (Realted upstream [bug](https://github.com/miekg/dns/issues/1198)) +- [ ] Shell completions + - [ ] bash + - [ ] zsh + - [ ] fish diff --git a/cmd/cli.go b/cmd/cli.go index e440af7..602d7d3 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -41,6 +41,7 @@ func main() { f.BoolP("dot", "S", false, "Use the DNS-over-TLS") // Resolver Options + f.Int("timeout", 5, "Sets the timeout for a query to T seconds. The default timeout is 5 seconds.") f.Bool("search", false, "Use the search list provided in resolv.conf. It sets the `ndots` parameter as well unless overriden by `ndots` flag.") f.Int("ndots", 1, "Specify the ndots paramter. Default value is taken from resolv.conf and fallbacks to 1 if ndots statement is missing in resolv.conf") @@ -72,12 +73,19 @@ func main() { hub.Logger.Debug("Starting doggo 🐶") // Parse Query Args - hub.loadQueryArgs() - + err := hub.loadQueryArgs() + if err != nil { + hub.Logger.WithError(err).Error("error parsing flags/arguments") + hub.Logger.Exit(2) + } // Start App if len(hub.QueryFlags.QNames) == 0 { f.Usage() + hub.Logger.Exit(0) + } + err = hub.Lookup() + if err != nil { + hub.Logger.WithError(err).Error("error looking up DNS records") + hub.Logger.Exit(2) } - hub.Lookup() - } diff --git a/cmd/hub.go b/cmd/hub.go index f483502..61fd8ea 100644 --- a/cmd/hub.go +++ b/cmd/hub.go @@ -1,6 +1,8 @@ package main import ( + "time" + "github.com/miekg/dns" "github.com/mr-karan/doggo/pkg/resolvers" "github.com/sirupsen/logrus" @@ -13,26 +15,27 @@ type Hub struct { QueryFlags QueryFlags FreeArgs []string Questions []dns.Question - Resolver resolvers.Resolver + Resolver []resolvers.Resolver } // QueryFlags is used store the value of CLI flags. type QueryFlags struct { - QNames []string `koanf:"query"` - QTypes []string `koanf:"type"` - QClasses []string `koanf:"class"` - Nameservers []string `koanf:"nameserver"` - IsDOH bool `koanf:"doh"` - IsDOT bool `koanf:"dot"` - IsUDP bool `koanf:"udp"` - UseTCP bool `koanf:"tcp"` - UseIPv4 bool `koanf:"ipv4"` - UseIPv6 bool `koanf:"ipv6"` - DisplayTimeTaken bool `koanf:"time"` - ShowJSON bool `koanf:"json"` - UseSearchList bool `koanf:"search"` - Ndots int `koanf:"ndots"` - Color bool `koanf:"color"` + QNames []string `koanf:"query"` + QTypes []string `koanf:"type"` + QClasses []string `koanf:"class"` + Nameservers []string `koanf:"nameserver"` + IsDOH bool `koanf:"doh"` + IsDOT bool `koanf:"dot"` + IsUDP bool `koanf:"udp"` + IsTCP bool `koanf:"tcp"` + UseIPv4 bool `koanf:"ipv4"` + UseIPv6 bool `koanf:"ipv6"` + DisplayTimeTaken bool `koanf:"time"` + ShowJSON bool `koanf:"json"` + UseSearchList bool `koanf:"search"` + Ndots int `koanf:"ndots"` + Color bool `koanf:"color"` + Timeout time.Duration `koanf:"timeout"` } // NewHub initializes an instance of Hub which holds app wide configuration. @@ -55,7 +58,7 @@ func NewHub(logger *logrus.Logger, buildVersion string) *Hub { func initLogger() *logrus.Logger { logger := logrus.New() logger.SetFormatter(&logrus.TextFormatter{ - DisableTimestamp: true, + FullTimestamp: true, DisableLevelTruncation: true, }) return logger diff --git a/cmd/lookup.go b/cmd/lookup.go index 09768b5..c1922b6 100644 --- a/cmd/lookup.go +++ b/cmd/lookup.go @@ -1,6 +1,7 @@ package main import ( + "errors" "strings" "github.com/miekg/dns" @@ -14,9 +15,17 @@ func (hub *Hub) Lookup() error { if err != nil { return err } - responses, err := hub.Resolver.Lookup(hub.Questions) - if err != nil { - return err + // for each type of resolver do a DNS lookup + responses := make([][]resolvers.Response, 0, len(hub.Questions)) + for _, r := range hub.Resolver { + resp, err := r.Lookup(hub.Questions) + if err != nil { + return err + } + responses = append(responses, resp) + } + if len(responses) == 0 { + return errors.New(`no DNS records found`) } hub.Output(responses) return nil diff --git a/cmd/output.go b/cmd/output.go index ccd6a65..9f9acbc 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -37,9 +37,9 @@ type JSONResponse struct { Response `json:"responses"` } -func (hub *Hub) outputJSON(out []Output, msgs []resolvers.Response) { +func (hub *Hub) outputJSON(out []Output) { // get the questions - queries := make([]Query, 0, len(msgs)) + queries := make([]Query, 0) for _, ques := range hub.Questions { q := Query{ Name: ques.Name, @@ -122,73 +122,77 @@ func (hub *Hub) outputTerminal(out []Output) { // Output takes a list of `dns.Answers` and based // on the output format specified displays the information. -func (hub *Hub) Output(responses []resolvers.Response) { +func (hub *Hub) Output(responses [][]resolvers.Response) { out := collectOutput(responses) if len(out) == 0 { hub.Logger.Info("No records found") hub.Logger.Exit(0) } if hub.QueryFlags.ShowJSON { - hub.outputJSON(out, responses) + hub.outputJSON(out) } else { hub.outputTerminal(out) } } -func collectOutput(responses []resolvers.Response) []Output { +func collectOutput(responses [][]resolvers.Response) []Output { var out []Output - // gather Output from the DNS Messages - for _, r := range responses { - var addr string - for _, a := range r.Message.Answer { - switch t := a.(type) { - case *dns.A: - addr = t.A.String() - case *dns.AAAA: - addr = t.AAAA.String() - case *dns.CNAME: - addr = t.Target - case *dns.CAA: - addr = t.Tag + " " + t.Value - case *dns.HINFO: - addr = t.Cpu + " " + t.Os - // case *dns.LOC: - // addr = t.String() - case *dns.PTR: - addr = t.Ptr - case *dns.SRV: - addr = strconv.Itoa(int(t.Priority)) + " " + - strconv.Itoa(int(t.Weight)) + " " + - t.Target + ":" + strconv.Itoa(int(t.Port)) - case *dns.TXT: - addr = t.String() - case *dns.NS: - addr = t.Ns - case *dns.MX: - addr = strconv.Itoa(int(t.Preference)) + " " + t.Mx - case *dns.SOA: - addr = t.String() - case *dns.NAPTR: - addr = t.String() - } + // for each resolver + for _, rslvr := range responses { + // get the response + for _, r := range rslvr { + var addr string + for _, a := range r.Message.Answer { + switch t := a.(type) { + case *dns.A: + addr = t.A.String() + case *dns.AAAA: + addr = t.AAAA.String() + case *dns.CNAME: + addr = t.Target + case *dns.CAA: + addr = t.Tag + " " + t.Value + case *dns.HINFO: + addr = t.Cpu + " " + t.Os + // case *dns.LOC: + // addr = t.String() + case *dns.PTR: + addr = t.Ptr + case *dns.SRV: + addr = strconv.Itoa(int(t.Priority)) + " " + + strconv.Itoa(int(t.Weight)) + " " + + t.Target + ":" + strconv.Itoa(int(t.Port)) + case *dns.TXT: + addr = t.String() + case *dns.NS: + addr = t.Ns + case *dns.MX: + addr = strconv.Itoa(int(t.Preference)) + " " + t.Mx + case *dns.SOA: + addr = t.String() + case *dns.NAPTR: + addr = t.String() + } - h := a.Header() - name := h.Name - qclass := dns.Class(h.Class).String() - ttl := strconv.FormatInt(int64(h.Ttl), 10) + "s" - qtype := dns.Type(h.Rrtype).String() - rtt := fmt.Sprintf("%dms", r.RTT.Milliseconds()) - o := Output{ - Name: name, - Type: qtype, - TTL: ttl, - Class: qclass, - Address: addr, - TimeTaken: rtt, - Nameserver: r.Nameserver, + h := a.Header() + name := h.Name + qclass := dns.Class(h.Class).String() + ttl := strconv.FormatInt(int64(h.Ttl), 10) + "s" + qtype := dns.Type(h.Rrtype).String() + rtt := fmt.Sprintf("%dms", r.RTT.Milliseconds()) + o := Output{ + Name: name, + Type: qtype, + TTL: ttl, + Class: qclass, + Address: addr, + TimeTaken: rtt, + Nameserver: r.Nameserver, + } + out = append(out, o) } - out = append(out, o) } } + return out } diff --git a/cmd/parse.go b/cmd/parse.go index 90eb5a9..8f147e6 100644 --- a/cmd/parse.go +++ b/cmd/parse.go @@ -7,22 +7,20 @@ import ( ) func (hub *Hub) loadQueryArgs() error { - err := hub.loadNamedArgs() - + if err != nil { + return err + } err = hub.loadFreeArgs() if err != nil { - hub.Logger.WithError(err).Error("Error parsing arguments") - hub.Logger.Exit(2) + return err } err = hub.initResolver() if err != nil { - hub.Logger.WithError(err).Error("Error parsing nameservers") - hub.Logger.Exit(2) + return err } - hub.loadFallbacks() - return err + return nil } // loadFreeArgs tries to parse all the arguments diff --git a/cmd/resolver.go b/cmd/resolver.go index 95c8e5a..5c263ee 100644 --- a/cmd/resolver.go +++ b/cmd/resolver.go @@ -1,7 +1,7 @@ package main import ( - "runtime" + "time" "github.com/mr-karan/doggo/pkg/resolvers" ) @@ -11,36 +11,39 @@ import ( func (hub *Hub) initResolver() error { // check if DOH flag is set. if hub.QueryFlags.IsDOH { - rslvr, err := resolvers.NewDOHResolver(hub.QueryFlags.Nameservers) - if err != nil { - return err - } - hub.Resolver = rslvr - return nil - } - if len(hub.QueryFlags.Nameservers) == 0 { - if runtime.GOOS == "windows" { - // TODO: Add a method for reading system default nameserver in windows. - } else { - rslvr, err := resolvers.NewSystemResolver(resolvers.DefaultResolvConfPath) - if err != nil { - return err - } - hub.Resolver = rslvr - return nil - } - } else { - rslvr, err := resolvers.NewClassicResolver(hub.QueryFlags.Nameservers, resolvers.ClassicResolverOpts{ - UseIPv4: hub.QueryFlags.UseIPv4, - UseIPv6: hub.QueryFlags.UseIPv6, - UseTLS: hub.QueryFlags.IsDOT, - UseTCP: hub.QueryFlags.UseTCP, + hub.Logger.Debug("initiating DOH resolver") + rslvr, err := resolvers.NewDOHResolver(hub.QueryFlags.Nameservers, resolvers.DOHResolverOpts{ + Timeout: hub.QueryFlags.Timeout * time.Second, }) if err != nil { return err } - hub.Resolver = rslvr - return nil + hub.Resolver = append(hub.Resolver, rslvr) + } + if hub.QueryFlags.IsTCP { + hub.Logger.Debug("initiating TCP resolver") + rslvr, err := resolvers.NewTCPResolver(hub.QueryFlags.Nameservers, resolvers.TCPResolverOpts{ + IPv4Only: hub.QueryFlags.UseIPv4, + IPv6Only: hub.QueryFlags.UseIPv6, + Timeout: hub.QueryFlags.Timeout * time.Second, + }) + if err != nil { + return err + } + hub.Resolver = append(hub.Resolver, rslvr) + } + // If so far no resolver has been set, then fallback to UDP. + if hub.QueryFlags.IsUDP || len(hub.Resolver) == 0 { + hub.Logger.Debug("initiating UDP resolver") + rslvr, err := resolvers.NewUDPResolver(hub.QueryFlags.Nameservers, resolvers.UDPResolverOpts{ + IPv4Only: hub.QueryFlags.UseIPv4, + IPv6Only: hub.QueryFlags.UseIPv6, + Timeout: hub.QueryFlags.Timeout * time.Second, + }) + if err != nil { + return err + } + hub.Resolver = append(hub.Resolver, rslvr) } return nil } diff --git a/pkg/resolvers/doh.go b/pkg/resolvers/doh.go index 997f5fd..b999ed4 100644 --- a/pkg/resolvers/doh.go +++ b/pkg/resolvers/doh.go @@ -2,8 +2,11 @@ package resolvers import ( "bytes" + "errors" + "fmt" "io/ioutil" "net/http" + "net/url" "time" "github.com/miekg/dns" @@ -15,10 +18,26 @@ type DOHResolver struct { servers []string } +type DOHResolverOpts struct { + Timeout time.Duration +} + // NewDOHResolver accepts a list of nameservers and configures a DOH based resolver. -func NewDOHResolver(servers []string) (Resolver, error) { +func NewDOHResolver(servers []string, opts DOHResolverOpts) (Resolver, error) { + if len(servers) == 0 { + return nil, errors.New(`no DOH server specified`) + } + for _, s := range servers { + u, err := url.ParseRequestURI(s) + if err != nil { + return nil, fmt.Errorf("%s is not a valid HTTPS nameserver", s) + } + if u.Scheme != "https" { + return nil, fmt.Errorf("missing https in %s", s) + } + } httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: opts.Timeout, } return &DOHResolver{ client: httpClient, @@ -46,7 +65,7 @@ func (d *DOHResolver) Lookup(questions []dns.Question) ([]Response, error) { return nil, err } if resp.StatusCode != http.StatusOK { - return nil, err + return nil, fmt.Errorf("error from nameserver %s", resp.Status) } rtt := time.Since(now) // extract the binary response in DNS Message. diff --git a/pkg/resolvers/resolver.go b/pkg/resolvers/resolver.go index ad11d8d..115461f 100644 --- a/pkg/resolvers/resolver.go +++ b/pkg/resolvers/resolver.go @@ -7,14 +7,14 @@ import ( ) // Resolver implements the configuration for a DNS -// Client. Different types of client like (UDP/TCP/DOH/DOT) -// can be initialised. +// Client. Different types of providers can load +// a DNS Resolver satisfying this interface. type Resolver interface { Lookup([]dns.Question) ([]Response, error) } // Response represents a custom output format -// which wraps certain metadata about the DNS query +// for DNS queries. It wraps metadata about the DNS query // and the DNS Answer as well. type Response struct { Message dns.Msg diff --git a/pkg/resolvers/system.go b/pkg/resolvers/system.go deleted file mode 100644 index 7e52b5f..0000000 --- a/pkg/resolvers/system.go +++ /dev/null @@ -1,73 +0,0 @@ -package resolvers - -import ( - "fmt" - "net" - - "github.com/miekg/dns" -) - -// SystemResolver represents the config options based on the -// resolvconf file. -type SystemResolver struct { - client *dns.Client - config *dns.ClientConfig - servers []string -} - -// NewSystemResolver loads the configuration from resolv config file -// and initialises a DNS resolver. -func NewSystemResolver(resolvFilePath string) (Resolver, error) { - if resolvFilePath == "" { - resolvFilePath = DefaultResolvConfPath - } - cfg, err := dns.ClientConfigFromFile(resolvFilePath) - if err != nil { - return nil, err - } - - servers := make([]string, 0, len(cfg.Servers)) - for _, s := range cfg.Servers { - ip := net.ParseIP(s) - // handle IPv6 - if ip != nil && ip.To4() != nil { - servers = append(servers, fmt.Sprintf("%s:%s", s, cfg.Port)) - } else { - servers = append(servers, fmt.Sprintf("[%s]:%s", s, cfg.Port)) - } - } - - client := &dns.Client{} - return &SystemResolver{ - client: client, - servers: servers, - config: cfg, - }, nil -} - -// Lookup prepare a list of DNS messages to be sent to the server. -// It's possible to send multiple question in one message -// but some nameservers are not able to -func (s *SystemResolver) Lookup(questions []dns.Question) ([]Response, error) { - var ( - messages = prepareMessages(questions) - responses []Response - ) - - for _, msg := range messages { - for _, srv := range s.servers { - in, rtt, err := s.client.Exchange(&msg, srv) - if err != nil { - return nil, err - } - msg.Answer = in.Answer - rsp := Response{ - Message: msg, - RTT: rtt, - Nameserver: srv, - } - responses = append(responses, rsp) - } - } - return responses, nil -} diff --git a/pkg/resolvers/tcp.go b/pkg/resolvers/tcp.go new file mode 100644 index 0000000..fc3fcf9 --- /dev/null +++ b/pkg/resolvers/tcp.go @@ -0,0 +1,93 @@ +package resolvers + +import ( + "net" + "time" + + "github.com/miekg/dns" +) + +const ( + // DefaultTLSPort specifies the default port for a DNS server connecting over TCP over TLS + DefaultTLSPort = "853" +) + +// TCPResolver represents the config options for setting up a Resolver. +type TCPResolver struct { + client *dns.Client + servers []string +} + +// TCPResolverOpts represents the config options for setting up a TCPResolver. +type TCPResolverOpts struct { + IPv4Only bool + IPv6Only bool + Timeout time.Duration +} + +// NewTCPResolver accepts a list of nameservers and configures a DNS resolver. +func NewTCPResolver(servers []string, opts TCPResolverOpts) (Resolver, error) { + client := &dns.Client{ + Timeout: opts.Timeout, + } + var nameservers []string + + // load list of nameservers to the config + if len(servers) == 0 { + ns, err := getDefaultServers() + if err != nil { + return nil, err + } + nameservers = ns + } else { + // load the list of servers that user specified. + for _, srv := range servers { + if i := net.ParseIP(srv); i != nil { + // if no port specified in nameserver, append defaults. + nameservers = append(nameservers, net.JoinHostPort(srv, DefaultTLSPort)) + } else { + // use the port user specified. + nameservers = append(nameservers, srv) + } + } + } + + client.Net = "tcp" + if opts.IPv4Only { + client.Net = "tcp4" + } + if opts.IPv6Only { + client.Net = "tcp6" + } + return &TCPResolver{ + client: client, + servers: nameservers, + }, nil +} + +// Lookup prepare a list of DNS messages to be sent to the server. +// It's possible to send multiple question in one message +// but some nameservers are not able to +func (r *TCPResolver) Lookup(questions []dns.Question) ([]Response, error) { + var ( + messages = prepareMessages(questions) + responses []Response + ) + + for _, msg := range messages { + for _, srv := range r.servers { + in, rtt, err := r.client.Exchange(&msg, srv) + if err != nil { + return nil, err + } + msg.Answer = in.Answer + rsp := Response{ + Message: msg, + RTT: rtt, + Nameserver: srv, + } + responses = append(responses, rsp) + } + } + return responses, nil +} diff --git a/pkg/resolvers/classic.go b/pkg/resolvers/udp.go similarity index 51% rename from pkg/resolvers/classic.go rename to pkg/resolvers/udp.go index 7d95ece..a6c04d2 100644 --- a/pkg/resolvers/classic.go +++ b/pkg/resolvers/udp.go @@ -2,6 +2,7 @@ package resolvers import ( "net" + "time" "github.com/miekg/dns" ) @@ -9,58 +10,58 @@ import ( const ( // DefaultUDPPort specifies the default port for a DNS server connecting over UDP DefaultUDPPort = "53" - // DefaultTLSPort specifies the default port for a DNS server connecting over TCP over TLS - DefaultTLSPort = "853" //DefaultResolvConfPath specifies path to default resolv config file on UNIX. DefaultResolvConfPath = "/etc/resolv.conf" ) -// ClassicResolver represents the config options for setting up a Resolver. -type ClassicResolver struct { +// UDPResolver represents the config options for setting up a Resolver. +type UDPResolver struct { client *dns.Client servers []string } -// ClassicResolverOpts holds options for setting up a Classic resolver. -type ClassicResolverOpts struct { - UseIPv4 bool - UseIPv6 bool - UseTCP bool - UseTLS bool +// UDPResolverOpts holds options for setting up a Classic resolver. +type UDPResolverOpts struct { + IPv4Only bool + IPv6Only bool + Timeout time.Duration } -// NewClassicResolver accepts a list of nameservers and configures a DNS resolver. -func NewClassicResolver(servers []string, opts ClassicResolverOpts) (Resolver, error) { - client := &dns.Client{} +// NewUDPResolver accepts a list of nameservers and configures a DNS resolver. +func NewUDPResolver(servers []string, opts UDPResolverOpts) (Resolver, error) { + client := &dns.Client{ + Timeout: opts.Timeout, + } var nameservers []string - for _, srv := range servers { - if i := net.ParseIP(srv); i != nil { - // if no port specified in nameserver, append defaults. - if opts.UseTLS == true { - nameservers = append(nameservers, net.JoinHostPort(srv, DefaultTLSPort)) - } else { + + // load list of nameservers to the config + if len(servers) == 0 { + ns, err := getDefaultServers() + if err != nil { + return nil, err + } + nameservers = ns + } else { + // load the list of servers that user specified. + for _, srv := range servers { + if i := net.ParseIP(srv); i != nil { + // if no port specified in nameserver, append defaults. nameservers = append(nameservers, net.JoinHostPort(srv, DefaultUDPPort)) + } else { + // use the port user specified. + nameservers = append(nameservers, srv) } - } else { - // use the port user specified. - nameservers = append(nameservers, srv) } } client.Net = "udp" - if opts.UseIPv4 { + if opts.IPv4Only { client.Net = "udp4" } - if opts.UseIPv6 { + if opts.IPv6Only { client.Net = "udp6" } - if opts.UseTCP { - client.Net = "tcp" - } - if opts.UseTLS { - client.Net = "tcp-tls" - } - return &ClassicResolver{ + return &UDPResolver{ client: client, servers: nameservers, }, nil @@ -69,7 +70,7 @@ func NewClassicResolver(servers []string, opts ClassicResolverOpts) (Resolver, e // Lookup prepare a list of DNS messages to be sent to the server. // It's possible to send multiple question in one message // but some nameservers are not able to -func (c *ClassicResolver) Lookup(questions []dns.Question) ([]Response, error) { +func (c *UDPResolver) Lookup(questions []dns.Question) ([]Response, error) { var ( messages = prepareMessages(questions) responses []Response diff --git a/pkg/resolvers/utils.go b/pkg/resolvers/utils.go index 7d2b043..b087c65 100644 --- a/pkg/resolvers/utils.go +++ b/pkg/resolvers/utils.go @@ -1,6 +1,13 @@ package resolvers -import "github.com/miekg/dns" +import ( + "errors" + "fmt" + "net" + "runtime" + + "github.com/miekg/dns" +) // prepareMessages takes a slice fo `dns.Question` // and initialises `dns.Messages` for each question @@ -16,3 +23,26 @@ func prepareMessages(questions []dns.Question) []dns.Msg { } return messages } + +func getDefaultServers() ([]string, error) { + if runtime.GOOS == "windows" { + // TODO: Add a method for reading system default nameserver in windows. + return nil, errors.New(`unable to read default nameservers in this machine`) + } + // if no nameserver is provided, take it from `resolv.conf` + cfg, err := dns.ClientConfigFromFile(DefaultResolvConfPath) + if err != nil { + return nil, err + } + servers := make([]string, 0, len(cfg.Servers)) + for _, s := range cfg.Servers { + ip := net.ParseIP(s) + // handle IPv6 + if ip != nil && ip.To4() != nil { + servers = append(servers, fmt.Sprintf("%s:%s", s, cfg.Port)) + } else { + servers = append(servers, fmt.Sprintf("[%s]:%s", s, cfg.Port)) + } + } + return servers, nil +}