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>
880 lines
26 KiB
Go
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
|