// 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/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), }, 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 }