From 020d3dea199f50499161ab70b5f811aed8eb0669 Mon Sep 17 00:00:00 2001 From: Radu Lucut Date: Thu, 18 Jul 2024 14:45:13 +0300 Subject: [PATCH] feat: integrate Globalping API for global network diagnostics --- cmd/doggo/cli.go | 25 +++++- go.mod | 2 + go.sum | 4 + internal/app/app.go | 10 ++- internal/app/globalping.go | 153 +++++++++++++++++++++++++++++++++++++ internal/app/output.go | 71 ++++++++--------- pkg/models/models.go | 4 + web/api.go | 2 +- 8 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 internal/app/globalping.go diff --git a/cmd/doggo/cli.go b/cmd/doggo/cli.go index 3b90ca0..c9e2215 100644 --- a/cmd/doggo/cli.go +++ b/cmd/doggo/cli.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/v2" "github.com/mr-karan/doggo/internal/app" @@ -43,6 +44,20 @@ func main() { logger := utils.InitLogger(cfg.debug) app := initializeApp(logger, cfg) + if app.QueryFlags.From != "" { + res, err := app.GlobalpingMeasurement() + if err != nil { + logger.Error("Error fetching globalping measurement", "error", err) + os.Exit(2) + } + err = app.OutputGlobalping(res) + if err != nil { + logger.Error("Error outputting globalping measurement", "error", err) + os.Exit(2) + } + return + } + if cfg.reverseLookup { app.ReverseLookup() } @@ -121,6 +136,9 @@ func setupFlags() *flag.FlagSet { f.StringSliceP("nameserver", "n", []string{}, "Address of the nameserver to send packets to") f.BoolP("reverse", "x", false, "Performs a DNS Lookup for an IPv4 or IPv6 address") + f.String("from", "", "Probe locations as a comma-separated list") + f.Int("limit", 1, "Limit the number of responses") + f.DurationP("timeout", "T", 5*time.Second, "Sets the timeout for a query") f.Bool("search", true, "Use the search list provided in resolv.conf") f.Int("ndots", -1, "Specify the ndots parameter") @@ -162,7 +180,12 @@ func parseAndLoadFlags(f *flag.FlagSet) error { } func initializeApp(logger *slog.Logger, cfg *config) *app.App { - app := app.New(logger, buildVersion) + globlpingClient := globalping.NewClient(globalping.Config{ + APIURL: "https://api.globalping.io/v1", + APIToken: os.Getenv("GLOBALPING_TOKEN"), + }) + + app := app.New(logger, globlpingClient, buildVersion) if err := k.Unmarshal("", &app.QueryFlags); err != nil { logger.Error("Error loading args", "error", err) diff --git a/go.mod b/go.mod index 9f17313..f5d4fe1 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/ameshkov/dnsstamps v1.0.3 github.com/fatih/color v1.17.0 github.com/go-chi/chi/v5 v5.1.0 + github.com/jsdelivr/globalping-cli v1.3.1-0.20240717104136-2edb7127957b github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/env v0.1.0 github.com/knadh/koanf/providers/file v1.0.0 @@ -23,6 +24,7 @@ require ( github.com/AdguardTeam/golibs v0.24.1 // indirect github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 6f98294..189ff64 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/ameshkov/dnscrypt/v2 v2.3.0 h1:pDXDF7eFa6Lw+04C0hoMh8kCAQM8NwUdFEllSP github.com/ameshkov/dnscrypt/v2 v2.3.0/go.mod h1:N5hDwgx2cNb4Ay7AhvOSKst+eUiOZ/vbKRO9qMpQttE= github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= @@ -26,6 +28,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg= github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/jsdelivr/globalping-cli v1.3.1-0.20240717104136-2edb7127957b h1:ZL7LfEaU+P2r6/Lxo99Qt6qw4Mb3BXt5UB4r+RoI6LA= +github.com/jsdelivr/globalping-cli v1.3.1-0.20240717104136-2edb7127957b/go.mod h1:2+lO4/xYSauKsf+pZ62bro1c4StxDO3cYcrLx4jsYmI= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= diff --git a/internal/app/app.go b/internal/app/app.go index 4bfe817..75f10a9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "log/slog" + "github.com/jsdelivr/globalping-cli/globalping" "github.com/miekg/dns" "github.com/mr-karan/doggo/pkg/models" "github.com/mr-karan/doggo/pkg/resolvers" @@ -17,10 +18,16 @@ type App struct { Resolvers []resolvers.Resolver ResolverOpts resolvers.Options Nameservers []models.Nameserver + + globalping globalping.Client } // NewApp initializes an instance of App which holds app wide configuration. -func New(logger *slog.Logger, buildVersion string) App { +func New( + logger *slog.Logger, + globalping globalping.Client, + buildVersion string, +) App { app := App{ Logger: logger, Version: buildVersion, @@ -31,6 +38,7 @@ func New(logger *slog.Logger, buildVersion string) App { Nameservers: []string{}, }, Nameservers: []models.Nameserver{}, + globalping: globalping, } return app } diff --git a/internal/app/globalping.go b/internal/app/globalping.go new file mode 100644 index 0000000..e914fe6 --- /dev/null +++ b/internal/app/globalping.go @@ -0,0 +1,153 @@ +package app + +import ( + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/fatih/color" + "github.com/jsdelivr/globalping-cli/globalping" + "github.com/olekukonko/tablewriter" +) + +var ( + ErrTargetIPVersionNotAllowed = errors.New("ipVersion is not allowed when target is not a domain") + ErrResolverIPVersionNotAllowed = errors.New("ipVersion is not allowed when resolver is not a domain") +) + +func (app *App) GlobalpingMeasurement() (*globalping.Measurement, error) { + target := app.QueryFlags.QNames[0] + resolver := "" + if len(app.QueryFlags.Nameservers) > 0 { + resolver = app.QueryFlags.Nameservers[0] + } + + if app.QueryFlags.UseIPv4 || app.QueryFlags.UseIPv6 { + if net.ParseIP(target) != nil { + return nil, ErrTargetIPVersionNotAllowed + } + if resolver != "" && net.ParseIP(resolver) != nil { + return nil, ErrResolverIPVersionNotAllowed + } + } + + o := &globalping.MeasurementCreate{ + Type: "dns", + Target: target, + Limit: app.QueryFlags.Limit, + Locations: parseGlobalpingLocations(app.QueryFlags.From), + Options: &globalping.MeasurementOptions{ + // TODO: Add support for these flags. + // Protocol: opts.Protocol, + // Port: opts.Port, + }, + } + if app.QueryFlags.UseIPv4 { + o.Options.IPVersion = globalping.IPVersion4 + } else if app.QueryFlags.UseIPv6 { + o.Options.IPVersion = globalping.IPVersion6 + } + if len(app.QueryFlags.Nameservers) > 0 { + o.Options.Resolver = app.QueryFlags.Nameservers[0] + } + if len(app.QueryFlags.QTypes) > 0 { + o.Options.Query = &globalping.QueryOptions{ + Type: app.QueryFlags.QTypes[0], + } + } + res, err := app.globalping.CreateMeasurement(o) + if err != nil { + return nil, err + } + measurement, err := app.globalping.GetMeasurement(res.ID) + if err != nil { + return nil, err + } + for measurement.Status == globalping.StatusInProgress { + time.Sleep(500 * time.Millisecond) + measurement, err = app.globalping.GetMeasurement(res.ID) + if err != nil { + return nil, err + } + } + + if measurement.Status != globalping.StatusFinished { + return nil, &globalping.MeasurementError{ + Message: "measurement did not complete successfully", + } + } + return measurement, nil +} + +// TODO: Add support for json output && short output +func (app *App) OutputGlobalping(m *globalping.Measurement) error { + // Disables colorized output if user specified. + if !app.QueryFlags.Color { + color.NoColor = true + } + + table := tablewriter.NewWriter(color.Output) + header := []string{"Location", "Name", "Type", "Class", "TTL", "Address", "Nameserver"} + + // Formatting options for the table. + table.SetHeader(header) + table.SetAutoWrapText(true) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetTablePadding("\t") // pad with tabs + table.SetNoWhiteSpace(true) + + for i := range m.Results { + table.Append([]string{getGlobalPingLocationText(&m.Results[i]), "", "", "", "", "", ""}) + answers, err := globalping.DecodeDNSAnswers(m.Results[i].Result.AnswersRaw) + if err != nil { + return err + } + resolver := m.Results[i].Result.Resolver + for _, ans := range answers { + typOut := getColoredType(ans.Type) + output := []string{"", TerminalColorGreen(ans.Name), typOut, ans.Class, fmt.Sprintf("%ds", ans.TTL), ans.Value, resolver} + table.Append(output) + } + } + table.Render() + return nil +} + +func parseGlobalpingLocations(from string) []globalping.Locations { + if from == "" { + return []globalping.Locations{ + { + Magic: "world", + }, + } + } + fromArr := strings.Split(from, ",") + locations := make([]globalping.Locations, len(fromArr)) + for i, v := range fromArr { + locations[i] = globalping.Locations{ + Magic: strings.TrimSpace(v), + } + } + return locations +} + +func getGlobalPingLocationText(m *globalping.ProbeMeasurement) string { + state := "" + if m.Probe.State != "" { + state = " (" + m.Probe.State + ")" + } + return m.Probe.City + state + ", " + + m.Probe.Country + ", " + + m.Probe.Continent + ", " + + m.Probe.Network + " " + + "(AS" + fmt.Sprint(m.Probe.ASN) + ")" +} diff --git a/internal/app/output.go b/internal/app/output.go index a8b32f9..a8cfa1e 100644 --- a/internal/app/output.go +++ b/internal/app/output.go @@ -11,6 +11,15 @@ import ( "github.com/olekukonko/tablewriter" ) +var ( + TerminalColorGreen = color.New(color.FgGreen, color.Bold).SprintFunc() + TerminalColorBlue = color.New(color.FgBlue, color.Bold).SprintFunc() + TerminalColorYellow = color.New(color.FgYellow, color.Bold).SprintFunc() + TerminalColorCyan = color.New(color.FgCyan, color.Bold).SprintFunc() + TerminalColorRed = color.New(color.FgRed, color.Bold).SprintFunc() + TerminalColorMagenta = color.New(color.FgMagenta, color.Bold).SprintFunc() +) + func (app *App) outputJSON(rsp []resolvers.Response) { jsonOutput := struct { Responses []resolvers.Response `json:"responses"` @@ -36,15 +45,6 @@ func (app *App) outputShort(rsp []resolvers.Response) { } func (app *App) outputTerminal(rsp []resolvers.Response) { - var ( - green = color.New(color.FgGreen, color.Bold).SprintFunc() - blue = color.New(color.FgBlue, color.Bold).SprintFunc() - yellow = color.New(color.FgYellow, color.Bold).SprintFunc() - cyan = color.New(color.FgCyan, color.Bold).SprintFunc() - red = color.New(color.FgRed, color.Bold).SprintFunc() - magenta = color.New(color.FgMagenta, color.Bold).SprintFunc() - ) - // Disables colorized output if user specified. if !app.QueryFlags.Color { color.NoColor = true @@ -92,32 +92,14 @@ func (app *App) outputTerminal(rsp []resolvers.Response) { for _, r := range rsp { for _, ans := range r.Answers { - var typOut string - switch typ := ans.Type; typ { - case "A": - typOut = blue(ans.Type) - case "AAAA": - typOut = blue(ans.Type) - case "MX": - typOut = magenta(ans.Type) - case "NS": - typOut = cyan(ans.Type) - case "CNAME": - typOut = yellow(ans.Type) - case "TXT": - typOut = yellow(ans.Type) - case "SOA": - typOut = red(ans.Type) - default: - typOut = blue(ans.Type) - } - output := []string{green(ans.Name), typOut, ans.Class, ans.TTL, ans.Address, ans.Nameserver} + typOut := getColoredType(ans.Type) + output := []string{TerminalColorGreen(ans.Name), typOut, ans.Class, ans.TTL, ans.Address, ans.Nameserver} // Print how long it took if app.QueryFlags.DisplayTimeTaken { output = append(output, ans.RTT) } if outputStatus { - output = append(output, red(ans.Status)) + output = append(output, TerminalColorRed(ans.Status)) } table.Append(output) } @@ -125,17 +107,17 @@ func (app *App) outputTerminal(rsp []resolvers.Response) { var typOut string switch typ := auth.Type; typ { case "SOA": - typOut = red(auth.Type) + typOut = TerminalColorRed(auth.Type) default: - typOut = blue(auth.Type) + typOut = TerminalColorBlue(auth.Type) } - output := []string{green(auth.Name), typOut, auth.Class, auth.TTL, auth.MName, auth.Nameserver} + output := []string{TerminalColorGreen(auth.Name), typOut, auth.Class, auth.TTL, auth.MName, auth.Nameserver} // Print how long it took if app.QueryFlags.DisplayTimeTaken { output = append(output, auth.RTT) } if outputStatus { - output = append(output, red(auth.Status)) + output = append(output, TerminalColorRed(auth.Status)) } table.Append(output) } @@ -143,6 +125,27 @@ func (app *App) outputTerminal(rsp []resolvers.Response) { table.Render() } +func getColoredType(t string) string { + switch t { + case "A": + return TerminalColorBlue(t) + case "AAAA": + return TerminalColorBlue(t) + case "MX": + return TerminalColorMagenta(t) + case "NS": + return TerminalColorCyan(t) + case "CNAME": + return TerminalColorYellow(t) + case "TXT": + return TerminalColorYellow(t) + case "SOA": + return TerminalColorRed(t) + default: + return TerminalColorBlue(t) + } +} + // Output takes a list of `dns.Answers` and based // on the output format specified displays the information. func (app *App) Output(responses []resolvers.Response) { diff --git a/pkg/models/models.go b/pkg/models/models.go index 7fd3d33..eb0ae96 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -45,6 +45,10 @@ type QueryFlags struct { InsecureSkipVerify bool `koanf:"skip-hostname-verification" skip-hostname-verification:"-"` TLSHostname string `koanf:"tls-hostname" tls-hostname:"-"` QueryAny bool `koanf:"any" json:"any"` + + // Globalping flags + From string `koanf:"from" json:"from"` + Limit int `koanf:"limit" json:"limit"` } // Nameserver represents the type of Nameserver diff --git a/web/api.go b/web/api.go index 2eae5eb..2a8e746 100644 --- a/web/api.go +++ b/web/api.go @@ -32,7 +32,7 @@ func main() { logger := utils.InitLogger(ko.Bool("app.debug")) // Initialize app. - app := app.New(logger, buildVersion) + app := app.New(logger, nil, buildVersion) // Register router instance. r := chi.NewRouter()