Files
jarl/AGENTS.md
adamandClaude Opus 5.5 9175d75d61 Add AGENTS.md and CLAUDE.md for coding agents
Ground rules, building and testing with real BIND, where things live,
web UI rules, BIND gotchas, and the git and release workflow.
docs/development.md lists the packages its layout was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:14 +01:00

9.2 KiB

Working on Jarl

Notes for coding agents (and people) changing this repository. Read this first. For more depth, see docs/development.md for layout, building, tests and releasing, and docs/concepts.md for how Jarl works from the outside.

Jarl manages a BIND 9.20 server. It has three parts:

  • jarld, a daemon with an HTTP API, an embedded web UI and SQLite storage;
  • jarl, a CLI for that API;
  • a Debian package.

It's all Go: module path jarl, version in go.mod, CGO_ENABLED=0. The SQLite driver is modernc.org/sqlite, so there is no cgo. The target is Debian/Ubuntu with BIND 9.20, using Debian's paths (/etc/bind, the bind group, AppArmor).

Ground rules

  • The database is the source of truth. Jarl renders named.conf, keys.conf and the zone files from it. Never hand-edit rendered output or add code that reads BIND's files back as state; the importer is the only exception.

  • Every change goes through the engine (internal/engine). An API request becomes ops in expand.go/ops.go. The engine then runs replay → validate → render → named-checkconf/named-checkzone → atomic install → rndc reload → verify serials, and rolls back on failure. Going through the engine gives a change permissions, dry runs, changesets, history and undo for free, so don't write to the store directly from handlers.

  • Every user-visible change updates CHANGELOG.md under ## [Unreleased], and the matching page in docs/ in the same commit. Docs that may need it:

    • cli.md, api.md and configuration.md;
    • validation.md, for new validation codes;
    • metrics.md;
    • the feature's own page.

    Write docs in plain, direct English, like the existing pages.

  • Error messages say what is wrong and how to fix it, naming the object. For example: view external is reachable from the internet …; set minimal-any yes.

  • New validation rules get a stable code, a test, and a row in docs/validation.md.

  • Schema changes append a migration to the migrations slice in internal/store/store.go, and never edit an old one. The statistics database has its own list, schema in internal/stats/store.go. Add a note under "Upgrading" in the CHANGELOG when a downgrade would break.

  • make test runs go vet and a gofmt -l check, and both must pass. goimports isn't installed, so fix imports by hand.

Building and testing

make                 # bin/jarld, bin/jarl, bash completion
make test            # vet + gofmt check + go test ./...
JARL_BIND_ROOT=DIR make test   # also runs the integration tests against real BIND
JARL_BIND_ROOT=DIR make e2e    # scripts/e2e.sh: real named + jarld, drives every CLI command
  • Real BIND without root. Get the packages with apt-get download and unpack them with dpkg -x into a directory; that directory is JARL_BIND_ROOT. The package list is in docs/development.md, under "Tests against real BIND". Without JARL_BIND_ROOT the integration tests skip, so a green make test alone proves less than it looks.
  • Run e2e after any engine, render, CLI or server change. It prints a pass count, such as 606/606; a failure prints the command and its output.
    • It uses fixed ports: DNS 25353, rndc 25953, HTTP 28053, lists 28054 and metrics 28055. Stop anything left running on them first.
    • KEEP=1 leaves named and jarld running; the last line of the log has their PIDs.
    • WORK=DIR must name an empty directory.
  • Certificate tests run against Pebble in Docker, unless you set PEBBLE=0. If docker info fails, the Pebble block is silently skipped; on this machine it needs DOCKER_HOST=unix:///var/run/docker.sock.
  • Unix socket paths are limited to 107 bytes. Put test sockets under /run/user/$UID/, not under a deep temporary directory.
  • The systemd sandbox isn't tested. e2e runs outside systemd, so bugs that only appear in the sandbox (RestrictAddressFamilies, capabilities, read-only paths) need a package test. Run make deb, then install it in a debian:trixie container. That container has BIND 9.20; run it with --privileged /sbin/init when you need the real unit.

Layout and where things live

docs/development.md has the full layout. Some places that are easy to miss:

  • Engine (internal/engine):
    • expand.go turns requests into ops.
    • linked.go: linked zones, kept in step across views by linkSync.
    • reverse.go: PTR fill, auto-PTR and reverse zone suggestions.
    • compare.go: compare views.
    • exposure.go: fixes for internet exposure.
  • Validation: internal/validate holds all checks and lints. internal/catalog is the BIND 9.20 grammar and ARM text for every statement. Use it rather than hard-coding option knowledge.
  • Rendering: internal/render writes the configuration. generated.go holds the statements Jarl adds itself, shown to users as "Added by Jarl".
  • Server: internal/server has one h_*.go file per API area; routes are in routes.go. The JSON types are in internal/api, shared with the Go client in internal/client.
  • CLI: cmd/jarl is built on cobra. complete.go completes the placeholders in usage lines (ZONE, VIEW, …), so name them consistently.
  • Statistics: internal/stats is query statistics, kept in a separate SQLite file, stats.db, with its own migrations.
  • Feature packages: each has its own doc page.
    • encdns: encrypted DNS;
    • certs and acme: certificates and ACME;
    • blocking: response policy zones;
    • dnssec;
    • nscheck: queries to other name servers;
    • mailcheck: mail reverse DNS;
    • totp: two-factor login;
    • backup;
    • porkbun: registrar API, with an in-memory Fake. Tests never call the real API.

Web UI rules

internal/web/static/ holds the UI: plain ES modules, no framework, no build step. The files are embedded with go:embed.

  • Rebuild jarld after editing them, or you'll be testing the old files.

  • Build DOM only with h() from assets/ui.js. Never use innerHTML or insertAdjacentHTML. That's how record data can't inject markup.

  • The CSP is self only. That rules out:

    • inline scripts;
    • style="…" attributes and inline style strings (el.setAttribute("style", …));
    • external fonts and CDNs.

    Use a CSS class in style.css, or set individual properties with el.style.prop = … (CSSOM is allowed).

  • Colors are CSS custom properties with light and dark values. Reuse the existing tokens.

  • Pages live in assets/pages/, one file per route. The router is in app.js and the API calls are in api.js.

  • What the user wants: functionality and ease of use matter more than visual polish. In pick lists, leave every checkbox unticked by default.

There's no JS test suite. To catch syntax and import errors without a browser, bundle assets/app.js with esbuild, or load the page in headless Chrome.

BIND facts that bit us

  • named 9.20 crashes on an empty keys {} in a dnssec-policy. Validation refuses it.
  • named-checkconf doesn't catch a TTL above max-zone-ttl, but named then refuses to load the zone.
  • responselog only takes effect when named starts; use rndc responselog on|off instead.
  • named -g sends all logging to stderr, so tests use -f.
  • RPZ client-IP rules see IPv4 clients as v4-mapped (::ffff:0:0/96), so IPv6 prefix covers must leave that range out.
  • named serves DoH in RFC 8484 wire format only, not the JSON API. Test it with BIND's dig +https / dig +tls, not curl or kdig.
  • named can't forward over DoH, only over DoT (forwarders port 853 tls NAME).
  • rndc reconfig makes named reload renewed TLS certificate files.

Git and releases

These are the maintainer's rules; follow them unless told otherwise:

  • Commit only when asked. Never push; the maintainer pushes, to Gitea at gitea.adbraun.lol.
  • Work on dev-X.Y.Z branches (for example dev-0.6.1) off main. Don't name a branch like a tag (v0.5.2): git push then fails with "src refspec matches more than one".
  • A release is a commit titled Release X.
    • It sets VERSION.
    • It updates the status line in the README.
    • For a final release, it moves [Unreleased] to ## [X] - YYYY-MM-DD. A release candidate (X-rc.N) may leave the notes under [Unreleased].
    • Tag it annotated: git tag -a vX -m "Jarl X".
    • Before tagging, make test and the e2e run must pass.
    • Check the notes with VERSION=X scripts/gitea-release.sh notes.
  • Pushing a v* tag runs .gitea/workflows/release.yml. It builds the tarballs, the CLI for each OS and the .debs, and publishes them.
  • Before a minor release, test an upgrade from the previous release's .debs in a container. Check data, history, answers from named, and that an old backup restores.

Shell hygiene

  • Never kill processes by pattern with pkill -f or pgrep -f … | xargs kill in the same shell command. The pattern matches the shell running it, and kills that shell. Find the PID with ps and kill it by number.
  • Start long-running test servers in the background (your tool's background mode, not & in a shell that exits), so they outlive the command.