diff --git a/CHANGES.md b/CHANGES.md index bdd5f6c8..b7102fb0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,11 +8,14 @@ ### New Features -- none +- It is now possible to obtain more detailed information about the cloudflared connectors to Cloudflare Edge via + `cloudflared tunnel info `. It is possible to sort the output as well as output in different formats, + such as: `cloudflared tunnel info --sort-by version --invert-sort --output json `. + You can obtain more information via `cloudflared tunnel info --help`. ### Improvements -- nonw +- none ### Bug Fixes diff --git a/cmd/cloudflared/tunnel/cmd.go b/cmd/cloudflared/tunnel/cmd.go index 20962f3d..5591fad5 100644 --- a/cmd/cloudflared/tunnel/cmd.go +++ b/cmd/cloudflared/tunnel/cmd.go @@ -100,6 +100,7 @@ func Commands() []*cli.Command { buildRouteCommand(), buildRunCommand(), buildListCommand(), + buildInfoCommand(), buildIngressSubcommand(), buildDeleteCommand(), buildCleanupCommand(), @@ -464,7 +465,7 @@ func tunnelFlags(shouldHide bool) []cli.Flag { credentialsFileFlag, altsrc.NewBoolFlag(&cli.BoolFlag{ Name: "is-autoupdated", - Usage: "Signal the new process that Argo Tunnel client has been autoupdated", + Usage: "Signal the new process that Argo Tunnel connector has been autoupdated", Value: false, Hidden: true, }), diff --git a/cmd/cloudflared/tunnel/configuration.go b/cmd/cloudflared/tunnel/configuration.go index 2e82904d..70326f73 100644 --- a/cmd/cloudflared/tunnel/configuration.go +++ b/cmd/cloudflared/tunnel/configuration.go @@ -199,9 +199,9 @@ func prepareTunnelConfig( if isNamedTunnel { clientUUID, err := uuid.NewRandom() if err != nil { - return nil, ingress.Ingress{}, errors.Wrap(err, "can't generate clientUUID") + return nil, ingress.Ingress{}, errors.Wrap(err, "can't generate connector UUID") } - log.Info().Msgf("Generated Client ID: %s", clientUUID) + log.Info().Msgf("Generated Connector ID: %s", clientUUID) features := append(c.StringSlice("features"), origin.FeatureSerializedHeaders) namedTunnel.Client = tunnelpogs.ClientInfo{ ClientID: clientUUID[:], diff --git a/cmd/cloudflared/tunnel/info.go b/cmd/cloudflared/tunnel/info.go new file mode 100644 index 00000000..bea77e46 --- /dev/null +++ b/cmd/cloudflared/tunnel/info.go @@ -0,0 +1,15 @@ +package tunnel + +import ( + "time" + + "github.com/cloudflare/cloudflared/tunnelstore" + "github.com/google/uuid" +) + +type Info struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + Connectors []*tunnelstore.ActiveClient `json:"conns"` +} diff --git a/cmd/cloudflared/tunnel/subcommand_context.go b/cmd/cloudflared/tunnel/subcommand_context.go index 01537d90..157ed814 100644 --- a/cmd/cloudflared/tunnel/subcommand_context.go +++ b/cmd/cloudflared/tunnel/subcommand_context.go @@ -343,6 +343,12 @@ func (sc *subcommandContext) findID(input string) (uuid.UUID, error) { // one Tunnelstore API call. func (sc *subcommandContext) findIDs(inputs []string) ([]uuid.UUID, error) { + // Shortcut without Tunnelstore call if we find that all inputs are already UUIDs. + uuids, err := convertNamesToUuids(inputs, make(map[string]uuid.UUID)) + if err == nil { + return uuids, nil + } + // First, look up all tunnels the user has filter := tunnelstore.NewFilter() filter.NoDeleted() @@ -362,7 +368,10 @@ func findIDs(tunnels []*tunnelstore.Tunnel, inputs []string) ([]uuid.UUID, error nameToID[tunnel.Name] = tunnel.ID } - // For each input, try to find the tunnel ID. + return convertNamesToUuids(inputs, nameToID) +} + +func convertNamesToUuids(inputs []string, nameToID map[string]uuid.UUID) ([]uuid.UUID, error) { tunnelIDs := make([]uuid.UUID, len(inputs)) var badInputs []string for i, input := range inputs { diff --git a/cmd/cloudflared/tunnel/subcommands.go b/cmd/cloudflared/tunnel/subcommands.go index 1640e201..9c51cf9d 100644 --- a/cmd/cloudflared/tunnel/subcommands.go +++ b/cmd/cloudflared/tunnel/subcommands.go @@ -29,9 +29,10 @@ import ( ) const ( - allSortByOptions = "name, id, createdAt, deletedAt, numConnections" - CredFileFlagAlias = "cred-file" - CredFileFlag = "credentials-file" + allSortByOptions = "name, id, createdAt, deletedAt, numConnections" + connsSortByOptions = "id, startedAt, numConnections, version" + CredFileFlagAlias = "cred-file" + CredFileFlag = "credentials-file" LogFieldTunnelID = "tunnelID" ) @@ -64,11 +65,11 @@ var ( Aliases: []string{"rd"}, Usage: "Include connections that have recently disconnected in the list", } - outputFormatFlag = altsrc.NewStringFlag(&cli.StringFlag{ + outputFormatFlag = &cli.StringFlag{ Name: "output", Aliases: []string{"o"}, Usage: "Render output using given `FORMAT`. Valid options are 'json' or 'yaml'", - }) + } sortByFlag = &cli.StringFlag{ Name: "sort-by", Value: "name", @@ -114,6 +115,17 @@ var ( EnvVars: []string{"TUNNEL_TRANSPORT_PROTOCOL"}, Hidden: true, }) + sortInfoByFlag = &cli.StringFlag{ + Name: "sort-by", + Value: "createdAt", + Usage: fmt.Sprintf("Sorts the list of connections of a tunnel by the given field. Valid options are {%s}", connsSortByOptions), + EnvVars: []string{"TUNNEL_INFO_SORT_BY"}, + } + invertInfoSortFlag = &cli.BoolFlag{ + Name: "invert-sort", + Usage: "Inverts the sort order of the tunnel info.", + EnvVars: []string{"TUNNEL_INFO_INVERT_SORT"}, + } ) func buildCreateCommand() *cli.Command { @@ -214,6 +226,9 @@ func listCommand(c *cli.Context) error { return err } + warningChecker := updater.StartWarningCheck(c) + defer warningChecker.LogWarningIfAny(sc.log) + filter := tunnelstore.NewFilter() if !c.Bool("show-deleted") { filter.NoDeleted() @@ -232,9 +247,6 @@ func listCommand(c *cli.Context) error { filter.ByTunnelID(tunnelID) } - warningChecker := updater.StartWarningCheck(c) - defer warningChecker.LogWarningIfAny(sc.log) - tunnels, err := sc.list(filter) if err != nil { return err @@ -284,17 +296,11 @@ func listCommand(c *cli.Context) error { } func formatAndPrintTunnelList(tunnels []*tunnelstore.Tunnel, showRecentlyDisconnected bool) { - const ( - minWidth = 0 - tabWidth = 8 - padding = 1 - padChar = ' ' - flags = 0 - ) - - writer := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags) + writer := tabWriter() defer writer.Flush() + _, _ = fmt.Fprintln(writer, "You can obtain more detailed information for each tunnel with `cloudflared tunnel info `") + // Print column headers with tabbed columns _, _ = fmt.Fprintln(writer, "ID\tNAME\tCREATED\tCONNECTIONS\t") @@ -336,6 +342,152 @@ func fmtConnections(connections []tunnelstore.Connection, showRecentlyDisconnect return strings.Join(output, ", ") } +func buildInfoCommand() *cli.Command { + return &cli.Command{ + Name: "info", + Action: cliutil.ConfiguredAction(tunnelInfo), + Usage: "List details about the active connectors for a tunnel", + UsageText: "cloudflared tunnel [tunnel command options] info [subcommand options] [TUNNEL]", + Description: "cloudflared tunnel info displays details about the active connectors for a given tunnel (identified by name or uuid).", + Flags: []cli.Flag{ + outputFormatFlag, + showRecentlyDisconnected, + sortInfoByFlag, + invertInfoSortFlag, + }, + CustomHelpTemplate: commandHelpTemplate(), + } +} + +func tunnelInfo(c *cli.Context) error { + sc, err := newSubcommandContext(c) + if err != nil { + return err + } + + warningChecker := updater.StartWarningCheck(c) + defer warningChecker.LogWarningIfAny(sc.log) + + if c.NArg() > 1 { + return cliutil.UsageError(`"cloudflared tunnel info" accepts only one argument, the ID or name of the tunnel to run.`) + } + tunnelID, err := sc.findID(c.Args().First()) + if err != nil { + return errors.Wrap(err, "error parsing tunnel ID") + } + + client, err := sc.client() + if err != nil { + return err + } + + clients, err := client.ListActiveClients(tunnelID) + if err != nil { + return err + } + + sortBy := c.String("sort-by") + invalidSortField := false + sort.Slice(clients, func(i, j int) bool { + cmp := func() bool { + switch sortBy { + case "id": + return clients[i].ID.String() < clients[j].ID.String() + case "createdAt": + return clients[i].RunAt.Unix() < clients[j].RunAt.Unix() + case "numConnections": + return len(clients[i].Connections) < len(clients[j].Connections) + case "version": + return clients[i].Version < clients[j].Version + default: + invalidSortField = true + return clients[i].RunAt.Unix() < clients[j].RunAt.Unix() + } + }() + if c.Bool("invert-sort") { + return !cmp + } + return cmp + }) + if invalidSortField { + sc.log.Error().Msgf("%s is not a valid sort field. Valid sort fields are %s. Defaulting to 'name'.", sortBy, connsSortByOptions) + } + + tunnel, err := getTunnel(sc, tunnelID) + if err != nil { + return err + } + info := Info{ + tunnel.ID, + tunnel.Name, + tunnel.CreatedAt, + clients, + } + + if outputFormat := c.String(outputFormatFlag.Name); outputFormat != "" { + return renderOutput(outputFormat, info) + } + + if len(clients) > 0 { + formatAndPrintConnectionsList(info, c.Bool("show-recently-disconnected")) + } else { + fmt.Printf("Your tunnel %s does not have any active connection.\n", tunnelID) + } + + return nil +} + +func getTunnel(sc *subcommandContext, tunnelID uuid.UUID) (*tunnelstore.Tunnel, error) { + filter := tunnelstore.NewFilter() + filter.ByTunnelID(tunnelID) + tunnels, err := sc.list(filter) + if err != nil { + return nil, err + } + if len(tunnels) != 1 { + return nil, errors.Errorf("Expected to find a single tunnel with uuid %v but found %d tunnels.", tunnelID, len(tunnels)) + } + return tunnels[0], nil +} + +func formatAndPrintConnectionsList(tunnelInfo Info, showRecentlyDisconnected bool) { + writer := tabWriter() + defer writer.Flush() + + _, _ = fmt.Fprintf(writer, "NAME: %s\nID: %s\nCREATED: %s\n\n", tunnelInfo.Name, tunnelInfo.ID, tunnelInfo.CreatedAt) + + _, _ = fmt.Fprintln(writer, "CONNECTOR ID\tCREATED\tARCHITECTURE\tVERSION\tORIGIN IP\tEDGE\t") + for _, c := range tunnelInfo.Connectors { + var originIp = "" + if len(c.Connections) > 0 { + originIp = c.Connections[0].OriginIP.String() + } + formattedStr := fmt.Sprintf( + "%s\t%s\t%s\t%s\t%s\t%s\t", + c.ID, + c.RunAt.Format(time.RFC3339), + c.Arch, + c.Version, + originIp, + fmtConnections(c.Connections, showRecentlyDisconnected), + ) + _, _ = fmt.Fprintln(writer, formattedStr) + } +} + +func tabWriter() *tabwriter.Writer { + const ( + minWidth = 0 + tabWidth = 8 + padding = 1 + padChar = ' ' + flags = 0 + ) + + writer := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags) + return writer +} + func buildDeleteCommand() *cli.Command { return &cli.Command{ Name: "delete", diff --git a/tunnelstore/client.go b/tunnelstore/client.go index 87cfdd31..dfed022c 100644 --- a/tunnelstore/client.go +++ b/tunnelstore/client.go @@ -43,6 +43,17 @@ type Connection struct { ColoName string `json:"colo_name"` ID uuid.UUID `json:"id"` IsPendingReconnect bool `json:"is_pending_reconnect"` + OriginIP net.IP `json:"origin_ip"` + OpenedAt time.Time `json:"opened_at"` +} + +type ActiveClient struct { + ID uuid.UUID `json:"id"` + Features []string `json:"features"` + Version string `json:"version"` + Arch string `json:"arch"` + RunAt time.Time `json:"run_at"` + Connections []Connection `json:"conns"` } type Change = string @@ -192,6 +203,7 @@ type Client interface { GetTunnel(tunnelID uuid.UUID) (*Tunnel, error) DeleteTunnel(tunnelID uuid.UUID) error ListTunnels(filter *Filter) ([]*Tunnel, error) + ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error) CleanupConnections(tunnelID uuid.UUID) error RouteTunnel(tunnelID uuid.UUID, route Route) (RouteResult, error) @@ -336,6 +348,28 @@ func parseListTunnels(body io.ReadCloser) ([]*Tunnel, error) { return tunnels, err } +func (r *RESTClient) ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error) { + endpoint := r.baseEndpoints.accountLevel + endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/connections", tunnelID)) + 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 parseConnectionsDetails(resp.Body) + } + + return nil, r.statusCodeToError("list connection details", resp) +} + +func parseConnectionsDetails(reader io.Reader) ([]*ActiveClient, error) { + var clients []*ActiveClient + err := parseResponse(reader, &clients) + return clients, err +} + func (r *RESTClient) CleanupConnections(tunnelID uuid.UUID) error { endpoint := r.baseEndpoints.accountLevel endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/connections", tunnelID)) diff --git a/tunnelstore/client_test.go b/tunnelstore/client_test.go index 60a14cb0..a3e0ec97 100644 --- a/tunnelstore/client_test.go +++ b/tunnelstore/client_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "io/ioutil" + "net" "reflect" "strings" "testing" @@ -205,3 +206,24 @@ func TestUnmarshalTunnelErr(t *testing.T) { assert.Error(t, err, fmt.Sprintf("Test #%v failed", i)) } } + +func TestUnmarshalConnections(t *testing.T) { + jsonBody := `{"success":true,"messages":[],"errors":[],"result":[{"id":"d4041254-91e3-4deb-bd94-b46e11680b1e","features":["ha-origin"],"version":"2021.2.5","arch":"darwin_amd64","conns":[{"colo_name":"LIS","id":"ac2286e5-c708-4588-a6a0-ba6b51940019","is_pending_reconnect":false,"origin_ip":"148.38.28.2","opened_at":"0001-01-01T00:00:00Z"}],"run_at":"0001-01-01T00:00:00Z"}]}` + expected := ActiveClient{ + ID: uuid.MustParse("d4041254-91e3-4deb-bd94-b46e11680b1e"), + Features: []string{"ha-origin"}, + Version: "2021.2.5", + Arch: "darwin_amd64", + RunAt: time.Time{}, + Connections: []Connection{{ + ID: uuid.MustParse("ac2286e5-c708-4588-a6a0-ba6b51940019"), + ColoName: "LIS", + IsPendingReconnect: false, + OriginIP: net.ParseIP("148.38.28.2"), + OpenedAt: time.Time{}, + }}, + } + actual, err := parseConnectionsDetails(bytes.NewReader([]byte(jsonBody))) + assert.NoError(t, err) + assert.Equal(t, []*ActiveClient{&expected}, actual) +}