diff --git a/CHANGELOG.md b/CHANGELOG.md index 418a41f..85ac0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ The first version, to be released as 0.1.0. the web UI. Networks smaller than a /24 explain that they belong in the enclosing /24's zone. - docs/operations.md: setting up a secondary server with its own jarld. +- A Debian package: `make deb` builds `dist/jarl__.deb` with + only `dpkg-deb` (no root), for the host or any other architecture + (`DEB_ARCH=arm64`). Installing it creates the `jarl` user and directories + and an example `/etc/jarl/jarl.yaml`; upgrades restart a running jarld and + never touch `/etc/jarl/jarl.yaml`; removing it stops jarld, and even a + purge keeps Jarl's database and the files named uses. +- Bash completion for `jarl` and `jarld`, installed by `make install` and + the Debian package. Besides commands and flags, it completes server and + group names from the CLI configuration, zone, view, ACL, TSIG key and + user names from the server, and record types. +- `make dist` tarballs include the completion scripts (`completion/`). - Token rotation: `jarl token rotate`, the Rotate button in the web UI and `POST /api/v1/tokens/{id}/rotate` give a token a new secret while it keeps its ID, name, user and grants. The old secret stops at once or after diff --git a/Makefile b/Makefile index bf17e98..ce7b57b 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,13 @@ # Jarl build and install. # -# make build bin/jarld and bin/jarl +# make build bin/jarld and bin/jarl (and their bash completion) # make test unit tests (integration tests run when BIND is found; # set JARL_BIND_ROOT, see docs/development.md) # make e2e end-to-end test against a real named (scripts/e2e.sh) -# sudo make install install binaries, systemd unit, sysusers/tmpfiles -# files and an example /etc/jarl/jarl.yaml +# sudo make install install binaries, bash completion, systemd unit, +# sysusers/tmpfiles files and an example /etc/jarl/jarl.yaml +# make deb a Debian package in dist/ (DEB_ARCH=arm64 for another +# architecture); install it with 'sudo apt install ./dist/jarl_*.deb' # make dist static binaries for linux/amd64 and linux/arm64 in dist/ VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev) @@ -13,21 +15,39 @@ LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION) GOFLAGS := -trimpath export CGO_ENABLED := 0 -PREFIX ?= /usr/local -BINDIR ?= $(PREFIX)/bin -DOCDIR ?= $(PREFIX)/share/doc/jarl -UNITDIR ?= /etc/systemd/system +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +DOCDIR ?= $(PREFIX)/share/doc/jarl +# bash-completion also looks in /usr/local/share (XDG_DATA_DIRS). +BASHCOMPDIR ?= $(PREFIX)/share/bash-completion/completions +UNITDIR ?= /etc/systemd/system SYSUSERSDIR ?= /usr/lib/sysusers.d TMPFILESDIR ?= /usr/lib/tmpfiles.d -DESTDIR ?= +DESTDIR ?= -.PHONY: all build test vet e2e install uninstall dist clean +# The binaries install-files copies (make deb points these at other +# architectures' builds). +JARLD_BIN ?= bin/jarld +JARL_BIN ?= bin/jarl + +# make deb: the package's architecture (Debian name) and version. +DEB_ARCH ?= $(shell dpkg --print-architecture 2>/dev/null || go env GOARCH) +DEB_VERSION ?= $(subst -,~,$(VERSION)) + +.PHONY: all build completion test vet e2e install install-files uninstall deb dist clean all: build build: go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/jarld ./cmd/jarld go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/jarl ./cmd/jarl + $(MAKE) --no-print-directory completion + +# Bash completion scripts, generated by the binaries themselves. +completion: + @mkdir -p bin/completion + bin/jarl completion bash > bin/completion/jarl + bin/jarld completion bash > bin/completion/jarld vet: go vet ./... @@ -39,19 +59,7 @@ test: vet e2e: build scripts/e2e.sh -install: build - install -D -m 0755 bin/jarld $(DESTDIR)$(BINDIR)/jarld - install -D -m 0755 bin/jarl $(DESTDIR)$(BINDIR)/jarl - install -D -m 0644 deploy/systemd/jarl.service $(DESTDIR)$(UNITDIR)/jarl.service - sed -i 's#/usr/local/bin/jarld#$(BINDIR)/jarld#; s#/usr/local/share/doc/jarl#$(DOCDIR)#' $(DESTDIR)$(UNITDIR)/jarl.service - install -D -m 0644 deploy/sysusers.d/jarl.conf $(DESTDIR)$(SYSUSERSDIR)/jarl.conf - install -D -m 0644 deploy/tmpfiles.d/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf - install -D -m 0644 README.md $(DESTDIR)$(DOCDIR)/README.md - install -D -m 0644 CHANGELOG.md $(DESTDIR)$(DOCDIR)/CHANGELOG.md - install -d $(DESTDIR)$(DOCDIR)/docs $(DESTDIR)$(DOCDIR)/contrib/acme.sh $(DESTDIR)$(DOCDIR)/contrib/lego - install -m 0644 docs/*.md $(DESTDIR)$(DOCDIR)/docs/ - install -m 0644 contrib/acme.sh/dns_jarl.sh $(DESTDIR)$(DOCDIR)/contrib/acme.sh/ - install -m 0755 contrib/lego/jarl-lego.sh $(DESTDIR)$(DOCDIR)/contrib/lego/ +install: build install-files @if [ ! -e $(DESTDIR)/etc/jarl/jarl.yaml ]; then \ install -d -m 0750 $(DESTDIR)/etc/jarl; \ bin/jarld config --example > $(DESTDIR)/etc/jarl/jarl.yaml; \ @@ -63,17 +71,41 @@ install: build @echo " chgrp jarl /etc/jarl /etc/jarl/jarl.yaml && systemctl daemon-reload" @echo "Then follow docs/install.md (init, import, adopt, start)." +# Everything but /etc/jarl/jarl.yaml (shared by install and deb). +install-files: + install -D -m 0755 $(JARLD_BIN) $(DESTDIR)$(BINDIR)/jarld + install -D -m 0755 $(JARL_BIN) $(DESTDIR)$(BINDIR)/jarl + install -D -m 0644 bin/completion/jarl $(DESTDIR)$(BASHCOMPDIR)/jarl + install -D -m 0644 bin/completion/jarld $(DESTDIR)$(BASHCOMPDIR)/jarld + install -D -m 0644 deploy/systemd/jarl.service $(DESTDIR)$(UNITDIR)/jarl.service + sed -i 's#/usr/local/bin/jarld#$(BINDIR)/jarld#; s#/usr/local/share/doc/jarl#$(DOCDIR)#' $(DESTDIR)$(UNITDIR)/jarl.service + install -D -m 0644 deploy/sysusers.d/jarl.conf $(DESTDIR)$(SYSUSERSDIR)/jarl.conf + install -D -m 0644 deploy/tmpfiles.d/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf + install -D -m 0644 README.md $(DESTDIR)$(DOCDIR)/README.md + install -D -m 0644 CHANGELOG.md $(DESTDIR)$(DOCDIR)/CHANGELOG.md + install -d $(DESTDIR)$(DOCDIR)/docs $(DESTDIR)$(DOCDIR)/contrib/acme.sh $(DESTDIR)$(DOCDIR)/contrib/lego + install -m 0644 docs/*.md $(DESTDIR)$(DOCDIR)/docs/ + install -m 0644 contrib/acme.sh/dns_jarl.sh $(DESTDIR)$(DOCDIR)/contrib/acme.sh/ + install -m 0755 contrib/lego/jarl-lego.sh $(DESTDIR)$(DOCDIR)/contrib/lego/ + uninstall: rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \ + $(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \ $(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf rm -rf $(DESTDIR)$(DOCDIR) @echo "Kept /etc/jarl, /var/lib/jarl and the files in /etc/bind/jarl and /var/lib/bind/jarl." -dist: +# A Debian package, built with dpkg-deb (no root needed). The package's +# scripts are in deploy/debian; scripts/mkdeb.sh explains the layout. +deb: build + DEB_ARCH='$(DEB_ARCH)' DEB_VERSION='$(DEB_VERSION)' VERSION='$(VERSION)' \ + LDFLAGS='$(LDFLAGS)' GOFLAGS='$(GOFLAGS)' scripts/mkdeb.sh + +dist: build for arch in amd64 arm64; do \ GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/jarl-$(VERSION)-linux-$$arch/jarld ./cmd/jarld && \ GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/jarl-$(VERSION)-linux-$$arch/jarl ./cmd/jarl && \ - cp -r README.md CHANGELOG.md docs deploy contrib dist/jarl-$(VERSION)-linux-$$arch/ && \ + cp -r README.md CHANGELOG.md docs deploy contrib bin/completion dist/jarl-$(VERSION)-linux-$$arch/ && \ tar -C dist -czf dist/jarl-$(VERSION)-linux-$$arch.tar.gz jarl-$(VERSION)-linux-$$arch || exit 1; \ done diff --git a/README.md b/README.md index d067cc4..9fe6197 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,7 @@ On a Debian/Ubuntu BIND 9.20 server ([install.md](docs/install.md) has the details): ```sh -make && sudo make install -sudo systemd-sysusers jarl.conf && sudo systemd-tmpfiles --create jarl.conf -sudo chgrp jarl /etc/jarl/jarl.yaml +make deb && sudo apt install ./dist/jarl_*.deb # or: make && sudo make install, see install.md sudo -u jarl jarld init --token # first admin + a CLI token sudo -u jarl jarld import # read the current named.conf and zones sudo jarld adopt # switch named over (keeps a backup, rolls back on failure) diff --git a/cmd/jarl/complete.go b/cmd/jarl/complete.go new file mode 100644 index 0000000..8757320 --- /dev/null +++ b/cmd/jarl/complete.go @@ -0,0 +1,221 @@ +package main + +// Shell completion. Cobra completes commands and flags by itself +// ('jarl completion bash'); this adds the values: server and group names +// from the configuration file, and zone, view, ACL, TSIG key, user and +// record type names from the server. Lookups on the server use the +// configured token and give up quickly, completing nothing, when the +// server can't be reached. + +import ( + "strings" + "time" + + "github.com/spf13/cobra" + + "jarl/internal/api" + "jarl/internal/rr" +) + +const completeTimeout = 3 * time.Second + +// setupCompletion adds value completion to the command tree. +func setupCompletion(root *cobra.Command) { + root.RegisterFlagCompletionFunc("server", completeServers(true)) + root.RegisterFlagCompletionFunc("output", cobra.FixedCompletions([]string{"table", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp)) + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, name := range []string{"view", "user", "zone"} { + if c.LocalFlags().Lookup(name) != nil { + c.RegisterFlagCompletionFunc(name, completeFromServer(name)) + } + } + if c.ValidArgsFunction == nil && c.Runnable() { + if fn := argCompletion(c); fn != nil { + c.ValidArgsFunction = fn + } + } + for _, sub := range c.Commands() { + walk(sub) + } + } + walk(root) +} + +// argCompletion completes a command's arguments from the placeholders in +// its usage line ("add ZONE NAME TYPE DATA..."). +func argCompletion(c *cobra.Command) cobra.CompletionFunc { + fields := strings.Fields(c.Use) + if len(fields) < 2 { + return nil + } + params := fields[1:] + parent := "" + if c.HasParent() { + parent = c.Parent().Name() + } + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) >= len(params) && !strings.HasSuffix(params[len(params)-1], "...") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + p := params[min(len(args), len(params)-1)] + p = strings.Trim(strings.TrimSuffix(p, "..."), "[]") + if i := strings.Index(p, "|"); i >= 0 { + p = p[i+1:] // "[ID|NAME]": complete names + } + switch { + case p == "FILE": + return nil, cobra.ShellCompDirectiveDefault + case p == "ZONE": + return lookup(cmd, "zone") + case p == "VIEW": + return lookup(cmd, "view") + case p == "ACL": + return lookup(cmd, "acl") + case p == "USER": + return lookup(cmd, "user") + case p == "KEY" && parent == "tsig": + return lookup(cmd, "tsig") + case p == "TYPE": + var out []cobra.Completion + for _, t := range rr.Types() { + out = append(out, cobra.CompletionWithDesc(t.Type, t.Description)) + } + return out, cobra.ShellCompDirectiveNoFileComp + case p == "NAME" && (parent == "server" || parent == "config" && cmd.Name() == "use"): + return completeServers(false)(cmd, args, toComplete) + case p == "NAME" && parent == "group" && len(args) == 0: + return completeGroups(cmd) + case p == "MEMBER": + all, d := completeServers(true)(cmd, args, toComplete) + var out []cobra.Completion + for _, c := range all { + name, _, _ := strings.Cut(c, "\t") + if name != "all" && name != args[0] { + out = append(out, c) + } + } + return out, d + } + return nil, cobra.ShellCompDirectiveNoFileComp + } +} + +// completeServers completes server names (and with groups, group names +// and "all") from the configuration file. +func completeServers(groups bool) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + cfg, err := loadCLIConfig() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + // After a comma, complete the next item of a list. + prefix := "" + if i := strings.LastIndex(toComplete, ","); i >= 0 { + prefix = toComplete[:i+1] + } + var out []cobra.Completion + for _, name := range sortedKeys(cfg.Servers) { + out = append(out, cobra.CompletionWithDesc(prefix+name, cfg.Servers[name].URL)) + } + if groups { + for _, name := range sortedKeys(cfg.Groups) { + out = append(out, cobra.CompletionWithDesc(prefix+name, "group: "+strings.Join(cfg.Groups[name], ", "))) + } + if len(cfg.Servers) > 0 { + out = append(out, cobra.CompletionWithDesc(prefix+"all", "every server")) + } + } + return out, cobra.ShellCompDirectiveNoFileComp + } +} + +func completeGroups(cmd *cobra.Command) ([]cobra.Completion, cobra.ShellCompDirective) { + cfg, err := loadCLIConfig() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return sortedKeys(cfg.Groups), cobra.ShellCompDirectiveNoFileComp +} + +func completeFromServer(kind string) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return lookup(cmd, kind) + } +} + +// lookup asks the server for names of a kind of object. +func lookup(cmd *cobra.Command, kind string) ([]cobra.Completion, cobra.ShellCompDirective) { + none := cobra.ShellCompDirectiveNoFileComp + c, err := newClient() + if err != nil { + return nil, none + } + c.HTTP.Timeout = completeTimeout + seen := map[string]bool{} + var out []cobra.Completion + addName := func(name, desc string) { + if name != "" && !seen[name] { + seen[name] = true + out = append(out, cobra.CompletionWithDesc(name, desc)) + } + } + switch kind { + case "zone": + var zones []api.Zone + if c.Get(ctx(), "/zones", nil, &zones) != nil { + return nil, none + } + // A zone in several views is one name; its description lists them. + views := map[string][]string{} + for _, z := range zones { + name := dot(z.Name) + if z.View != "" { + views[name] = append(views[name], z.View) + } + addName(name, z.Type) + } + for i, c := range out { + if vs := views[c[:strings.IndexByte(c, '\t')]]; len(vs) > 0 { + label := "view " + if len(vs) > 1 { + label = "views " + } + out[i] = c + ", " + label + strings.Join(vs, ", ") + } + } + case "view": + var views []api.View + if c.Get(ctx(), "/views", nil, &views) != nil { + return nil, none + } + for _, v := range views { + addName(v.Name, v.Comment) + } + case "acl": + var acls []api.ACL + if c.Get(ctx(), "/acls", nil, &acls) != nil { + return nil, none + } + for _, a := range acls { + addName(a.Name, a.Comment) + } + case "tsig": + var keys []api.TSIGKey + if c.Get(ctx(), "/tsig-keys", nil, &keys) != nil { + return nil, none + } + for _, k := range keys { + addName(k.Name, k.Algorithm) + } + case "user": + var users []api.User + if c.Get(ctx(), "/users", nil, &users) != nil { + return nil, none + } + for _, u := range users { + addName(u.Username, u.Comment) + } + } + return out, none +} diff --git a/cmd/jarl/main.go b/cmd/jarl/main.go index 726e9fb..a1edd64 100644 --- a/cmd/jarl/main.go +++ b/cmd/jarl/main.go @@ -207,6 +207,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found, add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), acmeCmd(), searchCmd(), statsCmd()) add("changes", applyCmd(), changesetCmd(), historyCmd()) add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd()) + setupCompletion(root) return root } diff --git a/deploy/debian/control.in b/deploy/debian/control.in new file mode 100644 index 0000000..d570945 --- /dev/null +++ b/deploy/debian/control.in @@ -0,0 +1,20 @@ +Package: jarl +Version: @VERSION@ +Architecture: @ARCH@ +Maintainer: @MAINTAINER@ +Installed-Size: @SIZE@ +Depends: systemd +Recommends: bind9 (>= 1:9.20) +Section: net +Priority: optional +Description: web UI, REST API and CLI for managing a BIND 9 server + Jarl manages a BIND 9.20 server: zones, records, views, ACLs, TSIG keys + and options, through a web UI, a REST API and the jarl command line + client. Every change is validated, checked with named-checkconf and + loaded into named as one changeset that can be reverted. Users and API + tokens have fine-grained permissions, and every change is audited. + . + The package installs jarld (the daemon, as the systemd service jarl) and + jarl (the CLI), with bash completion for both. jarld isn't started on a + first install: see /usr/share/doc/jarl/docs/install.md to create the + database and adopt the BIND configuration. diff --git a/deploy/debian/postinst b/deploy/debian/postinst new file mode 100755 index 0000000..1ac4f21 --- /dev/null +++ b/deploy/debian/postinst @@ -0,0 +1,38 @@ +#!/bin/sh +# Jarl: runs after the package's files are in place. +set -e + +if [ "$1" = configure ]; then + # The jarl system user (a member of group bind) and Jarl's directories: + # /etc/jarl, /var/lib/jarl, /etc/bind/jarl, /var/lib/bind/jarl, + # /var/cache/bind/jarl and /var/log/named. + systemd-sysusers jarl.conf + systemd-tmpfiles --create jarl.conf || true + + # An example configuration on the first install; an existing one is + # never touched. + if [ ! -e /etc/jarl/jarl.yaml ]; then + /usr/bin/jarld config --example > /etc/jarl/jarl.yaml.dpkg-new + chown root:jarl /etc/jarl/jarl.yaml.dpkg-new + chmod 0640 /etc/jarl/jarl.yaml.dpkg-new + mv /etc/jarl/jarl.yaml.dpkg-new /etc/jarl/jarl.yaml + fi + + if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true + # An upgrade restarts a running jarld (its database is upgraded + # when it starts). A first install starts nothing: Jarl needs + # 'jarld init' and 'jarld adopt' first. + if [ -n "$2" ] && systemctl is-active --quiet jarl.service; then + systemctl restart jarl.service || echo "jarl: restarting jarld failed; see 'journalctl -u jarl'" >&2 + fi + fi + + if [ -z "$2" ]; then + echo "Jarl is installed. Next: create the database and adopt your BIND" + echo "configuration, as described in /usr/share/doc/jarl/docs/install.md" + echo "(from step 3), then: systemctl enable --now jarl" + fi +fi + +exit 0 diff --git a/deploy/debian/postrm b/deploy/debian/postrm new file mode 100755 index 0000000..558f1ce --- /dev/null +++ b/deploy/debian/postrm @@ -0,0 +1,21 @@ +#!/bin/sh +# Jarl: runs after the package's files are removed. +set -e + +case "$1" in +remove|purge) + if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true + fi + ;; +esac + +# Even a purge keeps Jarl's data: the database in /var/lib/jarl is the +# source of your DNS configuration, and named.conf may still include the +# files in /etc/bind/jarl. Remove them by hand when you no longer need them. +if [ "$1" = purge ]; then + echo "jarl: kept /etc/jarl, /var/lib/jarl, /etc/bind/jarl, /var/lib/bind/jarl and" + echo "/var/cache/bind/jarl (named may still use them); remove them by hand if unused." +fi + +exit 0 diff --git a/deploy/debian/prerm b/deploy/debian/prerm new file mode 100755 index 0000000..642753f --- /dev/null +++ b/deploy/debian/prerm @@ -0,0 +1,12 @@ +#!/bin/sh +# Jarl: runs before the package's files are removed (also on upgrades). +set -e + +# On removal (not on upgrade) stop jarld and remove its boot-time link. +# named keeps running with the configuration Jarl last wrote. +if [ "$1" = remove ] && [ -d /run/systemd/system ]; then + systemctl stop jarl.service 2>/dev/null || true + systemctl disable jarl.service 2>/dev/null || true +fi + +exit 0 diff --git a/docs/cli.md b/docs/cli.md index 7397265..57c2955 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,7 +2,26 @@ `jarl` talks to jarld's REST API with an API token. It runs on the DNS server or on any machine that can reach jarld. Every command has `--help` with -examples, and `jarl completion bash|zsh|fish` prints shell completion. +examples. + +## Shell completion + +`make install` and the Debian package install bash completion for `jarl` +and `jarld`; it works in new shells (it needs the `bash-completion` +package, which Debian and Ubuntu install by default). Elsewhere, or for +zsh and fish, `jarl completion bash|zsh|fish` prints the script; for +example `jarl completion bash > ~/.local/share/bash-completion/completions/jarl`. + +Besides commands and flags, Tab completes: + +* `-s/--server` and the `config` commands: the server and group names in + your configuration file (and `all`; after a comma, the next name); +* `ZONE`, `VIEW`, `ACL`, `USER` and TSIG `KEY` arguments, and the `--view`, + `--zone` and `--user` flags: names from the server, asked with your + token. A zone in several views is offered once, with its views shown. + If the server can't be reached in 3 seconds, nothing is offered; +* record `TYPE`s, with a short description of each; +* `-o/--output`: `table`, `json`, `yaml`. ## Setup diff --git a/docs/development.md b/docs/development.md index fd83577..26a31dd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,9 +26,11 @@ internal/ acme/ ACME DNS-01 challenge records, shared by jarl acme and the httpreq endpoint config/ jarld's YAML configuration deploy/ systemd unit, 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) ``` The main flow is in `internal/engine/engine.go` (`applyOps`): @@ -38,11 +40,44 @@ 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) +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 (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 && 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`, diff --git a/docs/install.md b/docs/install.md index 843ccfe..3633470 100644 --- a/docs/install.md +++ b/docs/install.md @@ -22,10 +22,49 @@ make dist # optional: dist/jarl--linux-{amd64,arm64}.tar.gz ``` If you build on another machine, copy the binaries plus the `deploy/`, -`docs/` and `contrib/` directories to the DNS server. +`docs/` and `contrib/` directories to the DNS server, or build a Debian +package (below) and copy that. ## 2. Install the files +There are two ways: a Debian package, which is easiest to upgrade and +remove, or `make install`. + +### With the Debian package + +```sh +make deb # dist/jarl_0.1.0_amd64.deb +make deb DEB_ARCH=arm64 # for another architecture (also armhf, i386, ...) +sudo apt install ./dist/jarl_0.1.0_amd64.deb +``` + +`make deb` needs only `dpkg-deb` (no root, no debhelper) and cross-builds +for any architecture Go supports. apt also installs `bind9` (recommended) +if it's missing. Installing the package: + +* puts the binaries in `/usr/bin`, bash completion in + `/usr/share/bash-completion/completions`, the systemd unit in + `/usr/lib/systemd/system/jarl.service`, the sysusers and tmpfiles files + in `/usr/lib`, and the docs and contrib files in `/usr/share/doc/jarl`; +* creates the `jarl` user (in group `bind`) and Jarl's directories, and + writes `/etc/jarl/jarl.yaml` from the example on the first install. No + file in `/etc` belongs to the package, so your configuration is never + replaced or asked about on upgrades; +* doesn't start jarld: continue with [3. Configure](#3-configure), then + `systemctl enable --now jarl` in step 8. + +**Upgrading**: build and install the newer package the same way. A running +jarld is restarted, and upgrades its database as it starts. + +**Removing** (`sudo apt remove jarl`) stops jarld and removes the program, +but named keeps running with the configuration Jarl last wrote. Even +`apt purge jarl` keeps `/etc/jarl`, `/var/lib/jarl` (the database) and the +files in `/etc/bind/jarl`, `/var/lib/bind/jarl` and `/var/cache/bind/jarl`, +since named.conf may still include them. Remove them by hand once named no +longer uses them. + +### With make install + On the DNS server, as root: ```sh @@ -37,6 +76,7 @@ This installs: | File | Purpose | |------|---------| | `/usr/local/bin/jarld`, `/usr/local/bin/jarl` | daemon and CLI | +| `/usr/local/share/bash-completion/completions/jarl`, `.../jarld` | bash completion (loaded automatically by the `bash-completion` package) | | `/etc/systemd/system/jarl.service` | systemd unit (`Type=notify`, sandboxed) | | `/usr/lib/sysusers.d/jarl.conf` | creates the `jarl` system user and adds it to group `bind` | | `/usr/lib/tmpfiles.d/jarl.conf` | creates `/etc/jarl`, `/var/lib/jarl`, `/etc/bind/jarl`, `/var/lib/bind/jarl`, `/var/cache/bind/jarl` | diff --git a/scripts/mkdeb.sh b/scripts/mkdeb.sh new file mode 100755 index 0000000..486f1bb --- /dev/null +++ b/scripts/mkdeb.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Builds a Debian package of Jarl: dist/jarl__.deb. +# Run through 'make deb' (which sets the variables below); needs only +# dpkg-deb, no root and no debhelper. +# +# The package installs, with Debian's paths: +# /usr/bin/jarld, /usr/bin/jarl +# /usr/share/bash-completion/completions/{jarl,jarld} +# /usr/lib/systemd/system/jarl.service +# /usr/lib/sysusers.d/jarl.conf, /usr/lib/tmpfiles.d/jarl.conf +# /usr/share/doc/jarl/ (README, CHANGELOG, docs, contrib) +# and its scripts (deploy/debian) create the jarl user and directories and +# /etc/jarl/jarl.yaml on the first install, restart a running jarld on +# upgrades, and stop it on removal. No file in /etc belongs to the package, +# so an upgrade never asks about configuration files. +# +# Variables: DEB_ARCH (Debian architecture, e.g. amd64, arm64, armhf), +# DEB_VERSION, VERSION, LDFLAGS, GOFLAGS, and DEB_MAINTAINER (default: your +# git name and email). +set -eu + +: "${DEB_ARCH:?}" "${DEB_VERSION:?}" "${VERSION:?}" "${LDFLAGS:?}" +GOFLAGS=${GOFLAGS:-} + +# Debian architecture -> Go architecture. +goarm= +case "$DEB_ARCH" in +amd64) goarch=amd64 ;; +arm64) goarch=arm64 ;; +armhf) goarch=arm goarm=7 ;; +i386) goarch=386 ;; +ppc64el) goarch=ppc64le ;; +riscv64) goarch=riscv64 ;; +s390x) goarch=s390x ;; +*) echo "mkdeb: unsupported architecture $DEB_ARCH" >&2; exit 1 ;; +esac + +if [ -z "${DEB_MAINTAINER:-}" ]; then + name=$(git config user.name 2>/dev/null || true) + email=$(git config user.email 2>/dev/null || true) + DEB_MAINTAINER="${name:-Jarl packager} <${email:-root@localhost}>" +fi + +work=dist/deb/jarl_${DEB_VERSION}_${DEB_ARCH} +root=$work/root +out=dist/jarl_${DEB_VERSION}_${DEB_ARCH}.deb +rm -rf "$work" +mkdir -p "$root/DEBIAN" + +# Binaries for the package's architecture (bin/ holds the host's, which +# also generated the completion scripts). +echo "mkdeb: building for linux/$goarch" +# go reads GOFLAGS (-trimpath) from the environment. +export GOFLAGS +GOOS=linux GOARCH=$goarch GOARM=$goarm CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$work/jarld" ./cmd/jarld +GOOS=linux GOARCH=$goarch GOARM=$goarm CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o "$work/jarl" ./cmd/jarl + +make --no-print-directory install-files DESTDIR="$root" PREFIX=/usr \ + UNITDIR=/usr/lib/systemd/system JARLD_BIN="$work/jarld" JARL_BIN="$work/jarl" >/dev/null + +for f in postinst prerm postrm; do + install -m 0755 "deploy/debian/$f" "$root/DEBIAN/$f" +done +# Directories 0755 and no group/other write, whatever the build's umask. +find "$root" -type d -exec chmod 0755 {} + +chmod -R go-w "$root" +size=$(du -sk --exclude=DEBIAN "$root" | cut -f1) +sed -e "s/@VERSION@/$DEB_VERSION/" -e "s/@ARCH@/$DEB_ARCH/" -e "s/@SIZE@/$size/" \ + -e "s|@MAINTAINER@|$DEB_MAINTAINER|" deploy/debian/control.in > "$root/DEBIAN/control" +(cd "$root" && find . -path ./DEBIAN -prune -o -type f -print | sed 's|^\./||' | sort | xargs md5sum) > "$root/DEBIAN/md5sums" +chmod 0644 "$root/DEBIAN/control" "$root/DEBIAN/md5sums" + +# Files belong to root in the package, whoever builds it. +dpkg-deb --root-owner-group -Zxz --build "$root" "$out" >/dev/null +rm -rf dist/deb +echo "mkdeb: wrote $out" +dpkg-deb --info "$out" | sed -n 's/^ \(Package\|Version\|Architecture\|Installed-Size\):/ \1:/p'