TUN-3688: Subcommand for users to check which route an IP proxies through

This commit is contained in:
Adam Chalmers 2021-01-05 17:55:18 -06:00
parent 92fd039440
commit 78ffb1b846
6 changed files with 160 additions and 53 deletions

View File

@ -9,7 +9,7 @@ import (
const noClientMsg = "error while creating backend client" const noClientMsg = "error while creating backend client"
func (sc *subcommandContext) listRoutes(filter *teamnet.Filter) ([]*teamnet.Route, error) { func (sc *subcommandContext) listRoutes(filter *teamnet.Filter) ([]*teamnet.DetailedRoute, error) {
client, err := sc.client() client, err := sc.client()
if err != nil { if err != nil {
return nil, errors.Wrap(err, noClientMsg) return nil, errors.Wrap(err, noClientMsg)
@ -32,3 +32,11 @@ func (sc *subcommandContext) deleteRoute(network net.IPNet) error {
} }
return client.DeleteRoute(network) return client.DeleteRoute(network)
} }
func (sc *subcommandContext) getRouteByIP(ip net.IP) (teamnet.DetailedRoute, error) {
client, err := sc.client()
if err != nil {
return teamnet.DetailedRoute{}, errors.Wrap(err, noClientMsg)
}
return client.GetByIP(ip)
}

View File

@ -49,6 +49,13 @@ func buildRouteIPSubcommand() *cli.Command {
UsageText: "cloudflared tunnel [--config FILEPATH] route ip delete [CIDR]", UsageText: "cloudflared tunnel [--config FILEPATH] route ip delete [CIDR]",
Description: `Deletes the Cloudflare for Teams private route for a given CIDR`, Description: `Deletes the Cloudflare for Teams private route for a given CIDR`,
}, },
{
Name: "get",
Action: cliutil.ErrorHandler(getRouteByIPCommand),
Usage: "Check which row of the routing table matches a given IP",
UsageText: "cloudflared tunnel [--config FILEPATH] route ip get [IP]",
Description: `Checks which row of the routing table matches a given IP. This helps check and validate your config.`,
},
}, },
} }
} }
@ -140,7 +147,33 @@ func deleteRouteCommand(c *cli.Context) error {
return nil return nil
} }
func formatAndPrintRouteList(routes []*teamnet.Route) { func getRouteByIPCommand(c *cli.Context) error {
sc, err := newSubcommandContext(c)
if err != nil {
return err
}
if c.NArg() != 1 {
return errors.New("You must supply exactly one argument, an IP whose route will be queried (e.g. 1.2.3.4 or 2001:0db8:::7334)")
}
ipInput := c.Args().First()
ip := net.ParseIP(ipInput)
if ip == nil {
return fmt.Errorf("Invalid IP %s", ipInput)
}
route, err := sc.getRouteByIP(ip)
if err != nil {
return errors.Wrap(err, "API error")
}
if route.IsZero() {
fmt.Printf("No route matches the IP %s\n", ip)
} else {
formatAndPrintRouteList([]*teamnet.DetailedRoute{&route})
}
return nil
}
func formatAndPrintRouteList(routes []*teamnet.DetailedRoute) {
const ( const (
minWidth = 0 minWidth = 0
tabWidth = 8 tabWidth = 8
@ -153,7 +186,7 @@ func formatAndPrintRouteList(routes []*teamnet.Route) {
defer writer.Flush() defer writer.Flush()
// Print column headers with tabbed columns // Print column headers with tabbed columns
_, _ = fmt.Fprintln(writer, "NETWORK\tCOMMENT\tTUNNEL ID\tCREATED\tDELETED\t") _, _ = fmt.Fprintln(writer, "NETWORK\tCOMMENT\tTUNNEL ID\tTUNNEL NAME\tCREATED\tDELETED\t")
// Loop through routes, create formatted string for each, and print using tabwriter // Loop through routes, create formatted string for each, and print using tabwriter
for _, route := range routes { for _, route := range routes {

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pkg/errors"
) )
// Route is a mapping from customer's IP space to a tunnel. // Route is a mapping from customer's IP space to a tunnel.
@ -15,56 +16,35 @@ import (
// network, and says that eyeballs can reach that route using the corresponding // network, and says that eyeballs can reach that route using the corresponding
// tunnel. // tunnel.
type Route struct { type Route struct {
Network net.IPNet Network CIDR
TunnelID uuid.UUID
Comment string
CreatedAt time.Time
DeletedAt time.Time
}
// TableString outputs a table row summarizing the route, to be used
// when showing the user their routing table.
func (r Route) TableString() string {
deletedColumn := "-"
if !r.DeletedAt.IsZero() {
deletedColumn = r.DeletedAt.Format(time.RFC3339)
}
return fmt.Sprintf(
"%s\t%s\t%s\t%s\t%s\t",
r.Network.String(),
r.Comment,
r.TunnelID,
r.CreatedAt.Format(time.RFC3339),
deletedColumn,
)
}
// UnmarshalJSON handles fields with non-JSON types (e.g. net.IPNet).
func (r *Route) UnmarshalJSON(data []byte) error {
// This is the raw JSON format that cloudflared receives from tunnelstore.
// Note it does not understand types like IPNet.
var resp struct {
Network string `json:"network"`
TunnelID uuid.UUID `json:"tunnel_id"` TunnelID uuid.UUID `json:"tunnel_id"`
Comment string `json:"comment"` Comment string
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
DeletedAt time.Time `json:"deleted_at"` DeletedAt time.Time `json:"deleted_at"`
} }
if err := json.Unmarshal(data, &resp); err != nil {
return err // CIDR is just a newtype wrapper around net.IPNet. It adds JSON unmarshalling.
type CIDR net.IPNet
func (c *CIDR) String() string {
n := net.IPNet(*c)
return n.String()
} }
// Parse the raw JSON into a properly-typed response. // UnmarshalJSON parses a JSON string into net.IPNet
_, network, err := net.ParseCIDR(resp.Network) func (c *CIDR) UnmarshalJSON(data []byte) error {
if err != nil || network == nil { var s string
return fmt.Errorf("backend returned invalid network %s", resp.Network) if err := json.Unmarshal(data, &s); err != nil {
return errors.Wrap(err, "error parsing cidr string")
} }
r.Network = *network _, network, err := net.ParseCIDR(s)
r.TunnelID = resp.TunnelID if err != nil {
r.Comment = resp.Comment return errors.Wrap(err, "error parsing invalid network from backend")
r.CreatedAt = resp.CreatedAt }
r.DeletedAt = resp.DeletedAt if network == nil {
return fmt.Errorf("backend returned invalid network %s", s)
}
*c = CIDR(*network)
return nil return nil
} }
@ -78,7 +58,6 @@ type NewRoute struct {
// MarshalJSON handles fields with non-JSON types (e.g. net.IPNet). // MarshalJSON handles fields with non-JSON types (e.g. net.IPNet).
func (r NewRoute) MarshalJSON() ([]byte, error) { func (r NewRoute) MarshalJSON() ([]byte, error) {
return json.Marshal(&struct { return json.Marshal(&struct {
Network string `json:"network"`
TunnelID uuid.UUID `json:"tunnel_id"` TunnelID uuid.UUID `json:"tunnel_id"`
Comment string `json:"comment"` Comment string `json:"comment"`
}{ }{
@ -86,3 +65,36 @@ func (r NewRoute) MarshalJSON() ([]byte, error) {
Comment: r.Comment, Comment: r.Comment,
}) })
} }
// DetailedRoute is just a Route with some extra fields, e.g. TunnelName.
type DetailedRoute struct {
Network CIDR
TunnelID uuid.UUID `json:"tunnel_id"`
Comment string
CreatedAt time.Time `json:"created_at"`
DeletedAt time.Time `json:"deleted_at"`
TunnelName string `json:"tunnel_name"`
}
// IsZero checks if DetailedRoute is the zero value.
func (r *DetailedRoute) IsZero() bool {
return r.TunnelID == uuid.Nil
}
// TableString outputs a table row summarizing the route, to be used
// when showing the user their routing table.
func (r DetailedRoute) TableString() string {
deletedColumn := "-"
if !r.DeletedAt.IsZero() {
deletedColumn = r.DeletedAt.Format(time.RFC3339)
}
return fmt.Sprintf(
"%s\t%s\t%s\t%s\t%s\t%s\t",
r.Network.String(),
r.Comment,
r.TunnelID,
r.TunnelName,
r.CreatedAt.Format(time.RFC3339),
deletedColumn,
)
}

View File

@ -21,7 +21,7 @@ func TestUnmarshalRoute(t *testing.T) {
"deleted_at":null "deleted_at":null
}` }`
var r Route var r Route
err := r.UnmarshalJSON([]byte(data)) err := json.Unmarshal([]byte(data), &r)
// Check everything worked // Check everything worked
require.NoError(t, err) require.NoError(t, err)
@ -29,10 +29,34 @@ func TestUnmarshalRoute(t *testing.T) {
require.Equal(t, "test", r.Comment) require.Equal(t, "test", r.Comment)
_, cidr, err := net.ParseCIDR("10.1.2.40/29") _, cidr, err := net.ParseCIDR("10.1.2.40/29")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, *cidr, r.Network) require.Equal(t, CIDR(*cidr), r.Network)
require.Equal(t, "test", r.Comment) require.Equal(t, "test", r.Comment)
} }
func TestUnmarshalDetailedRoute(t *testing.T) {
// Response from the teamnet route backend
data := `{
"network":"10.1.2.40/29",
"tunnel_id":"fba6ffea-807f-4e7a-a740-4184ee1b82c8",
"tunnel_name":"Mr. Tun",
"comment":"test",
"created_at":"2020-12-22T02:00:15.587008Z",
"deleted_at":null
}`
var r DetailedRoute
err := json.Unmarshal([]byte(data), &r)
// Check everything worked
require.NoError(t, err)
require.Equal(t, uuid.MustParse("fba6ffea-807f-4e7a-a740-4184ee1b82c8"), r.TunnelID)
require.Equal(t, "test", r.Comment)
_, cidr, err := net.ParseCIDR("10.1.2.40/29")
require.NoError(t, err)
require.Equal(t, CIDR(*cidr), r.Network)
require.Equal(t, "test", r.Comment)
require.Equal(t, "Mr. Tun", r.TunnelName)
}
func TestMarshalNewRoute(t *testing.T) { func TestMarshalNewRoute(t *testing.T) {
_, network, err := net.ParseCIDR("1.2.3.4/32") _, network, err := net.ParseCIDR("1.2.3.4/32")
require.NoError(t, err) require.NoError(t, err)
@ -58,8 +82,8 @@ func TestRouteTableString(t *testing.T) {
_, network, err := net.ParseCIDR("1.2.3.4/32") _, network, err := net.ParseCIDR("1.2.3.4/32")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, network) require.NotNil(t, network)
r := Route{ r := DetailedRoute{
Network: *network, Network: CIDR(*network),
} }
row := r.TableString() row := r.TableString()
fmt.Println(row) fmt.Println(row)

View File

@ -196,9 +196,10 @@ type Client interface {
RouteTunnel(tunnelID uuid.UUID, route Route) (RouteResult, error) RouteTunnel(tunnelID uuid.UUID, route Route) (RouteResult, error)
// Teamnet endpoints // Teamnet endpoints
ListRoutes(filter *teamnet.Filter) ([]*teamnet.Route, error) ListRoutes(filter *teamnet.Filter) ([]*teamnet.DetailedRoute, error)
AddRoute(newRoute teamnet.NewRoute) (teamnet.Route, error) AddRoute(newRoute teamnet.NewRoute) (teamnet.Route, error)
DeleteRoute(network net.IPNet) error DeleteRoute(network net.IPNet) error
GetByIP(ip net.IP) (teamnet.DetailedRoute, error)
} }
type RESTClient struct { type RESTClient struct {

View File

@ -12,7 +12,7 @@ import (
) )
// ListRoutes calls the Tunnelstore GET endpoint for all routes under an account. // ListRoutes calls the Tunnelstore GET endpoint for all routes under an account.
func (r *RESTClient) ListRoutes(filter *teamnet.Filter) ([]*teamnet.Route, error) { func (r *RESTClient) ListRoutes(filter *teamnet.Filter) ([]*teamnet.DetailedRoute, error) {
endpoint := r.baseEndpoints.accountRoutes endpoint := r.baseEndpoints.accountRoutes
endpoint.RawQuery = filter.Encode() endpoint.RawQuery = filter.Encode()
resp, err := r.sendRequest("GET", endpoint, nil) resp, err := r.sendRequest("GET", endpoint, nil)
@ -22,7 +22,7 @@ func (r *RESTClient) ListRoutes(filter *teamnet.Filter) ([]*teamnet.Route, error
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusOK {
return parseListRoutes(resp.Body) return parseListDetailedRoutes(resp.Body)
} }
return nil, r.statusCodeToError("list routes", resp) return nil, r.statusCodeToError("list routes", resp)
@ -63,14 +63,43 @@ func (r *RESTClient) DeleteRoute(network net.IPNet) error {
return r.statusCodeToError("delete route", resp) return r.statusCodeToError("delete route", resp)
} }
// GetByIP checks which route will proxy a given IP.
func (r *RESTClient) GetByIP(ip net.IP) (teamnet.DetailedRoute, error) {
endpoint := r.baseEndpoints.accountRoutes
endpoint.Path = path.Join(endpoint.Path, "ip", url.PathEscape(ip.String()))
resp, err := r.sendRequest("GET", endpoint, nil)
if err != nil {
return teamnet.DetailedRoute{}, errors.Wrap(err, "REST request failed")
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return parseDetailedRoute(resp.Body)
}
return teamnet.DetailedRoute{}, r.statusCodeToError("get route by IP", resp)
}
func parseListRoutes(body io.ReadCloser) ([]*teamnet.Route, error) { func parseListRoutes(body io.ReadCloser) ([]*teamnet.Route, error) {
var routes []*teamnet.Route var routes []*teamnet.Route
err := parseResponse(body, &routes) err := parseResponse(body, &routes)
return routes, err return routes, err
} }
func parseListDetailedRoutes(body io.ReadCloser) ([]*teamnet.DetailedRoute, error) {
var routes []*teamnet.DetailedRoute
err := parseResponse(body, &routes)
return routes, err
}
func parseRoute(body io.ReadCloser) (teamnet.Route, error) { func parseRoute(body io.ReadCloser) (teamnet.Route, error) {
var route teamnet.Route var route teamnet.Route
err := parseResponse(body, &route) err := parseResponse(body, &route)
return route, err return route, err
} }
func parseDetailedRoute(body io.ReadCloser) (teamnet.DetailedRoute, error) {
var route teamnet.DetailedRoute
err := parseResponse(body, &route)
return route, err
}