package main import ( "fmt" "io" "os" "strconv" "strings" "github.com/spf13/cobra" "jarl/internal/api" "jarl/internal/client" ) // tokenRotateCmd is 'jarl token rotate'. func tokenRotateCmd() *cobra.Command { var user, grace, expires, write string var current, noExpiry, save bool cmd := &cobra.Command{ Use: "rotate [ID|NAME]", Short: "Give a token a new secret, keeping its name, grants and ID", Long: `Gives a token a new secret. The token keeps its ID, name, user and grants, so nothing has to be set up again: only the programs that use it need the new secret, which is printed once (or written with --write/--save). The old secret stops working at once. With --grace it keeps working for a while (at most 7d), so a client can be switched over without an outage; jarld logs every use of the old secret, so you can see what still uses it. To shut out a leaked token, rotate without --grace (or revoke it). A token that expires gets its lifetime again, counted from now (a 90-day token is good for another 90 days). --expires sets a new lifetime and --no-expiry removes the expiry. The token is given by ID, by name (your own tokens, or --user's), or with --current, the token this command runs with. A restricted token may rotate itself, which lets automation renew its own token.`, Example: ` jarl token rotate 12 jarl token rotate laptop --save # and update the CLI's configuration jarl token rotate --current --save --grace 10m jarl token rotate deploy --user ci --grace 1h -q jarl --token @/etc/jarl/certbot.token token rotate --current --write /etc/jarl/certbot.token`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if current == (len(args) == 1) { return fmt.Errorf("give a token ID or name, or --current") } // Check that --save can work before the old secret is gone. var origin tokenOrigin if save { var err error if origin, err = findTokenOrigin(); err != nil { return err } } c, err := newClient() if err != nil { return err } var id int64 if current { var who api.Whoami if err := c.Get(ctx(), "/auth/whoami", nil, &who); err != nil { return err } if who.Token == nil { return fmt.Errorf("--current: this command isn't using a token") } id = who.Token.ID } else if id, err = tokenID(c, args[0], user); err != nil { return err } in := api.TokenRotation{Grace: grace, ExpiresIn: expires, NoExpiry: noExpiry} var t api.Token if err := c.Do(ctx(), "POST", fmt.Sprintf("/tokens/%d/rotate", id), nil, in, &t); err != nil { return err } var saved []string if write != "" { if err := writeTokenFile(write, t.Secret); err != nil { return fmt.Errorf("the token was rotated, but writing %s failed: %w\nnew token: %s", write, err, t.Secret) } saved = append(saved, write) } if save { where, err := origin.store(t.Secret) if err != nil { return fmt.Errorf("the token was rotated, but saving it failed: %w\nnew token: %s", err, t.Secret) } saved = append(saved, where) } if flagQuiet { if len(saved) == 0 { fmt.Println(t.Secret) } return nil } return output(t, func(w io.Writer) { if len(saved) == 0 { fmt.Fprintf(w, "Token:\t%s\n", t.Secret) } fmt.Fprintf(w, "Name:\t%s (id %d, user %s)\n", t.Name, t.ID, t.Username) if t.Grants != nil { fmt.Fprintf(w, "Grants:\t%s\n", strings.Join(t.GrantStrings, "; ")) } fmt.Fprintf(w, "Expires:\t%s\n", fmtTime(t.ExpiresAt)) if t.PrevExpiresAt != nil { fmt.Fprintf(w, "Old secret:\tworks until %s\n", fmtTime(t.PrevExpiresAt)) } else { fmt.Fprintf(w, "Old secret:\tno longer works\n") } if len(saved) > 0 { fmt.Fprintf(w, "Saved to:\t%s\n", strings.Join(saved, ", ")) } else { fmt.Fprintln(w, "\nThe new token is shown only once; store it now.") if current && t.PrevExpiresAt == nil { fmt.Fprintln(w, "This was the token this command ran with, so the CLI needs the new one (next time: --save).") } } }) }, } f := cmd.Flags() f.BoolVar(¤t, "current", false, "rotate the token this command runs with") f.StringVar(&user, "user", "", "look the NAME up among this user's tokens (admin)") f.StringVar(&grace, "grace", "", "keep the old secret working this long, e.g. 10m, 24h or 2d (at most 7d)") f.StringVar(&expires, "expires", "", "new lifetime from now, e.g. 90d (default: the token's current lifetime again)") f.BoolVar(&noExpiry, "no-expiry", false, "the token no longer expires") f.StringVar(&write, "write", "", "write the new token to this file (mode 0600 if new) instead of printing it") f.BoolVar(&save, "save", false, "store the new token where the CLI gets its token: its configuration file, or the @FILE it reads") return cmd } // tokenID finds a token by ID or by name. func tokenID(c *client.Client, arg, user string) (int64, error) { if id, err := strconv.ParseInt(arg, 10, 64); err == nil { return id, nil } path := "/me/tokens" if user != "" { uid, err := userID(c, user) if err != nil { return 0, err } path = fmt.Sprintf("/users/%d/tokens", uid) } var tokens []api.Token if err := c.Get(ctx(), path, nil, &tokens); err != nil { return 0, err } var ids []string var found int64 for _, t := range tokens { if t.Name == arg && t.RevokedAt == nil { found = t.ID ids = append(ids, strconv.FormatInt(t.ID, 10)) } } switch len(ids) { case 0: if user == "" { return 0, fmt.Errorf("you have no token named %q (another user's: --user)", arg) } return 0, fmt.Errorf("user %s has no token named %q", user, arg) case 1: return found, nil } return 0, fmt.Errorf("several tokens are named %q (IDs %s); give the ID", arg, strings.Join(ids, ", ")) } // tokenOrigin is where the CLI's token comes from, for --save. type tokenOrigin struct { file string // the token is read from this file (@FILE) server string // else it's in the configuration file: this server's entry... top bool // ...or the top-level token } // findTokenOrigin follows the same rules as newClient. func findTokenOrigin() (tokenOrigin, error) { cfg, err := loadCLIConfig() if err != nil { return tokenOrigin{}, err } ts, err := resolveTargets(cfg, targetSpec(cfg)) if err != nil { return tokenOrigin{}, err } if len(ts) != 1 { return tokenOrigin{}, fmt.Errorf("--save needs a single server") } t := ts[0] var o tokenOrigin var token string switch { case t.Name != "" && t.Token != "": token, o.server = t.Token, t.Name case flagToken != "": token = flagToken case os.Getenv("JARL_TOKEN") != "": token = os.Getenv("JARL_TOKEN") case t.Name != "": token, o.server = t.Token, t.Name default: token, o.top = cfg.Token, true } if f, ok := strings.CutPrefix(token, "@"); ok { return tokenOrigin{file: f}, nil } if !o.top && o.server == "" { return tokenOrigin{}, fmt.Errorf("--save: the token comes from --token or JARL_TOKEN, so there's nowhere to save it; use --write FILE (or put the token in a file and pass --token @FILE)") } return o, nil } // store saves a new token where the old one came from, and says where. func (o tokenOrigin) store(secret string) (string, error) { if o.file != "" { return o.file, writeTokenFile(o.file, secret) } cfg, err := loadCLIConfig() if err != nil { return "", err } if o.server != "" { e := cfg.Servers[o.server] if e == nil { return "", fmt.Errorf("server %s is no longer in %s", o.server, flagConfig) } e.Token = secret } else { cfg.Token = secret } if err := saveCLIConfig(cfg); err != nil { return "", err } if o.server != "" { return flagConfig + " (server " + o.server + ")", nil } return flagConfig, nil } // saveTokenInConfig stores a token in the CLI configuration file, for the // selected server (its named entry, or the top-level token). func saveTokenInConfig(secret string) (string, error) { cfg, err := loadCLIConfig() if err != nil { return "", err } ts, err := resolveTargets(cfg, targetSpec(cfg)) if err != nil { return "", err } if len(ts) == 1 && ts[0].Name != "" { if e := cfg.Servers[ts[0].Name]; e != nil { e.Token = secret return flagConfig + " (server " + ts[0].Name + ")", saveCLIConfig(cfg) } } cfg.Token = secret return flagConfig, saveCLIConfig(cfg) } // writeTokenFile replaces a token file's content. An existing file keeps // its owner and mode; a new one is created with mode 0600. func writeTokenFile(path, secret string) error { return os.WriteFile(path, []byte(secret+"\n"), 0o600) }