From 78ffb1b846e1da6c631fd544e509fe576fe712f3 Mon Sep 17 00:00:00 2001 From: Adam Chalmers Date: Tue, 5 Jan 2021 17:55:18 -0600 Subject: [PATCH] TUN-3688: Subcommand for users to check which route an IP proxies through --- .../tunnel/subcommand_context_teamnet.go | 10 +- cmd/cloudflared/tunnel/teamnet_subcommands.go | 37 ++++++- teamnet/api.go | 98 +++++++++++-------- teamnet/api_test.go | 32 +++++- tunnelstore/client.go | 3 +- tunnelstore/client_teamnet.go | 33 ++++++- 6 files changed, 160 insertions(+), 53 deletions(-) diff --git a/cmd/cloudflared/tunnel/subcommand_context_teamnet.go b/cmd/cloudflared/tunnel/subcommand_context_teamnet.go index 99264d0e..f8dd3ee2 100644 --- a/cmd/cloudflared/tunnel/subcommand_context_teamnet.go +++ b/cmd/cloudflared/tunnel/subcommand_context_teamnet.go @@ -9,7 +9,7 @@ import ( 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() if err != nil { return nil, errors.Wrap(err, noClientMsg) @@ -32,3 +32,11 @@ func (sc *subcommandContext) deleteRoute(network net.IPNet) error { } 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) +} diff --git a/cmd/cloudflared/tunnel/teamnet_subcommands.go b/cmd/cloudflared/tunnel/teamnet_subcommands.go index a4c724f0..dd5ad798 100644 --- a/cmd/cloudflared/tunnel/teamnet_subcommands.go +++ b/cmd/cloudflared/tunnel/teamnet_subcommands.go @@ -49,6 +49,13 @@ func buildRouteIPSubcommand() *cli.Command { UsageText: "cloudflared tunnel [--config FILEPATH] route ip delete [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 } -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 ( minWidth = 0 tabWidth = 8 @@ -153,7 +186,7 @@ func formatAndPrintRouteList(routes []*teamnet.Route) { defer writer.Flush() // 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 for _, route := range routes { diff --git a/teamnet/api.go b/teamnet/api.go index b751030c..6a923984 100644 --- a/teamnet/api.go +++ b/teamnet/api.go @@ -7,6 +7,7 @@ import ( "time" "github.com/google/uuid" + "github.com/pkg/errors" ) // 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 // tunnel. type Route struct { - Network net.IPNet - TunnelID uuid.UUID + Network CIDR + TunnelID uuid.UUID `json:"tunnel_id"` Comment string - CreatedAt time.Time - DeletedAt time.Time + CreatedAt time.Time `json:"created_at"` + DeletedAt time.Time `json:"deleted_at"` } -// 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, - ) +// 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() } -// 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"` - Comment string `json:"comment"` - CreatedAt time.Time `json:"created_at"` - DeletedAt time.Time `json:"deleted_at"` +// UnmarshalJSON parses a JSON string into net.IPNet +func (c *CIDR) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return errors.Wrap(err, "error parsing cidr string") } - if err := json.Unmarshal(data, &resp); err != nil { - return err + _, network, err := net.ParseCIDR(s) + if err != nil { + return errors.Wrap(err, "error parsing invalid network from backend") } - - // Parse the raw JSON into a properly-typed response. - _, network, err := net.ParseCIDR(resp.Network) - if err != nil || network == nil { - return fmt.Errorf("backend returned invalid network %s", resp.Network) + if network == nil { + return fmt.Errorf("backend returned invalid network %s", s) } - r.Network = *network - r.TunnelID = resp.TunnelID - r.Comment = resp.Comment - r.CreatedAt = resp.CreatedAt - r.DeletedAt = resp.DeletedAt + *c = CIDR(*network) return nil } @@ -78,7 +58,6 @@ type NewRoute struct { // MarshalJSON handles fields with non-JSON types (e.g. net.IPNet). func (r NewRoute) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { - Network string `json:"network"` TunnelID uuid.UUID `json:"tunnel_id"` Comment string `json:"comment"` }{ @@ -86,3 +65,36 @@ func (r NewRoute) MarshalJSON() ([]byte, error) { 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, + ) +} diff --git a/teamnet/api_test.go b/teamnet/api_test.go index 6fab84e1..222998f8 100644 --- a/teamnet/api_test.go +++ b/teamnet/api_test.go @@ -21,7 +21,7 @@ func TestUnmarshalRoute(t *testing.T) { "deleted_at":null }` var r Route - err := r.UnmarshalJSON([]byte(data)) + err := json.Unmarshal([]byte(data), &r) // Check everything worked require.NoError(t, err) @@ -29,10 +29,34 @@ func TestUnmarshalRoute(t *testing.T) { require.Equal(t, "test", r.Comment) _, cidr, err := net.ParseCIDR("10.1.2.40/29") require.NoError(t, err) - require.Equal(t, *cidr, r.Network) + require.Equal(t, CIDR(*cidr), r.Network) 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) { _, network, err := net.ParseCIDR("1.2.3.4/32") require.NoError(t, err) @@ -58,8 +82,8 @@ func TestRouteTableString(t *testing.T) { _, network, err := net.ParseCIDR("1.2.3.4/32") require.NoError(t, err) require.NotNil(t, network) - r := Route{ - Network: *network, + r := DetailedRoute{ + Network: CIDR(*network), } row := r.TableString() fmt.Println(row) diff --git a/tunnelstore/client.go b/tunnelstore/client.go index 591ebdcb..e55c2306 100644 --- a/tunnelstore/client.go +++ b/tunnelstore/client.go @@ -196,9 +196,10 @@ type Client interface { RouteTunnel(tunnelID uuid.UUID, route Route) (RouteResult, error) // Teamnet endpoints - ListRoutes(filter *teamnet.Filter) ([]*teamnet.Route, error) + ListRoutes(filter *teamnet.Filter) ([]*teamnet.DetailedRoute, error) AddRoute(newRoute teamnet.NewRoute) (teamnet.Route, error) DeleteRoute(network net.IPNet) error + GetByIP(ip net.IP) (teamnet.DetailedRoute, error) } type RESTClient struct { diff --git a/tunnelstore/client_teamnet.go b/tunnelstore/client_teamnet.go index a725ae2b..04939038 100644 --- a/tunnelstore/client_teamnet.go +++ b/tunnelstore/client_teamnet.go @@ -12,7 +12,7 @@ import ( ) // 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.RawQuery = filter.Encode() 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() if resp.StatusCode == http.StatusOK { - return parseListRoutes(resp.Body) + return parseListDetailedRoutes(resp.Body) } 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) } +// 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) { var routes []*teamnet.Route err := parseResponse(body, &routes) 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) { var route teamnet.Route err := parseResponse(body, &route) return route, err } + +func parseDetailedRoute(body io.ReadCloser) (teamnet.DetailedRoute, error) { + var route teamnet.DetailedRoute + err := parseResponse(body, &route) + return route, err +}