Skip to content

Commit 89d0e45

Browse files
committed
TUN-3993: New cloudflared tunnel info to obtain details about the active connectors for a tunnel
1 parent a340997 commit 89d0e45

File tree

8 files changed

+259
-23
lines changed

8 files changed

+259
-23
lines changed

CHANGES.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88

99
### New Features
1010

11-
- none
11+
- It is now possible to obtain more detailed information about the cloudflared connectors to Cloudflare Edge via
12+
`cloudflared tunnel info <name/uuid>`. It is possible to sort the output as well as output in different formats,
13+
such as: `cloudflared tunnel info --sort-by version --invert-sort --output json <name/uuid>`.
14+
You can obtain more information via `cloudflared tunnel info --help`.
1215

1316
### Improvements
1417

15-
- nonw
18+
- none
1619

1720
### Bug Fixes
1821

cmd/cloudflared/tunnel/cmd.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ func Commands() []*cli.Command {
100100
buildRouteCommand(),
101101
buildRunCommand(),
102102
buildListCommand(),
103+
buildInfoCommand(),
103104
buildIngressSubcommand(),
104105
buildDeleteCommand(),
105106
buildCleanupCommand(),
@@ -464,7 +465,7 @@ func tunnelFlags(shouldHide bool) []cli.Flag {
464465
credentialsFileFlag,
465466
altsrc.NewBoolFlag(&cli.BoolFlag{
466467
Name: "is-autoupdated",
467-
Usage: "Signal the new process that Argo Tunnel client has been autoupdated",
468+
Usage: "Signal the new process that Argo Tunnel connector has been autoupdated",
468469
Value: false,
469470
Hidden: true,
470471
}),

cmd/cloudflared/tunnel/configuration.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,9 @@ func prepareTunnelConfig(
199199
if isNamedTunnel {
200200
clientUUID, err := uuid.NewRandom()
201201
if err != nil {
202-
return nil, ingress.Ingress{}, errors.Wrap(err, "can't generate clientUUID")
202+
return nil, ingress.Ingress{}, errors.Wrap(err, "can't generate connector UUID")
203203
}
204-
log.Info().Msgf("Generated Client ID: %s", clientUUID)
204+
log.Info().Msgf("Generated Connector ID: %s", clientUUID)
205205
features := append(c.StringSlice("features"), origin.FeatureSerializedHeaders)
206206
namedTunnel.Client = tunnelpogs.ClientInfo{
207207
ClientID: clientUUID[:],

cmd/cloudflared/tunnel/info.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package tunnel
2+
3+
import (
4+
"time"
5+
6+
"github.com/cloudflare/cloudflared/tunnelstore"
7+
"github.com/google/uuid"
8+
)
9+
10+
type Info struct {
11+
ID uuid.UUID `json:"id"`
12+
Name string `json:"name"`
13+
CreatedAt time.Time `json:"createdAt"`
14+
Connectors []*tunnelstore.ActiveClient `json:"conns"`
15+
}

cmd/cloudflared/tunnel/subcommand_context.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,12 @@ func (sc *subcommandContext) findID(input string) (uuid.UUID, error) {
343343
// one Tunnelstore API call.
344344
func (sc *subcommandContext) findIDs(inputs []string) ([]uuid.UUID, error) {
345345

346+
// Shortcut without Tunnelstore call if we find that all inputs are already UUIDs.
347+
uuids, err := convertNamesToUuids(inputs, make(map[string]uuid.UUID))
348+
if err == nil {
349+
return uuids, nil
350+
}
351+
346352
// First, look up all tunnels the user has
347353
filter := tunnelstore.NewFilter()
348354
filter.NoDeleted()
@@ -362,7 +368,10 @@ func findIDs(tunnels []*tunnelstore.Tunnel, inputs []string) ([]uuid.UUID, error
362368
nameToID[tunnel.Name] = tunnel.ID
363369
}
364370

365-
// For each input, try to find the tunnel ID.
371+
return convertNamesToUuids(inputs, nameToID)
372+
}
373+
374+
func convertNamesToUuids(inputs []string, nameToID map[string]uuid.UUID) ([]uuid.UUID, error) {
366375
tunnelIDs := make([]uuid.UUID, len(inputs))
367376
var badInputs []string
368377
for i, input := range inputs {

cmd/cloudflared/tunnel/subcommands.go

Lines changed: 169 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ import (
2929
)
3030

3131
const (
32-
allSortByOptions = "name, id, createdAt, deletedAt, numConnections"
33-
CredFileFlagAlias = "cred-file"
34-
CredFileFlag = "credentials-file"
32+
allSortByOptions = "name, id, createdAt, deletedAt, numConnections"
33+
connsSortByOptions = "id, startedAt, numConnections, version"
34+
CredFileFlagAlias = "cred-file"
35+
CredFileFlag = "credentials-file"
3536

3637
LogFieldTunnelID = "tunnelID"
3738
)
@@ -64,11 +65,11 @@ var (
6465
Aliases: []string{"rd"},
6566
Usage: "Include connections that have recently disconnected in the list",
6667
}
67-
outputFormatFlag = altsrc.NewStringFlag(&cli.StringFlag{
68+
outputFormatFlag = &cli.StringFlag{
6869
Name: "output",
6970
Aliases: []string{"o"},
7071
Usage: "Render output using given `FORMAT`. Valid options are 'json' or 'yaml'",
71-
})
72+
}
7273
sortByFlag = &cli.StringFlag{
7374
Name: "sort-by",
7475
Value: "name",
@@ -114,6 +115,17 @@ var (
114115
EnvVars: []string{"TUNNEL_TRANSPORT_PROTOCOL"},
115116
Hidden: true,
116117
})
118+
sortInfoByFlag = &cli.StringFlag{
119+
Name: "sort-by",
120+
Value: "createdAt",
121+
Usage: fmt.Sprintf("Sorts the list of connections of a tunnel by the given field. Valid options are {%s}", connsSortByOptions),
122+
EnvVars: []string{"TUNNEL_INFO_SORT_BY"},
123+
}
124+
invertInfoSortFlag = &cli.BoolFlag{
125+
Name: "invert-sort",
126+
Usage: "Inverts the sort order of the tunnel info.",
127+
EnvVars: []string{"TUNNEL_INFO_INVERT_SORT"},
128+
}
117129
)
118130

119131
func buildCreateCommand() *cli.Command {
@@ -214,6 +226,9 @@ func listCommand(c *cli.Context) error {
214226
return err
215227
}
216228

229+
warningChecker := updater.StartWarningCheck(c)
230+
defer warningChecker.LogWarningIfAny(sc.log)
231+
217232
filter := tunnelstore.NewFilter()
218233
if !c.Bool("show-deleted") {
219234
filter.NoDeleted()
@@ -232,9 +247,6 @@ func listCommand(c *cli.Context) error {
232247
filter.ByTunnelID(tunnelID)
233248
}
234249

235-
warningChecker := updater.StartWarningCheck(c)
236-
defer warningChecker.LogWarningIfAny(sc.log)
237-
238250
tunnels, err := sc.list(filter)
239251
if err != nil {
240252
return err
@@ -284,17 +296,11 @@ func listCommand(c *cli.Context) error {
284296
}
285297

286298
func formatAndPrintTunnelList(tunnels []*tunnelstore.Tunnel, showRecentlyDisconnected bool) {
287-
const (
288-
minWidth = 0
289-
tabWidth = 8
290-
padding = 1
291-
padChar = ' '
292-
flags = 0
293-
)
294-
295-
writer := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags)
299+
writer := tabWriter()
296300
defer writer.Flush()
297301

302+
_, _ = fmt.Fprintln(writer, "You can obtain more detailed information for each tunnel with `cloudflared tunnel info <name/uuid>`")
303+
298304
// Print column headers with tabbed columns
299305
_, _ = fmt.Fprintln(writer, "ID\tNAME\tCREATED\tCONNECTIONS\t")
300306

@@ -336,6 +342,152 @@ func fmtConnections(connections []tunnelstore.Connection, showRecentlyDisconnect
336342
return strings.Join(output, ", ")
337343
}
338344

345+
func buildInfoCommand() *cli.Command {
346+
return &cli.Command{
347+
Name: "info",
348+
Action: cliutil.ConfiguredAction(tunnelInfo),
349+
Usage: "List details about the active connectors for a tunnel",
350+
UsageText: "cloudflared tunnel [tunnel command options] info [subcommand options] [TUNNEL]",
351+
Description: "cloudflared tunnel info displays details about the active connectors for a given tunnel (identified by name or uuid).",
352+
Flags: []cli.Flag{
353+
outputFormatFlag,
354+
showRecentlyDisconnected,
355+
sortInfoByFlag,
356+
invertInfoSortFlag,
357+
},
358+
CustomHelpTemplate: commandHelpTemplate(),
359+
}
360+
}
361+
362+
func tunnelInfo(c *cli.Context) error {
363+
sc, err := newSubcommandContext(c)
364+
if err != nil {
365+
return err
366+
}
367+
368+
warningChecker := updater.StartWarningCheck(c)
369+
defer warningChecker.LogWarningIfAny(sc.log)
370+
371+
if c.NArg() > 1 {
372+
return cliutil.UsageError(`"cloudflared tunnel info" accepts only one argument, the ID or name of the tunnel to run.`)
373+
}
374+
tunnelID, err := sc.findID(c.Args().First())
375+
if err != nil {
376+
return errors.Wrap(err, "error parsing tunnel ID")
377+
}
378+
379+
client, err := sc.client()
380+
if err != nil {
381+
return err
382+
}
383+
384+
clients, err := client.ListActiveClients(tunnelID)
385+
if err != nil {
386+
return err
387+
}
388+
389+
sortBy := c.String("sort-by")
390+
invalidSortField := false
391+
sort.Slice(clients, func(i, j int) bool {
392+
cmp := func() bool {
393+
switch sortBy {
394+
case "id":
395+
return clients[i].ID.String() < clients[j].ID.String()
396+
case "createdAt":
397+
return clients[i].RunAt.Unix() < clients[j].RunAt.Unix()
398+
case "numConnections":
399+
return len(clients[i].Connections) < len(clients[j].Connections)
400+
case "version":
401+
return clients[i].Version < clients[j].Version
402+
default:
403+
invalidSortField = true
404+
return clients[i].RunAt.Unix() < clients[j].RunAt.Unix()
405+
}
406+
}()
407+
if c.Bool("invert-sort") {
408+
return !cmp
409+
}
410+
return cmp
411+
})
412+
if invalidSortField {
413+
sc.log.Error().Msgf("%s is not a valid sort field. Valid sort fields are %s. Defaulting to 'name'.", sortBy, connsSortByOptions)
414+
}
415+
416+
tunnel, err := getTunnel(sc, tunnelID)
417+
if err != nil {
418+
return err
419+
}
420+
info := Info{
421+
tunnel.ID,
422+
tunnel.Name,
423+
tunnel.CreatedAt,
424+
clients,
425+
}
426+
427+
if outputFormat := c.String(outputFormatFlag.Name); outputFormat != "" {
428+
return renderOutput(outputFormat, info)
429+
}
430+
431+
if len(clients) > 0 {
432+
formatAndPrintConnectionsList(info, c.Bool("show-recently-disconnected"))
433+
} else {
434+
fmt.Printf("Your tunnel %s does not have any active connection.\n", tunnelID)
435+
}
436+
437+
return nil
438+
}
439+
440+
func getTunnel(sc *subcommandContext, tunnelID uuid.UUID) (*tunnelstore.Tunnel, error) {
441+
filter := tunnelstore.NewFilter()
442+
filter.ByTunnelID(tunnelID)
443+
tunnels, err := sc.list(filter)
444+
if err != nil {
445+
return nil, err
446+
}
447+
if len(tunnels) != 1 {
448+
return nil, errors.Errorf("Expected to find a single tunnel with uuid %v but found %d tunnels.", tunnelID, len(tunnels))
449+
}
450+
return tunnels[0], nil
451+
}
452+
453+
func formatAndPrintConnectionsList(tunnelInfo Info, showRecentlyDisconnected bool) {
454+
writer := tabWriter()
455+
defer writer.Flush()
456+
457+
_, _ = fmt.Fprintf(writer, "NAME: %s\nID: %s\nCREATED: %s\n\n", tunnelInfo.Name, tunnelInfo.ID, tunnelInfo.CreatedAt)
458+
459+
_, _ = fmt.Fprintln(writer, "CONNECTOR ID\tCREATED\tARCHITECTURE\tVERSION\tORIGIN IP\tEDGE\t")
460+
for _, c := range tunnelInfo.Connectors {
461+
var originIp = ""
462+
if len(c.Connections) > 0 {
463+
originIp = c.Connections[0].OriginIP.String()
464+
}
465+
formattedStr := fmt.Sprintf(
466+
"%s\t%s\t%s\t%s\t%s\t%s\t",
467+
c.ID,
468+
c.RunAt.Format(time.RFC3339),
469+
c.Arch,
470+
c.Version,
471+
originIp,
472+
fmtConnections(c.Connections, showRecentlyDisconnected),
473+
)
474+
_, _ = fmt.Fprintln(writer, formattedStr)
475+
}
476+
}
477+
478+
func tabWriter() *tabwriter.Writer {
479+
const (
480+
minWidth = 0
481+
tabWidth = 8
482+
padding = 1
483+
padChar = ' '
484+
flags = 0
485+
)
486+
487+
writer := tabwriter.NewWriter(os.Stdout, minWidth, tabWidth, padding, padChar, flags)
488+
return writer
489+
}
490+
339491
func buildDeleteCommand() *cli.Command {
340492
return &cli.Command{
341493
Name: "delete",

tunnelstore/client.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ type Connection struct {
4343
ColoName string `json:"colo_name"`
4444
ID uuid.UUID `json:"id"`
4545
IsPendingReconnect bool `json:"is_pending_reconnect"`
46+
OriginIP net.IP `json:"origin_ip"`
47+
OpenedAt time.Time `json:"opened_at"`
48+
}
49+
50+
type ActiveClient struct {
51+
ID uuid.UUID `json:"id"`
52+
Features []string `json:"features"`
53+
Version string `json:"version"`
54+
Arch string `json:"arch"`
55+
RunAt time.Time `json:"run_at"`
56+
Connections []Connection `json:"conns"`
4657
}
4758

4859
type Change = string
@@ -192,6 +203,7 @@ type Client interface {
192203
GetTunnel(tunnelID uuid.UUID) (*Tunnel, error)
193204
DeleteTunnel(tunnelID uuid.UUID) error
194205
ListTunnels(filter *Filter) ([]*Tunnel, error)
206+
ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error)
195207
CleanupConnections(tunnelID uuid.UUID) error
196208
RouteTunnel(tunnelID uuid.UUID, route Route) (RouteResult, error)
197209

@@ -336,6 +348,28 @@ func parseListTunnels(body io.ReadCloser) ([]*Tunnel, error) {
336348
return tunnels, err
337349
}
338350

351+
func (r *RESTClient) ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error) {
352+
endpoint := r.baseEndpoints.accountLevel
353+
endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/connections", tunnelID))
354+
resp, err := r.sendRequest("GET", endpoint, nil)
355+
if err != nil {
356+
return nil, errors.Wrap(err, "REST request failed")
357+
}
358+
defer resp.Body.Close()
359+
360+
if resp.StatusCode == http.StatusOK {
361+
return parseConnectionsDetails(resp.Body)
362+
}
363+
364+
return nil, r.statusCodeToError("list connection details", resp)
365+
}
366+
367+
func parseConnectionsDetails(reader io.Reader) ([]*ActiveClient, error) {
368+
var clients []*ActiveClient
369+
err := parseResponse(reader, &clients)
370+
return clients, err
371+
}
372+
339373
func (r *RESTClient) CleanupConnections(tunnelID uuid.UUID) error {
340374
endpoint := r.baseEndpoints.accountLevel
341375
endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/connections", tunnelID))

0 commit comments

Comments
 (0)