jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.
Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
8.4 KiB
Go
269 lines
8.4 KiB
Go
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)
|
|
}
|