Files
jarl/cmd/jarl/admin_cmds.go
T
abrown38andClaude Opus 5.5 8f9cdd0a17 Add two-factor login (TOTP) with recovery codes
Users can set up an authenticator app from their account page; login
then asks for a code after the password, with one-time recovery codes
as a fallback. Sensitive actions (tokens, user changes, 2FA resets)
need a code from the last 5 minutes. totp.require (none/admins/all)
forces setup, and admins can reset a user's 2FA from the UI, the jarl
CLI or jarld on the server. The dashboard and jarl status flag users
missing 2FA and a shared "admin" account still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:12:24 +00:00

880 lines
26 KiB
Go

package main
import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
"jarl/internal/api"
"jarl/internal/client"
"jarl/internal/model"
)
// randomSecret returns a base64 TSIG secret as long as the algorithm's
// hash output.
func randomSecret(alg string) string {
n := map[string]int{"hmac-sha512": 64, "hmac-sha384": 48, "hmac-sha224": 28, "hmac-sha1": 20, "hmac-md5": 16}[strings.ToLower(alg)]
if n == 0 {
n = 32
}
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// readSecret reads a password or token from the terminal without echo, or
// a line from standard input when it is not a terminal.
func readSecret(prompt string) (string, error) {
fd := int(os.Stdin.Fd())
if term.IsTerminal(fd) {
fmt.Fprint(os.Stderr, prompt)
b, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
return strings.TrimSpace(string(b)), err
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
return strings.TrimSpace(line), nil
}
// readNewPassword asks for a password twice on a terminal.
func readNewPassword() (string, error) {
pw, err := readSecret("New password: ")
if err != nil {
return "", err
}
if term.IsTerminal(int(os.Stdin.Fd())) {
again, err := readSecret("Repeat it: ")
if err != nil {
return "", err
}
if again != pw {
return "", fmt.Errorf("the passwords don't match")
}
}
if pw == "" {
return "", fmt.Errorf("empty password")
}
return pw, nil
}
// fmtExpiry shows a token's expiry, flagging one that is close or past.
func fmtExpiry(exp, revoked *time.Time) string {
s := fmtTime(exp)
if exp == nil || revoked != nil {
return s
}
switch d := time.Until(*exp); {
case d < 0:
return s + " (EXPIRED)"
case d < model.TokenExpiryWarning:
return s + " (in " + fmtUntil(d) + ")"
}
return s
}
// parseDays parses a duration such as 14d, 36h or 90m.
func parseDays(s string) (time.Duration, error) {
if n, ok := strings.CutSuffix(s, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days <= 0 {
return 0, fmt.Errorf("invalid duration %q (use e.g. 14d or 36h)", s)
}
return time.Duration(days) * 24 * time.Hour, nil
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return 0, fmt.Errorf("invalid duration %q (use e.g. 14d or 36h)", s)
}
return d, nil
}
func fmtTime(t *time.Time) string {
if t == nil || t.IsZero() {
return "-"
}
return t.Local().Format("2006-01-02 15:04")
}
// ---- status, whoami, check, resync
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show the state of Jarl and named",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.StatusResponse
if err := c.Get(ctx(), "/status", nil, &resp); err != nil {
return err
}
var st struct {
Adopted bool `json:"adopted"`
AdoptError string `json:"adopt_error"`
NamedError string `json:"named_error"`
BINDVersion string `json:"bind_version"`
Named *struct {
Version string `json:"version"`
BootTime string `json:"boot_time"`
LastConfigured string `json:"last_configured"`
Zones string `json:"zones"`
Running bool `json:"running"`
} `json:"named"`
Drift []struct {
Path string `json:"path"`
Reason string `json:"reason"`
} `json:"drift"`
Deployed bool `json:"deployed"`
LastApplied *api.Changeset `json:"last_applied"`
OpenChangesets int `json:"open_changesets"`
Problems []client.Problem `json:"problems"`
Counts map[string]int `json:"counts"`
MainConfig string `json:"main_config"`
}
if err := json.Unmarshal(resp.Engine, &st); err != nil {
return err
}
return output(resp, func(w io.Writer) {
fmt.Fprintf(w, "Jarl:\t%s\n", resp.Version)
switch {
case st.Named != nil && st.Named.Running:
fmt.Fprintf(w, "named:\trunning (%s), up since %s, configured %s\n", firstNonEmpty(st.Named.Version, st.BINDVersion), st.Named.BootTime, st.Named.LastConfigured)
case st.NamedError != "":
fmt.Fprintf(w, "named:\tNOT REACHABLE: %s\n", st.NamedError)
default:
fmt.Fprintf(w, "named:\tnot checked (reloads disabled)\n")
}
adopted := "yes"
if !st.Adopted {
adopted = "NO: named does not load Jarl's configuration yet (run 'jarld adopt')"
if st.AdoptError != "" {
adopted += ": " + st.AdoptError
}
}
if st.MainConfig != "" {
fmt.Fprintf(w, "Adopted:\t%s\n", adopted)
}
if st.Counts != nil {
fmt.Fprintf(w, "Objects:\t%d zones, %d records, %d views, %d ACLs, %d TSIG keys\n", st.Counts["zones"], st.Counts["records"], st.Counts["views"], st.Counts["acls"], st.Counts["tsig_keys"])
}
if st.LastApplied != nil {
fmt.Fprintf(w, "Last change:\t#%d %s by %s (%s)\n", st.LastApplied.ID, fmtTime(st.LastApplied.AppliedAt), st.LastApplied.AppliedBy, st.LastApplied.Description)
}
fmt.Fprintf(w, "Open changesets:\t%d\n", st.OpenChangesets)
if len(st.Drift) == 0 {
fmt.Fprintf(w, "Drift:\tnone\n")
}
for _, d := range st.Drift {
fmt.Fprintf(w, "Drift:\t%s %s (fix with 'jarl resync --force' or apply with --force)\n", d.Path, d.Reason)
}
if b := resp.Backup; b != nil {
switch {
case b.LastAt == nil:
fmt.Fprintf(w, "Backup:\tnone recorded (see 'jarld backup --help')\n")
case b.Stale:
fmt.Fprintf(w, "Backup:\tLAST ONE %s AGO: %s (is jarl-backup.timer running?)\n", strings.ToUpper(fmtUntil(time.Since(*b.LastAt))), b.LastFile)
default:
fmt.Fprintf(w, "Backup:\t%s ago: %s\n", fmtUntil(time.Since(*b.LastAt)), b.LastFile)
}
if b.LastError != "" {
fmt.Fprintf(w, "Backup:\tFAILED %s: %s\n", fmtTime(b.LastErrorAt), b.LastError)
}
}
for _, t := range resp.ExpiringTokens {
fmt.Fprintf(w, "Token:\t%s/%s (id %d) expires %s\n", t.Username, t.Name, t.ID, fmtExpiry(t.ExpiresAt, nil))
}
if o := resp.TOTP; o != nil {
if len(o.Missing) > 0 {
fmt.Fprintf(w, "2FA:\trequired (%s) but not set up by %s\n", o.Policy, strings.Join(o.Missing, ", "))
}
if o.SharedAdmin != "" {
fmt.Fprintf(w, "Accounts:\tthe first account %q is still active next to personal administrator accounts; disable it if it is shared ('jarl user update %s --disabled')\n", o.SharedAdmin, o.SharedAdmin)
}
}
if len(st.Problems) > 0 {
w.(interface{ Flush() error }).Flush()
fmt.Println("Problems:")
printProblems(os.Stdout, st.Problems)
}
})
},
}
}
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Show who the token belongs to and what it may do",
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\n", me.User.Username)
if me.Token != nil {
fmt.Fprintf(w, "Token:\t%s (%s), expires %s\n", me.Token.Name, me.Token.PublicID, fmtTime(me.Token.ExpiresAt))
}
fmt.Fprintf(w, "Server:\t%s\n", me.Version)
for i, p := range me.Permissions {
label := ""
if i == 0 {
label = "Permissions:"
}
fmt.Fprintf(w, "%s\t%s\n", label, p)
}
})
},
}
}
// printResult prints the outcome of a check or resync.
func printResult(r *client.Result, ok string) error {
if r == nil {
return fmt.Errorf("empty response")
}
if flagOutput != "table" {
return output(r, nil)
}
if r.Error != nil {
fmt.Println("error:", r.Error.Message)
}
printProblems(os.Stdout, r.Problems)
printCheck(os.Stdout, r.Check) // errors only, not "loaded serial" lines
for _, w := range r.Warnings {
fmt.Println("warning:", w)
}
if r.Diff != "" {
fmt.Print(r.Diff)
}
if !r.Valid || r.Error != nil {
return fmt.Errorf("the configuration has errors")
}
if !flagQuiet {
fmt.Println(ok)
}
return nil
}
func checkCmd() *cobra.Command {
return &cobra.Command{
Use: "check",
Short: "Validate the current configuration and run named-checkconf on it",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var raw json.RawMessage
if err := c.Do(ctx(), "POST", "/system/check", nil, nil, &raw); err != nil {
return err
}
return printResult(client.ParseResult(raw), "ok: the configuration is valid")
},
}
}
func resyncCmd() *cobra.Command {
return &cobra.Command{
Use: "resync",
Short: "Rewrite all BIND files from Jarl's database and reload named (admin)",
Long: `Rewrites every file Jarl manages from the database and reloads named. Use
it after files were changed by hand (drift) or restored from a backup.
Files changed outside Jarl are only overwritten with --force; --dry-run
shows what would change.`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var raw json.RawMessage
err = c.Do(ctx(), "POST", "/system/resync", nil, map[string]bool{"dry_run": flagDryRun, "force": flagForce}, &raw)
if err != nil {
return err
}
msg := "resynced"
if flagDryRun {
msg = "dry run: nothing was changed"
}
return printResult(client.ParseResult(raw), msg)
},
}
}
// ---- users and tokens
// userID resolves a username (or numeric ID) to a user ID.
func userID(c *client.Client, name string) (int64, error) {
if id, err := strconv.ParseInt(name, 10, 64); err == nil {
return id, nil
}
var users []api.User
if err := c.Get(ctx(), "/users", nil, &users); err != nil {
return 0, err
}
for _, u := range users {
if strings.EqualFold(u.Username, name) {
return u.ID, nil
}
}
return 0, fmt.Errorf("no user %q", name)
}
// twoFA describes a user's two-factor login.
func twoFA(u api.User) string {
switch {
case u.TOTPEnabledAt != nil:
return "on since " + fmtTime(u.TOTPEnabledAt)
case u.TOTPRequired:
return "REQUIRED, not set up (they can only set it up until they do)"
case !u.HasPassword:
return "-"
}
return "off"
}
func printUser(w io.Writer, u api.User) {
fmt.Fprintf(w, "User:\t%s (id %d)\n", u.Username, u.ID)
role := "user"
if u.Admin {
role = "administrator"
}
if u.Disabled {
role += ", DISABLED"
}
if !u.HasPassword {
role += ", no password (tokens only)"
}
fmt.Fprintf(w, "Role:\t%s\n", role)
fmt.Fprintf(w, "2FA:\t%s\n", twoFA(u))
for i, g := range u.GrantStrings {
label := ""
if i == 0 {
label = "Grants:"
}
fmt.Fprintf(w, "%s\t%s\n", label, g)
}
if u.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", u.Comment)
}
fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt))
}
const grantHelp = `Grants use the form RESOURCE:ACCESS [zones=..] [views=..] [names=..] [types=..]
where RESOURCE is zones, records, views, acls, tsig, options, audit or *,
ACCESS is read or write, and filters take comma separated glob patterns:
records:write zones=adbraun.lol
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
*:read
Roles are ready-made grant sets: readonly, dns-editor, operator, acme.`
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
list := &cobra.Command{
Use: "list",
Short: "List users",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var users []api.User
if err := c.Get(ctx(), "/users", nil, &users); err != nil {
return err
}
return output(users, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\t2FA\tGRANTS\tLAST LOGIN")
for _, u := range users {
tfa := "off"
switch {
case u.TOTPEnabledAt != nil:
tfa = "on"
case u.TOTPRequired:
tfa = "MISSING"
case !u.HasPassword:
tfa = "-"
}
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, tfa, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt))
}
})
},
}
show := &cobra.Command{
Use: "show USER",
Short: "Show a user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, err := userID(c, args[0])
if err != nil {
return err
}
var u api.User
if err := c.Get(ctx(), fmt.Sprintf("/users/%d", id), nil, &u); err != nil {
return err
}
return output(u, func(w io.Writer) { printUser(w, u) })
},
}
var admin, noPassword, disabled bool
var role, comment, rename string
var grants []string
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
var in api.UserInput
f := cmd.Flags()
if f.Changed("admin") {
in.Admin = &admin
}
if f.Changed("role") {
in.Role = &role
}
if f.Changed("grant") {
in.GrantStrings = &grants
}
if f.Changed("comment") {
in.Comment = &comment
}
if f.Changed("disabled") {
in.Disabled = &disabled
}
if f.Changed("rename") {
in.Username = &rename
}
return in, nil
}
addUserFlags := func(c *cobra.Command) {
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
c.Flags().StringVar(&comment, "comment", "", "comment")
}
create := &cobra.Command{
Use: "create NAME",
Short: "Create a user (asks for a password unless --no-password)",
Example: ` jarl user create alice --role dns-editor
jarl user create certbot --no-password --role acme --comment "Let's Encrypt"
jarl user create bob --grant "records:write zones=lab.adbraun.lol" --grant "zones:read"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in, err := userBody(cmd)
if err != nil {
return err
}
name := args[0]
in.Username = &name
pw := ""
if !noPassword {
if pw, err = readNewPassword(); err != nil {
return err
}
}
in.Password = &pw
var u api.User
if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil {
return err
}
return output(u, func(w io.Writer) { printUser(w, u) })
},
}
addUserFlags(create)
create.Flags().BoolVar(&noPassword, "no-password", false, "a service account that can only use API tokens")
var setPassword, clearPassword bool
update := &cobra.Command{
Use: "update USER",
Short: "Change a user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id, err := userID(c, args[0])
if err != nil {
return err
}
in, err := userBody(cmd)
if err != nil {
return err
}
switch {
case setPassword:
pw, err := readNewPassword()
if err != nil {
return err
}
in.Password = &pw
case clearPassword:
empty := ""
in.Password = &empty
}
if in == (api.UserInput{}) {
return fmt.Errorf("nothing to change (see --help)")
}
var u api.User
if err := c.Do(ctx(), "PATCH", fmt.Sprintf("/users/%d", id), nil, in, &u); err != nil {
return err
}
return output(u, func(w io.Writer) { printUser(w, u) })
},
}
addUserFlags(update)
update.Flags().BoolVar(&disabled, "disabled", false, "disable the account (--disabled=false to enable it)")
update.Flags().StringVar(&rename, "rename", "", "new username")
update.Flags().BoolVar(&setPassword, "password", false, "set a new password (asks for it)")
update.Flags().BoolVar(&clearPassword, "no-password", false, "remove the password (tokens only)")
var yes bool
del := &cobra.Command{
Use: "delete USER",
Short: "Delete a user and revoke their tokens",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete user "+args[0]+"?", yes) {
return fmt.Errorf("canceled")
}
c, err := newClient()
if err != nil {
return err
}
id, err := userID(c, args[0])
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", fmt.Sprintf("/users/%d", id), 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 for confirmation")
totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
totpReset := &cobra.Command{
Use: "reset USER",
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
Long: `Turns off a user's two-factor login, deletes their recovery codes and logs
them out. They log in with the password alone next time and can set it up
again under Account (they must, if totp.require applies to them).
Users set up two-factor login themselves, in the web UI. Without any
administrator able to log in, use 'sudo -u jarl jarld user totp reset USER'
on the server.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Turn off two-factor login of "+args[0]+"?", yes) {
return fmt.Errorf("canceled")
}
c, err := newClient()
if err != nil {
return err
}
id, err := userID(c, args[0])
if err != nil {
return err
}
var u api.User
if err := c.Do(ctx(), "DELETE", fmt.Sprintf("/users/%d/totp", id), nil, nil, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("two-factor login of %s is off\n", u.Username)
}
return nil
},
}
totpReset.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
totpCmd.AddCommand(totpReset)
cmd.AddCommand(list, show, create, update, del, totpCmd)
return cmd
}
func tokenCmd() *cobra.Command {
cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "Manage API tokens", Long: "Manage API tokens. A token has its user's permissions, or fewer when it has\nits own grants.\n\n" + grantHelp}
var user, expiring string
var all, revoked bool
list := &cobra.Command{
Use: "list",
Short: "List your tokens (--user or --all for others; admin)",
Example: ` jarl token list
jarl token list --all --expiring # every token that expires within 14 days
jarl token list --all --expiring 60d`,
RunE: func(cmd *cobra.Command, args []string) error {
var within time.Duration
if expiring != "" {
d, err := parseDays(expiring)
if err != nil {
return fmt.Errorf("--expiring: %w", err)
}
within = d
}
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
switch {
case all:
path = "/tokens"
case user != "":
id, err := userID(c, user)
if err != nil {
return err
}
path = fmt.Sprintf("/users/%d/tokens", id)
}
qv := q()
if revoked {
qv.Set("all", "1")
}
var tokens []api.Token
if err := c.Get(ctx(), path, qv, &tokens); err != nil {
return err
}
if within > 0 {
// Expiring soon, or already expired (and not revoked).
now := time.Now()
kept := []api.Token{}
for _, t := range tokens {
if t.ExpiresWithin(now, within) {
kept = append(kept, t)
}
}
tokens = kept
}
return output(tokens, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tNAME\tPREFIX\tGRANTS\tEXPIRES\tROTATED\tLAST USED\tREVOKED")
for _, t := range tokens {
g := "(user's)"
if t.Grants != nil {
g = strings.Join(t.GrantStrings, "; ")
}
rot := fmtTime(t.RotatedAt)
if t.PrevExpiresAt != nil && time.Now().Before(*t.PrevExpiresAt) {
rot += " (old secret until " + fmtTime(t.PrevExpiresAt) + ")"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, t.PublicID, g, fmtExpiry(t.ExpiresAt, t.RevokedAt), rot, fmtTime(t.LastUsedAt), fmtTime(t.RevokedAt))
}
})
},
}
list.Flags().StringVar(&user, "user", "", "tokens of this user")
list.Flags().BoolVar(&all, "all", false, "tokens of every user")
list.Flags().BoolVar(&revoked, "revoked", false, "include revoked tokens")
list.Flags().StringVar(&expiring, "expiring", "", "only tokens that expire within this long, or have expired (default 14d)")
list.Flags().Lookup("expiring").NoOptDefVal = "14d"
var role, expires string
var grants []string
var save bool
create := &cobra.Command{
Use: "create NAME",
Short: "Create a token; the secret is printed once",
Example: ` jarl token create laptop
jarl token create certbot --user certbot --role acme --expires 365d
jarl token create lab-ci --grant "records:write zones=lab.adbraun.lol"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.TokenInput{Name: args[0], Role: role, ExpiresIn: expires}
if cmd.Flags().Changed("grant") {
in.GrantStrings = &grants
}
path := "/me/tokens"
if user != "" {
id, err := userID(c, user)
if err != nil {
return err
}
path = fmt.Sprintf("/users/%d/tokens", id)
}
var t api.Token
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
return err
}
where := flagConfig
if save {
if where, err = saveTokenInConfig(t.Secret); err != nil {
return err
}
}
if flagQuiet {
fmt.Println(t.Secret)
return nil
}
return output(t, func(w io.Writer) {
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 save {
fmt.Fprintf(w, "Saved to:\t%s\n", where)
} else {
fmt.Fprintln(w, "\nThe token is shown only once; store it now.")
}
})
},
}
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme")
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
revoke := &cobra.Command{
Use: "revoke ID",
Short: "Revoke a token (see 'token list' for IDs)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if _, err := strconv.ParseInt(args[0], 10, 64); err != nil {
return fmt.Errorf("give the token's numeric ID (see 'jarl token list')")
}
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
},
}
cmd.AddCommand(list, create, tokenRotateCmd(), revoke)
return cmd
}
// ---- audit and search
func auditCmd() *cobra.Command {
var user, action, outcome, objType, since, until, search string
var changeset int64
var limit int
cmd := &cobra.Command{
Use: "audit",
Short: "Show the audit log (logins, changes, users and tokens)",
Long: `Shows the audit log, newest first. Without the audit permission you only
see your own activity.
jarl audit --since 2026-10-01 --outcome failure
jarl audit --user certbot --limit 20
jarl audit --action login`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
qv := q("user", user, "action", action, "outcome", outcome, "object_type", objType, "since", since, "until", until, "q", search, "limit", strconv.Itoa(limit))
if changeset > 0 {
qv.Set("changeset", strconv.FormatInt(changeset, 10))
}
var events []struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
Username string `json:"username"`
Auth string `json:"auth"`
TokenName string `json:"token_name"`
IP string `json:"ip"`
Action string `json:"action"`
ObjectType string `json:"object_type"`
ObjectName string `json:"object_name"`
ChangesetID int64 `json:"changeset_id"`
Outcome string `json:"outcome"`
Message string `json:"message"`
}
if err := c.Get(ctx(), "/audit", qv, &events); err != nil {
return err
}
return output(events, func(w io.Writer) {
fmt.Fprintln(w, "TIME\tUSER\tVIA\tIP\tACTION\tOBJECT\tOUTCOME\tMESSAGE")
for _, e := range events {
via := e.Auth
if e.TokenName != "" {
via += ":" + e.TokenName
}
obj := e.ObjectName
if e.ChangesetID > 0 {
obj = strings.TrimSpace(fmt.Sprintf("%s (changeset %d)", obj, e.ChangesetID))
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), e.Username, via, e.IP, e.Action, obj, e.Outcome, e.Message)
}
})
},
}
f := cmd.Flags()
f.StringVar(&user, "user", "", "only this user")
f.StringVar(&action, "action", "", "only this action (e.g. login, changeset.apply, token.create)")
f.StringVar(&outcome, "outcome", "", "success, failure or denied")
f.StringVar(&objType, "object-type", "", "only events about this kind of object (zone, user, token, ...)")
f.StringVar(&since, "since", "", "from this time (YYYY-MM-DD or RFC 3339)")
f.StringVar(&until, "until", "", "up to this time")
f.StringVar(&search, "search", "", "only events whose text contains this")
f.Int64Var(&changeset, "changeset", 0, "only events of this changeset")
f.IntVar(&limit, "limit", 50, "how many")
return cmd
}
func searchCmd() *cobra.Command {
var limit int
cmd := &cobra.Command{
Use: "search TEXT",
Short: "Find zones, records and ACLs by name, data or comment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var hits []api.SearchResult
if err := c.Get(ctx(), "/search", q("q", args[0], "limit", strconv.Itoa(limit)), &hits); err != nil {
return err
}
return output(hits, func(w io.Writer) {
fmt.Fprintln(w, "KIND\tID\tMATCH\tWHERE")
for _, h := range hits {
fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", h.Kind, h.ID, h.Label, h.Detail)
}
})
},
}
cmd.Flags().IntVar(&limit, "limit", 100, "maximum number of results")
return cmd
}
// ---- CLI configuration