jarld also listens on /run/jarl/jarl.sock (socket in jarl.yaml). Requests on it from root or jarld's own user need no token and act as an administrator; the kernel's SO_PEERCRED says who is connecting, and anyone else must send a token. The jarl CLI uses the socket when no server is configured, or with -s local, so 'sudo jarl status' works on the server with nothing set up and a lost token or password never locks root out. Changes are audited under the system user's name via "socket". The systemd unit creates /run/jarl (0750). Socket paths over 107 bytes are refused with a clear message. The docs explain the socket and what happens when a token is revoked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
615 lines
19 KiB
Go
615 lines
19 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/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.Version,
|
|
}
|
|
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(), 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),
|
|
}, log)
|
|
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}
|
|
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 {
|
|
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 {
|
|
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 }()
|
|
}
|
|
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.Version, Stats: collector, Socket: a.cfg.Socket,
|
|
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)
|
|
a.log.Info("jarld starting", "version", version.Version, "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
|
|
},
|
|
}
|
|
}
|
|
|
|
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", "administrator username")
|
|
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")
|
|
}
|
|
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
|
|
},
|
|
}
|
|
cmd.AddCommand(add, passwd, token, list)
|
|
return cmd
|
|
}
|