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 }