Files
jarl/cmd/jarld/main.go
T
adamandClaude Opus 5.5 bdd7acfd3b Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.

A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.

Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).

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

713 lines
23 KiB
Go

// Command jarld is the Jarl daemon: it serves the web UI and the REST API
// and writes BIND's configuration. It also has administrative subcommands
// for setting up and migrating a server.
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/netip"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
"jarl/internal/auth"
"jarl/internal/bind"
"jarl/internal/config"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
"jarl/internal/render"
"jarl/internal/server"
"jarl/internal/stats"
"jarl/internal/store"
"jarl/internal/version"
"jarl/internal/web"
)
func main() {
if err := rootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
var configPath string
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "jarld",
Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI",
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
def := os.Getenv("JARL_CONFIG")
if def == "" {
def = config.DefaultPath
}
root.PersistentFlags().StringVarP(&configPath, "config", "c", def, "configuration file")
root.AddCommand(serveCmd(), initCmd(), userCmd(), importCmd(), adoptCmd(), checkCmd(), renderCmd(), resyncCmd(), backupCmd(), restoreCmd(), configCmd(), versionCmd())
return root
}
// app holds what most commands need.
type app struct {
cfg *config.Config
log *slog.Logger
store *store.Store
eng *engine.Engine
owner *dirOwner
}
func newLogger(cfg *config.Config, w io.Writer) *slog.Logger {
var level slog.Level
switch strings.ToLower(cfg.Log.Level) {
case "debug":
level = slog.LevelDebug
case "warn", "warning":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: level}
if cfg.Log.Format == "json" {
return slog.New(slog.NewJSONHandler(w, opts))
}
// systemd's journal adds timestamps.
if os.Getenv("JOURNAL_STREAM") != "" {
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
}
}
return slog.New(slog.NewTextHandler(w, opts))
}
func openApp(allowMissingConfig bool) (*app, error) {
cfg, err := config.Load(configPath, allowMissingConfig)
if err != nil {
return nil, fmt.Errorf("loading configuration: %w", err)
}
log := newLogger(cfg, os.Stderr)
owner := newDirOwner(filepath.Dir(cfg.Database))
if err := os.MkdirAll(filepath.Dir(cfg.Database), 0o750); err != nil {
return nil, err
}
st, err := store.Open(cfg.Database)
if err != nil {
return nil, fmt.Errorf("opening database %s: %w", cfg.Database, err)
}
tools := &bind.Tools{NamedCheckConf: cfg.BIND.NamedCheckConf, NamedCheckZone: cfg.BIND.NamedCheckZone, RNDC: cfg.BIND.RNDC, RNDCArgs: cfg.BIND.RNDCArgs}
eng := engine.New(st, tools, engine.Config{
Paths: render.Paths{ConfigDir: cfg.BIND.ConfigDir, ZoneDir: cfg.BIND.ZoneDir, CacheDir: cfg.BIND.CacheDir},
MainConfig: cfg.BIND.MainConfig,
StagingDir: cfg.StagingDir,
FileGroup: cfg.BIND.FileGroup,
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
NoReload: cfg.BIND.NoReload,
Extras: renderExtras(cfg),
LocalAddrs: nscheck.LocalAddrs,
}, log)
eng.Limits = engine.BlocklistLimits{
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
MaxBytes: int64(cfg.Blocking.MaxDownloadMB) << 20, MaxEntries: cfg.Blocking.MaxEntries,
}
return &app{cfg: cfg, log: log, store: st, eng: eng, owner: owner}, nil
}
// openStats opens the statistics database (next to, not inside, Jarl's
// configuration database).
func openStats(a *app) (*stats.Store, error) {
path := a.cfg.Stats.Database
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
ss, err := stats.OpenStore(path)
if err != nil {
return nil, err
}
return ss, nil
}
func statsOptions(cfg *config.Config) stats.Options {
sc := cfg.Stats
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
for _, p := range sc.ExcludeClients {
if pf, err := config.ParsePrefix(p); err == nil {
o.Exclude = append(o.Exclude, pf.Masked())
}
}
return o
}
// zoneIndex lists the zones named answers authoritatively, per view, so
// statistics can be broken down by zone.
func zoneIndex(a *app) stats.ZoneIndex {
return func() map[string][]string {
st, err := a.store.LoadState(context.Background())
if err != nil {
return nil
}
out := map[string][]string{}
for _, z := range st.Zones {
if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror || z.Disabled {
continue
}
v := st.ViewName(z)
out[v] = append(out[v], strings.TrimSuffix(z.Name, "."))
}
return out
}
}
// renderExtras turns jarl.yaml settings that change named's configuration
// into render extras.
func renderExtras(cfg *config.Config) render.Extras {
var x render.Extras
if cfg.Stats.Enabled {
h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)}
if cfg.Stats.QueryLog {
h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize
}
x.Stats = h
}
return x
}
func (a *app) close() {
a.store.Close()
// Commands run as root must not leave root-owned files where the
// daemon (running as its own user) needs to write.
a.owner.fix(filepath.Dir(a.cfg.Database), a.cfg.StagingDir, a.cfg.BIND.ConfigDir, a.cfg.BIND.ZoneDir, a.cfg.BIND.CacheDir)
}
// localPrincipal is used for administrative commands run on the server.
func (a *app) localPrincipal(ctx context.Context, username string) (*auth.Principal, error) {
if username == "" {
username = os.Getenv("SUDO_USER")
}
if username != "" {
if u, err := a.store.GetUserByName(ctx, username); err == nil {
if !u.Admin {
return nil, fmt.Errorf("user %s is not an administrator", username)
}
return &auth.Principal{User: u, Method: "local"}, nil
}
}
// Fall back to the first administrator, or a synthetic one.
users, err := a.store.ListUsers(ctx)
if err != nil {
return nil, err
}
for _, u := range users {
if u.Admin && !u.Disabled {
return &auth.Principal{User: &model.User{ID: u.ID, Username: u.Username + " (local)", Admin: true}, Method: "local"}, nil
}
}
return &auth.Principal{User: &model.User{Username: "local", Admin: true}, Method: "local"}, nil
}
// ---- serve
func serveCmd() *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Run the daemon (web UI and API)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath, true)
if err != nil {
return fmt.Errorf("loading configuration: %w", err)
}
unlock, err := lockDatabase(cfg.Database)
if err != nil {
return fmt.Errorf("%s: %w", cfg.Database, err)
}
defer unlock()
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if users, err := a.store.ListUsers(ctx); err == nil && len(users) == 0 {
a.log.Warn("there are no users yet; create an administrator with 'jarld init' or 'jarld user add --admin NAME'")
}
adopted, aerr := a.eng.Adopted()
if aerr == nil && !adopted {
a.log.Warn("named does not load Jarl's configuration yet; changes are written but named is not reloaded until 'jarld adopt' runs", "main_config", a.cfg.BIND.MainConfig)
}
if adopted {
// Settings in jarl.yaml (such as stats) can change named's
// configuration; bring it up to date.
if wrote, res, err := a.eng.SyncConfig(ctx); err != nil {
a.log.Warn("could not update named's configuration for the settings in jarl.yaml; fix the problem and restart jarl, or run 'jarl resync'", "err", err)
} else if wrote {
a.log.Info("updated named's configuration for the settings in jarl.yaml", "files", res.Files)
}
// Response logging is switched at runtime (the option only
// counts when named starts). Turn it off again when
// statistics no longer use it, unless the configuration
// asks for it.
if !a.cfg.Stats.Enabled || !a.cfg.Stats.QueryLog {
if st, err := a.store.LoadState(ctx); err == nil && st.FindOption(model.ScopeOptions, 0, "responselog") == nil {
if changed, err := a.eng.SetResponseLogging(ctx, false); err == nil && changed {
a.log.Info("turned off named's response logging, which query statistics no longer use")
}
}
}
}
var collector *stats.Collector
collectorDone := make(chan struct{})
if a.cfg.Stats.Enabled {
ss, err := openStats(a)
if err != nil {
return err
}
defer ss.Close()
opts := statsOptions(a.cfg)
if adopted {
opts.EnsureLogging = func(ctx context.Context) (bool, error) { return a.eng.SetResponseLogging(ctx, true) }
}
collector = stats.NewCollector(ss, opts, zoneIndex(a), a.log)
cctx, cancel := context.WithCancel(ctx)
go func() {
collector.Run(cctx)
close(collectorDone)
}()
// Stop the collector (it writes what it has) before the
// database closes, however serving ends.
defer func() { cancel(); <-collectorDone }()
}
var pb *porkbun.Client
if a.cfg.Porkbun.Enabled() {
pb = &porkbun.Client{BaseURL: a.cfg.Porkbun.APIURL, APIKey: a.cfg.Porkbun.APIKey, Secret: a.cfg.Porkbun.SecretAPIKey}
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
}
}
if a.cfg.Metrics.Enabled && a.cfg.Metrics.Token != "" {
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
a.log.Warn("the configuration file holds metrics.token and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
}
}
srv := server.New(server.Config{
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
Version: version.String(), Stats: collector, Socket: a.cfg.Socket,
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
AllowLoopbackHTTP: a.cfg.ACME.HTTPReq.AllowLoopbackHTTP,
},
}, a.eng, a.log, web.FS())
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
for range hup {
sdNotify("RELOADING=1")
srv.ReloadTLS()
sdNotify("READY=1")
}
}()
go auditRetention(ctx, a)
go blocklistLoop(ctx, a)
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
sdNotify("STOPPING=1")
a.log.Info("jarld stopped")
return err
},
}
}
// blocklistLoop keeps blocklists up to date (docs/blocking.md): every few
// minutes it downloads the lists that are due, and at once when a list
// was added or changed. It also ends a pause of blocking on time.
func blocklistLoop(ctx context.Context, a *app) {
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second): // let jarld finish starting
}
for {
a.eng.RefreshDue(ctx)
wait := 5 * time.Minute
if until := a.eng.ResumeBlocking(ctx); !until.IsZero() {
wait = min(wait, max(time.Until(until), time.Second))
}
t := time.NewTimer(wait)
select {
case <-ctx.Done():
t.Stop()
return
case <-t.C:
case <-a.eng.BlocklistKicks():
t.Stop()
}
}
}
func auditRetention(ctx context.Context, a *app) {
if a.cfg.Audit.RetentionDays <= 0 {
return
}
t := time.NewTicker(6 * time.Hour)
defer t.Stop()
for {
before := time.Now().Add(-time.Duration(a.cfg.Audit.RetentionDays) * 24 * time.Hour)
if n, err := a.store.PruneAudit(ctx, before); err != nil {
a.log.Warn("pruning audit log failed", "err", err)
} else if n > 0 {
a.log.Info("pruned audit log", "events", n)
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// sdNotify tells systemd about state changes (Type=notify).
func sdNotify(state string) {
sock := os.Getenv("NOTIFY_SOCKET")
if sock == "" {
return
}
addr := &net.UnixAddr{Name: sock, Net: "unixgram"}
if strings.HasPrefix(sock, "@") {
addr.Name = "\x00" + sock[1:]
}
c, err := net.DialUnix("unixgram", nil, addr)
if err != nil {
return
}
defer c.Close()
c.Write([]byte(state))
}
// ---- init and users
func readPassword(prompt string, fromStdin bool) (string, error) {
if fromStdin || !term.IsTerminal(int(os.Stdin.Fd())) {
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
fmt.Fprint(os.Stderr, prompt)
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
fmt.Fprint(os.Stderr, "Repeat: ")
b2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
if string(b) != string(b2) {
return "", errors.New("passwords don't match")
}
return string(b), nil
}
func createToken(ctx context.Context, a *app, u *model.User, name string) (string, error) {
secret, pid, hash := auth.NewToken()
if err := a.store.CreateToken(ctx, &model.Token{UserID: u.ID, Name: name, PublicID: pid, SecretHash: hash}); err != nil {
return "", err
}
return secret, nil
}
func initCmd() *cobra.Command {
var username string
var stdin, withToken bool
var nameservers []string
var rname string
cmd := &cobra.Command{
Use: "init",
Short: "Create the database, the first administrator and default server options",
Long: `Creates Jarl's database with a first administrator account.
Jarl starts empty. Either import your existing BIND configuration with
'jarld import', or build one from scratch in the web UI.`,
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
ctx := context.Background()
users, err := a.store.ListUsers(ctx)
if err != nil {
return err
}
if len(users) > 0 {
return fmt.Errorf("Jarl is already initialized (%d users); use 'jarld user add' to add users", len(users))
}
pw, err := readPassword("Password for "+username+": ", stdin)
if err != nil {
return err
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
u := &model.User{Username: username, PasswordHash: hash, Admin: true, Comment: "created by jarld init"}
if err := a.store.CreateUser(ctx, u); err != nil {
return err
}
p := &auth.Principal{User: u, Method: "local"}
set := engine.SettingsInput{}
if len(nameservers) > 0 {
set.Nameservers = &nameservers
}
if rname != "" {
set.RName = &rname
}
if set.Nameservers != nil || set.RName != nil {
if _, err := a.eng.Submit(ctx, []engine.Request{engine.NewRequest(engine.OpSettingsUpdate, 0, set)}, p, "cli", "initial settings", engine.ApplyOptions{NoReload: true}); err != nil {
return err
}
}
a.eng.Audit(ctx, p, "system.init", "user", u.ID, u.Username, "success", "")
fmt.Printf("Created administrator %q in %s.\n", u.Username, a.cfg.Database)
if withToken {
tok, err := createToken(ctx, a, u, "cli")
if err != nil {
return err
}
fmt.Printf("\nAPI token for the CLI (shown only once):\n\n %s\n\n", tok)
}
fmt.Println("\nNext steps:")
fmt.Println(" 1. Import your current BIND configuration: sudo -u jarl jarld import")
fmt.Println(" 2. Switch named over to Jarl: sudo jarld adopt")
fmt.Println(" 3. Start the daemon: sudo systemctl enable --now jarl")
return nil
},
}
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the first administrator's username: best your own name, as each person should have their own account")
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.Flags().BoolVar(&withToken, "token", false, "also create an API token for the CLI")
cmd.Flags().StringSliceVar(&nameservers, "nameservers", nil, "default name servers for new zones (e.g. ns1.example.com.)")
cmd.Flags().StringVar(&rname, "contact", "", "default SOA contact for new zones (e.g. hostmaster@example.com)")
return cmd
}
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Short: "Manage users locally (for bootstrapping and recovery)"}
var admin, stdin, withToken, service bool
var grants []string
add := &cobra.Command{
Use: "add NAME",
Short: "Create a user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
ctx := context.Background()
u := &model.User{Username: args[0], Admin: admin}
for _, g := range grants {
gr, err := auth.ParseGrant(g)
if err != nil {
return fmt.Errorf("grant %q: %w", g, err)
}
u.Grants = append(u.Grants, gr)
}
if !service {
pw, err := readPassword("Password for "+u.Username+": ", stdin)
if err != nil {
return err
}
if u.PasswordHash, err = auth.HashPassword(pw); err != nil {
return err
}
}
if err := a.store.CreateUser(ctx, u); err != nil {
return err
}
a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.create", "user", u.ID, u.Username, "success", "jarld user add")
fmt.Printf("Created user %s.\n", u.Username)
if withToken {
tok, err := createToken(ctx, a, u, "cli")
if err != nil {
return err
}
fmt.Printf("API token (shown only once): %s\n", tok)
}
return nil
},
}
add.Flags().BoolVar(&admin, "admin", false, "make the user an administrator")
add.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
add.Flags().BoolVar(&withToken, "token", false, "also create an API token")
add.Flags().BoolVar(&service, "service", false, "service account without password (API tokens only)")
add.Flags().StringArrayVar(&grants, "grant", nil, `permission, e.g. "records:write zones=example.com" (repeatable)`)
passwd := &cobra.Command{
Use: "passwd NAME",
Short: "Set a user's password (and enable the account)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("user %s: %w", args[0], err)
}
pw, err := readPassword("New password for "+u.Username+": ", stdin)
if err != nil {
return err
}
if u.PasswordHash, err = auth.HashPassword(pw); err != nil {
return err
}
u.Disabled = false
if err := a.store.UpdateUser(ctx, u); err != nil {
return err
}
a.store.DeleteUserSessions(ctx, u.ID, "")
a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.password", "user", u.ID, u.Username, "success", "jarld user passwd")
fmt.Printf("Password of %s changed.\n", u.Username)
return nil
},
}
passwd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
token := &cobra.Command{
Use: "token NAME [TOKEN-NAME]",
Short: "Create an API token for a user",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("user %s: %w", args[0], err)
}
name := "cli"
if len(args) > 1 {
name = args[1]
}
tok, err := createToken(ctx, a, u, name)
if err != nil {
return err
}
fmt.Println(tok)
return nil
},
}
list := &cobra.Command{
Use: "list",
Short: "List users",
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
users, err := a.store.ListUsers(context.Background())
if err != nil {
return err
}
for _, u := range users {
flags := []string{}
if u.Admin {
flags = append(flags, "admin")
}
if u.Disabled {
flags = append(flags, "disabled")
}
if !u.HasPassword {
flags = append(flags, "no password")
}
if u.HasTOTP() {
flags = append(flags, "2fa")
}
var gs []string
for _, g := range u.Grants {
gs = append(gs, auth.FormatGrant(g))
}
fmt.Printf("%-20s %-25s %s\n", u.Username, strings.Join(flags, ","), strings.Join(gs, "; "))
}
return nil
},
}
totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
totpReset := &cobra.Command{
Use: "reset NAME",
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
Long: `Turns off a user's two-factor login and deletes their recovery codes, and
logs them out. They log in with the password alone next time; if
totp.require applies to them, they must then set it up again before doing
anything else. Works with jarld stopped too.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("user %s: %w", args[0], err)
}
if !u.HasTOTP() && u.TOTPPending == "" {
fmt.Printf("%s doesn't use two-factor login.\n", u.Username)
return nil
}
if err := a.store.DisableTOTP(ctx, u.ID); err != nil {
return err
}
a.store.DeleteUserSessions(ctx, u.ID, "")
a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.totp_reset", "user", u.ID, u.Username, "success", "jarld user totp reset")
fmt.Printf("Two-factor login of %s is off; they log in with the password and can set it up again under Account.\n", u.Username)
return nil
},
}
totpCmd.AddCommand(totpReset)
cmd.AddCommand(add, passwd, token, list, totpCmd)
return cmd
}