TUN-3688: Subcommand for users to check which route an IP proxies through
This commit is contained in:
parent
92fd039440
commit
78ffb1b846
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
TunnelID uuid.UUID `json:"tunnel_id"`
|
||||||
Comment string
|
Comment string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeletedAt time.Time
|
DeletedAt time.Time `json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableString outputs a table row summarizing the route, to be used
|
// CIDR is just a newtype wrapper around net.IPNet. It adds JSON unmarshalling.
|
||||||
// when showing the user their routing table.
|
type CIDR net.IPNet
|
||||||
func (r Route) TableString() string {
|
|
||||||
deletedColumn := "-"
|
func (c *CIDR) String() string {
|
||||||
if !r.DeletedAt.IsZero() {
|
n := net.IPNet(*c)
|
||||||
deletedColumn = r.DeletedAt.Format(time.RFC3339)
|
return n.String()
|
||||||
}
|
|
||||||
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).
|
// UnmarshalJSON parses a JSON string into net.IPNet
|
||||||
func (r *Route) UnmarshalJSON(data []byte) error {
|
func (c *CIDR) UnmarshalJSON(data []byte) error {
|
||||||
|
var s string
|
||||||
// This is the raw JSON format that cloudflared receives from tunnelstore.
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
// Note it does not understand types like IPNet.
|
return errors.Wrap(err, "error parsing cidr string")
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &resp); err != nil {
|
_, network, err := net.ParseCIDR(s)
|
||||||
return err
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "error parsing invalid network from backend")
|
||||||
}
|
}
|
||||||
|
if network == nil {
|
||||||
// Parse the raw JSON into a properly-typed response.
|
return fmt.Errorf("backend returned invalid network %s", s)
|
||||||
_, network, err := net.ParseCIDR(resp.Network)
|
|
||||||
if err != nil || network == nil {
|
|
||||||
return fmt.Errorf("backend returned invalid network %s", resp.Network)
|
|
||||||
}
|
}
|
||||||
r.Network = *network
|
*c = CIDR(*network)
|
||||||
r.TunnelID = resp.TunnelID
|
|
||||||
r.Comment = resp.Comment
|
|
||||||
r.CreatedAt = resp.CreatedAt
|
|
||||||
r.DeletedAt = resp.DeletedAt
|
|
||||||
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue