Files
abrown38andClaude Opus 5.5 c3bd128778 hc user delete: ask first, and take -y
It deleted the user and their tokens at once, while every other delete
asks and takes -y/--yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:46:53 +01:00

676 lines
19 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"housecarl/internal/api"
"housecarl/internal/model"
)
func historyCmd() *cobra.Command {
cmd := &cobra.Command{Use: "history", Short: "The history of configuration changes, and undo"}
var limit int
list := &cobra.Command{
Use: "list", Short: "List recent changes", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var hs []model.HistoryEntry
if err := c.Get(ctx(), "/history", q("limit", strconv.Itoa(limit)), &hs); err != nil {
return err
}
return output(hs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tWHEN\tBY\tVIA\tCHANGES\tDESCRIPTION")
for _, h := range hs {
desc := h.Description
if h.RevertedBy != 0 {
desc += fmt.Sprintf(" (undone by %d)", h.RevertedBy)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04"), h.Username, h.Via, len(h.Changes), desc)
}
})
},
}
list.Flags().IntVar(&limit, "limit", 30, "how many")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show ID", Short: "Show a change: each object before and after", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var h model.HistoryEntry
if err := c.Get(ctx(), "/history/"+args[0], nil, &h); err != nil {
return err
}
return output(h, func(w io.Writer) {
fmt.Fprintf(w, "%d\t%s by %s (%s): %s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04:05"), h.Username, h.Via, h.Description)
for _, ch := range h.Changes {
fmt.Fprintf(w, "\n%s %s %s\n", ch.Action(), ch.Kind, ch.Label)
for _, line := range diffJSON(ch.Before, ch.After) {
fmt.Fprintln(w, " "+line)
}
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "diff ID", Short: "Show what a change did to the ruleset", Args: cobra.ExactArgs(1),
Long: `Shows the ruleset's diff for a change: the configuration before and after
it, rendered by this version of Housecarl.`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var d struct {
Diff string `json:"diff"`
}
if err := c.Get(ctx(), "/history/"+args[0]+"/diff", nil, &d); err != nil {
return err
}
return output(d, func(w io.Writer) {
if d.Diff == "" {
fmt.Fprintln(w, "the ruleset didn't change")
return
}
printDiff(w, d.Diff)
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "undo ID",
Short: "Undo a change",
Long: `Undoes a change by applying it backwards. It is refused when an object the
change touched has changed since; undo the later changes first.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/history/"+args[0]+"/revert", nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
})
return cmd
}
// diffJSON lists the fields that differ between two versions of an
// object: "name: office -> lab". An object within (settings' builtins)
// is compared field by field: "builtins.drop_invalid: false -> true".
func diffJSON(before, after json.RawMessage) []string {
var a, b map[string]any
json.Unmarshal(before, &a)
json.Unmarshal(after, &b)
a, b = flatten(a, "", map[string]any{}), flatten(b, "", map[string]any{})
keys := map[string]bool{}
for k := range a {
keys[k] = true
}
for k := range b {
keys[k] = true
}
var names []string
for k := range keys {
names = append(names, k)
}
sort.Strings(names)
var out []string
show := func(v any, ok bool) string {
if !ok {
return "-"
}
x, _ := json.Marshal(v)
return string(x)
}
for _, k := range names {
va, oka := a[k]
vb, okb := b[k]
sa, sb := show(va, oka), show(vb, okb)
if sa == sb {
continue
}
switch {
case before == nil:
out = append(out, k+": "+sb)
case after == nil:
out = append(out, k+": "+sa)
default:
out = append(out, k+": "+sa+" -> "+sb)
}
}
return out
}
// flatten lifts the fields of objects within m to dotted keys; lists
// stay whole.
func flatten(m map[string]any, prefix string, out map[string]any) map[string]any {
for k, v := range m {
if o, ok := v.(map[string]any); ok && len(o) > 0 {
flatten(o, prefix+k+".", out)
} else {
out[prefix+k] = v
}
}
return out
}
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return err
}
return output(me, func(w io.Writer) {
fmt.Fprintf(w, "User:\t%s (%s)\n", me.User.Username, me.Method)
fmt.Fprintf(w, "Administrator:\t%v\n", me.Admin)
fmt.Fprintf(w, "May:\t%s\n", strings.Join(me.Permissions, ", "))
fmt.Fprintf(w, "Server:\tHousecarl %s\n", me.Build.String())
})
},
}
}
func readSecret(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
if term.IsTerminal(int(os.Stdin.Fd())) {
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return strings.TrimSpace(string(b)), err
}
var s string
_, err := fmt.Fscanln(os.Stdin, &s)
return strings.TrimSpace(s), err
}
func userID(name string) (int64, error) {
c, err := newClient()
if err != nil {
return 0, err
}
var us []api.User
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
return 0, err
}
for _, u := range us {
if strings.EqualFold(u.Username, name) || strconv.FormatInt(u.ID, 10) == name {
return u.ID, nil
}
}
return 0, fmt.Errorf("no user %s", name)
}
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Users (administrators only)"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List users", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var us []api.User
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
return err
}
return output(us, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tADMIN\tGRANTS\tPASSWORD\tDISABLED\tLAST LOGIN")
for _, u := range us {
login := "-"
if u.LastLoginAt != nil {
login = fmtWhen(*u.LastLoginAt)
}
fmt.Fprintf(w, "%d\t%s\t%v\t%s\t%v\t%v\t%s\n", u.ID, u.Username, u.Admin, orDash(strings.Join(u.GrantStrings, " ")), u.HasPassword, u.Disabled, login)
}
})
},
})
var admin, noPassword bool
var role, comment string
var grants []string
add := &cobra.Command{
Use: "add USER",
Short: "Add a user (asks for the password)",
Long: `Adds a user. Without --admin, give what they may do: a role (viewer,
operator, log) or grants such as --grant config:read --grant sets:write:guests.
--no-password makes a service account that only uses API tokens.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.UserInput{Username: &args[0], Admin: &admin}
if !noPassword {
pw, err := readSecret("Password for " + args[0] + ": ")
if err != nil {
return err
}
in.Password = &pw
}
if role != "" {
in.Role = &role
}
if grants != nil {
in.Grants = &grants
}
if comment != "" {
in.Comment = &comment
}
var u api.User
if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("added user %s (%d)\n", u.Username, u.ID)
}
return nil
},
}
f := add.Flags()
f.BoolVar(&admin, "admin", false, "an administrator (may do everything)")
f.StringVar(&role, "role", "", "a role: viewer, operator or log")
f.StringArrayVar(&grants, "grant", nil, "a grant, resource:access (config, sets, log, audit; read or write; sets:write:SET,SET)")
f.BoolVar(&noPassword, "no-password", false, "no password: API tokens only")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var setPassword, disable, enable bool
update := &cobra.Command{
Use: "update USER", Short: "Change a user", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := userID(args[0])
if err != nil {
return err
}
c, _ := newClient()
in := api.UserInput{}
fl := cmd.Flags()
if fl.Changed("admin") {
in.Admin = &admin
}
if fl.Changed("role") {
in.Role = &role
}
if fl.Changed("grant") {
in.Grants = &grants
}
if fl.Changed("comment") {
in.Comment = &comment
}
if disable || enable {
in.Disabled = &disable
}
if setPassword {
pw, err := readSecret("New password for " + args[0] + ": ")
if err != nil {
return err
}
in.Password = &pw
}
var u api.User
if err := c.Do(ctx(), "PATCH", "/users/"+strconv.FormatInt(id, 10), nil, in, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("updated user %s\n", u.Username)
}
return nil
},
}
f = update.Flags()
f.BoolVar(&admin, "admin", false, "make (or with --admin=false, unmake) an administrator")
f.StringVar(&role, "role", "", "replace the grants with a role's")
f.StringArrayVar(&grants, "grant", nil, "replace the grants (repeat for several)")
f.BoolVar(&setPassword, "password", false, "set a new password (asks for it)")
f.BoolVar(&disable, "disable", false, "disable the user (logs them out)")
f.BoolVar(&enable, "enable", false, "enable the user again")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
var yes bool
del := &cobra.Command{
Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm(fmt.Sprintf("Delete user %s and their tokens?", args[0]), yes) {
return fmt.Errorf("not deleted")
}
id, err := userID(args[0])
if err != nil {
return err
}
c, _ := newClient()
if err := c.Do(ctx(), "DELETE", "/users/"+strconv.FormatInt(id, 10), nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("deleted user", args[0])
}
return nil
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
return cmd
}
func tokenCmd() *cobra.Command {
cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "API tokens"}
var all bool
var user string
list := &cobra.Command{
Use: "list", Short: "List your tokens (--all: everyone's, for administrators)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
if all {
path = "/tokens"
}
var ts []api.Token
if err := c.Get(ctx(), path, nil, &ts); err != nil {
return err
}
return output(ts, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tNAME\tGRANTS\tEXPIRES\tLAST USED")
for _, t := range ts {
exp, used := "never", "never"
if t.ExpiresAt != nil {
exp = fmtWhen(*t.ExpiresAt)
}
if t.LastUsedAt != nil {
used = fmtWhen(*t.LastUsedAt) + " from " + t.LastUsedIP
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, strings.Join(t.GrantStrings, " "), exp, used)
}
})
},
}
list.Flags().BoolVar(&all, "all", false, "everyone's tokens")
cmd.AddCommand(list)
var role, expires string
var grants []string
var save bool
create := &cobra.Command{
Use: "create NAME",
Short: "Make an API token (the secret is shown once)",
Long: `Makes an API token. It has all of its user's permissions unless --role or
--grant narrows it. On the server as root, --user names whose token it is.
--save puts it in the CLI's configuration.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
if user != "" {
id, err := userID(user)
if err != nil {
return err
}
path = "/users/" + strconv.FormatInt(id, 10) + "/tokens"
}
in := api.TokenInput{Name: args[0], Role: role, Grants: grants, ExpiresIn: expires}
var t api.Token
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
return err
}
if save {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
cfg.Token = t.Secret
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(t, nil)
}
fmt.Println(t.Secret)
if !flagQuiet {
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; keep it safe")
}
return nil
},
}
f := create.Flags()
f.StringVar(&role, "role", "", "narrow it to a role: viewer, operator or log")
f.StringArrayVar(&grants, "grant", nil, "narrow it to grants (resource:access)")
f.StringVar(&expires, "expires", "", "expire after this long (90d, 720h)")
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
f.BoolVar(&save, "save", false, "save it as this CLI's token")
cmd.AddCommand(create)
var grace, rexp string
var current, rsave bool
rotate := &cobra.Command{
Use: "rotate [ID]",
Short: "Give a token a new secret (shown once)",
Long: `Gives a token a new secret. It keeps its ID, name and permissions, so only
the programs that use it need the new secret. The old secret stops at once,
or after --grace (up to 7d) so you can update them without an outage. A
token that expires gets its lifetime again, from now, unless --expires says
otherwise (90d, or never).
hc token rotate --current --save # this CLI's own token`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id := ""
switch {
case current && len(args) == 0:
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return err
}
if me.TokenID == 0 {
return fmt.Errorf("this CLI isn't using a token (%s); give the token's ID", me.Method)
}
id = strconv.FormatInt(me.TokenID, 10)
case len(args) == 1 && !current:
id = args[0]
default:
return fmt.Errorf("give the token's ID, or --current")
}
body := api.TokenRotation{Grace: grace}
switch rexp {
case "":
case "never":
body.NoExpiry = true
default:
body.ExpiresIn = rexp
}
var t api.Token
if err := c.Do(ctx(), "POST", "/tokens/"+id+"/rotate", nil, body, &t); err != nil {
return err
}
if rsave {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
cfg.Token = t.Secret
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(t, nil)
}
fmt.Println(t.Secret)
if !flagQuiet {
if t.PrevExpiresAt != nil {
fmt.Fprintf(os.Stderr, "the old secret works until %s\n", t.PrevExpiresAt.Local().Format("2006-01-02 15:04"))
} else {
fmt.Fprintln(os.Stderr, "the old secret no longer works")
}
}
return nil
},
}
f = rotate.Flags()
f.StringVar(&grace, "grace", "", "keep the old secret working this long (30m, 24h, up to 7d)")
f.StringVar(&rexp, "expires", "", "a new lifetime from now (90d), or never")
f.BoolVar(&current, "current", false, "rotate the token this CLI uses")
f.BoolVar(&rsave, "save", false, "save the new secret as this CLI's token")
cmd.AddCommand(rotate)
cmd.AddCommand(&cobra.Command{
Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("revoked token", args[0])
}
return nil
},
})
return cmd
}
func passwordCmd() *cobra.Command {
return &cobra.Command{
Use: "password", Short: "Change your own password", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
cur, err := readSecret("Current password: ")
if err != nil {
return err
}
pw, err := readSecret("New password: ")
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/me/password", nil, api.PasswordChange{Current: cur, Password: pw}, nil); err != nil {
return err
}
fmt.Println("password changed")
return nil
},
}
}
func auditCmd() *cobra.Command {
var user, action, search string
var limit int
cmd := &cobra.Command{
Use: "audit", Short: "Show the audit log: logins, users, tokens, changes, confirmations, set entries", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var evs []model.AuditEvent
if err := c.Get(ctx(), "/audit", q("user", user, "action", action, "q", search, "limit", strconv.Itoa(limit)), &evs); err != nil {
return err
}
return output(evs, func(w io.Writer) {
fmt.Fprintln(w, "WHEN\tUSER\tFROM\tACTION\tOBJECT\tRESULT\tDETAIL")
for _, e := range evs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), orDash(e.Username), orDash(e.IP), e.Action, orDash(e.Object), e.Result, e.Detail)
}
})
},
}
f := cmd.Flags()
f.StringVar(&user, "user", "", "only this user's")
f.StringVar(&action, "action", "", "only actions starting with this (auth., config., set.)")
f.StringVar(&search, "search", "", "only events whose object or detail contains this")
f.IntVar(&limit, "limit", 100, "how many")
return cmd
}
func configCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "This CLI's settings: the server and token"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the CLI's configuration", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
tok := "-"
if cfg.Token != "" {
tok = cfg.Token[:min(len(cfg.Token), 24)] + "…"
}
fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca_cert: %s\n", flagConfig, orDash(cfg.Server)+" (empty: the local socket)", tok, cfg.Insecure, orDash(cfg.CACert))
return nil
},
})
cmd.AddCommand(&cobra.Command{
Use: "set KEY [VALUE]",
Short: "Set server, token (asks when no value), insecure or ca_cert",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
val := ""
if len(args) == 2 {
val = args[1]
} else if args[0] == "token" {
if val, err = readSecret("API token: "); err != nil {
return err
}
}
switch args[0] {
case "server":
cfg.Server = val
case "token":
cfg.Token = val
case "insecure":
cfg.Insecure = val == "true" || val == "yes" || val == "on"
case "ca_cert", "ca-cert":
cfg.CACert = val
default:
return fmt.Errorf("unknown setting %q (use server, token, insecure or ca_cert)", args[0])
}
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
return nil
},
})
return cmd
}