Skip to content

Commit

Permalink
merge goatapi and goatcli into goat -> v0.7.0
Browse files Browse the repository at this point in the history
  • Loading branch information
robert-kisteleki committed Nov 14, 2023
1 parent 92cdad7 commit 0eb35a7
Show file tree
Hide file tree
Showing 54 changed files with 7,102 additions and 1,414 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.vscode
.DS_Store
goatcli
cmd/goat/goat
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
# goatcli - Go (RIPE) Atlas Tools - CLI
# goat - Go (RIPE) Atlas Tools - CLI & API Wrapper

goatcli provides a CLI to interact with [RIPE Atlas](https://atlas.ripe.net/) [APIs](https://atlas.ripe.net/api/v2/) using [Golang](https://go.dev/). It uses the [goatapi](https://github.com/robert-kisteleki/goatapi/) under the hood. It is similar to [Magellan](https://github.com/RIPE-NCC/ripe-atlas-tools).
goat is a Go package to interact with [RIPE Atlas](https://atlas.ripe.net/)
[APIs](https://atlas.ripe.net/api/v2/) using [Golang](https://go.dev/).
It also provides a CLI interface to most of the APIs. It is similar to
[Cousteau](https://github.com/RIPE-NCC/ripe-atlas-cousteau),
[Sagan](https://github.com/RIPE-NCC/ripe-atlas-sagan) and
[Magellan](https://github.com/RIPE-NCC/ripe-atlas-tools)
combined.

It supports:
* finding probes, anchors and measurements
* scheduling new measurements and immediately show its results
* scheduling new measurements and immediately show their results
* stopping existing measurements
* modify participants of an existing measurement (add/remove probes)
* downloading and displaying results of measurements
* tuning in to result streaming
* loading a local file containing measurement results
* downloading results of measurements and turning them into Go objects, or displaying them
* tuning in to result streaming and turning them into Go objects
* loading a local file containing measurement results and turning them into Go objects
* various kinds of output formatters for displaying and aggregating measurement results

The tool needs Go 1.21 to compile.
Expand All @@ -22,10 +28,9 @@ It provides a number of vantage points ("probes") run by volunteers, that allow
various kinds of network measurements (pings, traceroutes, DNS queries, ...) to
be run by any user.


# Quick Start

Check the [Quick Start Guide](doc/quickstart.md)
Check the [API Wrapper Quick Start Guide](doc/quickstart-api.md) and the [CLI Quick Start Guide](doc/quickstart-cli.md).

# Future Additions / TODO

Expand All @@ -35,7 +40,7 @@ Check the [Quick Start Guide](doc/quickstart.md)

(C) 2022, 2023 [Robert Kisteleki](https://kistel.eu/) & [RIPE NCC](https://www.ripe.net)

Contribution is possible and encouraged via the [Github repo]("https://github.com/robert-kisteleki/goatcli/")
Contribution is possible and encouraged via the [Github repo]("https://github.com/robert-kisteleki/goat/")

# License

Expand Down
302 changes: 302 additions & 0 deletions anchors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
/*
(C) 2022 Robert Kisteleki & RIPE NCC
See LICENSE file for the license.
*/

package goat

import (
"encoding/json"
"fmt"
"net/netip"
"net/url"
)

// Anchor object, as it comes from the API
type Anchor struct {
ID uint `json:"id"`
Address4 *netip.Addr `json:"ip_v4"`
ASN4 *uint `json:"as_v4"`
IPv4Gateway *netip.Addr `json:"ip_v4_gateway"`
IPv4Netmask *netip.Addr `json:"ip_v4_netmask"`
Address6 *netip.Addr `json:"ip_v6"`
ASN6 *uint `json:"as_v6"`
IPv6Gateway *netip.Addr `json:"ip_v6_gateway"`
IPv6Netmask *netip.Addr `json:"ip_v6_netmask"`
FQDN string `json:"fqdn"`
ProbeID uint `json:"probe"`
CountryCode string `json:"country"`
City string `json:"city"`
Company string `json:"company"`
IPv4Only bool `json:"is_ipv4_only"`
Disabled bool `json:"is_disabled"`
NicHandle string `json:"nic_handle"`
Location Geolocation `json:"geometry"`
Type string `json:"type"`
TLSARecord string `json:"tlsa_record"`
LiveSince *uniTime `json:"date_live"`
HardwareVersion uint `json:"hardware_version"`
}

type AsyncAnchorResult struct {
Anchor Anchor
Error error
}

// Translate the anchor version (code) into something more understandable
func (anchor *Anchor) decodeHardwareVersion() string {
switch anchor.HardwareVersion {
case 1:
return "1"
case 2:
return "2"
case 3:
return "3"
case 99:
return "VM"
default:
return "?"
}
}

// ShortString produces a short textual description of the anchor
func (anchor *Anchor) ShortString() string {
text := fmt.Sprintf("%d\t%d\t%s\t%s\t%s",
anchor.ID,
anchor.ProbeID,
anchor.CountryCode,
anchor.City,
anchor.FQDN,
)

text += valueOrNA("AS", false, anchor.ASN4)
text += valueOrNA("AS", false, anchor.ASN6)
text += fmt.Sprintf("\t%v", anchor.Location.Coordinates)

return text
}

// LongString produces a longer textual description of the anchor
func (anchor *Anchor) LongString() string {
text := anchor.ShortString()

text += valueOrNA("", false, anchor.Address4)
text += valueOrNA("", false, anchor.Address6)
if anchor.NicHandle != "" {
text += "\t" + anchor.NicHandle
} else {
text += "\tN/A"
}

text += fmt.Sprintf("\t\"%s\" %v %v %s",
anchor.Company,
anchor.IPv4Only,
anchor.Disabled,
anchor.decodeHardwareVersion(),
)

return text
}

// the API paginates; this describes one such page
type anchorListingPage struct {
Count uint `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Anchors []Anchor `json:"results"`
}

// AnchorFilter struct holds specified filters and other options
type AnchorFilter struct {
params url.Values
id uint
limit uint
verbose bool
}

// NewAnchorFilter prepares a new anchor filter object
func NewAnchorFilter() AnchorFilter {
filter := AnchorFilter{}
filter.params = url.Values{}
return filter
}

// Verboe sets verbosity
func (filter *AnchorFilter) Verbose(verbose bool) {
filter.verbose = verbose
}

// FilterID filters by a particular anchor ID
func (filter *AnchorFilter) FilterID(id uint) {
filter.id = id
}

// FilterCountry filters by a country code (ISO3166-1 alpha-2)
func (filter *AnchorFilter) FilterCountry(cc string) {
filter.params.Add("country", cc)
}

// FilterSearch filters within the fields `city`, `fqdn` and `company`
func (filter *AnchorFilter) FilterSearch(text string) {
filter.params.Add("search", text)
}

// FilterASN4 filters for an ASN in IPv4 space
func (filter *AnchorFilter) FilterASN4(as uint) {
filter.params.Add("as_v4", fmt.Sprint(as))
}

// FilterASN6 filters for an ASN in IPv6 space
func (filter *AnchorFilter) FilterASN6(as uint) {
filter.params.Add("as_v6", fmt.Sprint(as))
}

// Limit limits the number of result retrieved
func (filter *AnchorFilter) Limit(max uint) {
filter.limit = max
}

// Verify sanity of applied filters
func (filter *AnchorFilter) verifyFilters() error {
if filter.params.Has("country") {
cc := filter.params.Get("country")
// TODO: properly verify country code
if len(cc) != 2 {
return fmt.Errorf("invalid country code")
}
}

return nil
}

// GetAnchorCount returns the count of anchors by filtering
func (filter *AnchorFilter) GetAnchorCount() (
count uint,
err error,
) {
// sanity checks - late in the process, but not too late
err = filter.verifyFilters()
if err != nil {
return
}

// counting needs application of the specified filters
query := apiBaseURL + "anchors/?" + filter.params.Encode()

resp, err := apiGetRequest(filter.verbose, query, nil)
if err != nil {
return 0, err
}
defer resp.Body.Close()

// grab and store the actual content
var page anchorListingPage
err = json.NewDecoder(resp.Body).Decode(&page)
if err != nil {
return 0, err
}

// the only really important data point is the count
return page.Count, nil
}

// GetAnchors returns a bunch of anchors by filtering
// Results (or an error) appear on a channel
func (filter *AnchorFilter) GetAnchors(
anchors chan AsyncAnchorResult,
) {
defer close(anchors)

// special case: a specific ID was "filtered"
if filter.id != 0 {
anchor, err := GetAnchor(filter.verbose, filter.id)
if err != nil {
anchors <- AsyncAnchorResult{Anchor{}, err}
return
}
anchors <- AsyncAnchorResult{*anchor, nil}
return
}

// sanity checks - late in the process, but not too late
err := filter.verifyFilters()
if err != nil {
anchors <- AsyncAnchorResult{Anchor{}, err}
return
}

query := apiBaseURL + "anchors/?" + filter.params.Encode()

resp, err := apiGetRequest(filter.verbose, query, nil)

// results are paginated with next= (and previous=)
var total uint = 0
for {
if err != nil {
anchors <- AsyncAnchorResult{Anchor{}, err}
}
defer resp.Body.Close()

if resp.StatusCode != 200 {
anchors <- AsyncAnchorResult{Anchor{}, err}
return
}

// grab and store the actual content
var page anchorListingPage
err = json.NewDecoder(resp.Body).Decode(&page)
if err != nil {
anchors <- AsyncAnchorResult{Anchor{}, err}
}

// return items while observing the limit
for _, anchor := range page.Anchors {
anchors <- AsyncAnchorResult{anchor, nil}
total++
if total >= filter.limit {
return
}
}

// no next page => we're done
if page.Next == "" {
break
}

// just follow the next link
resp, err = apiGetRequest(filter.verbose, page.Next, nil)
}
}

// GetAnchor retrieves data for a single anchor, by ID
// returns anchor, _ if an anchor was found
// returns nil, _ if an anchor was not found
// returns _, err on error
func GetAnchor(
verbose bool,
id uint,
) (
anchor *Anchor,
err error,
) {
query := fmt.Sprintf("%sanchors/%d/", apiBaseURL, id)

resp, err := apiGetRequest(verbose, query, nil)
if err != nil {
return
}
defer resp.Body.Close()

if resp.StatusCode != 200 {
return nil, parseAPIError(resp)
}

// grab and store the actual content
err = json.NewDecoder(resp.Body).Decode(&anchor)
if err != nil {
return
}

return
}
32 changes: 32 additions & 0 deletions anchors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
(C) 2022 Robert Kisteleki & RIPE NCC
See LICENSE file for the license.
*/

package goat

import (
"testing"
)

// Test if the filter validator does a decent job
func TestAnchorFilterValidator(t *testing.T) {
var err error
var filter AnchorFilter

badcc := "NED"
goodcc := "NL"
filter = NewAnchorFilter()
filter.FilterCountry(badcc)
err = filter.verifyFilters()
if err == nil {
t.Errorf("Bad country code '%s' not filtered properly", badcc)
}
filter = NewAnchorFilter()
filter.FilterCountry(goodcc)
err = filter.verifyFilters()
if err != nil {
t.Errorf("Good country code '%s' is not allowed", goodcc)
}
}
Loading

0 comments on commit 0eb35a7

Please sign in to comment.