diff --git a/cmd/cloudflared/tunnel/subcommand_context_teamnet.go b/cmd/cloudflared/tunnel/subcommand_context_teamnet.go new file mode 100644 index 00000000..a0786551 --- /dev/null +++ b/cmd/cloudflared/tunnel/subcommand_context_teamnet.go @@ -0,0 +1,21 @@ +package tunnel + +import ( + "github.com/cloudflare/cloudflared/teamnet" +) + +func (sc *subcommandContext) listRoutes(filter *teamnet.Filter) ([]*teamnet.Route, error) { + client, err := sc.client() + if err != nil { + return nil, err + } + return client.ListRoutes(filter) +} + +func (sc *subcommandContext) addRoute(newRoute teamnet.NewRoute) (teamnet.Route, error) { + client, err := sc.client() + if err != nil { + return teamnet.Route{}, err + } + return client.AddRoute(newRoute) +} diff --git a/cmd/cloudflared/tunnel/subcommands.go b/cmd/cloudflared/tunnel/subcommands.go index 0274b5f2..96ad9f76 100644 --- a/cmd/cloudflared/tunnel/subcommands.go +++ b/cmd/cloudflared/tunnel/subcommands.go @@ -203,14 +203,14 @@ func listCommand(c *cli.Context) error { } if len(tunnels) > 0 { - fmtAndPrintTunnelList(tunnels, c.Bool("show-recently-disconnected")) + formatAndPrintTunnelList(tunnels, c.Bool("show-recently-disconnected")) } else { fmt.Println("You have no tunnels, use 'cloudflared tunnel create' to define a new tunnel") } return nil } -func fmtAndPrintTunnelList(tunnels []*tunnelstore.Tunnel, showRecentlyDisconnected bool) { +func formatAndPrintTunnelList(tunnels []*tunnelstore.Tunnel, showRecentlyDisconnected bool) { const ( minWidth = 0 tabWidth = 8 @@ -407,6 +407,9 @@ func buildRouteCommand() *cli.Command { To use this tunnel as a load balancer origin, creating pool and load balancer if necessary: cloudflared tunnel route lb `, CustomHelpTemplate: commandHelpTemplate(), + Subcommands: []*cli.Command{ + buildRouteIPSubcommand(), + }, } } diff --git a/cmd/cloudflared/tunnel/teamnet_subcommands.go b/cmd/cloudflared/tunnel/teamnet_subcommands.go new file mode 100644 index 00000000..4bbc5487 --- /dev/null +++ b/cmd/cloudflared/tunnel/teamnet_subcommands.go @@ -0,0 +1,134 @@ +package tunnel + +import ( + "fmt" + "net" + "os" + "text/tabwriter" + + "github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil" + "github.com/cloudflare/cloudflared/teamnet" + "github.com/pkg/errors" + + "github.com/urfave/cli/v2" +) + +func buildRouteIPSubcommand() *cli.Command { + return &cli.Command{ + Name: "ip", + Category: "Tunnel", + Usage: "Configure and query Cloudflare for Teams private routes", + UsageText: "cloudflared tunnel [--config FILEPATH] route COMMAND [arguments...]", + Hidden: true, + Description: `cloudflared lets you provision private Cloudflare for Teams routes to origins in your corporate + network, so that you can ensure the only people who can access your private IP subnets are people using a + corporate device enrolled in Cloudflare for Teams. + `, + Subcommands: []*cli.Command{ + { + Name: "add", + Action: cliutil.ErrorHandler(addRouteCommand), + Usage: "Add a new Teamnet route to the table", + UsageText: "cloudflared tunnel [--config FILEPATH] route ip add [CIDR] [TUNNEL] [COMMENT?]", + Description: `Add a new Cloudflare for Teams private route from a given tunnel (identified by name or + UUID) to a given IP network in your private IP space. This route will go through your Gateway rules.`, + }, + { + Name: "show", + Action: cliutil.ErrorHandler(showRoutesCommand), + Usage: "Show the routing table", + UsageText: "cloudflared tunnel [--config FILEPATH] route ip show [flags]", + Description: `Shows all Cloudflare for Teams private routes. Using flags to specify filters means that + only routes which match that filter get shown.`, + Flags: teamnet.Flags, + }, + }, + } +} + +func showRoutesCommand(c *cli.Context) error { + sc, err := newSubcommandContext(c) + if err != nil { + return err + } + + filter, err := teamnet.NewFromCLI(c) + if err != nil { + return errors.Wrap(err, "invalid config for routing filters") + } + + routes, err := sc.listRoutes(filter) + if err != nil { + return err + } + + if outputFormat := c.String(outputFormatFlag.Name); outputFormat != "" { + return renderOutput(outputFormat, routes) + } + + if len(routes) > 0 { + formatAndPrintRouteList(routes) + } else { + fmt.Println("You have no routes, use 'cloudflared tunnel route ip add' to add a route") + } + return nil +} + +func addRouteCommand(c *cli.Context) error { + sc, err := newSubcommandContext(c) + if err != nil { + return err + } + if c.NArg() < 2 { + return fmt.Errorf("You must supply at least 2 arguments, first the network you wish to route (in CIDR form e.g. 1.2.3.4/32) and then the tunnel ID to proxy with") + } + args := c.Args() + _, network, err := net.ParseCIDR(args.Get(0)) + if err != nil { + return errors.Wrap(err, "Invalid network CIDR") + } + if network == nil { + return errors.New("Invalid network CIDR") + } + tunnelRef := args.Get(1) + tunnelID, err := sc.findID(tunnelRef) + if err != nil { + return errors.Wrap(err, "Invalid tunnel") + } + comment := "" + if c.NArg() >= 3 { + comment = args.Get(2) + } + _, err = sc.addRoute(teamnet.NewRoute{ + Comment: comment, + Network: *network, + TunnelID: tunnelID, + }) + if err != nil { + return errors.Wrap(err, "API error") + } + fmt.Printf("Successfully added route for %s over tunnel %s\n", network, tunnelID) + return nil +} + +func formatAndPrintRouteList(routes []*teamnet.Route) { + const ( + minWidth = 0 + tabWidth = 8 + padding = 1 + padChar = ' ' + flags = 0 + ) + + writer := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags) + defer writer.Flush() + + // Print column headers with tabbed columns + _, _ = fmt.Fprintln(writer, "NETWORK\tCOMMENT\tTUNNEL ID\tCREATED\tDELETED\t") + + // Loop through routes, create formatted string for each, and print using tabwriter + for _, route := range routes { + formattedStr := route.TableString() + _, _ = fmt.Fprintln(writer, formattedStr) + } +} diff --git a/teamnet/api.go b/teamnet/api.go new file mode 100644 index 00000000..b751030c --- /dev/null +++ b/teamnet/api.go @@ -0,0 +1,88 @@ +package teamnet + +import ( + "encoding/json" + "fmt" + "net" + "time" + + "github.com/google/uuid" +) + +// Route is a mapping from customer's IP space to a tunnel. +// Each route allows the customer to route eyeballs in their corporate network +// to certain private IP ranges. Each Route represents an IP range in their +// network, and says that eyeballs can reach that route using the corresponding +// tunnel. +type Route struct { + Network net.IPNet + 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"` + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at"` + DeletedAt time.Time `json:"deleted_at"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return err + } + + // 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) + } + r.Network = *network + r.TunnelID = resp.TunnelID + r.Comment = resp.Comment + r.CreatedAt = resp.CreatedAt + r.DeletedAt = resp.DeletedAt + return nil +} + +// NewRoute has all the parameters necessary to add a new route to the table. +type NewRoute struct { + Network net.IPNet + TunnelID uuid.UUID + Comment string +} + +// 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"` + }{ + TunnelID: r.TunnelID, + Comment: r.Comment, + }) +} diff --git a/teamnet/api_test.go b/teamnet/api_test.go new file mode 100644 index 00000000..6fab84e1 --- /dev/null +++ b/teamnet/api_test.go @@ -0,0 +1,67 @@ +package teamnet + +import ( + "encoding/json" + "fmt" + "net" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestUnmarshalRoute(t *testing.T) { + // Response from the teamnet route backend + data := `{ + "network":"10.1.2.40/29", + "tunnel_id":"fba6ffea-807f-4e7a-a740-4184ee1b82c8", + "comment":"test", + "created_at":"2020-12-22T02:00:15.587008Z", + "deleted_at":null + }` + var r Route + err := r.UnmarshalJSON([]byte(data)) + + // 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, r.Network) + require.Equal(t, "test", r.Comment) +} + +func TestMarshalNewRoute(t *testing.T) { + _, network, err := net.ParseCIDR("1.2.3.4/32") + require.NoError(t, err) + require.NotNil(t, network) + newRoute := NewRoute{ + Network: *network, + TunnelID: uuid.New(), + Comment: "hi", + } + + // Test where receiver is struct + serialized, err := json.Marshal(newRoute) + require.NoError(t, err) + require.True(t, strings.Contains(string(serialized), "tunnel_id")) + + // Test where receiver is pointer to struct + serialized, err = json.Marshal(&newRoute) + require.NoError(t, err) + require.True(t, strings.Contains(string(serialized), "tunnel_id")) +} + +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, + } + row := r.TableString() + fmt.Println(row) + require.True(t, strings.HasPrefix(row, "1.2.3.4/32")) +} diff --git a/teamnet/filter.go b/teamnet/filter.go new file mode 100644 index 00000000..fda8d3ca --- /dev/null +++ b/teamnet/filter.go @@ -0,0 +1,138 @@ +package teamnet + +import ( + "fmt" + "net" + "net/url" + "time" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" +) + +var ( + filterDeleted = cli.BoolFlag{ + Name: "filter-is-deleted", + Usage: "If false (default), only show non-deleted routes. If true, only show deleted routes.", + } + filterTunnelID = cli.StringFlag{ + Name: "filter-tunnel-id", + Usage: "Show only routes with the given tunnel ID.", + } + filterSubset = cli.StringFlag{ + Name: "filter-network-is-subset-of", + Aliases: []string{"nsub"}, + Usage: "Show only routes whose network is a subset of the given network.", + } + filterSuperset = cli.StringFlag{ + Name: "filter-network-is-superset-of", + Aliases: []string{"nsup"}, + Usage: "Show only routes whose network is a superset of the given network.", + } + filterComment = cli.StringFlag{ + Name: "filter-comment-is", + Usage: "Show only routes with this comment.", + } + // Flags contains all filter flags. + Flags = []cli.Flag{ + &filterDeleted, + &filterTunnelID, + &filterSubset, + &filterSuperset, + &filterComment, + } +) + +// Filter which routes get queried. +type Filter struct { + queryParams url.Values +} + +// NewFromCLI parses CLI flags to discover which filters should get applied. +func NewFromCLI(c *cli.Context) (*Filter, error) { + f := &Filter{ + queryParams: url.Values{}, + } + + // Set deletion filter + if flag := filterDeleted.Name; c.IsSet(flag) && c.Bool(flag) { + f.deleted() + } else { + f.notDeleted() + } + + if subset, err := cidrFromFlag(c, filterSubset); err != nil { + return nil, err + } else if subset != nil { + f.networkIsSupersetOf(*subset) + } + + if superset, err := cidrFromFlag(c, filterSuperset); err != nil { + return nil, err + } else if superset != nil { + f.networkIsSupersetOf(*superset) + } + + if comment := c.String(filterComment.Name); comment != "" { + f.commentIs(comment) + } + + if tunnelID := c.String(filterTunnelID.Name); tunnelID != "" { + u, err := uuid.Parse(tunnelID) + if err != nil { + return nil, errors.Wrap(err, "Couldn't parse UUID from --filter-tunnel-id") + } + f.tunnelID(u) + } + + return f, nil +} + +// Parses a CIDR from the flag. If the flag was unset, returns (nil, nil). +func cidrFromFlag(c *cli.Context, flag cli.StringFlag) (*net.IPNet, error) { + if !c.IsSet(flag.Name) { + return nil, nil + } + + _, subset, err := net.ParseCIDR(c.String(flag.Name)) + if err != nil { + return nil, err + } else if subset == nil { + return nil, fmt.Errorf("Invalid CIDR supplied for %s", flag.Name) + } + + return subset, nil +} + +func (f *Filter) commentIs(comment string) { + f.queryParams.Set("comment", comment) +} + +func (f *Filter) notDeleted() { + f.queryParams.Set("is_deleted", "false") +} + +func (f *Filter) deleted() { + f.queryParams.Set("is_deleted", "true") +} + +func (f *Filter) networkIsSubsetOf(superset net.IPNet) { + f.queryParams.Set("network_subset", superset.String()) +} + +func (f *Filter) networkIsSupersetOf(subset net.IPNet) { + f.queryParams.Set("network_superset", subset.String()) +} + +func (f *Filter) existedAt(existedAt time.Time) { + f.queryParams.Set("existed_at", existedAt.Format(time.RFC3339)) +} + +func (f *Filter) tunnelID(id uuid.UUID) { + f.queryParams.Set("tunnel_id", id.String()) +} + +func (f Filter) Encode() string { + return f.queryParams.Encode() +} diff --git a/tunnelstore/client.go b/tunnelstore/client.go index 2e9c9ff5..b0bb365b 100644 --- a/tunnelstore/client.go +++ b/tunnelstore/client.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/cloudflare/cloudflared/teamnet" "github.com/google/uuid" "github.com/pkg/errors" "github.com/rs/zerolog" @@ -185,12 +186,17 @@ func (res *LBRouteResult) SuccessSummary() string { } type Client interface { + // Named Tunnels endpoints CreateTunnel(name string, tunnelSecret []byte) (*Tunnel, error) GetTunnel(tunnelID uuid.UUID) (*Tunnel, error) DeleteTunnel(tunnelID uuid.UUID) error ListTunnels(filter *Filter) ([]*Tunnel, error) CleanupConnections(tunnelID uuid.UUID) error RouteTunnel(tunnelID uuid.UUID, route Route) (RouteResult, error) + + // Teamnet endpoints + ListRoutes(filter *teamnet.Filter) ([]*teamnet.Route, error) + AddRoute(newRoute teamnet.NewRoute) (teamnet.Route, error) } type RESTClient struct { @@ -202,8 +208,9 @@ type RESTClient struct { } type baseEndpoints struct { - accountLevel url.URL - zoneLevel url.URL + accountLevel url.URL + zoneLevel url.URL + accountRoutes url.URL } var _ Client = (*RESTClient)(nil) @@ -216,14 +223,19 @@ func NewRESTClient(baseURL, accountTag, zoneTag, authToken, userAgent string, lo if err != nil { return nil, errors.Wrap(err, "failed to create account level endpoint") } + accountRoutesEndpoint, err := url.Parse(fmt.Sprintf("%s/accounts/%s/routes", baseURL, accountTag)) + if err != nil { + return nil, errors.Wrap(err, "failed to create route account-level endpoint") + } zoneLevelEndpoint, err := url.Parse(fmt.Sprintf("%s/zones/%s/tunnels", baseURL, zoneTag)) if err != nil { return nil, errors.Wrap(err, "failed to create account level endpoint") } return &RESTClient{ baseEndpoints: &baseEndpoints{ - accountLevel: *accountLevelEndpoint, - zoneLevel: *zoneLevelEndpoint, + accountLevel: *accountLevelEndpoint, + zoneLevel: *zoneLevelEndpoint, + accountRoutes: *accountRoutesEndpoint, }, authToken: authToken, userAgent: userAgent, @@ -388,7 +400,10 @@ func parseResponse(reader io.Reader, data interface{}) error { } // At this point we know the API call succeeded, so, parse out the inner // result into the datatype provided as a parameter. - return json.Unmarshal(result.Result, &data) + if err := json.Unmarshal(result.Result, &data); err != nil { + return errors.Wrap(err, "the Cloudflare API response was an unexpected type") + } + return nil } func unmarshalTunnel(reader io.Reader) (*Tunnel, error) { diff --git a/tunnelstore/client_teamnet.go b/tunnelstore/client_teamnet.go new file mode 100644 index 00000000..decf3aa3 --- /dev/null +++ b/tunnelstore/client_teamnet.go @@ -0,0 +1,55 @@ +package tunnelstore + +import ( + "io" + "net/http" + "net/url" + "path" + + "github.com/cloudflare/cloudflared/teamnet" + "github.com/pkg/errors" +) + +func (r *RESTClient) ListRoutes(filter *teamnet.Filter) ([]*teamnet.Route, error) { + endpoint := r.baseEndpoints.accountRoutes + endpoint.RawQuery = filter.Encode() + resp, err := r.sendRequest("GET", endpoint, nil) + if err != nil { + return nil, errors.Wrap(err, "REST request failed") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return parseListRoutes(resp.Body) + } + + return nil, r.statusCodeToError("list routes", resp) +} + +func (r *RESTClient) AddRoute(newRoute teamnet.NewRoute) (teamnet.Route, error) { + endpoint := r.baseEndpoints.accountRoutes + endpoint.Path = path.Join(endpoint.Path, url.PathEscape(newRoute.Network.String())) + resp, err := r.sendRequest("POST", endpoint, newRoute) + if err != nil { + return teamnet.Route{}, errors.Wrap(err, "REST request failed") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return parseRoute(resp.Body) + } + + return teamnet.Route{}, r.statusCodeToError("add route", resp) +} + +func parseListRoutes(body io.ReadCloser) ([]*teamnet.Route, error) { + var routes []*teamnet.Route + 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 +}