# Developing Jarl ## Layout ``` cmd/jarld/ daemon: serve, init, import, adopt, check, render, resync, backup, restore, user cmd/jarl/ CLI internal/ model/ configuration objects (State: views, ACLs, keys, zones, records, options) and changesets dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog namedconf/ named.conf lexer, parser and printer; address match lists catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded validate/ whole-configuration validation and security lints render/ State → named.conf, keys.conf and zone files, with a source map for error messages bind/ named-checkconf, named-checkzone and rndc wrappers engine/ changesets: expand requests into ops, replay, validate, render, check, deploy, reload, verify, revert; import and adopt importer/ reads an existing named.conf tree and its zone files store/ SQLite (modernc.org/sqlite, no cgo): state, changesets, users, tokens, sessions, audit auth/ argon2id passwords, tokens, sessions, grants and roles server/ HTTP API, authentication, the web UI stats/ query statistics: statistics-channel poller, response-log tailer, collector, separate SQLite store web/static/ the web UI: plain ES modules, no build step, embedded with go:embed api/, client/ JSON types and the Go client shared by server and CLI acme/ ACME DNS-01 challenge records, shared by jarl acme and the httpreq endpoint backup/ database backups: write (VACUUM INTO, plus the DNSSEC key files) and check, list, prune by the time in the name nscheck/ DNS queries to other name servers: zone serials, the parent's delegation and DS records dnssec/ reads named's DNSSEC key and state files; DS records; the signing phases (docs/dnssec.md) porkbun/ Porkbun API client (name servers, glue, DS records) and an in-memory fake for tests blocking/ blocklist parser, response policy zone writers, and matching a name the way named does (the downloads are in engine/blocklists.go) config/ jarld's YAML configuration deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files deploy/debian/ the Debian package's control template and postinst/prerm/postrm contrib/acme.sh/ acme.sh DNS API hook contrib/lego/ program for lego's exec DNS provider scripts/e2e.sh end-to-end test against a real named scripts/mkdeb.sh builds the Debian package (make deb) scripts/logo.py traces artwork/logo.png into the web UI's logo (logo.svg, logo.js) scripts/banner.py builds the README's banner from the web UI's logo and wordmark artwork/ the logo's master image and the README's banner ``` The main flow is in `internal/engine/engine.go` (`applyOps`): expand → replay → validate → render → check → install → reload → verify → record. [concepts.md](concepts.md) describes it from the outside. ## Building and testing ```sh make # bin/jarld, bin/jarl (CGO_ENABLED=0, version from ./VERSION), # and bin/completion/{jarl,jarld} (bash completion) make test # go vet, gofmt check, go test ./... make e2e # scripts/e2e.sh make deb # dist/jarl__.deb (server) and jarl-cli__.deb (DEB_ARCH=arm64 etc. cross-builds) ``` ### The Debian package `make deb` runs `scripts/mkdeb.sh`: it builds the binaries for `DEB_ARCH`, installs them with `make install-files` into a staging tree with Debian's paths (`PREFIX=/usr`, the unit in `/usr/lib/systemd/system`), adds `deploy/debian/{postinst,prerm,postrm}` and a `control` file filled in from `deploy/debian/control.in`, and packs it with `dpkg-deb --root-owner-group`, so no root is needed. The version comes from `./VERSION` (a `-` becomes `~`, so `0.2.0-rc1` sorts before `0.2.0`; override with `DEB_VERSION`), and the maintainer from your git name and email (override with `DEB_MAINTAINER`). The package ships no files in `/etc`: postinst writes `/etc/jarl/jarl.yaml` once, so upgrades never touch it. To try the package without installing it on your machine: ```sh docker run --rm -it -v "$PWD/dist:/pkg:ro" debian:trixie \ sh -c 'apt-get update && apt-get install -y /pkg/jarl_*_amd64.deb /pkg/jarl-cli_*_amd64.deb && bash' ``` ### Shell completion The completion scripts are generated by cobra (`jarl completion bash`). `cmd/jarl/complete.go` adds value completion: it reads each command's usage line (`add ZONE NAME TYPE DATA...`) and completes the placeholders it knows (`ZONE`, `VIEW`, `ACL`, `USER`, TSIG `KEY`, `TYPE`, server and group names), so a new command whose usage line uses these names gets completion without extra code. Try it without a shell: `jarl __complete record add ''`. ### Tests against real BIND The render, engine and adopt tests run the real `named-checkconf`, `named-checkzone`, `named`, `rndc` and `dig` when they can find them, and skip otherwise. They are found through `JARL_BIND_ROOT` (an extracted package tree) or on the system. To get them without root or AppArmor in the way, extract the packages: ```sh mkdir -p ~/bind-root/debs && cd ~/bind-root/debs apt-get download bind9 bind9-utils bind9-libs bind9-dnsutils \ liblmdb0 libmaxminddb0 liburcu8t64 libuv1t64 libjemalloc2 libjson-c5 libnghttp2-14 for f in *.deb; do dpkg -x "$f" ~/bind-root; done cd - JARL_BIND_ROOT=~/bind-root make test JARL_BIND_ROOT=~/bind-root make e2e ``` The tests start named unprivileged on random high ports in temporary directories. On Debian/Ubuntu, AppArmor confines the installed `/usr/sbin/named` to BIND's own directories, so the tests that *start* named (engine integration tests, `make e2e`) need the extracted copy. The checker tests work with the installed `bind9-utils` too. `scripts/e2e.sh` builds a legacy BIND setup, starts named, runs `jarld init`, `import`, `adopt` and `serve`, then drives every CLI command (records, batches, changesets, views, ACLs, TSIG, options, users, tokens, ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks the answers with `dig`. The Porkbun code is tested against `porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real one. `KEEP=1` leaves named and jarld running afterwards. The output says where; set `JARL_CLI_CONFIG` to that directory's `cli.yaml` to keep using the CLI against it. ### A test machine `scripts/testenv.sh` turns a fresh Debian/Ubuntu VM or container into a server to try Jarl on, the way a user would. Run it as root from the repository: ```sh sudo scripts/testenv.sh # zone example.com sudo JARL_TEST_ZONE=lab.test scripts/testenv.sh # another zone ``` It installs BIND 9, make, git, curl and Go (from go.dev, at the version in `go.mod`, unless a new enough one is installed). It then writes a typical hand-made configuration for Jarl to import: an ACL, options with logging, an `internal` view (this machine's networks and the private ranges, with recursion) and an `external` view (everyone else, authoritative only). The zone is in both views with different answers. There is also a reverse zone for the machine's /24. The zone files in `/etc/bind/zones` use comments, `$TTL`, `$ORIGIN`, `$GENERATE`, legacy `type master` and most record types. The script checks the result with `named-checkconf -z`, (re)starts named (in the background where there is no systemd), and asks named a few questions with `dig`. At the end it prints the steps to build and install the packages, then run `init`, `import` and `adopt`. `JARL_TEST_VIEWS=no` leaves out the views, and `JARL_TEST_IP` sets the machine's address (default: the address of the default route). The old `/etc/bind` is saved in `/root/bind-before-testenv-