mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-18 12:49:07 +02:00
431 lines
12 KiB
Go
431 lines
12 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"runtime"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"google.golang.org/grpc/codes"
|
|
gstatus "google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
"github.com/netbirdio/netbird/util"
|
|
)
|
|
|
|
var (
|
|
profileListShowID bool
|
|
profileClaimOwner string
|
|
)
|
|
|
|
var profileCmd = &cobra.Command{
|
|
Use: "profile",
|
|
Short: "Manage NetBird client profiles",
|
|
Long: `Commands to list, add, remove, and switch profiles. Profiles allow you to maintain different accounts in one client app.`,
|
|
}
|
|
|
|
var profileListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List all profiles",
|
|
Long: `List all available profiles in the NetBird client.`,
|
|
Aliases: []string{"ls"},
|
|
RunE: listProfilesFunc,
|
|
}
|
|
|
|
var profileAddCmd = &cobra.Command{
|
|
Use: "add <profile_name>",
|
|
Short: "Add a new profile",
|
|
Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: addProfileFunc,
|
|
}
|
|
|
|
var profileRenameCmd = &cobra.Command{
|
|
Use: "rename <profile> <new_profile_name>",
|
|
Short: "Renames an existing profile",
|
|
Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: renameProfileFunc,
|
|
}
|
|
|
|
var profileRemoveCmd = &cobra.Command{
|
|
Use: "remove <profile>",
|
|
Short: "Remove a profile",
|
|
Long: `Remove a profile by name, ID, or unique ID prefix.`,
|
|
Aliases: []string{"rm"},
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: removeProfileFunc,
|
|
}
|
|
|
|
var profileSelectCmd = &cobra.Command{
|
|
Use: "select <profile>",
|
|
Short: "Select a profile",
|
|
Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: selectProfileFunc,
|
|
}
|
|
|
|
var profileClaimCmd = &cobra.Command{
|
|
Use: "claim <profile>",
|
|
Short: "Record an owner on a profile",
|
|
Long: `Record who owns a profile. Requires root or administrator privileges.
|
|
|
|
A profile with no owner is reachable by a privileged caller alone. Claiming is
|
|
how ownership is settled on a machine with no console user, such as one set up
|
|
from a setup key, and how a profile is handed to a different account.
|
|
|
|
The owner is given as a principal ("uid:1000", "sid:S-1-5-21-...") or an account
|
|
name, which the daemon resolves. Without --owner the profile is claimed for the
|
|
user who ran sudo. On Windows --owner is required, since elevation keeps no
|
|
record of who asked for it.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: claimProfileFunc,
|
|
}
|
|
|
|
func init() {
|
|
profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column")
|
|
profileClaimCmd.Flags().StringVar(&profileClaimOwner, "owner", "",
|
|
"principal (uid:1000, sid:S-1-5-21-...) or account name to record as the owner. Defaults to the user running the command.")
|
|
}
|
|
|
|
func setupCmd(cmd *cobra.Command) error {
|
|
SetFlagsFromEnvVars(rootCmd)
|
|
SetFlagsFromEnvVars(cmd)
|
|
|
|
cmd.SetOut(cmd.OutOrStdout())
|
|
|
|
err := util.InitLog(logLevel, "console")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
|
if err := setupCmd(cmd); err != nil {
|
|
return err
|
|
}
|
|
|
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
currUser, err := profilemanager.InvokingUser()
|
|
if err != nil {
|
|
return fmt.Errorf("get current user: %w", err)
|
|
}
|
|
|
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
|
|
|
resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
|
|
Username: currUser.Username,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
|
if profileListShowID {
|
|
fmt.Fprintln(tw, "ID\tNAME\tACTIVE\tOWNER")
|
|
} else {
|
|
fmt.Fprintln(tw, "NAME\tACTIVE\tOWNER")
|
|
}
|
|
for _, profile := range resp.Profiles {
|
|
marker := ""
|
|
if profile.IsActive {
|
|
marker = "✓"
|
|
}
|
|
name := profilemanager.StripCtrlChars(profile.Name)
|
|
id := profilemanager.ID(profile.Id)
|
|
// An unowned profile is reachable by a privileged caller alone, so say
|
|
// so rather than leaving the column blank.
|
|
owner := "unowned"
|
|
if len(profile.Owners) > 0 {
|
|
owner = profilemanager.StripCtrlChars(profile.Owners[0])
|
|
}
|
|
if profileListShowID {
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", id.ShortID(), name, marker, owner)
|
|
} else {
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\n", name, marker, owner)
|
|
}
|
|
}
|
|
return tw.Flush()
|
|
}
|
|
|
|
func claimProfileFunc(cmd *cobra.Command, args []string) error {
|
|
if err := setupCmd(cmd); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The daemon resolves and validates the owner. All that happens here is
|
|
// filling in who "me" is when the flag is omitted.
|
|
owner := profileClaimOwner
|
|
if owner == "" {
|
|
var err error
|
|
if owner, err = defaultClaimOwner(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
|
handle := args[0]
|
|
|
|
resp, err := daemonClient.ClaimProfile(cmd.Context(), &proto.ClaimProfileRequest{
|
|
Handle: handle,
|
|
Owner: owner,
|
|
})
|
|
if err != nil {
|
|
return daemonCallError("claim profile", wrapAmbiguityError(err, handle, "claim <id-prefix>"))
|
|
}
|
|
|
|
cmd.Printf("Profile %s claimed for %s\n", profilemanager.ID(resp.Id).ShortID(), resp.Owner)
|
|
return nil
|
|
}
|
|
|
|
// defaultClaimOwner names who to claim for when --owner is omitted.
|
|
//
|
|
// Unix has SUDO_USER, Windows has no equivalent.
|
|
func defaultClaimOwner() (string, error) {
|
|
if runtime.GOOS == "windows" {
|
|
return "", errors.New("name the owner with --owner, Windows keeps no record of who asked for elevation")
|
|
}
|
|
|
|
// Plain root has no invoking user to act for, so claiming for "me" would
|
|
// silently mean root.
|
|
if profilemanager.IsPlainRoot() {
|
|
return "", errors.New("no invoking user to claim for, name the owner with --owner")
|
|
}
|
|
|
|
u, err := profilemanager.InvokingUser()
|
|
if err != nil {
|
|
return "", fmt.Errorf("get current user: %w", err)
|
|
}
|
|
return u.Username, nil
|
|
}
|
|
|
|
func addProfileFunc(cmd *cobra.Command, args []string) error {
|
|
if err := setupCmd(cmd); err != nil {
|
|
return err
|
|
}
|
|
|
|
currUser, err := profilemanager.InvokingUser()
|
|
if err != nil {
|
|
return fmt.Errorf("get current user: %w", err)
|
|
}
|
|
|
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
|
profileName := args[0]
|
|
|
|
id, err := addProfileOnDaemon(cmd.Context(), daemonClient, profileName, currUser.Username)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName)
|
|
if dupCount > 1 {
|
|
cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName)
|
|
cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
|
|
}
|
|
|
|
cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName))
|
|
return nil
|
|
|
|
}
|
|
|
|
func renameProfileFunc(cmd *cobra.Command, args []string) error {
|
|
if err := setupCmd(cmd); err != nil {
|
|
return err
|
|
}
|
|
|
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
currUser, err := profilemanager.InvokingUser()
|
|
if err != nil {
|
|
return fmt.Errorf("get current user: %w", err)
|
|
}
|
|
|
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
|
handle := args[0]
|
|
newProfilename := args[1]
|
|
|
|
resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{
|
|
Handle: handle,
|
|
Username: currUser.Username,
|
|
NewProfileName: newProfilename,
|
|
})
|
|
if err != nil {
|
|
return wrapAmbiguityError(err, handle, "rename <id-prefix> <new_profile_name>")
|
|
}
|
|
|
|
dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename)
|
|
if dupCount > 1 {
|
|
cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename)
|
|
cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
|
|
}
|
|
|
|
cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename))
|
|
|
|
return nil
|
|
}
|
|
|
|
func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) {
|
|
resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n := 0
|
|
for _, p := range resp.Profiles {
|
|
if p.Name == name {
|
|
n++
|
|
}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func removeProfileFunc(cmd *cobra.Command, args []string) error {
|
|
if err := setupCmd(cmd); err != nil {
|
|
return err
|
|
}
|
|
|
|
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
currUser, err := profilemanager.InvokingUser()
|
|
if err != nil {
|
|
return fmt.Errorf("get current user: %w", err)
|
|
}
|
|
|
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
|
handle := args[0]
|
|
|
|
resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
|
|
ProfileName: handle,
|
|
Username: currUser.Username,
|
|
})
|
|
if err != nil {
|
|
return wrapAmbiguityError(err, handle, "remove <id-prefix>")
|
|
}
|
|
|
|
cmd.Printf("Profile removed: %s\n", resp.Id)
|
|
return nil
|
|
}
|
|
|
|
func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
|
if err := setupCmd(cmd); err != nil {
|
|
return err
|
|
}
|
|
|
|
profileManager := profilemanager.NewProfileManager()
|
|
handle := args[0]
|
|
|
|
currUser, err := profilemanager.InvokingUser()
|
|
if err != nil {
|
|
return fmt.Errorf("get current user: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*7)
|
|
defer cancel()
|
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to service CLI interface: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
daemonClient := proto.NewDaemonServiceClient(conn)
|
|
|
|
switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{
|
|
ProfileName: &handle,
|
|
Username: &currUser.Username,
|
|
})
|
|
if err != nil {
|
|
return wrapAmbiguityError(err, handle, "select <id-prefix>")
|
|
}
|
|
|
|
if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil {
|
|
return err
|
|
}
|
|
|
|
status, err := daemonClient.Status(ctx, &proto.StatusRequest{})
|
|
if err != nil {
|
|
return fmt.Errorf("get service status: %w", err)
|
|
}
|
|
|
|
if status.Status == string(internal.StatusConnected) {
|
|
if _, err := daemonClient.Down(ctx, &proto.DownRequest{}); err != nil {
|
|
return fmt.Errorf("call service down method: %w", err)
|
|
}
|
|
}
|
|
|
|
id := profilemanager.ID(switchResp.Id)
|
|
cmd.Printf("Profile switched to: %s\n", id.ShortID())
|
|
return nil
|
|
}
|
|
|
|
// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors
|
|
// (which carry the resolver's message verbatim) into CLI-friendly text
|
|
// that points the user at --show-id. retry names the command to run again by
|
|
// ID prefix, as it would be typed after `netbird profile`.
|
|
func wrapAmbiguityError(err error, handle, retry string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
st, ok := gstatus.FromError(err)
|
|
if !ok {
|
|
return err
|
|
}
|
|
switch st.Code() {
|
|
case codes.InvalidArgument:
|
|
msg := st.Message()
|
|
if strings.Contains(msg, "ambiguous") {
|
|
return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then retry by ID prefix:\n netbird profile " + retry)
|
|
}
|
|
case codes.NotFound:
|
|
return fmt.Errorf("profile %q not found", handle)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// addProfileOnDaemon issues the AddProfile RPC on an existing daemon client
|
|
// and returns the new profile's ID. It is the single entry point for profile
|
|
// creation, shared by `netbird profile add` and the `netbird up --profile
|
|
// <name>` auto-create path.
|
|
func addProfileOnDaemon(ctx context.Context, client proto.DaemonServiceClient, profileName, username string) (profilemanager.ID, error) {
|
|
resp, err := client.AddProfile(ctx, &proto.AddProfileRequest{
|
|
ProfileName: profileName,
|
|
Username: username,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("add profile failed: %w", err)
|
|
}
|
|
|
|
return profilemanager.ID(resp.Id), nil
|
|
}
|