diff --git a/cmd/cli/commands_clients.go b/cmd/cli/commands_clients.go new file mode 100644 index 0000000..498d06a --- /dev/null +++ b/cmd/cli/commands_clients.go @@ -0,0 +1,140 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/kardianos/service" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + "github.com/Control-D-Inc/ctrld/internal/clientinfo" +) + +// ClientsCommand handles clients-related operations +type ClientsCommand struct { + controlClient *controlClient +} + +// NewClientsCommand creates a new clients command handler +func NewClientsCommand() (*ClientsCommand, error) { + dir, err := socketDir() + if err != nil { + return nil, fmt.Errorf("failed to find ctrld home dir: %w", err) + } + + cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock)) + return &ClientsCommand{ + controlClient: cc, + }, nil +} + +// ListClients lists all connected clients +func (cc *ClientsCommand) ListClients(cmd *cobra.Command, args []string) error { + // Check service status first + sm, err := NewServiceManager() + if err != nil { + return err + } + + status, err := sm.Status() + if errors.Is(err, service.ErrNotInstalled) { + mainLog.Load().Warn().Msg("service not installed") + return nil + } + if status == service.StatusStopped { + mainLog.Load().Warn().Msg("service is not running") + return nil + } + + resp, err := cc.controlClient.post(listClientsPath, nil) + if err != nil { + return fmt.Errorf("failed to get clients: %w", err) + } + defer resp.Body.Close() + + var clients []*clientinfo.Client + if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil { + return fmt.Errorf("failed to decode clients result: %w", err) + } + + map2Slice := func(m map[string]struct{}) []string { + s := make([]string, 0, len(m)) + for k := range m { + if k == "" { // skip empty source from output. + continue + } + s = append(s, k) + } + sort.Strings(s) + return s + } + + // If metrics is enabled, server set this for all clients, so we can check only the first one. + // Ideally, we may have a field in response to indicate that query count should be shown, but + // it would break earlier version of ctrld, which only look list of clients in response. + withQueryCount := len(clients) > 0 && clients[0].IncludeQueryCount + data := make([][]string, len(clients)) + for i, c := range clients { + row := []string{ + c.IP.String(), + c.Hostname, + c.Mac, + strings.Join(map2Slice(c.Source), ","), + } + if withQueryCount { + row = append(row, strconv.FormatInt(c.QueryCount, 10)) + } + data[i] = row + } + + table := tablewriter.NewWriter(os.Stdout) + headers := []string{"IP", "Hostname", "Mac", "Discovered"} + if withQueryCount { + headers = append(headers, "Queries") + } + table.SetHeader(headers) + table.SetAutoFormatHeaders(false) + table.AppendBulk(data) + table.Render() + + return nil +} + +// InitClientsCmd creates the clients command with proper logic +func InitClientsCmd() *cobra.Command { + listClientsCmd := &cobra.Command{ + Use: "list", + Short: "List clients that ctrld discovered", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + checkHasElevatedPrivilege() + }, + RunE: func(cmd *cobra.Command, args []string) error { + cc, err := NewClientsCommand() + if err != nil { + return err + } + return cc.ListClients(cmd, args) + }, + } + + clientsCmd := &cobra.Command{ + Use: "clients", + Short: "Manage clients", + Args: cobra.OnlyValidArgs, + ValidArgs: []string{ + listClientsCmd.Use, + }, + } + clientsCmd.AddCommand(listClientsCmd) + rootCmd.AddCommand(clientsCmd) + + return clientsCmd +}