Files
abrown38andClaude Opus 5.5 bd6b8778e4 Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:03:13 +00:00

250 lines
8.3 KiB
Go

package engine
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"jarl/internal/auth"
"jarl/internal/importer"
"jarl/internal/model"
"jarl/internal/validate"
)
// ImportResult is returned by Import.
type ImportResult struct {
Report *importer.Report `json:"report"`
Changeset *model.Changeset `json:"changeset"`
Result *Result `json:"result,omitempty"`
Applied bool `json:"applied"`
}
// Import loads an existing BIND configuration into Jarl. The import becomes
// a changeset; if the configuration is valid it is applied right away
// (writing Jarl's files), otherwise it stays open so problems can be fixed
// before applying.
func (e *Engine) Import(ctx context.Context, mainPath string, p *auth.Principal, dryRun bool) (*ImportResult, error) {
cur, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
if len(cur.Zones)+len(cur.Views)+len(cur.ACLs)+len(cur.Keys)+len(cur.Options) > 0 {
return nil, errf(KindConflict, "Jarl already has a configuration; import only works into an empty Jarl")
}
st, rep, err := importer.Import(ctx, mainPath, importer.Options{Tools: e.tools, Alloc: e.store.AllocateIDs, User: p.Name()})
if err != nil {
return nil, errf(KindBadRequest, "reading %s: %v", mainPath, err)
}
// Keep Jarl's own settings (defaults for new zones), but take the
// name servers and contact from the imported zones if unset.
st.Settings = cur.Settings
if len(st.Settings.Nameservers) == 0 || st.Settings.RName == "" {
for _, z := range st.SortedZones() {
if z.Type != model.ZonePrimary {
continue
}
if len(st.Settings.Nameservers) == 0 {
for _, r := range st.ZoneRecords(z.ID) {
if r.Name == z.Name && r.Type == "NS" {
st.Settings.Nameservers = append(st.Settings.Nameservers, r.Data)
}
}
}
if st.Settings.RName == "" {
st.Settings.RName = z.SOA.RName
}
break
}
}
ops := DiffOps(cur, st)
now := e.Now()
for i := range ops {
ops[i].AddedAt = now
}
out := &ImportResult{Report: rep}
cs := &model.Changeset{Description: "import from " + mainPath, UserID: p.User.ID, Username: p.Name(), Via: "import", Status: model.ChangesetOpen, Ops: ops}
if dryRun {
out.Changeset = cs
return out, nil
}
if err := e.store.CreateChangeset(ctx, cs); err != nil {
return nil, err
}
out.Changeset = cs
e.audit(ctx, p, "import", "changeset", cs.ID, mainPath, cs.ID, "success", fmt.Sprintf("%d zones, %d records", len(st.Zones), len(st.Records)), nil)
res, err := e.Apply(ctx, cs.ID, p, ApplyOptions{NoReload: true})
out.Result = res
if err != nil {
// Leave the changeset open for fixing.
if _, ok := AsError(err); ok {
return out, nil
}
return out, err
}
out.Applied = true
return out, nil
}
// AdoptResult describes an adoption.
type AdoptResult struct {
MainConfig string `json:"main_config"`
Backup string `json:"backup"`
Serials []SerialChange `json:"serials"`
Already bool `json:"already_adopted"`
}
// mainConfigText is what Jarl writes to named's main configuration file.
func mainConfigText(jarlConf, backup string) string {
return fmt.Sprintf(`// %s
//
// This server's DNS configuration is managed by Jarl. Jarl generates the
// file included below; make changes through the Jarl web UI, API or CLI.
// The configuration from before Jarl took over was saved as:
// %s
include "%s";
`, "named.conf", backup, jarlConf)
}
// Adopt switches named over to Jarl's configuration: it bumps every
// primary zone's serial, makes named's main configuration file include
// Jarl's named.conf (saving the old file), reloads named and checks that
// every zone is served from Jarl's files. On failure the old main
// configuration is restored.
func (e *Engine) Adopt(ctx context.Context, p *auth.Principal) (*AdoptResult, error) {
main := e.cfg.MainConfig
jarlConf := e.cfg.Paths.MainConfig()
if main == "" || filepath.Clean(main) == filepath.Clean(jarlConf) {
return nil, errf(KindBadRequest, "bind.main_config is not set (or is Jarl's own named.conf); nothing to adopt")
}
res := &AdoptResult{MainConfig: main}
if ok, _ := e.Adopted(); ok {
res.Already = true
return res, nil
}
st, err := e.store.LoadState(ctx)
if err != nil {
return nil, err
}
if len(st.Zones) == 0 {
return nil, errf(KindConflict, "Jarl has no zones yet; import your configuration (jarld import) or create zones first")
}
if ps := e.validate(st); validate.HasErrors(ps) {
return nil, &Error{Kind: KindValidation, Message: "the configuration has errors; fix them before adopting: " + describeErrors(ps), Problems: validate.Errors(ps)}
}
// Bump every primary zone's serial so named (and secondaries) load
// the zones from Jarl's files.
var reqs []Request
for _, z := range st.SortedZones() {
if z.Type == model.ZonePrimary {
policy := z.SerialPolicy
if policy == "" {
policy = st.Settings.SerialPolicy
}
next := NextSerial(z.SOA.Serial, policy, e.Now())
reqs = append(reqs, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{Serial: &next}}))
res.Serials = append(res.Serials, SerialChange{ZoneID: z.ID, Zone: z.Name, View: st.ViewName(z), Old: z.SOA.Serial, New: next})
}
}
if len(reqs) > 0 {
if _, err := e.Submit(ctx, reqs, p, "adopt", "bump serials for adoption", ApplyOptions{NoReload: true, Force: true}); err != nil {
return nil, fmt.Errorf("preparing zones: %w", err)
}
}
// Make sure the files on disk are exactly Jarl's.
if _, err := e.Resync(ctx, p, ApplyOptions{NoReload: true, Force: true}); err != nil {
return nil, fmt.Errorf("writing Jarl's files: %w", err)
}
e.mu.Lock()
defer e.mu.Unlock()
old, err := os.ReadFile(main)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", main, err)
}
fi, err := os.Stat(main)
if err != nil {
return nil, err
}
backup := main + ".pre-jarl-" + e.Now().Format("20060102-150405")
res.Backup = backup
text := mainConfigText(jarlConf, backup)
// Check the new main configuration before touching anything.
dir, err := os.MkdirTemp(e.cfg.StagingDir, "adopt-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
staged := filepath.Join(dir, "named.conf")
if err := os.WriteFile(staged, []byte(text), 0o640); err != nil {
return nil, err
}
check, err := e.tools.CheckConfig(ctx, staged, true)
if err != nil {
return nil, err
}
if !check.OK {
return nil, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration:\n" + check.Output}
}
if err := os.WriteFile(backup, old, fi.Mode().Perm()); err != nil {
return nil, fmt.Errorf("saving %s: %w", backup, err)
}
restore := func() {
if err := os.WriteFile(main, old, fi.Mode().Perm()); err != nil {
e.log.Error("restoring main configuration failed", "path", main, "err", err)
}
if err := e.tools.Reconfig(ctx); err != nil {
e.log.Error("rndc reconfig after restoring failed", "err", err)
}
}
tmp := main + ".jarl-new"
if err := os.WriteFile(tmp, []byte(text), fi.Mode().Perm()); err != nil {
return nil, err
}
copyOwner(fi, tmp)
if err := os.Rename(tmp, main); err != nil {
os.Remove(tmp)
return nil, err
}
if err := e.tools.Reconfig(ctx); err != nil {
restore()
return nil, &Error{Kind: KindBIND, Message: "named rejected Jarl's configuration (the old configuration was restored): " + err.Error()}
}
st, err = e.store.LoadState(ctx)
if err != nil {
restore()
return nil, err
}
deadline := time.Now().Add(e.cfg.ReloadTimeout)
for _, z := range st.SortedZones() {
if z.Type != model.ZonePrimary {
continue
}
view := e.namedView(st, z)
_ = e.tools.ReloadZone(ctx, z.Name, view)
want := z.SOA.Serial
for {
zs, err := e.tools.ZoneStatus(ctx, z.Name, view)
if err == nil && zs.HasSerial && zs.Serial == want && strings.Contains(zs.Files, e.cfg.Paths.ZoneDir) {
break
}
if time.Now().After(deadline) {
restore()
detail := ""
if err != nil {
detail = err.Error()
} else {
detail = fmt.Sprintf("named serves serial %d from %s", zs.Serial, zs.Files)
}
return nil, &Error{Kind: KindBIND, Message: fmt.Sprintf("zone %s was not loaded from Jarl's file with serial %d (%s); the old configuration was restored", zoneDesc(st, z), want, detail)}
}
time.Sleep(100 * time.Millisecond)
}
}
e.audit(ctx, p, "system.adopt", "", 0, main, 0, "success", "named now loads "+jarlConf+"; previous configuration saved as "+backup, nil)
return res, nil
}