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>
This commit is contained in:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -0,0 +1,249 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user