- Daily database backups: jarl-backup.timer runs 'jarld backup', which writes jarl-<UTC time>.db into backup.dir, checks it before naming it, and prunes by the time in the name while always keeping the newest keep_at_least. New backup: section in jarl.yaml; the dashboard and 'jarl status' show the newest backup and warn when it is stale or failed. The Debian package enables the timer. - 'jarld restore [FILE | latest]': checks the backup, shows it next to the current database, keeps the current one aside and records the restore. 'jarld serve' holds a database lock, so a restore can't run under a live jarld and two jarlds can't share a database. - Token expiry warnings from 14 days ahead: dashboard, token lists, a daily log line, and a CLI warning via the Jarl-Token-Expires header; 'jarl token list --expiring'. - 'jarl zone serials' and the zone page's Name servers tab: compare the serial on a primary zone's secondaries (NS records, also-notify) or a secondary zone's primaries with this server's. - 'jarl zone delegation': for zones on the internet, check the parent's delegation and glue against the zone; an undelegated zone gets a note. - Server: Jarl/<version> header; CLI exit code 2 for failed checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
10 KiB
Go
390 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"jarl/internal/config"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/render"
|
|
"jarl/internal/validate"
|
|
"jarl/internal/version"
|
|
)
|
|
|
|
// dirOwner restores file ownership after commands run as root.
|
|
type dirOwner struct {
|
|
uid, gid int
|
|
active bool
|
|
}
|
|
|
|
func newDirOwner(dir string) *dirOwner {
|
|
o := &dirOwner{}
|
|
if os.Geteuid() != 0 {
|
|
return o
|
|
}
|
|
fi, err := os.Stat(dir)
|
|
if err != nil {
|
|
return o
|
|
}
|
|
if st, ok := fi.Sys().(*syscall.Stat_t); ok && st.Uid != 0 {
|
|
o.uid, o.gid, o.active = int(st.Uid), int(st.Gid), true
|
|
}
|
|
return o
|
|
}
|
|
|
|
// fix gives root-owned files under dirs to the daemon's user (keeping
|
|
// their group, which matters for named).
|
|
func (o *dirOwner) fix(dirs ...string) {
|
|
if !o.active {
|
|
return
|
|
}
|
|
for _, d := range dirs {
|
|
filepath.WalkDir(d, func(path string, de fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
info, err := de.Info()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid == 0 {
|
|
gid := int(st.Gid)
|
|
if path == d || strings.HasSuffix(path, ".db") || strings.Contains(path, ".db-") {
|
|
gid = o.gid
|
|
}
|
|
os.Lchown(path, o.uid, gid)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
|
|
func printJSON(v any) {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
enc.Encode(v)
|
|
}
|
|
|
|
func printProblems(ps []validate.Problem) {
|
|
for _, p := range ps {
|
|
fmt.Println(" " + p.String())
|
|
}
|
|
}
|
|
|
|
func printResult(res *engine.Result) {
|
|
if res == nil {
|
|
return
|
|
}
|
|
if len(res.Problems) > 0 {
|
|
fmt.Println("Problems:")
|
|
printProblems(res.Problems)
|
|
}
|
|
for _, m := range res.Check {
|
|
if !m.Error && (strings.HasPrefix(m.Text, "loaded serial") || m.Text == "OK") {
|
|
continue
|
|
}
|
|
obj := ""
|
|
if m.Object != "" {
|
|
obj = " [" + m.Object + "]"
|
|
}
|
|
label := "named-checkconf"
|
|
if !m.Error {
|
|
label += " warning"
|
|
}
|
|
fmt.Printf(" %s: %s%s\n", label, m.Text, obj)
|
|
}
|
|
for _, w := range res.Warnings {
|
|
fmt.Println("warning:", w)
|
|
}
|
|
for _, d := range res.Drift {
|
|
fmt.Printf("drift: %s (%s)\n", d.Path, d.Reason)
|
|
}
|
|
}
|
|
|
|
func importCmd() *cobra.Command {
|
|
var from string
|
|
var dryRun, asJSON bool
|
|
cmd := &cobra.Command{
|
|
Use: "import",
|
|
Short: "Import the existing BIND configuration into Jarl",
|
|
Long: `Reads named.conf (following includes) and all primary zone files into
|
|
Jarl. Key files used by rndc (rndc.key) stay as include statements.
|
|
|
|
If the imported configuration is valid it is applied right away: Jarl
|
|
writes its own files (bind.config_dir, bind.zone_dir), but named keeps
|
|
running on the old configuration until you run 'jarld adopt'. If there
|
|
are problems, the import is kept as an open changeset that you can fix in
|
|
the web UI or with the CLI.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
a, err := openApp(true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer a.close()
|
|
ctx := context.Background()
|
|
if from == "" {
|
|
from = a.cfg.BIND.MainConfig
|
|
}
|
|
p, err := a.localPrincipal(ctx, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := a.eng.Import(ctx, from, p, dryRun)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if asJSON {
|
|
printJSON(res)
|
|
return nil
|
|
}
|
|
rep := res.Report
|
|
fmt.Printf("Read %s:\n", from)
|
|
fmt.Printf(" %d views, %d zones, %d records, %d ACLs, %d TSIG keys, %d options\n",
|
|
rep.Counts["views"], rep.Counts["zones"], rep.Counts["records"], rep.Counts["acls"], rep.Counts["tsig_keys"], rep.Counts["options"])
|
|
for _, z := range rep.Zones {
|
|
view := ""
|
|
if z.View != "" {
|
|
view = " [view " + z.View + "]"
|
|
}
|
|
extra := ""
|
|
if z.Skipped > 0 {
|
|
extra = fmt.Sprintf(", %d skipped", z.Skipped)
|
|
}
|
|
fmt.Printf(" zone %-35s %-10s %4d records%s\n", z.Name+view, z.Type, z.Records, extra)
|
|
}
|
|
for _, k := range rep.Kept {
|
|
fmt.Printf(" kept as include: %s\n", k)
|
|
}
|
|
for _, w := range rep.Warnings {
|
|
fmt.Println("warning:", w)
|
|
}
|
|
if len(rep.Skipped) > 0 {
|
|
fmt.Println("Not imported:")
|
|
for _, s := range rep.Skipped {
|
|
fmt.Println(" " + s)
|
|
}
|
|
}
|
|
if dryRun {
|
|
fmt.Printf("\nDry run: %d operations would be applied; nothing was changed.\n", len(res.Changeset.Ops))
|
|
return nil
|
|
}
|
|
if !res.Applied {
|
|
fmt.Printf("\nThe import is saved as changeset #%d but was NOT applied:\n", res.Changeset.ID)
|
|
if res.Result != nil && res.Result.Error != nil {
|
|
fmt.Println(" " + res.Result.Error.Message)
|
|
}
|
|
printResult(res.Result)
|
|
fmt.Println("\nFix the problems (web UI: Changes, or 'jarl changeset show " + fmt.Sprint(res.Changeset.ID) + "'), apply the changeset, then run 'jarld adopt'.")
|
|
return nil
|
|
}
|
|
printResult(res.Result)
|
|
fmt.Printf("\nImported and applied as changeset #%d. Jarl's files are in %s and %s.\n", res.Result.ChangesetID, a.cfg.BIND.ConfigDir, a.cfg.BIND.ZoneDir)
|
|
fmt.Println("named still uses the old configuration. Review Jarl's files, then switch named over with:")
|
|
fmt.Println("\n sudo jarld adopt")
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&from, "from", "", "named.conf to import (default: bind.main_config)")
|
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "only show what would be imported")
|
|
cmd.Flags().BoolVar(&asJSON, "json", false, "print the result as JSON")
|
|
return cmd
|
|
}
|
|
|
|
func adoptCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "adopt",
|
|
Short: "Switch named over to Jarl's configuration (run as root)",
|
|
Long: `Makes named load Jarl's configuration:
|
|
|
|
1. bumps the serial of every primary zone,
|
|
2. saves bind.main_config (e.g. /etc/bind/named.conf) as
|
|
named.conf.pre-jarl-<date> and replaces it with a single include of
|
|
Jarl's named.conf,
|
|
3. runs named-checkconf, rndc reconfig and reloads every zone,
|
|
4. checks that named serves every zone from Jarl's files.
|
|
|
|
If anything fails the previous named.conf is restored. Writing
|
|
named.conf needs root.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
a, err := openApp(true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer a.close()
|
|
ctx := context.Background()
|
|
p, err := a.localPrincipal(ctx, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := a.eng.Adopt(ctx, p)
|
|
if err != nil {
|
|
if ee, ok := engine.AsError(err); ok && len(ee.Problems) > 0 {
|
|
fmt.Println("Problems:")
|
|
printProblems(ee.Problems)
|
|
}
|
|
return err
|
|
}
|
|
if res.Already {
|
|
fmt.Printf("named already loads Jarl's configuration (%s includes %s).\n", res.MainConfig, filepath.Join(a.cfg.BIND.ConfigDir, "named.conf"))
|
|
return nil
|
|
}
|
|
fmt.Printf("named now loads Jarl's configuration.\n %s includes %s\n previous configuration saved as %s\n", res.MainConfig, filepath.Join(a.cfg.BIND.ConfigDir, "named.conf"), res.Backup)
|
|
for _, s := range res.Serials {
|
|
fmt.Printf(" zone %s serial %d -> %d\n", strings.TrimSuffix(s.Zone, "."), s.Old, s.New)
|
|
}
|
|
fmt.Println("\nFrom now on, change DNS only through Jarl. To go back: restore the saved file and run 'rndc reconfig'.")
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func checkCmd() *cobra.Command {
|
|
var asJSON bool
|
|
cmd := &cobra.Command{
|
|
Use: "check",
|
|
Short: "Validate the current configuration and run named-checkconf on it",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
a, err := openApp(true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer a.close()
|
|
res, err := a.eng.CheckCurrent(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if asJSON {
|
|
printJSON(res)
|
|
} else {
|
|
printResult(res)
|
|
if res.Diff != "" {
|
|
fmt.Println("Files on disk differ from the configuration; 'jarld resync' rewrites them:")
|
|
fmt.Print(res.Diff)
|
|
}
|
|
}
|
|
if !res.Valid {
|
|
return fmt.Errorf("the configuration has errors")
|
|
}
|
|
if !asJSON {
|
|
fmt.Println("OK: the configuration is valid and named-checkconf accepts it.")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&asJSON, "json", false, "print the result as JSON")
|
|
return cmd
|
|
}
|
|
|
|
func renderCmd() *cobra.Command {
|
|
var out string
|
|
cmd := &cobra.Command{
|
|
Use: "render",
|
|
Short: "Write the generated configuration to a directory (for inspection)",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if out == "" {
|
|
return fmt.Errorf("--out is required")
|
|
}
|
|
a, err := openApp(true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer a.store.Close()
|
|
st, err := a.store.LoadState(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
paths := render.Paths{ConfigDir: filepath.Join(out, "config"), ZoneDir: filepath.Join(out, "zones"), CacheDir: filepath.Join(out, "cache")}
|
|
o := render.Render(st, paths, renderExtras(a.cfg))
|
|
for _, f := range o.Files {
|
|
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(f.Path, f.Content, 0o640); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(f.Path)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVarP(&out, "out", "o", "", "output directory")
|
|
return cmd
|
|
}
|
|
|
|
func resyncCmd() *cobra.Command {
|
|
var force, dryRun bool
|
|
cmd := &cobra.Command{
|
|
Use: "resync",
|
|
Short: "Rewrite all generated files from the database and reload named",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
a, err := openApp(true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer a.close()
|
|
ctx := context.Background()
|
|
p, err := a.localPrincipal(ctx, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := a.eng.Resync(ctx, p, engine.ApplyOptions{Force: force, DryRun: dryRun})
|
|
printResult(res)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res.Diff != "" {
|
|
fmt.Print(res.Diff)
|
|
}
|
|
if dryRun {
|
|
fmt.Printf("Dry run: %d files would be written.\n", len(res.Files))
|
|
} else {
|
|
fmt.Printf("%d files written; named reloaded: %v\n", len(res.Files), res.Reloaded)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&force, "force", false, "overwrite files changed outside Jarl")
|
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "only show what would change")
|
|
return cmd
|
|
}
|
|
|
|
func configCmd() *cobra.Command {
|
|
var example bool
|
|
cmd := &cobra.Command{
|
|
Use: "config",
|
|
Short: "Show the effective configuration (or an example file)",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if example {
|
|
fmt.Print(config.Example)
|
|
return nil
|
|
}
|
|
cfg, err := config.Load(configPath, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
printJSON(cfg)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration file")
|
|
return cmd
|
|
}
|
|
|
|
func versionCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "version",
|
|
Short: "Print the version",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
fmt.Println("jarld", version.Version)
|
|
},
|
|
}
|
|
}
|