Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb6b4ab64d | ||
|
|
1d6c28ba69 | ||
|
|
020223d1a5 | ||
|
|
d961cb8792 | ||
|
|
e1bcb5d479 | ||
|
|
fb1ee09250 | ||
|
|
88bb89a9ff | ||
|
|
ca0f148b78 | ||
|
|
a95dffb471 | ||
|
|
24a2c875fa | ||
|
|
4b79abf296 | ||
|
|
21cb14262d | ||
|
|
db9ebb14dd | ||
|
|
b05711b13e | ||
|
|
8f9cdd0a17 | ||
|
|
b22ffee1bb | ||
|
|
a3a1da54df | ||
|
|
5630e7a848 |
No files matched your search
@@ -2,7 +2,9 @@
|
||||
# is pushed:
|
||||
# * a Gitea release with the CHANGELOG notes, the linux tarballs, the CLI
|
||||
# for Linux, macOS and Windows, the .debs and SHA256SUMS;
|
||||
# * the .debs (amd64, arm64) in the owner's Debian package registry.
|
||||
# * the .debs (amd64, arm64) in the owner's Debian package registry:
|
||||
# distribution "stable", or "testing" for a pre-release (a version
|
||||
# with a hyphen), which Gitea also marks as a pre-release.
|
||||
#
|
||||
# Needs a repository secret RELEASE_TOKEN: an access token of a user who
|
||||
# can write to the repository, with the write:repository and write:package
|
||||
@@ -30,8 +32,10 @@ jobs:
|
||||
echo "::error::tag $GITHUB_REF_NAME does not match VERSION ($version)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "^## \[$version\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no '## [$version]' section"
|
||||
# The release notes (a pre-release may use its final version's
|
||||
# section or Unreleased; see scripts/gitea-release.sh).
|
||||
if ! scripts/gitea-release.sh notes > /dev/null; then
|
||||
echo "::error::no release notes for $version in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -48,6 +52,10 @@ jobs:
|
||||
run: make test
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
# Checked against VERSION above; don't depend on the checkout
|
||||
# having the tag.
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')"
|
||||
make dist
|
||||
|
||||
+148
@@ -6,6 +6,154 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- DNSSEC signing per zone (docs/dnssec.md): `jarl zone dnssec
|
||||
enable|status|check-ds|disable|wipe` and the zone page's DNSSEC tab, or
|
||||
the zone's new `dnssec_policy` field in the API. named signs the zone
|
||||
with BIND's `default` policy (one ECDSA P-256 key that is never replaced)
|
||||
or a custom one, using inline signing, with keys in a per-view directory
|
||||
under `/var/lib/bind/jarl/dnssec-keys`. Jarl shows the keys and the DS
|
||||
record to give the registrar, says when it's safe to publish it, and
|
||||
checks the parent's DS records (`check-ds`, which also tells named with
|
||||
`rndc dnssec -checkds`). It refuses steps that would break a signed
|
||||
domain: switching straight to unsigned (it goes through BIND's
|
||||
`insecure` policy instead, and finishes only once named is done),
|
||||
deleting a signed zone or moving it to another view, and TTLs above the
|
||||
policy's `max-zone-ttl`. `wipe` starts over with a clean slate once the
|
||||
parent has no DS. Each step in the web UI opens a dialog that says what it
|
||||
needs first. `jarl zone delegation` now also compares the parent's DS
|
||||
records with the zone's keys.
|
||||
- Porkbun support (optional, docs/dnssec.md#porkbun): with an API key in
|
||||
the new `porkbun:` section of jarl.yaml (and nowhere else), `jarl zone
|
||||
registrar status|sync-glue|set-ns|publish-ds|remove-ds` and the zone
|
||||
page's Registrar tab show and change the domain's name servers, glue
|
||||
records (one per name server inside the domain, with all its IPv4 and
|
||||
IPv6 addresses) and DS records at Porkbun. Every change first checks that
|
||||
it won't take the domain offline (the name servers answer for the zone,
|
||||
glue matches, a DS goes out only once the domain is delegated to servers
|
||||
that serve the signed zone, DS records match the zone's keys before the
|
||||
name servers change); `--dry-run` shows the checks and `--force` skips
|
||||
them. Administrators only.
|
||||
- Backups include the DNSSEC keys (private keys too), stored inside the
|
||||
backup file, and `jarld restore` puts them back for named
|
||||
(`bind.named_user`, default `bind`). `jarl-backup.service` has
|
||||
`CAP_DAC_READ_SEARCH` to read them; `--without-keys` skips them.
|
||||
- Jarl raises a zone's serial above the one named has loaded whenever it
|
||||
writes a zone file whose serial isn't higher (after restoring an older
|
||||
database, say), so named loads it (it refuses otherwise for a signed
|
||||
zone) and secondaries pick it up.
|
||||
- Two-factor login to the web UI with an authenticator app (TOTP: SHA-1,
|
||||
6 digits, 30 s, as every app supports). Users turn it on under Account
|
||||
(password, QR code, a code to confirm) and get 10 one-time recovery
|
||||
codes. Logging in then asks for a code (or a recovery code) after the
|
||||
password; each code works once. In a session with two-factor login,
|
||||
creating or rotating API tokens, changing users and resetting someone's
|
||||
two-factor login need a code from the last 5 minutes: the web UI asks
|
||||
for one, API clients send `X-Jarl-TOTP`. API tokens and the local socket
|
||||
are not affected.
|
||||
- `totp.require` in jarl.yaml (`none`, `admins`, `all`) makes two-factor
|
||||
login compulsory; such a user who hasn't set it up can only set it up
|
||||
after logging in. `totp.issuer` names the server in authenticator apps
|
||||
(default `Jarl <hostname>`).
|
||||
- Resetting a user's two-factor login (lost phone): Users → Reset 2FA,
|
||||
`jarl user totp reset NAME`, or on the server
|
||||
`jarld user totp reset NAME` (also with jarld stopped).
|
||||
- The dashboard and `jarl status` list users who must use two-factor login
|
||||
but haven't set it up, and suggest disabling the first `admin` account
|
||||
when it is still active next to personal administrator accounts.
|
||||
`jarl user list`/`show` and the Users page show who uses it.
|
||||
- API: `POST /auth/login/totp`, `/me/totp` (`setup`, `enable`, `disable`,
|
||||
`recovery-codes`), `DELETE /users/{id}/totp`; database migration 4.
|
||||
- `scripts/testenv.sh`: turns a fresh Debian/Ubuntu machine into a test bed.
|
||||
It installs BIND and the build tools (Go from go.dev), writes a hand-made
|
||||
style configuration with internal and external views, a split-horizon
|
||||
zone (`example.com` or `JARL_TEST_ZONE`) and a reverse zone, starts named
|
||||
and prints the steps to install Jarl and adopt the configuration
|
||||
(docs/development.md).
|
||||
- View colours. Give a view a colour (Views → Edit, `jarl view color`, or
|
||||
`PUT /views/{id}/color`) and the zone list, the zone page and the views
|
||||
list show its name in that colour. The colours are muted, and pastel in
|
||||
dark mode. The default is the link colour. A colour isn't configuration,
|
||||
so setting one needs no changeset or deploy.
|
||||
- A "← Zones" link above a zone, back to the zone list, and "← History"
|
||||
above a changeset. The zone list keeps its filter, view and type in the
|
||||
address, so going back to it (with that link or the browser's back
|
||||
button) restores them. A view opened from a zone or the zone list has a
|
||||
"← example.com (external)" or "← Zones" link back.
|
||||
|
||||
### Changed
|
||||
|
||||
- The version moved from the header to a footer: "Powered by Jarl v0.1.0",
|
||||
linking to the release on Gitea. A build that isn't of a release tag
|
||||
shows its commit instead, linked to the commit and marked `-modified`
|
||||
with uncommitted changes. Before, every build of a commit after a
|
||||
release still showed the release's version. `GET /status` and
|
||||
`/auth/whoami` report the build as `build` (docs/development.md).
|
||||
- `jarld version`, `jarl --version`, `jarl status` and `jarl whoami` give
|
||||
a version number only for release builds. Other builds say they are
|
||||
development builds and how far past the last tag they are: `dev
|
||||
ca0f148b78b6 (3 commits after v0.2.0-rc.1)`. Before, they printed the
|
||||
version and the commit (`0.2.0-rc.1 (ca0f148b78b6)`), which read like a
|
||||
release.
|
||||
- `make deb` gives development builds their own package version after
|
||||
the last tag (`0.2.0~rc.1+git3.gca0f148`, plus the build time with
|
||||
uncommitted changes). Installing one over the RC or an older build is
|
||||
an upgrade: no more `apt install --reinstall`. It also removes older
|
||||
packages from `dist/` (docs/development.md).
|
||||
- The view in the zone list, and on the zone page, links to that view,
|
||||
and the views page highlights it. It used to be plain text, and the zone
|
||||
page linked to the views page as a whole.
|
||||
- The zone's Name servers tab shows at once and follows the check as it
|
||||
happens. Each query is listed as a dig command line (`dig @192.0.2.1
|
||||
example.org. SOA +norec`) with a "waiting 1.8 s" counter until it is
|
||||
answered. The finished result then replaces the progress, and the queries
|
||||
stay under "Queries sent". Results, the delegation check's included, are
|
||||
kept for 10 minutes, so returning to the tab shows them at once; "Check
|
||||
again" asks anew. A check keeps running if you leave the tab. Behind it,
|
||||
`GET /zones/{zone}/serials?stream=1` streams the queries as
|
||||
newline-delimited JSON (docs/api.md).
|
||||
- The delegation check streams its queries the same way, every one of
|
||||
them: finding the parent zone, its servers' addresses, the NS and DS
|
||||
queries to each parent server and the SOA from each delegated server.
|
||||
Each shows a short answer ("referral to katelyn.ns.cloudflare.com., …",
|
||||
"DS 2371", "NXDOMAIN"). The serials check now lists its address lookups
|
||||
as separate A and AAAA queries. `GET /zones/{zone}/delegation?stream=1`
|
||||
streams them.
|
||||
- Switching a zone's tabs (Records, Settings, …) no longer blanks the page
|
||||
while the tab loads: the page changes once the tab is ready, and
|
||||
"Loading…" appears only if a page takes a while.
|
||||
- `jarl zone serials` compares the serial named serves, which for a signed
|
||||
zone is the signed zone's (ahead of Jarl's).
|
||||
- `jarld import` turns `dnssec-policy` in an existing configuration into
|
||||
the zones' DNSSEC setting, and warns that their keys must be moved into
|
||||
Jarl's key directory before adopting.
|
||||
- `dnssec-policy` is no longer accepted as a zone, view or server option
|
||||
(zones have a DNSSEC setting instead), nor `key-directory` as a zone
|
||||
option.
|
||||
- Deleting a zone also removes the journal and signed files named kept
|
||||
next to it.
|
||||
- Release candidates (tags like `v0.2.0-rc.1`) publish their `.deb`s to a
|
||||
separate `testing` distribution of the package registry instead of
|
||||
`stable`. Servers that follow `stable` can't pick up an RC with
|
||||
`apt upgrade`. An RC may also take its release notes from the
|
||||
`## [0.2.0]` or `## [Unreleased]` section of the CHANGELOG instead of
|
||||
needing its own (docs/development.md, "Release candidates").
|
||||
|
||||
### Fixed
|
||||
|
||||
- Publishing a release candidate failed: `scripts/gitea-release.sh`
|
||||
rejected its `.deb`s, whose names carry the Debian version
|
||||
(`0.2.0~rc.1`) instead of `0.2.0-rc.1`.
|
||||
- `jarld init -u` and the install guide recommend naming the first
|
||||
administrator after yourself (one account per person).
|
||||
- `jarld import` no longer warns that a zone with `allow-update { none; }`
|
||||
uses dynamic updates (validation already allowed it).
|
||||
- docs/cli.md gives the CLI configuration file's location on macOS
|
||||
(`~/Library/Application Support/jarl/config.yaml`) and Windows
|
||||
(`%AppData%\jarl\config.yaml`). It was documented only as
|
||||
`~/.config/jarl/config.yaml`.
|
||||
|
||||
## [0.1.0] - 2026-10-09
|
||||
|
||||
The first version.
|
||||
|
||||
@@ -14,7 +14,15 @@
|
||||
# (jarl-cli-<version>-<os>-<arch>[.exe])
|
||||
|
||||
VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev)
|
||||
LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION)
|
||||
# RELEASE_TAG marks a build of a release (the web UI then shows and links
|
||||
# the release rather than the commit): set when HEAD is tagged v$(VERSION)
|
||||
# and no tracked file is changed.
|
||||
RELEASE_TAG ?= $(shell t=v$(VERSION); git tag --points-at HEAD 2>/dev/null | grep -qx "$$t" && git diff --quiet HEAD 2>/dev/null && echo $$t)
|
||||
# DESCRIBE places other builds relative to the last tag ("3 commits after
|
||||
# v0.2.0-rc.1").
|
||||
DESCRIBE ?= $(shell git describe --tags --long --match 'v[0-9]*' --abbrev=12 HEAD 2>/dev/null)
|
||||
LDFLAGS := -s -w -X jarl/internal/version.Version=$(VERSION) -X jarl/internal/version.Tag=$(RELEASE_TAG) \
|
||||
-X jarl/internal/version.Describe=$(DESCRIBE)
|
||||
GOFLAGS := -trimpath
|
||||
export CGO_ENABLED := 0
|
||||
|
||||
@@ -33,9 +41,18 @@ DESTDIR ?=
|
||||
JARLD_BIN ?= bin/jarld
|
||||
JARL_BIN ?= bin/jarl
|
||||
|
||||
# make deb: the package's architecture (Debian name) and version.
|
||||
# make deb: the package's architecture (Debian name) and version. A
|
||||
# release is VERSION with ~ for - (0.2.0-rc.1 -> 0.2.0~rc.1, which apt
|
||||
# sorts before 0.2.0). A development build is versioned after the last
|
||||
# tag: 3 commits past v0.2.0-rc.1 is 0.2.0~rc.1+git3.gca0f148, which apt
|
||||
# sorts after that RC and before 0.2.0, so installing it upgrades without
|
||||
# --reinstall. Uncommitted changes add the build time (.m20261009153000),
|
||||
# so each such build is newer than the last.
|
||||
DEB_ARCH ?= $(shell dpkg --print-architecture 2>/dev/null || go env GOARCH)
|
||||
DEB_VERSION ?= $(subst -,~,$(VERSION))
|
||||
DEB_DEV_VERSION := $(shell [ -n '$(DESCRIBE)' ] || exit 0; \
|
||||
m=; git diff --quiet HEAD 2>/dev/null || m=.m$$(date -u +%Y%m%d%H%M%S); \
|
||||
echo '$(DESCRIBE)' | sed -E 's/^v(.*)-([0-9]+)-g([0-9a-f]{7})[0-9a-f]*$$/\1+git\2.g\3/' | tr - '~' | sed "s/$$/$$m/")
|
||||
DEB_VERSION ?= $(if $(RELEASE_TAG),$(subst -,~,$(VERSION)),$(or $(DEB_DEV_VERSION),$(subst -,~,$(VERSION))))
|
||||
|
||||
.PHONY: all build completion test vet e2e install install-files uninstall deb dist cli-dist clean
|
||||
|
||||
|
||||
@@ -43,14 +43,26 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
using it).
|
||||
Passwords use argon2id, sessions have CSRF protection, logins are rate
|
||||
limited.
|
||||
* **Two-factor login** with any authenticator app (TOTP), with recovery
|
||||
codes; optional per user, or required for administrators or everyone
|
||||
(`totp.require`). With it on, creating API tokens and changing users
|
||||
asks for a fresh code, so a stolen password can't be turned into a token.
|
||||
* **Audit log** of every login, change and token, with user, token, IP and
|
||||
outcome.
|
||||
* **Daily backups** of the database by a systemd timer, checked as they are
|
||||
written, with retention that never deletes the last few; `jarld restore`
|
||||
puts one back safely.
|
||||
* **Daily backups** of the database and the DNSSEC keys by a systemd timer,
|
||||
checked as they are written, with retention that never deletes the last
|
||||
few; `jarld restore` puts one back safely, and raises zone serials so
|
||||
named and the secondaries take the restored data.
|
||||
* **Name server checks**: whether the secondaries serve the current serial,
|
||||
and, for zones on the internet, whether the parent zone delegates them
|
||||
to the right name servers with the right glue.
|
||||
to the right name servers with the right glue and DS records.
|
||||
* **DNSSEC signing** per zone, by BIND itself, with every step that involves
|
||||
the parent zone guarded: Jarl says when the DS record can be published,
|
||||
refuses to make a zone unsigned while its DS may still be out there, turns
|
||||
signing off in safe steps, and can wipe the slate clean.
|
||||
* **Porkbun support** (optional): the domain's name servers, glue records
|
||||
(any number, IPv4 and IPv6) and DS records at the registrar, set from Jarl
|
||||
after checks that the change won't take the domain offline.
|
||||
* **No token needed on the server**: root's `sudo jarl ...` talks to jarld
|
||||
over a local unix socket, so a lost token or password never locks you
|
||||
out.
|
||||
@@ -99,8 +111,9 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
|
||||
| [docs/configuration.md](docs/configuration.md) | `/etc/jarl/jarl.yaml` reference |
|
||||
| [docs/cli.md](docs/cli.md) | The `jarl` command line client |
|
||||
| [docs/api.md](docs/api.md) | REST API reference |
|
||||
| [docs/permissions.md](docs/permissions.md) | Users, tokens, grants and roles |
|
||||
| [docs/permissions.md](docs/permissions.md) | Users, tokens, grants and roles, two-factor login |
|
||||
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
|
||||
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
|
||||
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
||||
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
||||
| [docs/validation.md](docs/validation.md) | Every check Jarl makes |
|
||||
@@ -123,8 +136,10 @@ these pages:
|
||||
fields adapt to the zone type), per-zone records
|
||||
with inline editing, type-specific hints and a preview of the exact record
|
||||
that will be added (reverse zones take addresses and suggest host names), settings (SOA, TTL, serial
|
||||
policy, view), options, the generated zone file, and pasting a zone file
|
||||
to merge or replace records.
|
||||
policy, view), options, the generated zone file, pasting a zone file
|
||||
to merge or replace records, name server checks, DNSSEC (keys, the DS
|
||||
record to copy, and each step with what it needs first) and, with a
|
||||
Porkbun key, the registrar.
|
||||
* **Views, ACLs, TSIG keys, Server options**: wherever an address match list
|
||||
is entered, the existing ACLs and keys are offered and unknown names are
|
||||
flagged. The options editor knows every
|
||||
@@ -133,7 +148,7 @@ these pages:
|
||||
* **Changes**: pending edits with the diff, named-checkconf output and
|
||||
problems, applied as one changeset. History lists applied changesets with
|
||||
their diffs and a revert button.
|
||||
* **Audit, Users, Account** (password, API tokens) and **Search**.
|
||||
* **Audit, Users, Account** (password, two-factor login, API tokens) and **Search**.
|
||||
|
||||
By default, edits are collected as pending changes. Pending rows are shown
|
||||
inline (green = new, yellow = changed, struck out = deleted) until you
|
||||
@@ -151,9 +166,11 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.1.0, the first release. It is feature-complete for one server's
|
||||
configuration. DNSSEC signing and managing several servers are not handled
|
||||
yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||
Version 0.2.0 is in testing (0.2.0-rc.2); 0.1.0 is the current release.
|
||||
0.2.0 adds DNSSEC signing, Porkbun support, two-factor login and view
|
||||
colours. Jarl is feature-complete for one server's configuration.
|
||||
Managing several servers from one jarld and dynamic updates are not
|
||||
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+70
-3
@@ -200,6 +200,14 @@ func statusCmd() *cobra.Command {
|
||||
for _, t := range resp.ExpiringTokens {
|
||||
fmt.Fprintf(w, "Token:\t%s/%s (id %d) expires %s\n", t.Username, t.Name, t.ID, fmtExpiry(t.ExpiresAt, nil))
|
||||
}
|
||||
if o := resp.TOTP; o != nil {
|
||||
if len(o.Missing) > 0 {
|
||||
fmt.Fprintf(w, "2FA:\trequired (%s) but not set up by %s\n", o.Policy, strings.Join(o.Missing, ", "))
|
||||
}
|
||||
if o.SharedAdmin != "" {
|
||||
fmt.Fprintf(w, "Accounts:\tthe first account %q is still active next to personal administrator accounts; disable it if it is shared ('jarl user update %s --disabled')\n", o.SharedAdmin, o.SharedAdmin)
|
||||
}
|
||||
}
|
||||
if len(st.Problems) > 0 {
|
||||
w.(interface{ Flush() error }).Flush()
|
||||
fmt.Println("Problems:")
|
||||
@@ -333,6 +341,19 @@ func userID(c *client.Client, name string) (int64, error) {
|
||||
return 0, fmt.Errorf("no user %q", name)
|
||||
}
|
||||
|
||||
// twoFA describes a user's two-factor login.
|
||||
func twoFA(u api.User) string {
|
||||
switch {
|
||||
case u.TOTPEnabledAt != nil:
|
||||
return "on since " + fmtTime(u.TOTPEnabledAt)
|
||||
case u.TOTPRequired:
|
||||
return "REQUIRED, not set up (they can only set it up until they do)"
|
||||
case !u.HasPassword:
|
||||
return "-"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
||||
func printUser(w io.Writer, u api.User) {
|
||||
fmt.Fprintf(w, "User:\t%s (id %d)\n", u.Username, u.ID)
|
||||
role := "user"
|
||||
@@ -346,6 +367,7 @@ func printUser(w io.Writer, u api.User) {
|
||||
role += ", no password (tokens only)"
|
||||
}
|
||||
fmt.Fprintf(w, "Role:\t%s\n", role)
|
||||
fmt.Fprintf(w, "2FA:\t%s\n", twoFA(u))
|
||||
for i, g := range u.GrantStrings {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
@@ -384,9 +406,18 @@ func userCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
return output(users, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\tGRANTS\tLAST LOGIN")
|
||||
fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\t2FA\tGRANTS\tLAST LOGIN")
|
||||
for _, u := range users {
|
||||
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt))
|
||||
tfa := "off"
|
||||
switch {
|
||||
case u.TOTPEnabledAt != nil:
|
||||
tfa = "on"
|
||||
case u.TOTPRequired:
|
||||
tfa = "MISSING"
|
||||
case !u.HasPassword:
|
||||
tfa = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, tfa, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -548,7 +579,43 @@ func userCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
cmd.AddCommand(list, show, create, update, del)
|
||||
totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
|
||||
totpReset := &cobra.Command{
|
||||
Use: "reset USER",
|
||||
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
|
||||
Long: `Turns off a user's two-factor login, deletes their recovery codes and logs
|
||||
them out. They log in with the password alone next time and can set it up
|
||||
again under Account (they must, if totp.require applies to them).
|
||||
|
||||
Users set up two-factor login themselves, in the web UI. Without any
|
||||
administrator able to log in, use 'sudo -u jarl jarld user totp reset USER'
|
||||
on the server.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm("Turn off two-factor login of "+args[0]+"?", yes) {
|
||||
return fmt.Errorf("canceled")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := userID(c, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var u api.User
|
||||
if err := c.Do(ctx(), "DELETE", fmt.Sprintf("/users/%d/totp", id), nil, nil, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("two-factor login of %s is off\n", u.Username)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
totpReset.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
totpCmd.AddCommand(totpReset)
|
||||
cmd.AddCommand(list, show, create, update, del, totpCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/rr"
|
||||
)
|
||||
|
||||
@@ -71,6 +72,8 @@ func argCompletion(c *cobra.Command) cobra.CompletionFunc {
|
||||
return lookup(cmd, "zone")
|
||||
case p == "VIEW":
|
||||
return lookup(cmd, "view")
|
||||
case p == "COLOR":
|
||||
return append([]string{"default"}, model.ViewColors...), cobra.ShellCompDirectiveNoFileComp
|
||||
case p == "ACL":
|
||||
return lookup(cmd, "acl")
|
||||
case p == "USER":
|
||||
|
||||
+39
-3
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -27,9 +28,9 @@ func viewCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
return output(views, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES")
|
||||
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
||||
for i, v := range views {
|
||||
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones)
|
||||
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -172,10 +173,45 @@ func viewCmd() *cobra.Command {
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, create, update, del, order)
|
||||
color := &cobra.Command{
|
||||
Use: "color VIEW COLOR",
|
||||
Short: "Set the colour the web UI shows a view in",
|
||||
Long: "Set the colour the web UI shows a view in, so zones of the same view are easy to spot.\n" +
|
||||
"Colours: default (the link colour), " + strings.Join(model.ViewColors, ", ") + ".\n" +
|
||||
"This only changes the web UI: it applies at once, with no changeset or deploy.",
|
||||
Example: ` jarl view color internal green
|
||||
jarl view color internal default`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
col := strings.ToLower(args[1])
|
||||
if col == "default" {
|
||||
col = ""
|
||||
} else if !slices.Contains(model.ViewColors, col) {
|
||||
return fmt.Errorf("unknown colour %q: use default, %s", args[1], strings.Join(model.ViewColors, ", "))
|
||||
}
|
||||
var v api.View
|
||||
if err := c.Do(ctx(), "PUT", "/views/"+url.PathEscape(args[0])+"/color", nil, api.ViewColor{Color: col}, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("View %s: colour %s\n", v.Name, orDefault(v.Color))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(list, create, update, del, order, color)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func orDefault(s string) string {
|
||||
if s == "" {
|
||||
return "default"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func orAny(list []string) string {
|
||||
if len(list) == 0 {
|
||||
return "any"
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/dnssec"
|
||||
)
|
||||
|
||||
func zoneDNSSECCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dnssec",
|
||||
Short: "Sign a zone with DNSSEC, and turn signing off safely",
|
||||
Long: `named signs the zone itself (BIND's dnssec-policy with inline signing): Jarl
|
||||
keeps writing the unsigned zone, and named keeps a signed copy next to it
|
||||
and its keys in Jarl's key directory. See docs/dnssec.md.
|
||||
|
||||
Turning signing on is safe at any time; validation only starts once you add
|
||||
the zone's DS record at the parent zone (your registrar), and 'status' says
|
||||
when that is safe. Turning it off goes through BIND's insecure policy, which
|
||||
keeps the zone signed until the DS is gone from the parent.`,
|
||||
}
|
||||
var view string
|
||||
status := &cobra.Command{
|
||||
Use: "status ZONE",
|
||||
Short: "Show the zone's keys, its DS record and the next step",
|
||||
Example: " jarl zone dnssec status adbraun.lol --view external",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
resp, err := dnssecStatus(args[0], view)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(resp, func(w io.Writer) { printDNSSEC(w, resp) })
|
||||
},
|
||||
}
|
||||
status.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
|
||||
var policy string
|
||||
enable := &cobra.Command{
|
||||
Use: "enable ZONE",
|
||||
Short: "Start signing the zone",
|
||||
Long: `Starts signing the zone with BIND's default policy (one ECDSA P-256 key that
|
||||
is never replaced, so the DS record at the parent never has to change) or a
|
||||
custom dnssec-policy statement.
|
||||
|
||||
Nothing changes for resolvers until the zone's DS record is added at the
|
||||
parent zone. named first publishes the key for long enough that every cache
|
||||
has it ('status' shows when: about a day with the default policy); add the
|
||||
DS after that.`,
|
||||
Example: " jarl zone dnssec enable adbraun.lol --view external",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"dnssec_policy": policy})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(resp); err != nil || !resp.Applied {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
enable.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
enable.Flags().StringVar(&policy, "policy", "default", "dnssec-policy: default, or a custom policy defined as a top-level dnssec-policy statement")
|
||||
|
||||
var yes bool
|
||||
disable := &cobra.Command{
|
||||
Use: "disable ZONE",
|
||||
Short: "Turn signing off, one safe step at a time",
|
||||
Long: `Turns signing off in two steps, so the zone never goes unsigned while the
|
||||
parent zone still has its DS record:
|
||||
|
||||
1. The first run switches the zone to BIND's insecure policy. named keeps it
|
||||
signed and publishes a CDS "delete" record. Remove the DS record at your
|
||||
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
|
||||
waits until resolvers have forgotten the DS (the parent's DS TTL, often a
|
||||
day) and then removes the keys and signatures.
|
||||
2. Run it again when 'status' says turning off has finished: DNSSEC is then
|
||||
switched off for the zone.
|
||||
|
||||
A zone whose DS never reached the parent can be wiped at once instead
|
||||
('jarl zone dnssec wipe').`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
st, err := dnssecStatus(args[0], view)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var next string
|
||||
switch {
|
||||
case st.Policy == "":
|
||||
fmt.Println("DNSSEC is already off for this zone.")
|
||||
if st.Phase.Code == dnssec.PhaseLeftover {
|
||||
fmt.Println("Old keys are still in its key directory; 'jarl zone dnssec wipe' removes them.")
|
||||
}
|
||||
return nil
|
||||
case st.Policy != "insecure":
|
||||
if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record at your registrar.", displayName(st.Zone)), yes) {
|
||||
return fmt.Errorf("canceled")
|
||||
}
|
||||
next = "insecure"
|
||||
case !st.Phase.CanFinish:
|
||||
fmt.Fprintf(os.Stderr, "%s\n", st.Phase.Summary)
|
||||
if st.Phase.Next != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", st.Phase.Next)
|
||||
}
|
||||
return &checkFailed{"turning DNSSEC off hasn't finished yet"}
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"dnssec_policy": next})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(resp); err != nil || !resp.Applied || flagQuiet {
|
||||
return err
|
||||
}
|
||||
if next == "insecure" {
|
||||
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
||||
} else {
|
||||
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
disable.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
disable.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
|
||||
var resolver string
|
||||
checkDS := &cobra.Command{
|
||||
Use: "check-ds ZONE",
|
||||
Short: "Check the parent zone's DS records and tell named",
|
||||
Long: `Asks every server of the parent zone for the zone's DS records, and tells
|
||||
named (rndc dnssec -checkds) when its key's DS has appeared there (after you
|
||||
added it at the registrar) or is gone (while turning DNSSEC off). named
|
||||
moves to the next step only once it knows.
|
||||
|
||||
The parent is found through jarld's resolver, or --resolver. Exits with 2
|
||||
when the parent couldn't be checked.`,
|
||||
Example: " jarl zone dnssec check-ds adbraun.lol --view external",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
if resolver != "" {
|
||||
qv.Set("resolver", resolver)
|
||||
}
|
||||
var resp api.CheckDSResponse
|
||||
if err := c.Do(ctx(), "POST", path+"/dnssec/check-ds", qv, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
err = output(resp, func(w io.Writer) {
|
||||
p := resp.Parent
|
||||
if p.Error != "" {
|
||||
fmt.Fprintf(w, "error: %s\n", p.Error)
|
||||
} else {
|
||||
fmt.Fprintf(w, "Parent zone:\t%s (%d of %d servers answered, found through %s)\n", displayName(p.Parent), p.Reached, len(p.Servers), p.Resolver)
|
||||
if !p.Delegated {
|
||||
fmt.Fprintf(w, "Delegation:\tnone (the zone isn't delegated from %s)\n", displayName(p.Parent))
|
||||
}
|
||||
if len(p.DS) == 0 {
|
||||
fmt.Fprintln(w, "DS records:\tnone")
|
||||
}
|
||||
for _, d := range p.DS {
|
||||
fmt.Fprintf(w, "DS record:\tkey %d, algorithm %d, digest type %d\n", d.KeyTag, d.Algorithm, d.DigestType)
|
||||
}
|
||||
}
|
||||
flush(w)
|
||||
for _, m := range resp.Marked {
|
||||
fmt.Printf("named told: %s\n", m)
|
||||
}
|
||||
for _, n := range resp.Notes {
|
||||
fmt.Printf("note: %s\n", n)
|
||||
}
|
||||
if resp.Status != nil {
|
||||
fmt.Printf("\n%s\n", resp.Status.Phase.Summary)
|
||||
if resp.Status.Phase.Next != "" {
|
||||
fmt.Println(resp.Status.Phase.Next)
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Parent.Error != "" {
|
||||
return &checkFailed{"the parent zone couldn't be checked"}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
checkDS.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
checkDS.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with, e.g. 9.9.9.9 (default: the server's)")
|
||||
|
||||
var force bool
|
||||
wipe := &cobra.Command{
|
||||
Use: "wipe ZONE",
|
||||
Short: "Switch signing off at once and delete the zone's keys (a clean slate)",
|
||||
Long: `Switches DNSSEC off for the zone at once (skipping the insecure policy) and
|
||||
deletes its keys and named's signed copy, so that signing it again starts
|
||||
from scratch. Also cleans up a zone that is off but still has old keys.
|
||||
|
||||
This is only safe when the parent zone has no DS record for the zone: Jarl
|
||||
checks the parent first and refuses while it has one. When the parent can't
|
||||
be checked (a zone that isn't on the internet, or no internet access),
|
||||
--force wipes anyway. Needs an administrator.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm(fmt.Sprintf("Switch DNSSEC off for %s and delete its keys? Make sure the parent zone has no DS record for it.", strings.TrimSuffix(args[0], ".")), yes) {
|
||||
return fmt.Errorf("canceled")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
if resolver != "" {
|
||||
qv.Set("resolver", resolver)
|
||||
}
|
||||
if flagDryRun {
|
||||
qv.Set("dry_run", "1")
|
||||
}
|
||||
var resp api.DNSSECWipeResponse
|
||||
if err := c.Do(ctx(), "POST", path+"/dnssec/wipe", qv, api.DNSSECWipe{Force: force, DryRun: flagDryRun}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(resp, func(w io.Writer) {
|
||||
verb := "removed"
|
||||
if resp.DryRun {
|
||||
verb = "would remove"
|
||||
}
|
||||
for _, f := range resp.Removed {
|
||||
fmt.Printf("%s %s\n", verb, f)
|
||||
}
|
||||
if resp.DryRun {
|
||||
fmt.Println("dry run: nothing was changed")
|
||||
} else {
|
||||
fmt.Println("DNSSEC is off and the zone's keys are gone.")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
wipe.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
wipe.Flags().BoolVar(&force, "force", false, "wipe although the parent zone couldn't be checked")
|
||||
wipe.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with (default: the server's)")
|
||||
wipe.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
|
||||
cmd.AddCommand(status, enable, disable, checkDS, wipe)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func viewFlag(view string) string {
|
||||
if view == "" {
|
||||
return ""
|
||||
}
|
||||
return " --view " + view
|
||||
}
|
||||
|
||||
func dnssecStatus(zone, view string) (*api.DNSSECResponse, error) {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path, qv := zonePath(zone, view)
|
||||
var resp api.DNSSECResponse
|
||||
if err := c.Get(ctx(), path+"/dnssec", qv, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
|
||||
head := displayName(r.Zone)
|
||||
if r.View != "" {
|
||||
head += " [view " + r.View + "]"
|
||||
}
|
||||
fmt.Fprintf(w, "Zone:\t%s\n", head)
|
||||
policy := r.Policy
|
||||
switch policy {
|
||||
case "":
|
||||
policy = "none (not signed)"
|
||||
case "insecure":
|
||||
policy = "insecure (turning DNSSEC off)"
|
||||
}
|
||||
fmt.Fprintf(w, "Policy:\t%s\n", policy)
|
||||
fmt.Fprintf(w, "Status:\t%s\n", r.Phase.Summary)
|
||||
if r.Phase.Next != "" {
|
||||
fmt.Fprintf(w, "Next:\t%s\n", r.Phase.Next)
|
||||
}
|
||||
if r.Named != nil && r.Named.Secure {
|
||||
fmt.Fprintf(w, "named:\tsigned serial %d (zone file %d)", r.Named.SignedSerial, r.Named.Serial)
|
||||
if r.Named.NextKeyEvent != "" {
|
||||
fmt.Fprintf(w, ", next key event %s", r.Named.NextKeyEvent)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if r.NamedError != "" {
|
||||
fmt.Fprintf(w, "named:\t%s\n", r.NamedError)
|
||||
}
|
||||
if r.Policy != "" || len(r.Keys) > 0 {
|
||||
fmt.Fprintf(w, "Key directory:\t%s\n", r.KeyDir)
|
||||
}
|
||||
if r.KeyError != "" {
|
||||
fmt.Fprintf(w, "Keys:\tcan't be read: %s\n", r.KeyError)
|
||||
}
|
||||
flush(w)
|
||||
if len(r.Keys) > 0 {
|
||||
fmt.Fprintln(w, "\nKEY\tROLE\tALGORITHM\tGOAL\tDNSKEY\tDS AT PARENT\tCREATED")
|
||||
for _, k := range r.Keys {
|
||||
created := "-"
|
||||
if k.Created != nil {
|
||||
created = k.Created.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", k.Tag, k.Role, k.AlgorithmName, dash(k.Goal), dash(k.DNSKEYState), dash(k.DSState), created)
|
||||
}
|
||||
flush(w)
|
||||
}
|
||||
var ds []*dnssec.Key
|
||||
for _, k := range r.Keys {
|
||||
if k.DS != nil && k.Goal == dnssec.Omnipresent {
|
||||
ds = append(ds, k)
|
||||
}
|
||||
}
|
||||
if len(ds) > 0 && r.Policy != "insecure" {
|
||||
where := "at your registrar"
|
||||
if r.ParentZone != "" {
|
||||
where = "in the parent zone " + displayName(r.ParentZone) + " (in Jarl: add it as a DS record there)"
|
||||
}
|
||||
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
|
||||
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
|
||||
fmt.Fprintf(w, " (not yet: wait until %s)\n", r.Phase.DSReadyAt.Local().Format(time.RFC1123))
|
||||
}
|
||||
for _, k := range ds {
|
||||
fmt.Fprintf(w, " %s\n", strings.ReplaceAll(k.DS.Record, "\t", " "))
|
||||
fmt.Fprintf(w, " key tag %d, algorithm %d (%s), digest type %d (SHA-256), digest %s\n", k.DS.KeyTag, k.DS.Algorithm, k.AlgorithmName, k.DS.DigestType, k.DS.Digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dash(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
+1
-1
@@ -225,7 +225,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
5 conflict, 6 invalid or rejected by validation, 7 server or BIND error.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version.Version,
|
||||
Version: version.String(),
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return checkFanOut(cmd)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"jarl/internal/api"
|
||||
)
|
||||
|
||||
func zoneRegistrarCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "registrar",
|
||||
Short: "The domain's name servers, glue and DS records at the registrar (Porkbun)",
|
||||
Long: `Shows and changes a domain's delegation at the registrar Porkbun: its name
|
||||
servers, the glue records of name servers inside the domain, and its DNSSEC
|
||||
DS records. Needs a Porkbun API key in jarl.yaml (porkbun:, see
|
||||
docs/dnssec.md#porkbun) and an administrator.
|
||||
|
||||
Every change checks first that it won't break the domain (for example that
|
||||
the new name servers serve the zone, or that the zone is signed with the
|
||||
key a DS record points at) and is refused otherwise; --dry-run shows those
|
||||
checks, --force skips them.`,
|
||||
}
|
||||
var view string
|
||||
status := &cobra.Command{
|
||||
Use: "status ZONE",
|
||||
Short: "Compare the registrar's name servers, glue and DS records with the zone",
|
||||
Example: " jarl zone registrar status adbraun.lol --view external",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
var resp api.RegistrarResponse
|
||||
if err := c.Get(ctx(), path+"/registrar", qv, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
err = output(resp, func(w io.Writer) {
|
||||
if resp.Error != "" {
|
||||
fmt.Fprintf(w, "error: %s\n", resp.Error)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Domain:\t%s at %s\n", resp.Domain, resp.Registrar)
|
||||
fmt.Fprintf(w, "Name servers:\t%s\n", strings.Join(resp.NS, ", "))
|
||||
fmt.Fprintf(w, "Zone's NS records:\t%s\n", strings.Join(resp.JarlNS, ", "))
|
||||
flush(w)
|
||||
if len(resp.Glue) > 0 {
|
||||
fmt.Fprintln(w, "\nGLUE FOR\tAT REGISTRAR\tIN THE ZONE\tTO DO")
|
||||
for _, g := range resp.Glue {
|
||||
todo := g.Action
|
||||
if todo == "" {
|
||||
todo = "-"
|
||||
if !g.Needed && !g.InUse {
|
||||
todo = "unused"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", g.Host, firstNonEmpty(strings.Join(g.Registry, ", "), "none"), firstNonEmpty(strings.Join(g.Jarl, ", "), "-"), todo)
|
||||
}
|
||||
flush(w)
|
||||
}
|
||||
if len(resp.DS) > 0 {
|
||||
fmt.Fprintln(w, "\nDS KEY TAG\tALGORITHM\tDIGEST TYPE\tMATCHES THE ZONE")
|
||||
for _, d := range resp.DS {
|
||||
fmt.Fprintf(w, "%d\t%d\t%d\t%v\n", d.KeyTag, d.Algorithm, d.DigestType, d.Matches)
|
||||
}
|
||||
flush(w)
|
||||
}
|
||||
fmt.Println()
|
||||
for _, f := range resp.Findings {
|
||||
fmt.Printf("%-8s %s\n", f.Level+":", f.Message)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return &checkFailed{"the registrar couldn't be asked"}
|
||||
}
|
||||
for _, f := range resp.Findings {
|
||||
if f.Level == "error" {
|
||||
return &checkFailed{"the registrar's records don't match the zone"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
status.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
|
||||
var force bool
|
||||
change := func(use, short, long, method, sub string, args cobra.PositionalArgs, body func(args []string) (string, any, error)) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Long: long,
|
||||
Args: args,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cl, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
if flagDryRun {
|
||||
qv.Set("dry_run", "1")
|
||||
}
|
||||
extra, b, err := body(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.RegistrarChangeResponse
|
||||
if err := cl.Do(ctx(), method, path+"/registrar/"+sub+extra, qv, b, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(resp, func(w io.Writer) {
|
||||
for _, f := range resp.Checks {
|
||||
fmt.Printf("%-8s %s\n", f.Level+":", f.Message)
|
||||
}
|
||||
verb := "done:"
|
||||
if resp.DryRun {
|
||||
verb = "would:"
|
||||
}
|
||||
for _, d := range resp.Done {
|
||||
fmt.Printf("%-8s %s\n", verb, d)
|
||||
}
|
||||
if len(resp.Done) == 0 {
|
||||
fmt.Println("nothing to change")
|
||||
}
|
||||
for _, wn := range resp.Warnings {
|
||||
fmt.Printf("note: %s\n", wn)
|
||||
}
|
||||
if resp.DryRun {
|
||||
fmt.Println("dry run: nothing was changed")
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
c.Flags().BoolVar(&force, "force", false, "skip the checks that the change won't break the domain")
|
||||
return c
|
||||
}
|
||||
syncGlue := change("sync-glue ZONE", "Create or update glue records for the zone's name servers inside the domain",
|
||||
`Gives every name server in the zone's NS records that is inside the domain
|
||||
(ns1.adbraun.lol for adbraun.lol) a glue record at the registrar with the
|
||||
zone's A and AAAA records for it: one glue record per name server, each with
|
||||
all its addresses. Resolvers need glue to find such a server at all.
|
||||
|
||||
Changing the glue of a name server the domain is delegated to sends
|
||||
resolvers to the new addresses, so they must answer for the zone first.`,
|
||||
"POST", "glue", cobra.ExactArgs(1), func([]string) (string, any, error) {
|
||||
return "", api.RegistrarChange{Force: force, DryRun: flagDryRun}, nil
|
||||
})
|
||||
var key uint16
|
||||
publishDS := change("publish-ds ZONE", "Publish the zone's DS record at the registrar (turns DNSSEC validation on)",
|
||||
`Adds the DS record of the zone's key at the registrar, which publishes it in
|
||||
the parent zone: from then on validating resolvers check the zone's
|
||||
signatures. Refused until named says the zone is ready, and unless the
|
||||
domain is delegated to name servers that serve the signed zone.
|
||||
|
||||
Afterwards run 'jarl zone dnssec check-ds ZONE' once the parent zone has it.`,
|
||||
"POST", "ds", cobra.ExactArgs(1), func([]string) (string, any, error) {
|
||||
return "", api.RegistrarChange{KeyTag: key, Force: force, DryRun: flagDryRun}, nil
|
||||
})
|
||||
publishDS.Flags().Uint16Var(&key, "key", 0, "key tag (default: every key that needs a DS)")
|
||||
removeDS := change("remove-ds ZONE KEYTAG", "Remove a DS record at the registrar",
|
||||
`Removes a DS record at the registrar. Without the zone's DS record, resolvers
|
||||
stop validating it (it stays signed). This is the step 'jarl zone dnssec
|
||||
disable' asks for; a DS record that matches none of the zone's keys breaks
|
||||
the domain and should go at once.`,
|
||||
"DELETE", "ds/", cobra.ExactArgs(2), func(args []string) (string, any, error) {
|
||||
return args[1], nil, nil
|
||||
})
|
||||
setNS := change("set-ns ZONE [NAMESERVER...]", "Delegate the domain to the zone's name servers (or the ones given)",
|
||||
`Sets the domain's name servers at the registrar: by default to the zone's NS
|
||||
records. Checked first: each name server answers for the zone with the
|
||||
current serial, name servers inside the domain have glue records that match
|
||||
the zone (run sync-glue before), and DS records at the registrar match the
|
||||
zone's keys (otherwise the domain goes dark for validating resolvers).`,
|
||||
"PUT", "ns", cobra.MinimumNArgs(1), func(args []string) (string, any, error) {
|
||||
return "", api.RegistrarChange{NS: args[1:], Force: force, DryRun: flagDryRun}, nil
|
||||
})
|
||||
cmd.AddCommand(status, syncGlue, publishDS, removeDS, setNS)
|
||||
return cmd
|
||||
}
|
||||
+1
-1
@@ -367,7 +367,7 @@ equal to the file: records not in the file are deleted.`,
|
||||
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
||||
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
||||
|
||||
cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd())
|
||||
cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -383,7 +383,7 @@ func versionCmd() *cobra.Command {
|
||||
Use: "version",
|
||||
Short: "Print the version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("jarld", version.Version)
|
||||
fmt.Println("jarld", version.String())
|
||||
},
|
||||
}
|
||||
}
|
||||
+167
-14
@@ -7,8 +7,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
@@ -18,11 +21,13 @@ import (
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/backup"
|
||||
"jarl/internal/config"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
func backupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var withoutKeys bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "backup [FILE]",
|
||||
Short: "Back up the database (safe while jarld runs)",
|
||||
Long: `Writes a consistent copy of Jarl's database and checks it.
|
||||
@@ -35,10 +40,18 @@ say. This is what the jarl-backup.timer systemd timer runs every day.
|
||||
With FILE it writes a single copy there, which is never deleted
|
||||
automatically.
|
||||
|
||||
Each backup also holds the DNSSEC keys of signed zones (from Jarl's key
|
||||
directory, private keys included), so that 'jarld restore' can put them
|
||||
back: a zone whose keys are lost stops validating while its parent still
|
||||
has a DS record for them. named's private keys are readable only by named's
|
||||
user, so run this as root; jarl-backup.service has the capability to read
|
||||
them. --without-keys backs up only the database.
|
||||
|
||||
Every backup is recorded in the database, and the dashboard shows the newest.
|
||||
Restore one with 'jarld restore'.`,
|
||||
Example: ` sudo -u jarl jarld backup
|
||||
sudo -u jarl jarld backup /root/jarl-before-upgrade.db`,
|
||||
Example: ` sudo jarld backup
|
||||
sudo jarld backup /root/jarl-before-upgrade.db
|
||||
sudo systemctl start jarl-backup.service`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(configPath, true)
|
||||
@@ -59,7 +72,14 @@ Restore one with 'jarld restore'.`,
|
||||
defer newDirOwner(filepath.Dir(cfg.Database)).fix(filepath.Dir(cfg.Database))
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
keysDir := render.Paths{ZoneDir: cfg.BIND.ZoneDir}.KeysDir()
|
||||
if withoutKeys {
|
||||
keysDir = ""
|
||||
}
|
||||
fail := func(err error) error {
|
||||
if strings.Contains(err.Error(), "DNSSEC keys") {
|
||||
err = fmt.Errorf("%w; or back up only the database with --without-keys", err)
|
||||
}
|
||||
if rerr := st.RecordBackup(ctx, store.BackupRecord{At: now, Error: err.Error()}); rerr != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: couldn't record the failure in the database:", rerr)
|
||||
}
|
||||
@@ -74,27 +94,33 @@ Restore one with 'jarld restore'.`,
|
||||
os.Remove(dst)
|
||||
return fail(fmt.Errorf("backing up %s: %w", cfg.Database, err))
|
||||
}
|
||||
if keysDir != "" {
|
||||
if _, err := backup.AddFiles(ctx, dst, keysDir); err != nil {
|
||||
os.Remove(dst)
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
if err := backup.Verify(ctx, dst); err != nil {
|
||||
os.Remove(dst)
|
||||
return fail(fmt.Errorf("the backup failed its check: %w", err))
|
||||
}
|
||||
fi, _ := os.Stat(dst)
|
||||
st.RecordBackup(ctx, store.BackupRecord{At: now, File: dst, Size: fi.Size()})
|
||||
fmt.Printf("Database backed up to %s (%s)\n", dst, size(fi.Size()))
|
||||
fmt.Printf("Database backed up to %s (%s)%s\n", dst, size(fi.Size()), keysNote(ctx, dst))
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := cfg.Backup.Dir
|
||||
defer newDirOwner(dir).fix(dir)
|
||||
f, err := backup.Write(ctx, cfg.Database, dir, backup.KindJarl, now)
|
||||
f, err := backup.Write(ctx, cfg.Database, dir, backup.KindJarl, now, keysDir)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("backing up %s: %w", cfg.Database, err))
|
||||
}
|
||||
fmt.Printf("Database backed up to %s (%s)\n", f.Path, size(f.Size))
|
||||
fmt.Printf("Database backed up to %s (%s)%s\n", f.Path, size(f.Size), keysNote(ctx, f.Path))
|
||||
kinds := []string{backup.KindJarl}
|
||||
if cfg.Backup.Stats {
|
||||
if _, err := os.Stat(cfg.Stats.Database); err == nil {
|
||||
sf, err := backup.Write(ctx, cfg.Stats.Database, dir, backup.KindStats, now)
|
||||
sf, err := backup.Write(ctx, cfg.Stats.Database, dir, backup.KindStats, now, "")
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("backing up the statistics database %s: %w", cfg.Stats.Database, err))
|
||||
}
|
||||
@@ -117,10 +143,20 @@ Restore one with 'jarld restore'.`,
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&withoutKeys, "without-keys", false, "back up only the database, not the DNSSEC keys")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// keysNote says how many DNSSEC key files a backup holds.
|
||||
func keysNote(ctx context.Context, path string) string {
|
||||
if in, err := backup.Inspect(ctx, path); err == nil && in.KeyFiles > 0 {
|
||||
return ", with " + plural(in.KeyFiles, "DNSSEC key file")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func restoreCmd() *cobra.Command {
|
||||
var yes bool
|
||||
var yes, withoutKeys bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore [FILE | latest]",
|
||||
Short: "Replace the database with a backup (jarld must be stopped)",
|
||||
@@ -132,13 +168,20 @@ confirm replaces the database with it. jarld must be stopped first. The
|
||||
current database is kept as DATABASE.before-restore-<UTC time>, and the
|
||||
restore is recorded in the restored database's audit log.
|
||||
|
||||
The DNSSEC keys stored in the backup go back into Jarl's key directory,
|
||||
owned by named's user (bind.named_user), which needs root. Key files that
|
||||
are there but not in the backup are left alone. --without-keys restores
|
||||
only the database.
|
||||
|
||||
Then start jarld and bring named's files in line with the restored database:
|
||||
|
||||
systemctl start jarl
|
||||
jarl resync --dry-run # what differs
|
||||
jarl resync --force # write everything and reload named
|
||||
|
||||
See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
|
||||
Jarl raises the serials of zones whose data goes back to an older state
|
||||
above what named and the secondaries have, so they pick it up. See
|
||||
"Restoring" in docs/operations.md.`,
|
||||
Example: ` sudo jarld restore
|
||||
sudo systemctl stop jarl
|
||||
sudo jarld restore latest
|
||||
@@ -180,6 +223,24 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
|
||||
if info.Schema > store.SchemaVersion() {
|
||||
return fmt.Errorf("%s was made by a newer Jarl (schema %d, this one knows %d); install that version to restore it", src, info.Schema, store.SchemaVersion())
|
||||
}
|
||||
keysDir := render.Paths{ZoneDir: cfg.BIND.ZoneDir}.KeysDir()
|
||||
var keyFiles []backup.StoredFile
|
||||
if !withoutKeys {
|
||||
var root string
|
||||
root, keyFiles, err = backup.Files(ctx, src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the DNSSEC keys in %s: %w", src, err)
|
||||
}
|
||||
if len(keyFiles) > 0 && os.Geteuid() != 0 && !runningAs(cfg.BIND.NamedUser) {
|
||||
return fmt.Errorf("the backup holds %s, and putting them back for named needs root: run this as root (or add --without-keys to restore only the database)", plural(len(keyFiles), "DNSSEC key file"))
|
||||
}
|
||||
if root != "" && root != keysDir && len(keyFiles) > 0 {
|
||||
fmt.Printf("The keys were backed up from %s; they go to %s now.\n", root, keysDir)
|
||||
}
|
||||
}
|
||||
if info.SignedZones > 0 && len(keyFiles) == 0 && !withoutKeys {
|
||||
fmt.Fprintf(os.Stderr, "warning: the backup has %s but no DNSSEC keys (it was made with --without-keys, or before Jarl backed them up). Unless the keys are still in %s, named makes new ones, and a DS record for the old ones at the parent stops matching.\n", plural(info.SignedZones, "signed zone"), keysDir)
|
||||
}
|
||||
unlock, err := lockDatabase(cfg.Database)
|
||||
if errors.Is(err, errInUse) {
|
||||
return fmt.Errorf("jarld is running; stop it first (systemctl stop jarl), then run this again")
|
||||
@@ -192,6 +253,9 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "Backup:\t%s\n", src)
|
||||
describe(w, info, backupTime(src))
|
||||
if len(keyFiles) > 0 {
|
||||
fmt.Fprintf(w, " DNSSEC keys\t%s (go back to %s)\n", plural(len(keyFiles), "file"), keysDir)
|
||||
}
|
||||
if cur, err := backup.Inspect(ctx, cfg.Database); err == nil {
|
||||
fmt.Fprintf(w, "\nCurrent database:\t%s\n", cfg.Database)
|
||||
describe(w, cur, time.Time{})
|
||||
@@ -224,13 +288,27 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if err := copyFile(src, db, 0o640); err != nil {
|
||||
restoreErr := copyFile(src, db, 0o640)
|
||||
if restoreErr == nil {
|
||||
// The keys go back to their own files, not into the
|
||||
// live database.
|
||||
restoreErr = backup.DropFiles(ctx, db)
|
||||
}
|
||||
if restoreErr != nil {
|
||||
os.Remove(db)
|
||||
if moved {
|
||||
os.Rename(aside, db)
|
||||
os.Rename(aside+"-wal", db+"-wal")
|
||||
os.Rename(aside+"-shm", db+"-shm")
|
||||
}
|
||||
return fmt.Errorf("restoring: %w (the current database was left in place)", err)
|
||||
return fmt.Errorf("restoring: %w (the current database was left in place)", restoreErr)
|
||||
}
|
||||
if len(keyFiles) > 0 {
|
||||
n, err := restoreKeys(cfg, keysDir, keyFiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the database was restored, but putting back the DNSSEC keys failed after %d files: %w", n, err)
|
||||
}
|
||||
fmt.Printf("Put back %s in %s.\n", plural(n, "DNSSEC key file"), keysDir)
|
||||
}
|
||||
// Open it as jarld will: an older backup gets the current
|
||||
// schema now, and the restore goes into its audit log.
|
||||
@@ -255,17 +333,89 @@ Next:
|
||||
jarl resync --dry-run # what differs between the restored database and named's files
|
||||
jarl resync --force # write them and reload named
|
||||
|
||||
If you have secondary servers, zone serials older than what they last saw
|
||||
are ignored by them: raise them with 'jarl zone update ZONE --serial N'
|
||||
(see "Restoring" in docs/operations.md).
|
||||
Zones whose data goes back to an older state get a serial above the one
|
||||
named has, so named and the secondaries load them (see "Restoring" in
|
||||
docs/operations.md).
|
||||
`)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
||||
cmd.Flags().BoolVar(&withoutKeys, "without-keys", false, "restore only the database, not the DNSSEC keys")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runningAs reports whether this process runs as the named user.
|
||||
func runningAs(name string) bool {
|
||||
u, err := user.Lookup(name)
|
||||
return err == nil && u.Uid == strconv.Itoa(os.Geteuid())
|
||||
}
|
||||
|
||||
// restoreKeys writes DNSSEC key files from a backup into keysDir, owned
|
||||
// by named's user and Jarl's file group with their backed-up modes, so
|
||||
// named can use them. Directories get the owner of the zone directory
|
||||
// (jarld's user), like the ones jarld makes. Files already there are
|
||||
// replaced; others are left alone.
|
||||
func restoreKeys(cfg *config.Config, keysDir string, files []backup.StoredFile) (int, error) {
|
||||
uid, gid := -1, -1
|
||||
if u, err := user.Lookup(cfg.BIND.NamedUser); err == nil {
|
||||
uid, _ = strconv.Atoi(u.Uid)
|
||||
} else {
|
||||
return 0, fmt.Errorf("named's user %q (bind.named_user): %w", cfg.BIND.NamedUser, err)
|
||||
}
|
||||
if g, err := user.LookupGroup(cfg.BIND.FileGroup); err == nil {
|
||||
gid, _ = strconv.Atoi(g.Gid)
|
||||
}
|
||||
dirUID, dirGID := -1, gid
|
||||
if fi, err := os.Stat(cfg.BIND.ZoneDir); err == nil {
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
dirUID = int(st.Uid)
|
||||
}
|
||||
}
|
||||
var mkdir func(dir string) error
|
||||
mkdir = func(dir string) error {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := mkdir(filepath.Dir(dir)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o770); err != nil && !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
os.Chown(dir, dirUID, dirGID)
|
||||
return os.Chmod(dir, 0o770|os.ModeSetgid)
|
||||
}
|
||||
n := 0
|
||||
for _, f := range files {
|
||||
if filepath.IsAbs(f.Path) || strings.HasPrefix(filepath.Clean(f.Path), "..") {
|
||||
return n, fmt.Errorf("refusing the file name %q in the backup", f.Path)
|
||||
}
|
||||
dst := filepath.Join(keysDir, f.Path)
|
||||
if err := mkdir(filepath.Dir(dst)); err != nil {
|
||||
return n, err
|
||||
}
|
||||
tmp := dst + ".restore-tmp"
|
||||
if err := os.WriteFile(tmp, f.Data, 0o600); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if err := os.Chown(tmp, uid, gid); err != nil {
|
||||
os.Remove(tmp)
|
||||
return n, err
|
||||
}
|
||||
if err := os.Chmod(tmp, f.Mode); err != nil {
|
||||
os.Remove(tmp)
|
||||
return n, err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
os.Remove(tmp)
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func listBackups(dir string) error {
|
||||
files, err := backup.List(dir)
|
||||
if err != nil {
|
||||
@@ -290,6 +440,9 @@ func describe(w io.Writer, in *backup.Info, made time.Time) {
|
||||
fmt.Fprintf(w, " made\t%s (%s ago)\n", made.Local().Format("2006-01-02 15:04:05"), fmtDuration(time.Since(made)))
|
||||
}
|
||||
fmt.Fprintf(w, " contents\t%s, %s, %s (schema %d)\n", plural(in.Zones, "zone"), plural(in.Records, "record"), plural(in.Users, "user"), in.Schema)
|
||||
if in.SignedZones > 0 {
|
||||
fmt.Fprintf(w, " signed zones\t%d\n", in.SignedZones)
|
||||
}
|
||||
if in.LastChange != nil {
|
||||
fmt.Fprintf(w, " last change\t%s\n", in.LastChange.Local().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
+52
-5
@@ -27,6 +27,7 @@ import (
|
||||
"jarl/internal/config"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/server"
|
||||
"jarl/internal/stats"
|
||||
@@ -50,7 +51,7 @@ func rootCmd() *cobra.Command {
|
||||
Short: "Jarl: manage BIND (named) through a web UI, a REST API and a CLI",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version.Version,
|
||||
Version: version.String(),
|
||||
}
|
||||
def := os.Getenv("JARL_CONFIG")
|
||||
if def == "" {
|
||||
@@ -291,12 +292,21 @@ func serveCmd() *cobra.Command {
|
||||
// database closes, however serving ends.
|
||||
defer func() { cancel(); <-collectorDone }()
|
||||
}
|
||||
var pb *porkbun.Client
|
||||
if a.cfg.Porkbun.Enabled() {
|
||||
pb = &porkbun.Client{BaseURL: a.cfg.Porkbun.APIURL, APIKey: a.cfg.Porkbun.APIKey, Secret: a.cfg.Porkbun.SecretAPIKey}
|
||||
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
|
||||
a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
||||
}
|
||||
}
|
||||
srv := server.New(server.Config{
|
||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
||||
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
||||
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
|
||||
Version: version.Version, Stats: collector, Socket: a.cfg.Socket,
|
||||
Version: version.String(), Stats: collector, Socket: a.cfg.Socket,
|
||||
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
|
||||
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
||||
HTTPReq: server.HTTPReq{
|
||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||
@@ -313,7 +323,7 @@ func serveCmd() *cobra.Command {
|
||||
}
|
||||
}()
|
||||
go auditRetention(ctx, a)
|
||||
a.log.Info("jarld starting", "version", version.Version, "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
||||
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
||||
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
|
||||
sdNotify("STOPPING=1")
|
||||
a.log.Info("jarld stopped")
|
||||
@@ -464,7 +474,7 @@ Jarl starts empty. Either import your existing BIND configuration with
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&username, "user", "u", "admin", "administrator username")
|
||||
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the first administrator's username: best your own name, as each person should have their own account")
|
||||
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
|
||||
cmd.Flags().BoolVar(&withToken, "token", false, "also create an API token for the CLI")
|
||||
cmd.Flags().StringSliceVar(&nameservers, "nameservers", nil, "default name servers for new zones (e.g. ns1.example.com.)")
|
||||
@@ -610,6 +620,9 @@ func userCmd() *cobra.Command {
|
||||
if !u.HasPassword {
|
||||
flags = append(flags, "no password")
|
||||
}
|
||||
if u.HasTOTP() {
|
||||
flags = append(flags, "2fa")
|
||||
}
|
||||
var gs []string
|
||||
for _, g := range u.Grants {
|
||||
gs = append(gs, auth.FormatGrant(g))
|
||||
@@ -619,6 +632,40 @@ func userCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(add, passwd, token, list)
|
||||
totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
|
||||
totpReset := &cobra.Command{
|
||||
Use: "reset NAME",
|
||||
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
|
||||
Long: `Turns off a user's two-factor login and deletes their recovery codes, and
|
||||
logs them out. They log in with the password alone next time; if
|
||||
totp.require applies to them, they must then set it up again before doing
|
||||
anything else. Works with jarld stopped too.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.close()
|
||||
ctx := context.Background()
|
||||
u, err := a.store.GetUserByName(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("user %s: %w", args[0], err)
|
||||
}
|
||||
if !u.HasTOTP() && u.TOTPPending == "" {
|
||||
fmt.Printf("%s doesn't use two-factor login.\n", u.Username)
|
||||
return nil
|
||||
}
|
||||
if err := a.store.DisableTOTP(ctx, u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
a.store.DeleteUserSessions(ctx, u.ID, "")
|
||||
a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.totp_reset", "user", u.ID, u.Username, "success", "jarld user totp reset")
|
||||
fmt.Printf("Two-factor login of %s is off; they log in with the password and can set it up again under Account.\n", u.Username)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
totpCmd.AddCommand(totpReset)
|
||||
cmd.AddCommand(add, passwd, token, list, totpCmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
# Jarl: one database backup, run daily by jarl-backup.timer.
|
||||
# Jarl: one database backup (with the DNSSEC keys), run daily by
|
||||
# jarl-backup.timer.
|
||||
# Installed as /etc/systemd/system/jarl-backup.service (see
|
||||
# docs/operations.md#backups). What to keep, and where, is in the backup:
|
||||
# section of /etc/jarl/jarl.yaml; when it runs is in jarl-backup.timer.
|
||||
@@ -28,8 +29,12 @@ PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
PrivateNetwork=yes
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
# Read access to every file, and nothing else: named's DNSSEC private keys
|
||||
# (under /var/lib/bind/jarl/dnssec-keys) are readable only by named's user,
|
||||
# and the backup includes them. Everything outside the paths above stays
|
||||
# read-only (ProtectSystem=strict).
|
||||
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
|
||||
AmbientCapabilities=CAP_DAC_READ_SEARCH
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
|
||||
+149
-2
@@ -26,7 +26,27 @@ change something (anything but GET/HEAD) must send it as `X-CSRF-Token`.
|
||||
`POST /auth/logout` ends the session.
|
||||
|
||||
`GET /auth/whoami` returns the user, the token (if any), the effective
|
||||
permissions and whether the caller is an administrator.
|
||||
permissions and whether the caller is an administrator. For sessions it
|
||||
also has `totp` (the user's two-factor login: `enabled`, `required`,
|
||||
`recovery_codes_left`) and `totp_setup_required`.
|
||||
|
||||
**Two-factor login** ([permissions.md](permissions.md#two-factor-login)):
|
||||
for a user with it, `POST /auth/login` answers a correct password with
|
||||
`{"totp_required": true, "challenge": "..."}` and no cookie. Send
|
||||
`POST /auth/login/totp` with `{"challenge": "...", "code": "123456"}` (or
|
||||
a recovery code) within 5 minutes to get the session. Wrong codes answer
|
||||
401 `totp_invalid`; after 5, or 5 minutes, `login_expired` (start again).
|
||||
|
||||
Creating or rotating tokens, changing users and resetting someone's
|
||||
two-factor login need, in a session of a user with two-factor login, a
|
||||
code proved in the last 5 minutes. Otherwise they answer 403
|
||||
`totp_step_up`: send the request again with the current code in
|
||||
`X-Jarl-TOTP: 123456` (a wrong one gives 403 `totp_invalid`). Tokens and
|
||||
the local socket never need one.
|
||||
|
||||
When `totp.require` applies to a user who hasn't set it up, a session can
|
||||
only call `/auth/whoami`, `/auth/logout`, `/me/password` and `/me/totp...`;
|
||||
everything else answers 403 `totp_setup_required`.
|
||||
|
||||
**The local socket**: on jarld's unix socket (`/run/jarl/jarl.sock`),
|
||||
requests from root or jarld's own user need no `Authorization` header and
|
||||
@@ -144,6 +164,14 @@ may be the name.
|
||||
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
|
||||
| GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) |
|
||||
| GET | `/zones/{zone}/delegation` | checks the parent zone's delegation of a primary zone (below); `?resolver=IP` |
|
||||
| GET | `/zones/{zone}/dnssec` | the zone's DNSSEC keys, DS record and phase ([DNSSEC](#dnssec)) |
|
||||
| POST | `/zones/{zone}/dnssec/check-ds` | asks the parent for the zone's DS records and tells named; `?resolver=IP` |
|
||||
| POST | `/zones/{zone}/dnssec/wipe` | `{"force": false, "dry_run": false}`: signing off at once, keys deleted (administrators) |
|
||||
| GET | `/zones/{zone}/registrar` | name servers, glue and DS records at the registrar ([Registrar](#registrar-porkbun)) |
|
||||
| POST | `/zones/{zone}/registrar/glue` | create or update glue records |
|
||||
| PUT | `/zones/{zone}/registrar/ns` | set the domain's name servers |
|
||||
| POST | `/zones/{zone}/registrar/ds` | publish the zone's DS record |
|
||||
| DELETE | `/zones/{zone}/registrar/ds/{keytag}` | remove a DS record |
|
||||
| POST | `/zones/{zone}/import` | `{"text": "<zone file>", "mode": "merge"\|"replace", "use_soa": false, "dry_run": false}` |
|
||||
|
||||
ZoneInput:
|
||||
@@ -159,6 +187,7 @@ ZoneInput:
|
||||
"refresh": 3600, "retry": 900, "expire": 1209600, "minimum": 300},
|
||||
"default_ttl": 3600,
|
||||
"serial_policy": "date", // date, increment, unixtime
|
||||
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
|
||||
"comment": "",
|
||||
// create only:
|
||||
"nameservers": ["ns1.adbraun.lol."], // default: settings
|
||||
@@ -191,12 +220,114 @@ when called ([operations.md](operations.md#checking-name-servers-and-the-delegat
|
||||
`error` (with `error`). `source` is `NS record`, `also-notify` or
|
||||
`primaries`; `port` is set for a non-default port.
|
||||
|
||||
`/serials?stream=1` and `/delegation?stream=1` answer with
|
||||
newline-delimited JSON (`application/x-ndjson`) while they work, which is
|
||||
how the web UI shows a check's progress. Each DNS query is an event when it
|
||||
is sent and another when it is answered: `{"kind": "start", "id": 2,
|
||||
"query": "dig @192.0.2.1 example.org. SOA +norec"}`, then `{"kind": "end",
|
||||
"id": 2, "answer": "serial 2026100900", "rtt_ms": 12}`. `answer` sums the
|
||||
answer up (addresses, `referral to …`, `NXDOMAIN`, `no such record`);
|
||||
`error` replaces it when there was none. The serials check's SOA queries
|
||||
end with `result`, the `servers` entry, instead. ID 0 is `rndc zonestatus`,
|
||||
which asks this server's named. The last line is `{"kind": "done",
|
||||
"response": {...}}`, the endpoint's usual response.
|
||||
|
||||
`/delegation` returns `parent`, `resolver`, `delegated`, `parent_ns`,
|
||||
`jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`,
|
||||
`not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue`
|
||||
(`name`, `parent`, `jarl`, `match`), `servers` (as above, for the delegated
|
||||
name servers) and `findings`: `{"level": "ok"|"info"|"warning"|"error",
|
||||
"message": "..."}`. A zone that isn't delegated gets one `info` finding.
|
||||
`parent_ds` lists the zone's DS records at the parent, and the findings
|
||||
compare them with the zone's keys (a DS that matches no key, or a DS for a
|
||||
zone that isn't signed, is an `error`).
|
||||
|
||||
Zones in responses also carry `registrar` (`"porkbun"`) when Jarl can
|
||||
manage the domain at a registrar.
|
||||
|
||||
#### DNSSEC
|
||||
|
||||
Signing is the zone's `dnssec_policy` field, changed with `PATCH
|
||||
/zones/{zone}` like any other field, so it goes through validation,
|
||||
changesets and history. Jarl refuses unsafe steps there: `""` straight from
|
||||
a signed policy (use `"insecure"` first), `""` before named has finished
|
||||
removing the signatures, deleting a signed zone, or moving it to another
|
||||
view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)).
|
||||
|
||||
`GET /zones/{zone}/dnssec`:
|
||||
|
||||
```json
|
||||
{
|
||||
"zone": "adbraun.lol.", "view": "external", "type": "primary",
|
||||
"policy": "default",
|
||||
"key_dir": "/var/lib/bind/jarl/dnssec-keys/view-3",
|
||||
"keys": [{
|
||||
"tag": 51126, "algorithm": 13, "algorithm_name": "ECDSAP256SHA256", "role": "CSK",
|
||||
"dnskey": "adbraun.lol.\t3600\tIN\tDNSKEY\t257 3 13 ...",
|
||||
"ds": {"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "record": "adbraun.lol.\t3600\tIN\tDS\t51126 13 2 FFBE..."},
|
||||
"goal": "omnipresent", "dnskey_state": "rumoured", "ds_state": "hidden",
|
||||
"zone_rrsig_state": "rumoured", "key_rrsig_state": "rumoured",
|
||||
"created": "...", "published": "...", "active": "...", "publish_cds": "...",
|
||||
"files": ["Kadbraun.lol.+013+51126.key", "Kadbraun.lol.+013+51126.private", "Kadbraun.lol.+013+51126.state"]
|
||||
}],
|
||||
"phase": {
|
||||
"code": "signing", "summary": "named is signing the zone. ...", "next": "Wait until ...",
|
||||
"ds_ready_at": "...", "can_publish_ds": false, "can_finish": false
|
||||
},
|
||||
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
|
||||
"policies": ["default"],
|
||||
"parent_zone": "", // set when the parent zone is a primary zone in Jarl
|
||||
"registrar": "porkbun" // when the DS can be published through Jarl
|
||||
}
|
||||
```
|
||||
|
||||
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
|
||||
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
|
||||
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
|
||||
|
||||
`POST /dnssec/check-ds` (zones write) returns `parent` (the parent's
|
||||
servers with their `ds`, the union `ds`, `delegated`, `reached`,
|
||||
`consistent`, or `error`), `marked` (what named was told, `rndc dnssec
|
||||
-checkds`), `notes` and the updated `status`.
|
||||
|
||||
`POST /dnssec/wipe` (administrators) asks the parent first and answers
|
||||
`409` while it has a DS record for the zone, or while named still counts a
|
||||
removed one as cached; when the parent can't be asked, it needs `"force":
|
||||
true`. It returns `removed` (the files deleted) and `changeset_id`.
|
||||
|
||||
#### Registrar (Porkbun)
|
||||
|
||||
Administrators only, and only with a Porkbun key in jarl.yaml
|
||||
([dnssec.md](dnssec.md#porkbun)). `GET /zones/{zone}/registrar`:
|
||||
|
||||
```json
|
||||
{
|
||||
"registrar": "porkbun", "domain": "adbraun.lol",
|
||||
"ns": ["curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"], "jarl_ns": ["ns1.adbraun.lol", "ns2.adbraun.lol"],
|
||||
"ns_match": false,
|
||||
"glue": [{"host": "ns1.adbraun.lol", "registry": [], "jarl": ["203.0.113.1", "2001:db8::1"],
|
||||
"needed": true, "in_use": false, "action": "create"}],
|
||||
"ds": [{"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "matches": true}],
|
||||
"findings": [{"level": "warning", "message": "..."}],
|
||||
"error": "" // e.g. "Porkbun: ... (API_ACCESS_DISABLED)"; the rest is then empty
|
||||
}
|
||||
```
|
||||
|
||||
The changes take `{"force": false, "dry_run": false}` (`PUT /ns` also
|
||||
`"ns": [...]`, default the zone's NS records; `POST /ds` also `"key_tag"`)
|
||||
and answer:
|
||||
|
||||
```json
|
||||
{"done": ["create glue for ns1.adbraun.lol: 203.0.113.1, 2001:db8::1"], "dry_run": false,
|
||||
"checks": [{"level": "ok"|"info"|"warning"|"error", "message": "..."}], "warnings": []}
|
||||
```
|
||||
|
||||
Each change runs its checks first: the name servers answer for the zone,
|
||||
glue matches, the DS points at a key the zone has, and the domain is
|
||||
delegated to servers that serve the signed zone before a DS is published. An
|
||||
`error` check refuses the change with `409` unless `force` is set; a dry run
|
||||
returns the checks without changing anything. Errors from Porkbun are `502
|
||||
registrar_error`.
|
||||
|
||||
### Records
|
||||
|
||||
@@ -235,10 +366,20 @@ Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
|
||||
| POST | `/views` | `{"name": "internal", "match_clients": ["homelab", "localhost"], "match_destinations": [], "match_recursive_only": null, "comment": "", "adopt_zones": true}` |
|
||||
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
|
||||
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
|
||||
| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default |
|
||||
|
||||
`adopt_zones` moves zones that are not in a view into the new view. named
|
||||
requires every zone to be in a view as soon as one view exists.
|
||||
|
||||
`color` is how the web UI shows the view: one of `green`, `teal`,
|
||||
`purple`, `pink`, `orange`, `yellow`, `red` and `grey`. When it's left out,
|
||||
the view uses the link colour. It isn't part of the BIND configuration, so
|
||||
`PUT /views/{id}/color` applies at once and creates no changeset. It needs
|
||||
write access to the view, and it is logged in the audit log as
|
||||
`view.color`. To colour a view that so far exists only in a changeset,
|
||||
send the request with that changeset (`?changeset=` or
|
||||
`X-Jarl-Changeset`).
|
||||
|
||||
### ACLs and TSIG keys
|
||||
|
||||
| Method | Path | Body |
|
||||
@@ -354,13 +495,19 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|
||||
|--------|------|------|
|
||||
| GET | `/users` | admin |
|
||||
| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) |
|
||||
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled` |
|
||||
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
|
||||
| GET / POST | `/users/{id}/tokens` | admin (or yourself) |
|
||||
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
|
||||
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
|
||||
| DELETE | `/tokens/{id}` | revoke |
|
||||
| POST | `/tokens/{id}/rotate` | New secret, same token. Optional body `{"grace": "1h", "expires_in": "90d", "expires_at": "...", "no_expiry": false}`. The response's `token` field is the new secret, shown once; `previous_secret_valid_until` says when the old one stops. A restricted token may rotate itself and tokens no wider than itself |
|
||||
| POST | `/me/password` | `{"current_password": "...", "password": "..."}` (sessions only) |
|
||||
| GET | `/me/totp` | Your two-factor login: `enabled`, `enabled_at`, `required`, `recovery_codes_left`, `policy` |
|
||||
| POST | `/me/totp/setup` | `{"password": "..."}` (sessions only). A new secret: `secret`, `uri` (`otpauth://`), `qr` (an SVG `data:` URI), `issuer`, `account`. Not active until confirmed. With two-factor login already on (a new phone), it needs a recent code |
|
||||
| POST | `/me/totp/enable` | `{"code": "123456"}`: confirms the secret from setup. Returns `{"recovery_codes": [...]}`, shown once, and logs out your other sessions |
|
||||
| POST | `/me/totp/disable` | `{"password": "...", "code": "..."}` (a code or a recovery code). Refused (409) while `totp.require` applies to you |
|
||||
| POST | `/me/totp/recovery-codes` | New recovery codes, replacing the old ones (needs a recent code) |
|
||||
| DELETE | `/users/{id}/totp` | admin: turn off someone's two-factor login, delete their recovery codes and end their sessions |
|
||||
|
||||
User and token changes take effect immediately. They are not changesets,
|
||||
because they don't touch named, but they are audited.
|
||||
|
||||
+54
-4
@@ -44,8 +44,19 @@ jarl whoami
|
||||
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
|
||||
|
||||
Flags win over the environment, and the environment wins over the file. The
|
||||
file is `~/.config/jarl/config.yaml` (mode 0600), or `--config` /
|
||||
`JARL_CLI_CONFIG`. `jarl config show` prints it with the token masked.
|
||||
file is `jarl/config.yaml` in the user's configuration directory, or
|
||||
`--config` / `JARL_CLI_CONFIG`:
|
||||
|
||||
| System | File |
|
||||
|--------|------|
|
||||
| Linux and other Unix | `~/.config/jarl/config.yaml` (`$XDG_CONFIG_HOME/jarl/config.yaml` if set) |
|
||||
| macOS | `~/Library/Application Support/jarl/config.yaml` |
|
||||
| Windows | `%AppData%\jarl\config.yaml` (`C:\Users\<you>\AppData\Roaming\jarl\config.yaml`) |
|
||||
|
||||
It is written with mode 0600 (on Windows, the AppData folder is already
|
||||
private to the user). `jarl config show` prints its path and the settings,
|
||||
with the token masked. On Windows and macOS there is no local jarld, so
|
||||
always set a server.
|
||||
|
||||
Get tokens from the web UI (Account → API tokens), from
|
||||
`jarl token create`, or from `jarld init --token` on the server.
|
||||
@@ -139,7 +150,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Other error (bad arguments, connection failure, ...) |
|
||||
| 2 | A check found problems (`zone serials`, `zone delegation`); its report was printed |
|
||||
| 2 | A check found problems (`zone serials`, `zone delegation`, `zone dnssec check-ds`, `zone registrar status`), or a step can't be taken yet (`zone dnssec disable` before turning off has finished); the report was printed |
|
||||
| 3 | Not authenticated, or not allowed (token lacks the permission) |
|
||||
| 4 | Not found |
|
||||
| 5 | Conflict: concurrent change, object in use, drift |
|
||||
@@ -200,6 +211,37 @@ zone's delegation and glue against the zone, and calls a zone that isn't
|
||||
delegated (an internal-only zone) a note, not a problem. Both only run when
|
||||
you run them; see [operations.md](operations.md#checking-name-servers-and-the-delegation).
|
||||
|
||||
### DNSSEC and the registrar
|
||||
|
||||
```sh
|
||||
jarl zone dnssec status adbraun.lol --view external # keys, DS record, what's next
|
||||
jarl zone dnssec enable adbraun.lol --view external [--policy NAME]
|
||||
jarl zone dnssec check-ds adbraun.lol --view external # has the parent got the DS? tells named
|
||||
jarl zone dnssec disable adbraun.lol --view external # step 1: insecure; run again to finish
|
||||
jarl zone dnssec wipe adbraun.lol --view external [--force] # off at once, keys deleted
|
||||
|
||||
jarl zone registrar status adbraun.lol --view external # Porkbun: NS, glue, DS vs the zone
|
||||
jarl zone registrar sync-glue adbraun.lol --view external [--dry-run]
|
||||
jarl zone registrar set-ns adbraun.lol --view external [NS...] [--dry-run]
|
||||
jarl zone registrar publish-ds adbraun.lol --view external [--key TAG]
|
||||
jarl zone registrar remove-ds adbraun.lol 51126 --view external
|
||||
```
|
||||
|
||||
[dnssec.md](dnssec.md) explains the steps. `enable` is safe at any time:
|
||||
validation only starts once the zone's DS record is at the parent, and
|
||||
`status` says when to publish it. `disable` never makes a zone unsigned
|
||||
while the parent may still have its DS: its first run switches to BIND's
|
||||
insecure policy, and it finishes (exit 0) only when named is done (exit 2
|
||||
before). `wipe` switches off at once and deletes the keys, and refuses
|
||||
while the parent zone has a DS record for the zone; `--force` wipes when the
|
||||
parent can't be checked.
|
||||
|
||||
The `registrar` commands need a Porkbun API key in jarl.yaml and an
|
||||
administrator. Each change checks first that it won't break the domain and
|
||||
is refused otherwise: `--dry-run` shows the checks, `--force` skips them.
|
||||
(Here `--force` means that; elsewhere it overwrites files changed outside
|
||||
Jarl.)
|
||||
|
||||
## Records
|
||||
|
||||
```sh
|
||||
@@ -289,6 +331,7 @@ jarl view create external --match-clients any
|
||||
jarl view order internal external
|
||||
jarl view update external --match-clients "!homelab,any"
|
||||
jarl view delete old [--cascade]
|
||||
jarl view color internal green # web UI colour; "default" resets it
|
||||
|
||||
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
|
||||
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
||||
@@ -323,7 +366,9 @@ jarl user create alice --role dns-editor # asks for a password
|
||||
jarl user create ci --no-password --grant "records:write zones=lab.adbraun.lol"
|
||||
jarl user update alice --admin
|
||||
jarl user update alice --password # set a new password
|
||||
jarl user update bob --disabled # logs bob out, blocks his tokens
|
||||
jarl user update bob --disabled # logs bob out, blocks bob's tokens
|
||||
jarl user update admin --rename adam # rename (e.g. the first account, to your own name)
|
||||
jarl user totp reset alice # turn off alice's two-factor login (lost phone)
|
||||
jarl user delete bob -y
|
||||
|
||||
jarl token list [--user alice | --all] [--revoked]
|
||||
@@ -335,6 +380,11 @@ jarl token rotate laptop --save # new secret, same token
|
||||
jarl token rotate --current --save --grace 10m # the token this CLI uses
|
||||
jarl token revoke 7
|
||||
|
||||
# 'jarl user list' and 'jarl user show' include two-factor login (2FA: on,
|
||||
# off, or MISSING when totp.require applies and it isn't set up). Users set
|
||||
# it up themselves in the web UI (Account); the CLI's tokens never need a
|
||||
# code (permissions.md#two-factor-login).
|
||||
|
||||
jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100]
|
||||
jarl search 192.168.1.20
|
||||
jarl status # named, adoption, drift, open changesets, backups, expiring tokens, problems
|
||||
|
||||
+2
-4
@@ -40,8 +40,8 @@ renders the complete configuration from the database:
|
||||
`jarld adopt` turns `/etc/bind/named.conf` into a single
|
||||
`include "/etc/bind/jarl/named.conf";`, so named loads only what Jarl
|
||||
generates. Zone files go under `/var/lib/bind` because Debian's AppArmor
|
||||
profile lets named write there. That keeps journals and future DNSSEC
|
||||
signing working.
|
||||
profile lets named write there: its journals, the signed copies of zones
|
||||
it signs, and their keys (under `dnssec-keys/`, see [dnssec.md](dnssec.md)).
|
||||
|
||||
Because files are always regenerated, anything you edit by hand is
|
||||
overwritten. Jarl records a checksum of every file it writes and reports
|
||||
@@ -290,8 +290,6 @@ journal through jarld's log.
|
||||
* **Dynamic updates** (`allow-update`, `update-policy`): named would write
|
||||
to zone files that Jarl regenerates, so the validator rejects them on Jarl
|
||||
zones. Use the API (for example `jarl acme`) instead of nsupdate.
|
||||
* **DNSSEC signing**: not managed yet. The zone directory is
|
||||
named-writable, so `dnssec-policy` can be added later.
|
||||
* **Several servers from one jarld**: each jarld manages the named on its
|
||||
own host. A second server runs its own jarld with secondary zones, which
|
||||
copy the primary's zones by normal AXFR/IXFR (see "A secondary server" in
|
||||
|
||||
+26
-3
@@ -25,6 +25,8 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
|
||||
| `session.lifetime` | `12h` | A web session ends this long after login... |
|
||||
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
|
||||
| `session.cookie_secure` | `auto` | `auto` sets the cookie's Secure flag when the request came over HTTPS (directly or via a trusted proxy); `always` / `never` force it. |
|
||||
| `totp.require` | `none` | Who must use [two-factor login](permissions.md#two-factor-login): `none` (each user decides), `admins`, or `all` (every user with a password). Someone who must and hasn't set it up can only set it up after logging in. API tokens and the local socket are not affected. |
|
||||
| `totp.issuer` | `""` | The name authenticator apps show for the account. Empty means `Jarl <short hostname>`, so several servers are told apart. At most 64 characters, no `:`. Changing it doesn't affect apps already set up. |
|
||||
|
||||
Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
|
||||
|
||||
@@ -39,8 +41,8 @@ Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
|
||||
### Backups
|
||||
|
||||
`jarld backup` without a file name, which the `jarl-backup.timer` systemd
|
||||
timer runs daily, writes `jarl-<UTC time>.db` into `backup.dir` and then
|
||||
deletes old backups there. It reads these settings from this file each
|
||||
timer runs daily, writes `jarl-<UTC time>.db` into `backup.dir` (with the
|
||||
DNSSEC keys of signed zones inside it) and then deletes old backups there. It reads these settings from this file each
|
||||
time it runs; when it runs is the timer's (`systemctl edit
|
||||
jarl-backup.timer`). See [operations.md](operations.md#backups), also for
|
||||
restoring.
|
||||
@@ -91,9 +93,10 @@ jarld restarts.
|
||||
|-----|---------|---------|
|
||||
| `bind.main_config` | `/etc/bind/named.conf` | The file named loads. `jarld import` reads it, and `jarld adopt` replaces it with an include of Jarl's configuration. |
|
||||
| `bind.config_dir` | `/etc/bind/jarl` | Where Jarl writes `named.conf` and `keys.conf`. Must be readable by named. |
|
||||
| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`<view>/<zone>.zone`). named may write journals here. |
|
||||
| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`<view>/<zone>.zone`). named may write journals, signed copies of zones and, under `dnssec-keys/`, DNSSEC keys here. |
|
||||
| `bind.cache_dir` | `/var/cache/bind/jarl` | Where named stores secondary, stub and mirror zones. |
|
||||
| `bind.file_group` | `bind` | Group given to generated files and directories so named can read them. Empty leaves the group alone. |
|
||||
| `bind.named_user` | `bind` | The user named runs as. `jarld restore` gives it the DNSSEC keys it puts back. |
|
||||
| `bind.named_checkconf` | `/usr/bin/named-checkconf` | Used to check every candidate configuration (`-z` also loads the zones). |
|
||||
| `bind.named_checkzone` | `/usr/bin/named-checkzone` | Used to check zone files. |
|
||||
| `bind.rndc` | `/usr/sbin/rndc` | Used to reconfigure and reload named and to read zone serials. |
|
||||
@@ -111,6 +114,26 @@ there (`ReadWritePaths`). If you change them, update both
|
||||
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
|
||||
```
|
||||
|
||||
### Porkbun (registrar)
|
||||
|
||||
With an API key, Jarl can show and set a domain's name servers, glue
|
||||
records and DNSSEC DS records at Porkbun, after checking that the change
|
||||
won't break the domain ([dnssec.md](dnssec.md#porkbun)). Without a key
|
||||
(the default) nothing about Porkbun shows up.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
||||
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
||||
| `porkbun.domains` | `[]` | Only these domains (default: every primary zone the key can reach). |
|
||||
|
||||
The key can move your domain anywhere, so it is only ever read from this
|
||||
file: it isn't stored in the database or backups, and there is no API to
|
||||
set it. Keep the file readable only by root and jarld (`chown root:jarl
|
||||
jarl.yaml; chmod 640 jarl.yaml`, as the Debian package installs it); jarld
|
||||
warns at start when everyone can read it. At porkbun.com, switch on "API
|
||||
Access" for the domain, and limit the key to the domain if you can.
|
||||
|
||||
## Example: management network with TLS
|
||||
|
||||
```yaml
|
||||
|
||||
+96
-7
@@ -24,8 +24,10 @@ internal/
|
||||
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) and check, list, prune by the time in the name
|
||||
nscheck/ DNS queries to other name servers: zone serials and the parent's delegation
|
||||
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
|
||||
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
|
||||
@@ -110,10 +112,43 @@ 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, audit) and checks the answers with `dig`. `KEEP=1` leaves named
|
||||
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-<time>.tgz`. Once Jarl
|
||||
has adopted the configuration, running the script again needs
|
||||
`JARL_TEST_FORCE=1`. Debian 13 has BIND 9.20; Ubuntu has it from 25.04.
|
||||
|
||||
### The web UI
|
||||
|
||||
The UI is plain JavaScript modules under `internal/web/static/`, with no
|
||||
@@ -155,7 +190,8 @@ files are embedded at build time, so rebuild jarld after changes.
|
||||
## Releasing
|
||||
|
||||
1. Move the "Unreleased" section of `CHANGELOG.md` under
|
||||
`## [<version>] - <date>`.
|
||||
`## [<version>] - <date>`. (A release candidate may skip this; see
|
||||
below.)
|
||||
2. Put the version in `VERSION`.
|
||||
3. `make test && JARL_BIND_ROOT=... make e2e` (CI runs only `make test`;
|
||||
it has no BIND).
|
||||
@@ -168,9 +204,62 @@ amd64 and arm64) and runs `scripts/gitea-release.sh`. That creates the
|
||||
Gitea release (the CHANGELOG section as notes; the tarballs, the CLI for
|
||||
Linux, macOS and Windows, the `.deb`s and `SHA256SUMS` attached) and
|
||||
uploads the `.deb`s to the owner's Debian registry, distribution `stable`,
|
||||
component `main` ([install.md](install.md#from-the-package-registry)). A
|
||||
version with a hyphen (`1.0.0-rc.1`) becomes a pre-release; its packages
|
||||
get version `1.0.0~rc.1`, which apt sorts before `1.0.0`.
|
||||
component `main` ([install.md](install.md#from-the-package-registry)).
|
||||
|
||||
### Release candidates
|
||||
|
||||
A version with a hyphen (`0.2.0-rc.1`, tag `v0.2.0-rc.1`) is a
|
||||
pre-release:
|
||||
|
||||
* **On Gitea** it is marked "Pre-Release". Gitea doesn't count it as the
|
||||
latest release: the repository page, `/releases/latest` and the API's
|
||||
latest release keep pointing at the last stable one.
|
||||
* **The `.deb`s go to the `testing` distribution**, not `stable`. Their
|
||||
version is `0.2.0~rc.1`, which apt sorts after `0.1.0` but before
|
||||
`0.2.0`. So in `stable` an RC would reach every server on its next
|
||||
`apt upgrade`. Servers that follow only `stable` never see RCs. A test
|
||||
machine that also lists `testing` gets each RC, and then the final
|
||||
`0.2.0` from `stable`, which supersedes it.
|
||||
* **Release notes**: its own `## [0.2.0-rc.1]` section if there is one;
|
||||
otherwise the section of the version it leads up to (`## [0.2.0]`), or
|
||||
else `## [Unreleased]`. So you can tag RCs while the changes are still
|
||||
under Unreleased, and move them under `## [0.2.0]` for the real release.
|
||||
A stable release still needs its own section.
|
||||
|
||||
`scripts/gitea-release.sh notes` prints the notes a release would get
|
||||
(`VERSION=0.2.0-rc.1 scripts/gitea-release.sh notes`), and the workflow
|
||||
runs it as a check before building.
|
||||
|
||||
### Which version a build reports
|
||||
|
||||
The footer of the web UI, `jarld version`, `jarl --version`, `jarl status`
|
||||
and `jarl whoami` say what a binary was built from (the footer links to
|
||||
it on Gitea). Only a release build reports a version number:
|
||||
|
||||
* **A release**: `make` run on the commit tagged `v<VERSION>` with no
|
||||
tracked file changed. The Makefile then sets `RELEASE_TAG`, and the
|
||||
footer reads "Powered by Jarl v0.2.0", linking to the release. The
|
||||
release workflow sets `RELEASE_TAG` itself.
|
||||
* **Any other build** is a development build, named by its commit (which
|
||||
Go records from git) rather than `VERSION`: "Powered by Jarl
|
||||
1a2b3c4d5e6f", linking to the commit. With uncommitted changes it is
|
||||
marked `-modified`. The other places add where it stands relative to
|
||||
the last tag, from `git describe` (the Makefile's `DESCRIBE`):
|
||||
`dev 1a2b3c4d5e6f (3 commits after v0.2.0-rc.1)`, or
|
||||
`dev 1a2b3c4d5e6f-modified (v0.2.0-rc.1 with uncommitted changes)`.
|
||||
* **No git** (a build from a source archive): `VERSION`, marked as such:
|
||||
`0.2.0 (source build)`.
|
||||
|
||||
`RELEASE_TAG=v0.2.0 make` forces the first case.
|
||||
|
||||
`make deb` versions the packages the same way. A release gets `VERSION`
|
||||
with `~` for `-` (`0.2.0~rc.1`). A development build gets a version after
|
||||
the last tag: 3 commits past `v0.2.0-rc.1` is `0.2.0~rc.1+git3.gca0f148`,
|
||||
and uncommitted changes add the build time (`.m20261009153000`). apt sorts
|
||||
these after that RC and before the next one or `0.2.0`. So installing a
|
||||
newer build over an older one, or over the RC, is an upgrade: plain
|
||||
`apt install ./dist/…` works, without `--reinstall`. `make deb` removes
|
||||
older packages of the same architecture from `dist/` first.
|
||||
|
||||
Pushing the same tag again (`git tag -f`, `git push -f`) re-runs it, and
|
||||
the script replaces the attachments and packages, so a failed release can
|
||||
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
# DNSSEC signing
|
||||
|
||||
DNSSEC lets resolvers that validate (Cloudflare 1.1.1.1, Google 8.8.8.8,
|
||||
Quad9, many ISPs) detect forged answers for your domain. Jarl signs zones
|
||||
with BIND's own key and signing policy (`dnssec-policy`), and guides you
|
||||
through the steps that involve the parent zone, where getting the order
|
||||
wrong takes a domain offline.
|
||||
|
||||
- [Should you sign?](#should-you-sign)
|
||||
- [How it works](#how-it-works)
|
||||
- [Turning signing on](#turning-signing-on)
|
||||
- [Putting your own name servers on the internet](#putting-your-own-name-servers-on-the-internet)
|
||||
- [Porkbun](#porkbun)
|
||||
- [Turning signing off](#turning-signing-off)
|
||||
- [Wipe: a clean slate](#wipe-a-clean-slate)
|
||||
- [Keys, backups and moving servers](#keys-backups-and-moving-servers)
|
||||
- [What Jarl refuses, and why](#what-jarl-refuses-and-why)
|
||||
- [Phases](#phases)
|
||||
- [Custom policies](#custom-policies)
|
||||
- [Adopting a signed zone](#adopting-a-signed-zone)
|
||||
|
||||
## Should you sign?
|
||||
|
||||
Sign a zone that the internet sees through a delegation from its parent:
|
||||
your registered domain (`adbraun.lol`), as served to the outside world.
|
||||
Validation only happens once the parent zone (`lol`) publishes a DS
|
||||
record for the zone's key, which you add at your registrar.
|
||||
|
||||
Signing an internal-only zone (one no registrar delegates, or a split-DNS
|
||||
view only your own network reaches) adds nothing. There is no parent to
|
||||
publish a DS, and BIND doesn't validate the zones it serves itself.
|
||||
|
||||
**Split DNS.** In Jarl a zone belongs to a view, so you sign the external
|
||||
view's `adbraun.lol` and leave the internal view's copy unsigned. That is
|
||||
the usual setup, with one catch. Once the DS is at the parent, the world
|
||||
expects `adbraun.lol` to be signed. A device on your network that validates
|
||||
DNSSEC *itself* and gets the internal, unsigned copy rejects it. Examples
|
||||
are a Pi-hole or Unbound with DNSSEC on, systemd-resolved with
|
||||
`DNSSEC=yes`, and some routers. Devices that just ask this server are fine.
|
||||
If you run such a validator, sign the internal copy too and publish both
|
||||
DS records.
|
||||
|
||||
**What it costs.** It is one more way for the domain to break: if the DS at
|
||||
the parent stops matching the zone's keys (keys lost, wiped, or a server
|
||||
moved without them), every validating resolver rejects the whole domain.
|
||||
Most of Jarl's DNSSEC features exist to stop that from happening.
|
||||
|
||||
## How it works
|
||||
|
||||
named signs the zone itself, with *inline signing*:
|
||||
|
||||
- Jarl writes the zone file as before (`/var/lib/bind/jarl/<view>/<zone>.zone`).
|
||||
named keeps a signed copy next to it (`.signed`, `.signed.jnl`), signs
|
||||
every change Jarl makes, and re-signs before signatures expire.
|
||||
- The keys live in Jarl's key directory,
|
||||
`/var/lib/bind/jarl/dnssec-keys/<view>/`. The subdirectory is named after
|
||||
the view's ID, so renaming a view doesn't move the keys. Each key is
|
||||
three files, `K<zone>.+013+<tag>.key`, `.private` (readable only by
|
||||
named's user) and `.state`. Zones of the same name in two views have
|
||||
separate directories, because named requires that.
|
||||
- With BIND's `default` policy there is one key, a CSK (combined signing
|
||||
key) using ECDSA P-256 (algorithm 13). It is never replaced, so the DS
|
||||
record at the registrar never has to change.
|
||||
- The signed zone has its own serial, usually a little ahead of Jarl's.
|
||||
Jarl's reload check compares the serial of the file it wrote. The serials
|
||||
check (`jarl zone serials`) compares the signed serial, which is what
|
||||
secondaries serve.
|
||||
- Secondaries transfer the signed zone and need nothing else.
|
||||
- The server's clock must be right (NTP): signatures are valid for a fixed
|
||||
time window.
|
||||
|
||||
## Turning signing on
|
||||
|
||||
```sh
|
||||
jarl zone dnssec enable adbraun.lol --view external
|
||||
jarl zone dnssec status adbraun.lol --view external
|
||||
```
|
||||
|
||||
or, in the web UI, the zone's **DNSSEC** tab → *Turn signing on*. This is
|
||||
safe at any time. Resolvers keep treating the zone as unsigned until its
|
||||
DS record is at the parent.
|
||||
|
||||
`status` shows the key, the DS record, and when it is safe to publish:
|
||||
|
||||
```
|
||||
Status: named is signing the zone. Resolvers still treat it as unsigned, ...
|
||||
Next: Wait until Fri 9 Oct 21:13 UTC before adding the DS at the parent: ...
|
||||
|
||||
KEY ROLE ALGORITHM GOAL DNSKEY DS AT PARENT CREATED
|
||||
51126 CSK ECDSAP256SHA256 omnipresent rumoured hidden 2026-10-08 21:08
|
||||
|
||||
DS record for the parent zone (at your registrar):
|
||||
(not yet: wait until Fri, 09 Oct 2026 21:13:51 UTC)
|
||||
adbraun.lol. 3600 IN DS 51126 13 2 FFBECE3D...
|
||||
key tag 51126, algorithm 13 (ECDSAP256SHA256), digest type 2 (SHA-256), digest FFBECE3D...
|
||||
```
|
||||
|
||||
named first publishes the key for long enough that every resolver's cache
|
||||
has caught up: about a day with the default policy. Adding the DS earlier
|
||||
can make validating resolvers reject cached answers. Then:
|
||||
|
||||
1. **Make sure the domain is delegated to servers that serve the signed
|
||||
zone.** Your own servers, or secondaries that transfer from them.
|
||||
Publishing a DS while the registrar still points at other name servers
|
||||
(Porkbun's own, say) breaks the domain. `jarl zone delegation ZONE`
|
||||
shows where the parent points.
|
||||
2. **Publish the DS record at the registrar**, by hand (the DNSSEC tab has
|
||||
copy buttons for the whole record and for its four fields), or with
|
||||
Jarl's [Porkbun](#porkbun) support, which checks step 1 first.
|
||||
3. **Tell named.** Once the parent zone has the DS (minutes to hours after
|
||||
the registrar accepts it), run the DS check:
|
||||
|
||||
```sh
|
||||
jarl zone dnssec check-ds adbraun.lol --view external
|
||||
```
|
||||
|
||||
It asks every server of the parent zone for the zone's DS records, and
|
||||
tells named (`rndc dnssec -checkds`) when all of them have it. named
|
||||
waits for the parent's DS TTL before it counts the DS as in place.
|
||||
4. **Check.** `jarl zone delegation ZONE` now also compares the parent's
|
||||
DS with the zone's keys. A DS that matches no key is an error.
|
||||
[DNSViz](https://dnsviz.net/) gives an independent picture.
|
||||
|
||||
If the parent zone is itself a primary zone in Jarl (`home.adbraun.lol`
|
||||
inside `adbraun.lol`), the DNSSEC tab says so: add the DS as a record in
|
||||
that zone.
|
||||
|
||||
## Putting your own name servers on the internet
|
||||
|
||||
To serve your domain from your own server, the parent zone must delegate
|
||||
it to your server's *name* (NS records can't hold addresses). If that name
|
||||
is inside the domain itself (`ns1.adbraun.lol` for `adbraun.lol`),
|
||||
resolvers couldn't look it up without already reaching your server. **Glue
|
||||
records** solve this: the registry publishes `ns1.adbraun.lol`'s address
|
||||
together with the delegation. Each name server inside the domain needs its
|
||||
own glue record, with all of its addresses (IPv4 and IPv6).
|
||||
|
||||
The order matters:
|
||||
|
||||
1. In the zone (external view): NS records for your name servers, and A/AAAA
|
||||
records for the ones inside the domain.
|
||||
2. At the registrar: a glue record for each of those (Porkbun calls them
|
||||
"glue records", others "host records" or "child name servers").
|
||||
3. Check that every name server answers for the zone from the internet.
|
||||
4. At the registrar: set the domain's name servers.
|
||||
5. `jarl zone delegation ZONE` until it is all green. Resolvers may use the
|
||||
old name servers for a day or two after the change, so keep those
|
||||
serving the zone until then.
|
||||
|
||||
Jarl's [Porkbun](#porkbun) support does steps 2 to 4 with checks.
|
||||
|
||||
Before you go public:
|
||||
|
||||
- **Two name servers.** Registries and RFC 1034 expect at least two, on
|
||||
different networks: with one server at home, a power or internet outage
|
||||
takes the domain off the internet. A free secondary service (Hurricane
|
||||
Electric's dns.he.net, for example) or a small VPS transfers the zone
|
||||
from Jarl, signed zone included. A *hidden primary* goes one step
|
||||
further: your server feeds the secondaries but isn't in the NS records,
|
||||
so your home address isn't advertised.
|
||||
- **A dynamic home address** means the glue must follow it.
|
||||
- **Inbound port 53** (UDP and TCP) must reach the server: some ISPs block
|
||||
it, and CGNAT makes it impossible.
|
||||
|
||||
## Porkbun
|
||||
|
||||
With a Porkbun API key, Jarl manages a domain's name servers, glue records
|
||||
and DS records at Porkbun, and checks each change before making it.
|
||||
|
||||
**Setting it up**
|
||||
|
||||
1. At porkbun.com, *Account → API Access*: create a key. If you can,
|
||||
limit it to the domain and the permissions it needs (domains, read),
|
||||
and to your server's address.
|
||||
2. On the domain's page, switch **API Access** on (Porkbun refuses API
|
||||
calls for a domain until you do).
|
||||
3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld:
|
||||
|
||||
```yaml
|
||||
porkbun:
|
||||
api_key: pk1_...
|
||||
secret_api_key: sk1_...
|
||||
domains: [adbraun.lol] # optional: only these
|
||||
```
|
||||
|
||||
The key can move your domain anywhere, so it lives only in that file:
|
||||
never in Jarl's database or its backups, and there is no API or CLI command
|
||||
to upload it. Keep the file readable by root and jarld only (`chown
|
||||
root:jarl`, `chmod 640`, as the package installs it); jarld warns at start
|
||||
when everyone can read it. Only administrators can use the registrar
|
||||
features.
|
||||
|
||||
**Using it**: the zone's **Registrar** tab, or `jarl zone registrar`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `status ZONE` | Name servers, glue and DS records at Porkbun next to the zone's, with what doesn't match. |
|
||||
| `sync-glue ZONE` | Gives every name server inside the domain a glue record with the zone's A and AAAA records for it. Changing the glue of a name server the domain already uses sends resolvers to the new addresses, so Jarl checks first that they answer for the zone. |
|
||||
| `set-ns ZONE [NS...]` | Sets the domain's name servers (default: the zone's NS records). Checked first: each answers for the zone with the current serial, the ones inside the domain have matching glue, and DS records at Porkbun match the zone's keys (otherwise the domain goes dark for validating resolvers). |
|
||||
| `publish-ds ZONE` | Publishes the zone's DS record. Refused until named says the zone is ready, and unless the domain is delegated to the zone's name servers and they serve the signed zone. |
|
||||
| `remove-ds ZONE KEYTAG` | Removes a DS record (the step when turning signing off; also the fix for a DS that matches no key). |
|
||||
|
||||
Every change can be tried first with `--dry-run`, which shows the checks.
|
||||
Errors refuse the change; `--force` (in the web UI, a checkbox) overrides
|
||||
them, for example when this server can't reach its own public address to
|
||||
check it.
|
||||
|
||||
## Turning signing off
|
||||
|
||||
Never switch a signed zone straight to unsigned while its DS might still
|
||||
be at the parent: validating resolvers would reject the whole domain until
|
||||
the DS is gone *and* its TTL has passed (often a day). Jarl refuses that
|
||||
step and goes through BIND's `insecure` policy instead:
|
||||
|
||||
```sh
|
||||
jarl zone dnssec disable adbraun.lol --view external
|
||||
```
|
||||
|
||||
1. The zone switches to the `insecure` policy. named keeps it signed and
|
||||
publishes a CDS "delete" record. Nothing breaks.
|
||||
2. Remove the DS record at your registrar (`jarl zone registrar remove-ds`
|
||||
with Porkbun). Once the parent zone no longer has it, run `jarl zone
|
||||
dnssec check-ds` so named knows.
|
||||
3. named waits until resolvers have forgotten the DS (the parent's DS TTL
|
||||
plus a margin, about a day), then removes the keys and signatures.
|
||||
`status` shows the progress.
|
||||
4. Run `jarl zone dnssec disable` again (or *Finish turning off* in the web
|
||||
UI) when `status` says so: DNSSEC is then off for the zone.
|
||||
|
||||
If the DS never reached the parent, named skips the wait and you can
|
||||
finish at once. The old keys stay in the key directory (named keeps them
|
||||
retired; signing again makes a new key) until you wipe them.
|
||||
|
||||
## Wipe: a clean slate
|
||||
|
||||
```sh
|
||||
jarl zone dnssec wipe adbraun.lol --view external
|
||||
```
|
||||
|
||||
or *Wipe (clean slate)* on the DNSSEC tab. It switches signing off at once,
|
||||
without the insecure policy, and deletes the zone's keys and named's signed
|
||||
copy, so signing again starts from scratch. It also cleans up a zone that
|
||||
is off but still has old keys.
|
||||
|
||||
That is only safe when the parent zone has no DS record for the zone, so
|
||||
Jarl asks the parent's servers first and **refuses while they have one**.
|
||||
It also refuses while named still counts a recently removed DS as cached.
|
||||
When the parent can't be asked (no internet access, or a zone that isn't on
|
||||
the internet), `--force` wipes anyway. Wiping needs an administrator, and
|
||||
the web UI asks you to type the zone's name.
|
||||
|
||||
Deleting a zone that is signed is refused: turn signing off first.
|
||||
|
||||
## Keys, backups and moving servers
|
||||
|
||||
The zone's keys exist only in the key directory: Jarl's database doesn't
|
||||
hold them. Losing them while the parent has a DS for them breaks the
|
||||
domain.
|
||||
|
||||
- **Backups.** Every backup (`jarld backup`, and the daily
|
||||
`jarl-backup.timer`) stores the key directory's files inside the backup
|
||||
file, private keys included. Keep `/var/backups/jarl` private (it is
|
||||
`0700`). named's private keys are readable only by named's user, so
|
||||
`jarl-backup.service` runs with the one capability it needs to read them
|
||||
(`CAP_DAC_READ_SEARCH`); a manual backup must run as root. `--without-keys`
|
||||
backs up only the database.
|
||||
- **Restoring.** `jarld restore` puts the keys back, owned by named's user
|
||||
(`bind.named_user`), which needs root. Key files that exist but aren't in
|
||||
the backup are left alone.
|
||||
- **Serials after a restore.** A restored database may have older zone
|
||||
serials than named has. named won't load a signed zone's file unless its
|
||||
serial went up, and secondaries ignore a serial that went down. Jarl
|
||||
checks this whenever it writes a zone file, and raises the serial above
|
||||
named's when needed (it shows in the result, and in the audit log).
|
||||
- **Moving to a new server.** Restore the backup *before* named loads
|
||||
Jarl's configuration there. Otherwise named finds no keys and makes new
|
||||
ones, which the parent's DS doesn't match.
|
||||
|
||||
## What Jarl refuses, and why
|
||||
|
||||
| Change | Why |
|
||||
|---|---|
|
||||
| Switching a signed zone straight to unsigned | The parent may still have the DS: use `disable` (insecure first), or `wipe` when the parent has none. |
|
||||
| Finishing `disable` before named is done | Resolvers may still have the DS cached. |
|
||||
| Deleting a signed zone | Turn signing off first. |
|
||||
| Moving a signed zone to another view | It would get new keys (another key directory) that the DS doesn't match. |
|
||||
| A TTL above the policy's `max-zone-ttl` (a day by default) | named refuses to load such a signed zone and keeps serving the old one. |
|
||||
| `dnssec-policy` as a zone, view or server option | Jarl sets it per zone, so it can guard the steps. Top-level `dnssec-policy NAME { ... }` statements (custom policies) are fine. |
|
||||
| A custom policy with an empty `keys { }` list | It crashes named 9.20 when the configuration loads (named-checkconf accepts it). Use the built-in `insecure` policy. |
|
||||
| `inline-signing no` on a signed zone | Jarl rewrites zone files; named must keep its signatures in a separate copy. |
|
||||
| Signing a secondary zone | A secondary gets its signatures from its primary. |
|
||||
|
||||
## Phases
|
||||
|
||||
`status`, the DNSSEC tab and the API report a phase:
|
||||
|
||||
| Phase | Meaning | Next |
|
||||
|---|---|---|
|
||||
| `off` | Not signed. | |
|
||||
| `leftover_keys` | Not signed, but old keys are in the key directory. | Wipe them, or sign again. |
|
||||
| `starting` | Signing is on; named hasn't made the key yet. | Wait a moment. |
|
||||
| `signing` | Signed, but caches may not have the key yet. | Wait until the time shown, then publish the DS. |
|
||||
| `ready` | Safe to add the DS at the parent. | Publish it, then run the DS check. |
|
||||
| `ds_pending` | named knows the DS is at the parent and waits for its TTL. | |
|
||||
| `secure` | Signed, and the parent's DS points at the key. | |
|
||||
| `removing_ds` | Turning off; the DS must go from the parent first. | Remove it, then run the DS check. |
|
||||
| `ds_expiring` | The DS is gone; caches may still have it. | Wait. |
|
||||
| `removing_keys` | named removes the keys and signatures. | Finish when you like (it's safe now). |
|
||||
| `unsigned` | Turning off has finished. | Switch DNSSEC off for the zone. |
|
||||
|
||||
The key states come from named (`rndc dnssec -status ZONE` shows the same):
|
||||
*rumoured* means published, but caches may not have it yet; *omnipresent*
|
||||
means everyone has it; *unretentive* means removed, but caches may still
|
||||
have it; *hidden* means gone.
|
||||
|
||||
## Custom policies
|
||||
|
||||
`default` suits most domains. For anything else (scheduled key rollovers,
|
||||
NSEC3, other algorithms), define a policy as a top-level statement
|
||||
(`jarl option set dnssec-policy 'NAME { ... }' --toplevel`, or *Server
|
||||
options* in the web UI) and enable it with `jarl zone dnssec enable ZONE
|
||||
--policy NAME`. With a policy that rolls the key-signing key,
|
||||
the DS at the parent must change at every rollover: `status` and the DS
|
||||
check tell you when, and named waits for the DS check before it retires
|
||||
the old key.
|
||||
|
||||
## Adopting a signed zone
|
||||
|
||||
When `jarld import` finds a zone that named already signs, it imports its
|
||||
policy and warns. Before `jarld adopt`, copy the zone's key files
|
||||
(`K<zone>.+*`) from its old key directory into the one Jarl uses (`jarl
|
||||
zone dnssec status ZONE` shows it), owned by named's user. Otherwise named
|
||||
makes new keys, and if the parent has a DS for the old ones, the zone stops
|
||||
validating.
|
||||
+26
-9
@@ -60,6 +60,17 @@ sudo apt update
|
||||
sudo apt install jarl
|
||||
```
|
||||
|
||||
Release candidates (`0.2.0-rc.1`) are published to a separate `testing`
|
||||
distribution, so the `stable` line above only ever installs releases. To
|
||||
try release candidates on a test machine, add `testing` as well. apt then
|
||||
installs the newest of either, which is the RC until the final release
|
||||
replaces it:
|
||||
|
||||
```sh
|
||||
echo "deb [signed-by=/etc/apt/keyrings/jarl.asc] https://gitea.adbraun.lol/api/packages/adam/debian testing main" \
|
||||
| sudo tee -a /etc/apt/sources.list.d/jarl.list
|
||||
```
|
||||
|
||||
The packages are as visible as the Gitea account that owns them, not the
|
||||
repository: with a public account, apt needs no login even if the
|
||||
repository is private. For a private or limited account, give apt a token
|
||||
@@ -156,7 +167,7 @@ This installs:
|
||||
| `/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) |
|
||||
| `/etc/systemd/system/jarl-backup.service`, `jarl-backup.timer` | the daily database backup ([operations.md](operations.md#backups)) |
|
||||
| `/etc/systemd/system/jarl-backup.service`, `jarl-backup.timer` | the daily database backup, DNSSEC keys included ([operations.md](operations.md#backups)) |
|
||||
| `/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`, `/var/backups/jarl` |
|
||||
| `/etc/jarl/jarl.yaml` | example configuration (only if none exists) |
|
||||
@@ -230,12 +241,14 @@ tar czf /root/bind-before-jarl-$(date +%F).tgz /etc/bind /var/lib/bind /var/cach
|
||||
## 5. Create the database and the first administrator
|
||||
|
||||
```sh
|
||||
sudo -u jarl jarld init --token \
|
||||
sudo -u jarl jarld init --token -u adam \
|
||||
--nameservers ns1.adbraun.lol. --contact hostmaster@adbraun.lol
|
||||
```
|
||||
|
||||
It asks for the administrator's password (user `admin`, change it with
|
||||
`-u NAME`). `--token` also prints an API token for the CLI, shown only
|
||||
It asks for the administrator's password. Name the account after yourself
|
||||
(`-u`, default `admin`): each person should have their own account, with
|
||||
their own [two-factor login](permissions.md#two-factor-login), which you
|
||||
can turn on under Account once you have logged in. `--token` also prints an API token for the CLI, shown only
|
||||
once. `--nameservers` and `--contact` are the defaults for zones you create
|
||||
later, and you can change them in the UI or with `jarl settings set`.
|
||||
|
||||
@@ -322,7 +335,8 @@ jarl status
|
||||
jarl zone list
|
||||
```
|
||||
|
||||
The CLI's settings are stored in `~/.config/jarl/config.yaml` (mode 0600).
|
||||
The CLI's settings are stored in `~/.config/jarl/config.yaml` (mode 0600;
|
||||
macOS and Windows use their own folders, see [cli.md](cli.md#setup)).
|
||||
`JARL_SERVER` and `JARL_TOKEN` override them, which is handy in scripts.
|
||||
Create more tokens in the web UI (Account → API tokens) or with
|
||||
`jarl token create NAME`. See [cli.md](cli.md).
|
||||
@@ -373,12 +387,15 @@ sudo apt update && sudo apt upgrade # Debian packages from the registry
|
||||
make && sudo make install && sudo systemctl restart jarl # make install
|
||||
```
|
||||
|
||||
Database migrations run automatically at startup. Before an upgrade, take
|
||||
a backup (it is safe while jarld runs); `jarld restore` puts it back if
|
||||
needed:
|
||||
Database migrations run automatically at startup. An older Jarl refuses
|
||||
to start on a database a newer one has upgraded ("database schema version
|
||||
4 is newer than this Jarl"). So going back to an earlier version, after
|
||||
trying a release candidate say, means restoring a backup taken before the
|
||||
upgrade. Before an upgrade, take a backup (it is safe while jarld runs);
|
||||
`jarld restore` puts it back if needed:
|
||||
|
||||
```sh
|
||||
sudo -u jarl jarld backup
|
||||
sudo jarld backup
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
+38
-18
@@ -6,7 +6,8 @@
|
||||
|------|-----|
|
||||
| Is everything healthy? | `jarl status`, or the web UI dashboard: named reachable, adoption, drift, open changesets, configuration problems, the newest backup, tokens about to expire |
|
||||
| Are the secondaries up to date? | `jarl zone serials ZONE`, or the zone's Name servers tab ([below](#checking-name-servers-and-the-delegation)) |
|
||||
| Back up / restore | Daily by `jarl-backup.timer`; by hand `sudo -u jarl jarld backup`; restore with `jarld restore` ([below](#backups)) |
|
||||
| Back up / restore | Daily by `jarl-backup.timer`; by hand `sudo jarld backup`; restore with `jarld restore` ([below](#backups)) |
|
||||
| DNSSEC | `jarl zone dnssec status ZONE`, or the zone's DNSSEC tab ([dnssec.md](dnssec.md)) |
|
||||
| What changed and who did it? | `jarl history`, `jarl changeset show ID` (with diff), `jarl audit` |
|
||||
| Undo a change | `jarl changeset revert ID`, review it, then `jarl changeset apply NEW_ID` (or History → Revert in the UI) |
|
||||
| Check the configuration | `jarl check`: full validation plus `named-checkconf -z` |
|
||||
@@ -23,8 +24,10 @@ loaded. jarld is only needed to make changes.
|
||||
|
||||
Everything Jarl knows is in its database (`/var/lib/jarl/jarl.db`): zones,
|
||||
records, views, ACLs, TSIG keys, options, users, tokens, history and the
|
||||
audit log. named's files are generated from it, so the database is the one
|
||||
thing to back up.
|
||||
audit log. named's files are generated from it. The one exception is the
|
||||
DNSSEC keys of signed zones, which named keeps in Jarl's key directory
|
||||
(`/var/lib/bind/jarl/dnssec-keys`): every backup stores those too, inside the
|
||||
backup file ([dnssec.md](dnssec.md#keys-backups-and-moving-servers)).
|
||||
|
||||
### The daily backup
|
||||
|
||||
@@ -41,17 +44,19 @@ Each run:
|
||||
1. writes a consistent copy of the database with SQLite's `VACUUM INTO`
|
||||
(safe while jarld runs and changes things) to a temporary file in the
|
||||
backup directory;
|
||||
2. checks the copy with SQLite's `integrity_check`, and only then gives it
|
||||
2. stores the DNSSEC keys in it (every file of the key directory, private
|
||||
keys included);
|
||||
3. checks the copy with SQLite's `integrity_check`, and only then gives it
|
||||
its name, `jarl-<UTC time>.db`, e.g. `jarl-20261008T031512Z.db`. A file
|
||||
with that name is always a complete, intact backup;
|
||||
3. deletes backups older than `retention`, but always keeps the newest
|
||||
4. deletes backups older than `retention`, but always keeps the newest
|
||||
`keep_at_least`, however old they are. If backups stop for a while (the
|
||||
machine was off, the disk was full), the old ones aren't all deleted by
|
||||
the next run. Ages come from the time in the name, not the file's
|
||||
modification time, which copying the directory would change. Only files
|
||||
named `jarl-<time>.db` (and `stats-<time>.db`) are ever deleted, so
|
||||
anything else you put in the directory is safe;
|
||||
4. records the result in the database, so the dashboard and `jarl status`
|
||||
5. records the result in the database, so the dashboard and `jarl status`
|
||||
show when the newest backup was made, and whether the last attempt
|
||||
failed.
|
||||
|
||||
@@ -87,7 +92,9 @@ systemctl edit jarl-backup.timer
|
||||
|
||||
The backup service runs as the `jarl` user in a sandbox that may only
|
||||
write to `/var/lib/jarl` and `/var/backups/jarl` (created by
|
||||
`tmpfiles.d/jarl.conf`, mode 0700). To keep backups elsewhere, set
|
||||
`tmpfiles.d/jarl.conf`, mode 0700). It has one capability,
|
||||
`CAP_DAC_READ_SEARCH`, to read named's DNSSEC private keys, which only
|
||||
named's user may read. To keep backups elsewhere, set
|
||||
`backup.dir`, create the directory (owned by `jarl`, mode 0700) and allow
|
||||
it:
|
||||
|
||||
@@ -104,18 +111,23 @@ well.
|
||||
### By hand
|
||||
|
||||
```sh
|
||||
sudo -u jarl jarld backup # into backup.dir, as the timer does
|
||||
sudo -u jarl jarld backup /root/before-upgrade.db # one copy, never deleted automatically
|
||||
sudo jarld restore # list the backups in backup.dir
|
||||
sudo jarld backup # into backup.dir, as the timer does
|
||||
sudo jarld backup /root/before-upgrade.db # one copy, never deleted automatically
|
||||
sudo jarld restore # list the backups in backup.dir
|
||||
sudo systemctl start jarl-backup # or exactly what the timer runs
|
||||
```
|
||||
|
||||
Query statistics live in a separate database (`/var/lib/jarl/stats.db`).
|
||||
DNS doesn't need it, so it is backed up only with `backup.stats: true`, as
|
||||
`stats-<UTC time>.db`.
|
||||
|
||||
Run it as root (files it creates are given to `jarl`): named's DNSSEC
|
||||
private keys are readable only by named's user. As another user it fails
|
||||
when there are keys; `--without-keys` backs up only the database.
|
||||
|
||||
The database holds password and token *hashes*, and TSIG secrets in the
|
||||
clear. Backups are written with mode 0600 into a 0700 directory; keep any
|
||||
copies just as private.
|
||||
clear, and backups hold the DNSSEC private keys too. Backups are written
|
||||
with mode 0600 into a 0700 directory; keep any copies just as private.
|
||||
|
||||
### Restoring
|
||||
|
||||
@@ -138,7 +150,11 @@ changing anything (`--yes` skips the question). Then it:
|
||||
`jarl.db.before-restore-<UTC time>` (with its `-wal` and `-shm` files),
|
||||
so a restore can be undone the same way;
|
||||
* copies the backup in place, upgrades it to the current schema if it is
|
||||
older, and records the restore in its audit log.
|
||||
older, and records the restore in its audit log;
|
||||
* puts the DNSSEC keys stored in the backup back into the key directory,
|
||||
owned by named's user (`bind.named_user`) with the modes they had. Key
|
||||
files that are there but not in the backup are left alone.
|
||||
`--without-keys` skips this; without it, a backup with keys needs root.
|
||||
|
||||
Then start jarld and bring named in line with the restored database:
|
||||
|
||||
@@ -152,11 +168,14 @@ jarl resync --force # write everything and reload named
|
||||
Things to know:
|
||||
|
||||
* **Zone serials.** An older backup can carry lower zone serials than named
|
||||
last served. Your own named doesn't mind, but secondaries and caches
|
||||
ignore a serial that went backwards, so they keep the newer data. If you
|
||||
have secondaries, raise the serials after a restore:
|
||||
`jarl zone update ZONE --serial <higher than before>`, then check with
|
||||
`jarl zone serials ZONE`.
|
||||
last served. Secondaries ignore a serial that went backwards, and named
|
||||
refuses one for a signed zone. Jarl handles it: whenever it writes a zone
|
||||
file whose serial isn't above the one named has loaded, it raises the
|
||||
serial past named's (the resync result and the audit log show it). Check
|
||||
the secondaries afterwards with `jarl zone serials ZONE`.
|
||||
* **DNSSEC keys** must be back before named loads a signed zone, or named
|
||||
makes new keys that the parent's DS record doesn't match. On a new
|
||||
machine, restore before adopting.
|
||||
* **Tokens and passwords** go back to what they were when the backup was
|
||||
made: tokens created since are gone, and revoked ones work again. Rotate
|
||||
or revoke as needed.
|
||||
@@ -218,6 +237,7 @@ socket, without a token.
|
||||
| Forgot the admin password | `sudo jarl user update admin --password`, or (also with jarld stopped) `sudo -u jarl jarld user passwd admin` |
|
||||
| Locked out by the login rate limit | wait 15 minutes, or `systemctl restart jarl` (the limiter is in memory) |
|
||||
| Lost every admin account | `sudo -u jarl jarld user add --admin rescue` |
|
||||
| Lost the phone with the authenticator app, and the recovery codes | another administrator: Users → Reset 2FA, or `jarl user totp reset NAME`; on the server: `sudo -u jarl jarld user totp reset NAME` |
|
||||
| Need a token without the web UI | `sudo jarl token create cli --user admin`, or `sudo -u jarl jarld user token admin cli` |
|
||||
|
||||
## A secondary server
|
||||
|
||||
+94
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
| Who | How | Notes |
|
||||
|-----|-----|-------|
|
||||
| People in the web UI | Username + password → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. |
|
||||
| People in the web UI | Username + password (+ a code from an authenticator app, with [two-factor login](#two-factor-login)) → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. |
|
||||
| CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. |
|
||||
| lego / Traefik (`httpreq`) | HTTP Basic: the token's user and the API token | Only on `/api/v1/acme/httpreq/...`, only when `acme.httpreq.enabled` is set, and by default only over HTTPS ([acme.md](acme.md)). The token's grants apply as usual. |
|
||||
| Root on the server, with the CLI | jarld's local socket (`/run/jarl/jarl.sock`): no token | `sudo jarl ...` with no server configured. The kernel tells jarld which system user is connecting; root and the `jarl` user are administrators, anyone else must send a token. Audited as the system user (e.g. `root`) via `socket`. See [The local socket](#the-local-socket). |
|
||||
@@ -24,11 +24,100 @@ A user **without a password** (`--no-password`) is a service account. It
|
||||
can't log in to the web UI and is used only through its tokens, which suits
|
||||
certbot, CI jobs and backups.
|
||||
|
||||
## Two-factor login
|
||||
|
||||
Users can protect their web login with a second factor: a 6-digit code
|
||||
from an authenticator app on their phone (TOTP: Aegis, Google
|
||||
Authenticator, 1Password, Bitwarden and the like). A stolen password alone
|
||||
then gets nobody in.
|
||||
|
||||
**Setting it up** (Account → Two-factor login): enter your password, scan
|
||||
the QR code (or type the key shown under it), and enter the code the app
|
||||
shows. Only then is it on, so a bad scan can't lock you out. Jarl then
|
||||
shows **10 recovery codes**, once: save them in a password manager or print
|
||||
them. Each logs you in once instead of a code, for when the phone is lost.
|
||||
Turning it on logs out your other sessions.
|
||||
|
||||
**Logging in**: username and password first; the second screen asks for
|
||||
the code (or, under "Lost your phone?", a recovery code). The password
|
||||
step alone gives no session: it is good for 5 minutes and 5 tries. Codes
|
||||
from one 30-second step either side of now are accepted, for clocks that
|
||||
are a little off, and each code works only once. Wrong codes count
|
||||
against the same rate limit as wrong passwords.
|
||||
|
||||
**Actions that ask for a code again.** Some things would let a stolen
|
||||
session or password outlive the second factor, so with two-factor login
|
||||
they need a code from the last 5 minutes (logging in counts): creating
|
||||
or rotating API tokens, creating, changing or deleting users, and
|
||||
resetting someone's two-factor login. The web UI asks for a code when
|
||||
needed; API clients send it in the `X-Jarl-TOTP` header
|
||||
([api.md](api.md#authentication)).
|
||||
|
||||
**What it doesn't cover**: API tokens and the local socket. A token is
|
||||
already its own long random secret, and scripts can't answer a code
|
||||
prompt, which is why making one asks for a code. Root on the server can
|
||||
read the database anyway, so a code would add nothing there.
|
||||
|
||||
**Making it compulsory**: `totp.require` in jarl.yaml
|
||||
([configuration.md](configuration.md#web-ui-and-api)):
|
||||
|
||||
| `totp.require` | Who must use it |
|
||||
|--|--|
|
||||
| `none` (default) | nobody; each user decides |
|
||||
| `admins` | administrators |
|
||||
| `all` | every user with a password (service accounts can't log in anyway) |
|
||||
|
||||
A user who must use it and hasn't set it up yet can log in with the
|
||||
password, but then only sees "Set up two-factor login" until they do. They
|
||||
can't turn it off while it is required (moving to a new phone is "Move to
|
||||
a new phone" under Account). The setting lives in jarl.yaml rather than in
|
||||
the web UI on purpose: someone who steals an administrator's login can't
|
||||
switch it off. The dashboard and `jarl status` list users who must use it
|
||||
but haven't set it up.
|
||||
|
||||
**Lost phone**:
|
||||
|
||||
* With a recovery code: log in with it, then Account → Move to a new
|
||||
phone. New recovery codes replace the old ones (Account → New recovery
|
||||
codes; Jarl warns when 3 or fewer are left).
|
||||
* Without: an administrator resets it (Users → Reset 2FA, or `jarl user
|
||||
totp reset NAME`). That turns it off, deletes the recovery codes and
|
||||
logs the user out; they log in with the password and set it up again.
|
||||
* No administrator can log in: on the server, `sudo -u jarl jarld user
|
||||
totp reset NAME` (works with jarld stopped too).
|
||||
|
||||
Every setup, change, failed code, recovery code use and reset is in the
|
||||
audit log (`user.totp_*`, `auth.totp_failed`).
|
||||
|
||||
The secret is stored in the database, like TSIG secrets (it can't be
|
||||
hashed: the server needs it to work out the codes), so it is as safe as
|
||||
the database file and its backups (mode 0600, root and `jarl` only).
|
||||
Recovery codes are stored hashed.
|
||||
|
||||
### One account per person
|
||||
|
||||
`jarld init` creates the first administrator, `admin` unless you name it
|
||||
(`-u NAME`). If several people use that one account, nobody can tell from
|
||||
the audit log who did what, and it can't have two-factor login per person.
|
||||
So:
|
||||
|
||||
* **Just you**: rename it to your name (`jarl user update admin --rename
|
||||
adam`, or Users → Edit) and turn on two-factor login.
|
||||
* **Several people**: give each their own administrator account with their
|
||||
own two-factor login, then disable `admin` (`jarl user update admin
|
||||
--disabled`). The dashboard and `jarl status` point this out while an
|
||||
enabled `admin` exists next to other administrators.
|
||||
|
||||
Nothing is lost by disabling it: root on the server always gets back in
|
||||
([Recovering access](#recovering-access)), and Jarl refuses to disable,
|
||||
demote or delete the last enabled administrator.
|
||||
|
||||
## Grants
|
||||
|
||||
A user's permissions are a list of grants. Administrators (`admin: true`)
|
||||
may do everything, and only they can manage users, see everyone's tokens
|
||||
and run `resync`.
|
||||
may do everything, and only they can manage users, see everyone's tokens,
|
||||
run `resync`, wipe a zone's DNSSEC keys, and use the registrar (Porkbun,
|
||||
whose key can move the domain).
|
||||
|
||||
A grant is written as:
|
||||
|
||||
@@ -38,7 +127,7 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
|
||||
|
||||
| Resource | Covers |
|
||||
|----------|--------|
|
||||
| `zones` | zone definitions (create, delete, SOA, TTL, view) and zone options |
|
||||
| `zones` | zone definitions (create, delete, SOA, TTL, view, DNSSEC signing and the DS check) and zone options |
|
||||
| `records` | records in primary zones |
|
||||
| `views` | views and view options |
|
||||
| `acls` | ACLs |
|
||||
@@ -237,4 +326,5 @@ sudo -u jarl jarld user list
|
||||
sudo -u jarl jarld user passwd admin # set a new password and re-enable the account
|
||||
sudo -u jarl jarld user add --admin rescue # a new administrator
|
||||
sudo -u jarl jarld user token admin cli # a new API token for a user
|
||||
sudo -u jarl jarld user totp reset admin # turn off two-factor login (a lost phone)
|
||||
```
|
||||
+22
-1
@@ -74,11 +74,32 @@ showing them.
|
||||
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
|
||||
| `option_syntax`, `option_value_invalid` | error | The value must parse (balanced braces, strings, the grammar's value type: yes/no, enums, numbers, durations, sizes, address match lists) |
|
||||
| `option_duplicate` | error | Options that may appear once, appear once |
|
||||
| `option_managed` | error | Statements Jarl manages itself can't be raw options: `zone`, `view`, `acl`, `key`, `options`, a zone's `type`/`file`/`in-view`, a view's `match-*` |
|
||||
| `option_managed` | error | Statements Jarl manages itself can't be raw options: `zone`, `view`, `acl`, `key`, `options`, a zone's `type`/`file`/`in-view`/`key-directory`, a view's `match-*`, and `dnssec-policy` in zones, views and server options (set per zone instead; top-level `dnssec-policy NAME { }` definitions are options) |
|
||||
| `option_deprecated`, `option_obsolete`, `option_unavailable` | warning | Flags from the BIND 9.20 reference |
|
||||
| `option_experimental` | info | |
|
||||
| `settings_invalid` | error | Defaults for new zones are valid |
|
||||
|
||||
## DNSSEC
|
||||
|
||||
These rules come from [dnssec.md](dnssec.md#what-jarl-refuses-and-why).
|
||||
Those that compare the change with the current state are checked when it is
|
||||
applied.
|
||||
|
||||
| Code | Severity | Rule |
|
||||
|------|----------|------|
|
||||
| `dnssec_zone_type` | error | Only primary zones are signed by Jarl |
|
||||
| `dnssec_policy_unknown` | error | The zone's policy must be `default`, `insecure` or a top-level `dnssec-policy` statement |
|
||||
| `dnssec_policy_no_keys` | error | A custom policy with an empty `keys { }` list (named 9.20 crashes loading it) |
|
||||
| `dnssec_ttl_too_long` | error | TTLs (and the default TTL) of a signed zone must be at most the policy's `max-zone-ttl` (a day for the built-in policies); named refuses to load the zone otherwise |
|
||||
| `dnssec_inline_signing` | error | No `inline-signing no` on a signed zone |
|
||||
| `dnssec_off_unsafe` | error | A signed zone can't go straight to unsigned: use the `insecure` policy first (or wipe, when the parent has no DS) |
|
||||
| `dnssec_off_too_early`, `dnssec_off_unknown` | error | Switching off after `insecure` waits until named is done removing the signatures |
|
||||
| `dnssec_delete_signed` | error | A signed zone can't be deleted; turn signing off first |
|
||||
| `dnssec_keys_moved` | error | A signed zone can't move to another view (its keys would change) |
|
||||
| `dnssec_policy_change` | warning | Changing the policy may replace the keys, and with them the DS at the parent |
|
||||
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
||||
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
||||
|
||||
## Security lints
|
||||
|
||||
| Code | Severity | What it catches |
|
||||
|
||||
@@ -130,5 +130,10 @@ On the server, `dig -b <source address>` picks which view you test.
|
||||
`jarl view list` shows it.
|
||||
* Deleting a view is refused while it still has zones, unless you pass
|
||||
`--cascade`.
|
||||
* Give each view a colour (Views → Edit → Colour, or
|
||||
`jarl view color internal green`) to tell the zones of each view apart
|
||||
in the zone list. The colour only affects the web UI, so it applies at
|
||||
once, with no changeset or deploy. `jarl view color internal default`
|
||||
goes back to the link colour.
|
||||
* Zone files are stored per view: `/var/lib/bind/jarl/internal/adbraun.lol.zone`,
|
||||
`/var/lib/bind/jarl/external/adbraun.lol.zone`.
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/term v0.46.0
|
||||
modernc.org/sqlite v1.60.1
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -72,3 +72,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
+197
-1
@@ -6,8 +6,11 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/version"
|
||||
)
|
||||
|
||||
// Version is the API path prefix.
|
||||
@@ -21,6 +24,9 @@ type Zone struct {
|
||||
Records int `json:"record_count"`
|
||||
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
|
||||
RName string `json:"rname_email,omitempty"`
|
||||
// Registrar names the registrar Jarl can manage the domain at
|
||||
// ("porkbun"), when one is configured for it.
|
||||
Registrar string `json:"registrar,omitempty"`
|
||||
// Pending is set when listing with a changeset: add, change or delete.
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
@@ -41,7 +47,13 @@ type Record struct {
|
||||
// View is a view with its zone count.
|
||||
type View struct {
|
||||
model.View
|
||||
Zones int `json:"zone_count"`
|
||||
Zones int `json:"zone_count"`
|
||||
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
|
||||
}
|
||||
|
||||
// ViewColor sets the colour the web UI shows a view in.
|
||||
type ViewColor struct {
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// ACL is an ACL with the objects that use it.
|
||||
@@ -68,6 +80,9 @@ type Option struct {
|
||||
type User struct {
|
||||
model.User
|
||||
GrantStrings []string `json:"grant_strings"`
|
||||
// TOTPRequired says that totp.require in jarl.yaml makes two-factor
|
||||
// login compulsory for the user.
|
||||
TOTPRequired bool `json:"totp_required,omitempty"`
|
||||
}
|
||||
|
||||
// Token is an API token. Secret is only set when the token is created.
|
||||
@@ -86,6 +101,69 @@ type Whoami struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
Admin bool `json:"admin"`
|
||||
Version string `json:"version"`
|
||||
// TOTP is the user's two-factor login (sessions only).
|
||||
TOTP *TOTPStatus `json:"totp,omitempty"`
|
||||
// TOTPSetupRequired: the user must set up two-factor login before
|
||||
// doing anything else (totp.require).
|
||||
TOTPSetupRequired bool `json:"totp_setup_required,omitempty"`
|
||||
// Build is what the server was built from (release or commit).
|
||||
Build version.Build `json:"build"`
|
||||
}
|
||||
|
||||
// LoginChallenge answers a correct password of a user with two-factor
|
||||
// login: send a code with the challenge to POST /auth/login/totp.
|
||||
type LoginChallenge struct {
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
|
||||
// LoginTOTP is the second login step.
|
||||
type LoginTOTP struct {
|
||||
Challenge string `json:"challenge"`
|
||||
// Code is the current code from the authenticator app, or a recovery
|
||||
// code.
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// TOTPStatus is a user's two-factor login (GET /me/totp).
|
||||
type TOTPStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EnabledAt *time.Time `json:"enabled_at,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
RecoveryCodesLeft int `json:"recovery_codes_left"`
|
||||
// Policy is totp.require: none, admins or all.
|
||||
Policy string `json:"policy"`
|
||||
}
|
||||
|
||||
// TOTPSetupRequest starts setting up two-factor login.
|
||||
type TOTPSetupRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// TOTPSetup is a new secret to put into an authenticator app: QR is the
|
||||
// QR code of URI, as an image data: URI.
|
||||
type TOTPSetup struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
QR string `json:"qr"`
|
||||
Issuer string `json:"issuer"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
// TOTPCode confirms a setup.
|
||||
type TOTPCode struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// TOTPDisable turns two-factor login off.
|
||||
type TOTPDisable struct {
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// RecoveryCodes are new recovery codes, shown once.
|
||||
type RecoveryCodes struct {
|
||||
Codes []string `json:"recovery_codes"`
|
||||
}
|
||||
|
||||
// LoginRequest logs in.
|
||||
@@ -150,6 +228,7 @@ type Changeset struct {
|
||||
// StatusResponse is GET /status.
|
||||
type StatusResponse struct {
|
||||
Version string `json:"version"`
|
||||
Build version.Build `json:"build"`
|
||||
Engine json.RawMessage `json:"engine"`
|
||||
Time time.Time `json:"time"`
|
||||
// ExpiringTokens are tokens that expire within TokenExpiryWarning or
|
||||
@@ -158,6 +237,21 @@ type StatusResponse struct {
|
||||
ExpiringTokens []Token `json:"expiring_tokens"`
|
||||
// Backup is the state of database backups (administrators only).
|
||||
Backup *BackupStatus `json:"backup,omitempty"`
|
||||
// TOTP is about two-factor login across users (administrators only).
|
||||
TOTP *TOTPOverview `json:"totp,omitempty"`
|
||||
}
|
||||
|
||||
// TOTPOverview is what the dashboard reports about two-factor login.
|
||||
type TOTPOverview struct {
|
||||
// Policy is totp.require: none, admins or all.
|
||||
Policy string `json:"policy"`
|
||||
// Missing are enabled users who must use two-factor login and
|
||||
// haven't set it up (they can do nothing else until they do).
|
||||
Missing []string `json:"missing"`
|
||||
// SharedAdmin is set when an enabled administrator called "admin"
|
||||
// (the usual first account) exists next to other enabled
|
||||
// administrators: it is probably shared, and worth disabling.
|
||||
SharedAdmin string `json:"shared_admin,omitempty"`
|
||||
}
|
||||
|
||||
// TokenExpiresHeader is sent with responses to a token that expires
|
||||
@@ -267,3 +361,105 @@ type DelegationResponse struct {
|
||||
Notes []string `json:"notes"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
// DNSSECResponse is GET /zones/{zone}/dnssec.
|
||||
type DNSSECResponse struct {
|
||||
Zone string `json:"zone"`
|
||||
View string `json:"view,omitempty"`
|
||||
Type string `json:"type"`
|
||||
*dnssec.Status
|
||||
// ParentZone is set when the parent zone is a primary zone in Jarl
|
||||
// (in the same view): the DS record then goes there, as a record.
|
||||
ParentZone string `json:"parent_zone,omitempty"`
|
||||
// Registrar names the registrar Jarl can publish the DS at
|
||||
// ("porkbun"), when one is configured in jarl.yaml.
|
||||
Registrar string `json:"registrar,omitempty"`
|
||||
}
|
||||
|
||||
// CheckDSResponse is POST /zones/{zone}/dnssec/check-ds: what the parent
|
||||
// zone has, and what named was told.
|
||||
type CheckDSResponse struct {
|
||||
Parent *nscheck.ParentDSReport `json:"parent"`
|
||||
Marked []string `json:"marked"`
|
||||
Notes []string `json:"notes"`
|
||||
Status *dnssec.Status `json:"status"`
|
||||
}
|
||||
|
||||
// DNSSECWipe is POST /zones/{zone}/dnssec/wipe.
|
||||
type DNSSECWipe struct {
|
||||
// Force wipes although the parent zone's DS records couldn't be
|
||||
// checked, or named still counts a removed DS as cached.
|
||||
Force bool `json:"force,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// DNSSECWipeResponse is the result of a wipe.
|
||||
type DNSSECWipeResponse struct {
|
||||
Removed []string `json:"removed"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
ChangesetID int64 `json:"changeset_id,omitempty"`
|
||||
Parent *nscheck.ParentDSReport `json:"parent,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarResponse is GET /zones/{zone}/registrar: the domain's
|
||||
// delegation at the registrar, next to what the zone says.
|
||||
type RegistrarResponse struct {
|
||||
Registrar string `json:"registrar"` // "porkbun"
|
||||
Domain string `json:"domain"`
|
||||
// NS are the name servers at the registry, JarlNS those in the
|
||||
// zone's NS records (both without trailing dots).
|
||||
NS []string `json:"ns"`
|
||||
JarlNS []string `json:"jarl_ns"`
|
||||
NSMatch bool `json:"ns_match"`
|
||||
// Glue covers every glue record at the registry and every name server
|
||||
// inside the zone that needs one.
|
||||
Glue []GlueStatus `json:"glue"`
|
||||
// DS are the DS records at the registry.
|
||||
DS []RegistrarDS `json:"ds"`
|
||||
Findings []nscheck.Finding `json:"findings"`
|
||||
// Error is set when the registrar couldn't be asked; the rest is
|
||||
// then empty.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GlueStatus compares a glue record at the registry with the zone.
|
||||
type GlueStatus struct {
|
||||
Host string `json:"host"`
|
||||
Registry []string `json:"registry"` // addresses at the registry (none: no glue record)
|
||||
Jarl []string `json:"jarl"` // the zone's A and AAAA records for the host
|
||||
// Needed: a name server in the zone's NS records that is inside the
|
||||
// domain, so resolvers need its glue. InUse: in the registry's NS.
|
||||
Needed bool `json:"needed"`
|
||||
InUse bool `json:"in_use"`
|
||||
// Action is what syncing the glue does: "create", "update" or "".
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarDS is a DS record at the registry.
|
||||
type RegistrarDS struct {
|
||||
porkbun.DS
|
||||
// Matches: it points at a key named publishes for the zone.
|
||||
Matches bool `json:"matches"`
|
||||
}
|
||||
|
||||
// RegistrarChange asks for a change at the registrar.
|
||||
type RegistrarChange struct {
|
||||
// NS (set-ns) are the name servers to set (default: the zone's NS
|
||||
// records).
|
||||
NS []string `json:"ns,omitempty"`
|
||||
// KeyTag (publish DS) picks the key (default: every key that needs
|
||||
// one).
|
||||
KeyTag uint16 `json:"key_tag,omitempty"`
|
||||
// Force skips the checks that the change won't break the domain.
|
||||
Force bool `json:"force,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarChangeResponse is the result of a change at the registrar.
|
||||
type RegistrarChangeResponse struct {
|
||||
// Done lists what was changed (or would be, in a dry run).
|
||||
Done []string `json:"done"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Checks []nscheck.Finding `json:"checks"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
+144
-3
@@ -59,9 +59,11 @@ func parse(name string) (kind string, t time.Time, ok bool) {
|
||||
}
|
||||
|
||||
// Write makes a consistent copy of the live SQLite database src as
|
||||
// dir/Name(kind, now), and checks the copy before giving it its final
|
||||
// name: a file with a backup's name is always a complete, intact backup.
|
||||
func Write(ctx context.Context, src, dir, kind string, now time.Time) (*File, error) {
|
||||
// dir/Name(kind, now), stores the files under filesDir in it (the DNSSEC
|
||||
// keys; "" for none, see AddFiles), and checks the copy before giving it
|
||||
// its final name: a file with a backup's name is always a complete,
|
||||
// intact backup.
|
||||
func Write(ctx context.Context, src, dir, kind string, now time.Time, filesDir string) (*File, error) {
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,6 +80,12 @@ func Write(ctx context.Context, src, dir, kind string, now time.Time) (*File, er
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if filesDir != "" {
|
||||
if _, err := AddFiles(ctx, tmp, filesDir); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := Verify(ctx, tmp); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, fmt.Errorf("the new backup failed its check: %w", err)
|
||||
@@ -144,6 +152,130 @@ func openCopy(path string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite", "file:"+escape(path)+"?mode=ro&immutable=1")
|
||||
}
|
||||
|
||||
// FilesTable holds the files a backup stores next to the database. It
|
||||
// exists only in backups: 'jarld restore' writes the files back and drops
|
||||
// the table.
|
||||
const FilesTable = "backup_files"
|
||||
|
||||
// StoredFile is a file stored in a backup.
|
||||
type StoredFile struct {
|
||||
// Path is relative to the directory the files came from (Root).
|
||||
Path string
|
||||
Mode os.FileMode
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// AddFiles stores every regular file under root in the backup database
|
||||
// at path (the DNSSEC keys named keeps under Jarl's key directory,
|
||||
// private keys included). A missing root stores nothing. Files that can't
|
||||
// be read are an error: named's private keys are readable only by named's
|
||||
// user, so the backup must run as root or with CAP_DAC_READ_SEARCH (as
|
||||
// jarl-backup.service does).
|
||||
func AddFiles(ctx context.Context, path, root string) (int, error) {
|
||||
var files []StoredFile
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && p == root {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
files = append(files, StoredFile{Path: rel, Mode: fi.Mode().Perm(), Data: b})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
return 0, fmt.Errorf("reading the DNSSEC keys: %w (named's private keys are readable only by its own user: back up as root, or through jarl-backup.service)", err)
|
||||
}
|
||||
return 0, fmt.Errorf("reading the DNSSEC keys: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+escape(path)+"?mode=rw")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer db.Close()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `CREATE TABLE `+FilesTable+` (root TEXT NOT NULL, path TEXT NOT NULL, mode INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (root, path))`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, f := range files {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO `+FilesTable+` (root, path, mode, data) VALUES (?, ?, ?, ?)`, root, f.Path, int(f.Mode), f.Data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(files), tx.Commit()
|
||||
}
|
||||
|
||||
// Files returns the files stored in a backup and the directory they came
|
||||
// from ("" and none for a backup without files).
|
||||
func Files(ctx context.Context, path string) (string, []StoredFile, error) {
|
||||
db, err := openCopy(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
if !hasTable(ctx, db, FilesTable) {
|
||||
return "", nil, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT root, path, mode, data FROM `+FilesTable+` ORDER BY path`)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var root string
|
||||
var out []StoredFile
|
||||
for rows.Next() {
|
||||
var f StoredFile
|
||||
var mode int
|
||||
if err := rows.Scan(&root, &f.Path, &mode, &f.Data); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
f.Mode = os.FileMode(mode).Perm()
|
||||
out = append(out, f)
|
||||
}
|
||||
return root, out, rows.Err()
|
||||
}
|
||||
|
||||
func hasTable(ctx context.Context, db *sql.DB, name string) bool {
|
||||
var n int
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// DropFiles removes the stored files from a database restored from a
|
||||
// backup, and vacuums it so the private keys don't linger in free pages.
|
||||
func DropFiles(ctx context.Context, path string) error {
|
||||
db, err := sql.Open("sqlite", "file:"+escape(path)+"?mode=rw")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if !hasTable(ctx, db, FilesTable) {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE `+FilesTable); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(ctx, `VACUUM`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Info describes the contents of a configuration database (KindJarl).
|
||||
type Info struct {
|
||||
Schema int `json:"schema"`
|
||||
@@ -151,6 +283,10 @@ type Info struct {
|
||||
Records int `json:"records"`
|
||||
Users int `json:"users"`
|
||||
LastChange *time.Time `json:"last_change,omitempty"`
|
||||
// KeyFiles is the number of DNSSEC key files stored in a backup, and
|
||||
// SignedZones how many zones were signed.
|
||||
KeyFiles int `json:"key_files"`
|
||||
SignedZones int `json:"signed_zones"`
|
||||
}
|
||||
|
||||
// Inspect reads a configuration database's schema version and size. It
|
||||
@@ -168,6 +304,11 @@ func Inspect(ctx context.Context, path string) (*Info, error) {
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM zones`).Scan(&in.Zones)
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM records`).Scan(&in.Records)
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&in.Users)
|
||||
if hasTable(ctx, db, FilesTable) {
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM `+FilesTable).Scan(&in.KeyFiles)
|
||||
}
|
||||
// dnssec_policy exists from schema 3 on.
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM zones WHERE dnssec_policy != ''`).Scan(&in.SignedZones)
|
||||
var last sql.NullString
|
||||
db.QueryRowContext(ctx, `SELECT max(applied_at) FROM changesets`).Scan(&last)
|
||||
if t, err := time.Parse(time.RFC3339Nano, last.String); err == nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +27,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
src := newDB(t)
|
||||
dir := filepath.Join(t.TempDir(), "backups")
|
||||
now := time.Date(2026, 10, 8, 3, 15, 12, 0, time.UTC)
|
||||
f, err := Write(ctx, src, dir, KindJarl, now)
|
||||
f, err := Write(ctx, src, dir, KindJarl, now, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -44,7 +45,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
t.Errorf("inspect: %+v %v", in, err)
|
||||
}
|
||||
// The same second again is refused rather than overwritten.
|
||||
if _, err := Write(ctx, src, dir, KindJarl, now); err == nil {
|
||||
if _, err := Write(ctx, src, dir, KindJarl, now, ""); err == nil {
|
||||
t.Error("overwrote a backup")
|
||||
}
|
||||
// No temporary files are left.
|
||||
@@ -54,7 +55,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
}
|
||||
// A missing database is an error, and isn't created.
|
||||
missing := filepath.Join(t.TempDir(), "nope.db")
|
||||
if _, err := Write(ctx, missing, dir, KindJarl, now.Add(time.Second)); err == nil {
|
||||
if _, err := Write(ctx, missing, dir, KindJarl, now.Add(time.Second), ""); err == nil {
|
||||
t.Error("backed up a missing database")
|
||||
}
|
||||
if _, err := os.Stat(missing); err == nil {
|
||||
@@ -149,3 +150,52 @@ func TestPruneTempFiles(t *testing.T) {
|
||||
t.Error("a running backup's temporary file was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := newDB(t)
|
||||
keys := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(keys, "view-3"), 0o755)
|
||||
os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.key"), []byte("public"), 0o644)
|
||||
os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), []byte("private-key-material-xyz"), 0o600)
|
||||
dir := t.TempDir()
|
||||
f, err := Write(ctx, src, dir, KindJarl, time.Now(), keys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, files, err := Files(ctx, f.Path)
|
||||
if err != nil || root != keys || len(files) != 2 {
|
||||
t.Fatalf("files: %q %+v %v", root, files, err)
|
||||
}
|
||||
if files[1].Path != "view-3/Kexample.com.+013+12345.private" || string(files[1].Data) != "private-key-material-xyz" || files[1].Mode != 0o600 {
|
||||
t.Errorf("private key: %+v", files[1])
|
||||
}
|
||||
if in, err := Inspect(ctx, f.Path); err != nil || in.KeyFiles != 2 {
|
||||
t.Errorf("inspect: %+v %v", in, err)
|
||||
}
|
||||
// A restored database loses the table again.
|
||||
restored := filepath.Join(t.TempDir(), "jarl.db")
|
||||
b, _ := os.ReadFile(f.Path)
|
||||
os.WriteFile(restored, b, 0o600)
|
||||
if err := DropFiles(ctx, restored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, files, _ := Files(ctx, restored); len(files) != 0 {
|
||||
t.Error("files left after DropFiles")
|
||||
}
|
||||
if b, _ := os.ReadFile(restored); strings.Contains(string(b), "private-key-material-xyz") {
|
||||
t.Error("the private key is still in the file")
|
||||
}
|
||||
// No key directory: nothing stored. An unreadable key: an error.
|
||||
if f, err := Write(ctx, src, dir, KindJarl, time.Now().Add(time.Second), filepath.Join(keys, "missing")); err != nil {
|
||||
t.Error(err)
|
||||
} else if _, files, _ := Files(ctx, f.Path); len(files) != 0 {
|
||||
t.Errorf("files from a missing directory: %v", files)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
os.Chmod(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), 0)
|
||||
if _, err := Write(ctx, src, dir, KindJarl, time.Now().Add(2*time.Second), keys); err == nil || !strings.Contains(err.Error(), "back up as root") {
|
||||
t.Errorf("unreadable key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-10
@@ -223,6 +223,24 @@ func (t *Tools) ReloadZone(ctx context.Context, zone, view string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckDS tells named that the parent zone has published (or withdrawn)
|
||||
// the DS record for key tag of a zone it signs (rndc dnssec -checkds).
|
||||
func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg uint8, published bool) (string, error) {
|
||||
which := "withdrawn"
|
||||
if published {
|
||||
which = "published"
|
||||
}
|
||||
z := zoneArgs("", zone, view)[1:]
|
||||
args := append([]string{"dnssec", "-checkds", "-key", strconv.Itoa(int(tag)), "-alg", strconv.Itoa(int(alg)), which}, z...)
|
||||
return t.RNDCRun(ctx, args...)
|
||||
}
|
||||
|
||||
// ZoneCommand is the rndc command line for a zone command, as shown to
|
||||
// users ("rndc zonestatus example.org IN internal").
|
||||
func ZoneCommand(cmd, zone, view string) string {
|
||||
return "rndc " + strings.Join(zoneArgs(cmd, zone, view), " ")
|
||||
}
|
||||
|
||||
func zoneArgs(cmd, zone, view string) []string {
|
||||
z := strings.TrimSuffix(zone, ".")
|
||||
if z == "" {
|
||||
@@ -236,16 +254,32 @@ func zoneArgs(cmd, zone, view string) []string {
|
||||
|
||||
// ZoneStatus is the result of rndc zonestatus.
|
||||
type ZoneStatus struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Files string `json:"files"`
|
||||
Serial uint32 `json:"serial"`
|
||||
HasSerial bool `json:"-"`
|
||||
Nodes int `json:"nodes"`
|
||||
LastLoaded string `json:"last_loaded"`
|
||||
Secure bool `json:"secure"`
|
||||
Dynamic bool `json:"dynamic"`
|
||||
Raw string `json:"raw"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Files string `json:"files"`
|
||||
// Serial is the serial of the zone file named loaded. For a zone
|
||||
// named signs (inline signing) that is the unsigned zone Jarl wrote;
|
||||
// the signed zone that named serves has its own serial, SignedSerial.
|
||||
Serial uint32 `json:"serial"`
|
||||
HasSerial bool `json:"-"`
|
||||
SignedSerial uint32 `json:"signed_serial,omitempty"`
|
||||
HasSignedSerial bool `json:"-"`
|
||||
Nodes int `json:"nodes"`
|
||||
LastLoaded string `json:"last_loaded"`
|
||||
Secure bool `json:"secure"`
|
||||
InlineSigning bool `json:"inline_signing"`
|
||||
NextKeyEvent string `json:"next_key_event,omitempty"`
|
||||
Dynamic bool `json:"dynamic"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// ServedSerial is the serial named answers queries with: the signed
|
||||
// zone's for a zone it signs, otherwise the loaded one.
|
||||
func (zs *ZoneStatus) ServedSerial() (uint32, bool) {
|
||||
if zs.HasSignedSerial {
|
||||
return zs.SignedSerial, true
|
||||
}
|
||||
return zs.Serial, zs.HasSerial
|
||||
}
|
||||
|
||||
// ZoneStatus returns named's view of a zone.
|
||||
@@ -277,6 +311,14 @@ func ParseZoneStatus(out string) *ZoneStatus {
|
||||
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
|
||||
zs.Serial, zs.HasSerial = uint32(n), true
|
||||
}
|
||||
case "signed serial":
|
||||
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
|
||||
zs.SignedSerial, zs.HasSignedSerial = uint32(n), true
|
||||
}
|
||||
case "inline signing":
|
||||
zs.InlineSigning = v == "yes"
|
||||
case "next key event":
|
||||
zs.NextKeyEvent = v
|
||||
case "nodes":
|
||||
zs.Nodes, _ = strconv.Atoi(v)
|
||||
case "last loaded":
|
||||
|
||||
@@ -17,3 +17,33 @@ zone example.com/IN/internal: loaded serial 1
|
||||
t.Errorf("exit 1: %+v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZoneStatusSigned(t *testing.T) {
|
||||
// rndc zonestatus of a zone named signs with dnssec-policy (BIND 9.20).
|
||||
zs := ParseZoneStatus(`name: example.test
|
||||
type: primary
|
||||
files: /var/lib/bind/jarl/_default/example.test.zone
|
||||
serial: 2026100802
|
||||
signed serial: 2026100804
|
||||
nodes: 4
|
||||
last loaded: Thu, 08 Oct 2026 19:03:31 GMT
|
||||
secure: yes
|
||||
inline signing: yes
|
||||
key maintenance: automatic
|
||||
next key event: Thu, 08 Oct 2026 21:08:31 GMT
|
||||
next resign node: new.example.test/A
|
||||
next resign time: Sat, 17 Oct 2026 07:32:59 GMT
|
||||
dynamic: no
|
||||
reconfigurable via modzone: no
|
||||
`)
|
||||
if zs.Serial != 2026100802 || zs.SignedSerial != 2026100804 || !zs.Secure || !zs.InlineSigning || zs.NextKeyEvent != "Thu, 08 Oct 2026 21:08:31 GMT" {
|
||||
t.Errorf("%+v", zs)
|
||||
}
|
||||
if s, ok := zs.ServedSerial(); !ok || s != 2026100804 {
|
||||
t.Errorf("served serial %d %v", s, ok)
|
||||
}
|
||||
plain := ParseZoneStatus("name: example.com\nserial: 7\nsecure: no\n")
|
||||
if s, ok := plain.ServedSerial(); !ok || s != 7 || plain.HasSignedSerial {
|
||||
t.Errorf("unsigned: %+v", plain)
|
||||
}
|
||||
}
|
||||
@@ -102,13 +102,25 @@ var managed = map[string]map[string]string{
|
||||
"match-recursive-only": "the view's match-recursive-only field",
|
||||
"key": "TSIG keys (define keys at the top level)",
|
||||
"zone": "zones",
|
||||
"dnssec-policy": dnssecManaged,
|
||||
},
|
||||
ScopeOptions: {
|
||||
"dnssec-policy": dnssecManaged,
|
||||
},
|
||||
}
|
||||
|
||||
// dnssecManaged: Jarl turns signing on and off per zone, so that it can
|
||||
// guard the steps (a zone mustn't go unsigned while the parent zone still
|
||||
// has a DS record for it). dnssec-policy statements that define custom
|
||||
// policies (at the top level) are still options.
|
||||
const dnssecManaged = "each zone's DNSSEC setting (jarl zone dnssec enable ZONE; see docs/dnssec.md)"
|
||||
|
||||
var zoneManaged = map[string]string{
|
||||
"type": "the zone's type field",
|
||||
"file": "the zone's file (generated by Jarl)",
|
||||
"in-view": "the zone's in-view field",
|
||||
"type": "the zone's type field",
|
||||
"file": "the zone's file (generated by Jarl)",
|
||||
"in-view": "the zone's in-view field",
|
||||
"dnssec-policy": dnssecManaged,
|
||||
"key-directory": "Jarl (signed zones keep their keys in Jarl's key directory, which its backups include)",
|
||||
}
|
||||
|
||||
// synonyms are older spellings that named 9.20 still accepts.
|
||||
|
||||
@@ -183,7 +183,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "jarl-cli/"+version.Version)
|
||||
req.Header.Set("User-Agent", "jarl-cli/"+version.Short())
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
|
||||
+128
-6
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -29,11 +30,68 @@ type Config struct {
|
||||
StagingDir string `yaml:"staging_dir"`
|
||||
Log Log `yaml:"log"`
|
||||
Session Session `yaml:"session"`
|
||||
TOTP TOTP `yaml:"totp"`
|
||||
BIND BIND `yaml:"bind"`
|
||||
Audit Audit `yaml:"audit"`
|
||||
Backup Backup `yaml:"backup"`
|
||||
Stats Stats `yaml:"stats"`
|
||||
ACME ACME `yaml:"acme"`
|
||||
Porkbun Porkbun `yaml:"porkbun"`
|
||||
}
|
||||
|
||||
// Porkbun lets Jarl manage domains' name servers, glue records and DNSSEC
|
||||
// DS records at the registrar Porkbun (docs/dnssec.md#porkbun). The key
|
||||
// lives only here, in a file root and jarld can read, and never in the
|
||||
// database (so not in backups either).
|
||||
type Porkbun struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
SecretAPIKey string `yaml:"secret_api_key"`
|
||||
// Domains limits which domains Jarl may manage at Porkbun (default:
|
||||
// every Jarl zone the key can reach).
|
||||
Domains []string `yaml:"domains"`
|
||||
// APIURL is the API's base URL (for tests).
|
||||
APIURL string `yaml:"api_url"`
|
||||
}
|
||||
|
||||
// Enabled reports whether a Porkbun key is configured.
|
||||
func (p *Porkbun) Enabled() bool { return p.APIKey != "" }
|
||||
|
||||
// Allows reports whether Jarl may manage domain (no trailing dot) at
|
||||
// Porkbun.
|
||||
func (p *Porkbun) Allows(domain string) bool {
|
||||
if !p.Enabled() {
|
||||
return false
|
||||
}
|
||||
if len(p.Domains) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, d := range p.Domains {
|
||||
if strings.EqualFold(strings.TrimSuffix(d, "."), domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Porkbun) check() error {
|
||||
if (p.APIKey == "") != (p.SecretAPIKey == "") {
|
||||
return fmt.Errorf("set both api_key and secret_api_key (or neither)")
|
||||
}
|
||||
if p.APIURL != "" {
|
||||
u, err := url.Parse(p.APIURL)
|
||||
if err != nil || (u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname()))) {
|
||||
return fmt.Errorf("api_url must be an https:// URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(h string) bool {
|
||||
if h == "localhost" {
|
||||
return true
|
||||
}
|
||||
a, err := netip.ParseAddr(h)
|
||||
return err == nil && a.IsLoopback()
|
||||
}
|
||||
|
||||
// Backup configures 'jarld backup' run without a file name, which is what
|
||||
@@ -93,12 +151,37 @@ type Session struct {
|
||||
}
|
||||
|
||||
// BIND says where BIND's files and tools are.
|
||||
// TOTP configures two-factor login to the web UI (docs/permissions.md).
|
||||
type TOTP struct {
|
||||
// Require is who must use it: none (each user decides), admins or
|
||||
// all (every user who logs in with a password).
|
||||
Require string `yaml:"require"`
|
||||
// Issuer is the name authenticator apps show for the account ("" is
|
||||
// "Jarl <hostname>").
|
||||
Issuer string `yaml:"issuer"`
|
||||
}
|
||||
|
||||
// IssuerName is Issuer, or "Jarl <short hostname>".
|
||||
func (t TOTP) IssuerName() string {
|
||||
if t.Issuer != "" {
|
||||
return t.Issuer
|
||||
}
|
||||
h, _ := os.Hostname()
|
||||
if h, _, _ = strings.Cut(h, "."); h == "" {
|
||||
return "Jarl"
|
||||
}
|
||||
return "Jarl " + h
|
||||
}
|
||||
|
||||
type BIND struct {
|
||||
MainConfig string `yaml:"main_config"`
|
||||
ConfigDir string `yaml:"config_dir"`
|
||||
ZoneDir string `yaml:"zone_dir"`
|
||||
CacheDir string `yaml:"cache_dir"`
|
||||
FileGroup string `yaml:"file_group"`
|
||||
MainConfig string `yaml:"main_config"`
|
||||
ConfigDir string `yaml:"config_dir"`
|
||||
ZoneDir string `yaml:"zone_dir"`
|
||||
CacheDir string `yaml:"cache_dir"`
|
||||
FileGroup string `yaml:"file_group"`
|
||||
// NamedUser is the user named runs as. 'jarld restore' gives it the
|
||||
// DNSSEC keys it puts back.
|
||||
NamedUser string `yaml:"named_user"`
|
||||
NamedCheckConf string `yaml:"named_checkconf"`
|
||||
NamedCheckZone string `yaml:"named_checkzone"`
|
||||
RNDC string `yaml:"rndc"`
|
||||
@@ -194,12 +277,14 @@ func Default() *Config {
|
||||
StagingDir: "/var/lib/jarl/staging",
|
||||
Log: Log{Level: "info", Format: "text"},
|
||||
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
|
||||
TOTP: TOTP{Require: "none"},
|
||||
BIND: BIND{
|
||||
MainConfig: "/etc/bind/named.conf",
|
||||
ConfigDir: "/etc/bind/jarl",
|
||||
ZoneDir: "/var/lib/bind/jarl",
|
||||
CacheDir: "/var/cache/bind/jarl",
|
||||
FileGroup: "bind",
|
||||
NamedUser: "bind",
|
||||
NamedCheckConf: "/usr/bin/named-checkconf",
|
||||
NamedCheckZone: "/usr/bin/named-checkzone",
|
||||
RNDC: "/usr/sbin/rndc",
|
||||
@@ -267,12 +352,23 @@ func (c *Config) check() error {
|
||||
default:
|
||||
return fmt.Errorf("session.cookie_secure must be auto, always or never")
|
||||
}
|
||||
switch c.TOTP.Require {
|
||||
case "none", "admins", "all":
|
||||
default:
|
||||
return fmt.Errorf("totp.require must be none, admins or all")
|
||||
}
|
||||
if len(c.TOTP.Issuer) > 64 || strings.ContainsAny(c.TOTP.Issuer, ":") {
|
||||
return fmt.Errorf("totp.issuer must be at most 64 characters, without ':'")
|
||||
}
|
||||
if err := c.Stats.check(); err != nil {
|
||||
return fmt.Errorf("stats: %w", err)
|
||||
}
|
||||
if err := c.Backup.check(); err != nil {
|
||||
return fmt.Errorf("backup: %w", err)
|
||||
}
|
||||
if err := c.Porkbun.check(); err != nil {
|
||||
return fmt.Errorf("porkbun: %w", err)
|
||||
}
|
||||
switch c.Log.Format {
|
||||
case "text", "json":
|
||||
default:
|
||||
@@ -404,6 +500,15 @@ session:
|
||||
idle_timeout: 2h # ...or after this long without activity
|
||||
cookie_secure: auto # auto, always or never
|
||||
|
||||
# Two-factor login to the web UI with an authenticator app (TOTP). Each
|
||||
# user can turn it on under Account. require makes it compulsory: none,
|
||||
# admins, or all (every user with a password). A user who must use it and
|
||||
# hasn't set it up can only set it up after logging in. API tokens and
|
||||
# the local socket are not affected. See docs/permissions.md.
|
||||
totp:
|
||||
require: none
|
||||
issuer: "" # the name in the app; "" is "Jarl <hostname>"
|
||||
|
||||
bind:
|
||||
# The file named loads. 'jarld adopt' makes it include Jarl's config.
|
||||
main_config: /etc/bind/named.conf
|
||||
@@ -415,6 +520,8 @@ bind:
|
||||
cache_dir: /var/cache/bind/jarl
|
||||
# Group of generated files; named must be able to read them.
|
||||
file_group: bind
|
||||
# The user named runs as ('jarld restore' gives it the DNSSEC keys back).
|
||||
named_user: bind
|
||||
named_checkconf: /usr/bin/named-checkconf
|
||||
named_checkzone: /usr/bin/named-checkzone
|
||||
rndc: /usr/sbin/rndc
|
||||
@@ -434,7 +541,9 @@ audit:
|
||||
# 'systemctl edit jarl-backup.timer'. Each run writes
|
||||
# DIR/jarl-<UTC time>.db, checks it, then deletes backups older than
|
||||
# 'retention', always keeping the newest 'keep_at_least'. Only files named
|
||||
# like that are ever deleted. Restore with 'jarld restore'.
|
||||
# like that are ever deleted. Each backup also holds the DNSSEC keys of
|
||||
# signed zones (private keys included), so keep the directory private.
|
||||
# Restore with 'jarld restore'.
|
||||
backup:
|
||||
dir: /var/backups/jarl # the timer's sandbox may write only here (see the docs to move it)
|
||||
retention: 30d # 0 keeps every backup
|
||||
@@ -478,4 +587,17 @@ acme:
|
||||
# this server. Leave false if a reverse proxy runs on this server but
|
||||
# isn't in trusted_proxies: its requests would look local.
|
||||
allow_loopback_http: false
|
||||
|
||||
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
|
||||
# show and set a domain's name servers, glue records and DNSSEC DS records
|
||||
# there, after checking that the change won't break the domain. Off without
|
||||
# a key. Create the key at porkbun.com (Account -> API Access), switch on
|
||||
# "API Access" for the domain, and limit the key to that domain if you can.
|
||||
# The key can move your domain elsewhere: it lives only in this file (keep
|
||||
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
|
||||
# Jarl's database or backups.
|
||||
porkbun:
|
||||
api_key: "" # pk1_...
|
||||
secret_api_key: "" # sk1_...
|
||||
domains: [] # only these domains (default: any Jarl zone the key reaches)
|
||||
`
|
||||
@@ -0,0 +1,320 @@
|
||||
// Package dnssec reads the DNSSEC keys named keeps for the zones it signs
|
||||
// (dnssec-policy with inline signing) and works out where a zone is in the
|
||||
// steps of turning signing on or off.
|
||||
//
|
||||
// named writes three files per key into the zone's key-directory:
|
||||
// K<zone>.+<alg>+<tag>.key (the public DNSKEY, world-readable),
|
||||
// .private (the private key, readable only by named's user) and .state
|
||||
// (the key's timing and state, see the dnssec-keymgr states in the BIND
|
||||
// ARM). Jarl only reads the .key and .state files.
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Key states, as named writes them in .state files.
|
||||
const (
|
||||
Hidden = "hidden" // not in the zone (or the parent)
|
||||
Rumoured = "rumoured" // published, but caches may not have it yet
|
||||
Omnipresent = "omnipresent" // published long enough that everyone has it
|
||||
Unretentive = "unretentive" // removed, but caches may still have it
|
||||
)
|
||||
|
||||
// Status describes how a zone is signed.
|
||||
type Status struct {
|
||||
Policy string `json:"policy"`
|
||||
// KeyDir is the zone's key-directory.
|
||||
KeyDir string `json:"key_dir"`
|
||||
Keys []*Key `json:"keys"`
|
||||
Phase Phase `json:"phase"`
|
||||
Named *NamedSigning `json:"named,omitempty"`
|
||||
// NamedError says why named couldn't be asked.
|
||||
NamedError string `json:"named_error,omitempty"`
|
||||
// KeyError says why the key directory couldn't be read.
|
||||
KeyError string `json:"key_error,omitempty"`
|
||||
// Policies are the policies the zone could use: default and the
|
||||
// custom dnssec-policy statements.
|
||||
Policies []string `json:"policies"`
|
||||
}
|
||||
|
||||
// NamedSigning is what named reports about a zone it signs.
|
||||
type NamedSigning struct {
|
||||
Secure bool `json:"secure"`
|
||||
InlineSigning bool `json:"inline_signing"`
|
||||
Serial uint32 `json:"serial"`
|
||||
SignedSerial uint32 `json:"signed_serial,omitempty"`
|
||||
NextKeyEvent string `json:"next_key_event,omitempty"`
|
||||
}
|
||||
|
||||
// DS is a DS record for a key: what the parent zone publishes to vouch
|
||||
// for it.
|
||||
type DS struct {
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
DigestType uint8 `json:"digest_type"`
|
||||
Digest string `json:"digest"`
|
||||
// Record is the DS record in zone file format.
|
||||
Record string `json:"record"`
|
||||
}
|
||||
|
||||
// Key is one DNSSEC key of a zone.
|
||||
type Key struct {
|
||||
Tag uint16 `json:"tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
AlgorithmName string `json:"algorithm_name"`
|
||||
// Role is CSK (signs everything: BIND's default policy), KSK (signs
|
||||
// the DNSKEY set; the parent's DS points at it) or ZSK.
|
||||
Role string `json:"role"`
|
||||
// DNSKEY is the public key record, and DS its DS record (KSK and CSK
|
||||
// only).
|
||||
DNSKEY string `json:"dnskey,omitempty"`
|
||||
DS *DS `json:"ds,omitempty"`
|
||||
// States from the .state file.
|
||||
Goal string `json:"goal,omitempty"`
|
||||
DNSKEYState string `json:"dnskey_state,omitempty"`
|
||||
DSState string `json:"ds_state,omitempty"`
|
||||
ZoneRRSIG string `json:"zone_rrsig_state,omitempty"`
|
||||
KeyRRSIG string `json:"key_rrsig_state,omitempty"`
|
||||
// Times from the .state file.
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Published *time.Time `json:"published,omitempty"`
|
||||
Active *time.Time `json:"active,omitempty"`
|
||||
Retired *time.Time `json:"retired,omitempty"`
|
||||
Removed *time.Time `json:"removed,omitempty"`
|
||||
// PublishCDS is when named publishes CDS/CDNSKEY records: from then on
|
||||
// the DS can safely be added at the parent.
|
||||
PublishCDS *time.Time `json:"publish_cds,omitempty"`
|
||||
// DSPublished and DSRemoved are when named was told (or saw) that the
|
||||
// parent added or removed the DS.
|
||||
DSPublished *time.Time `json:"ds_published,omitempty"`
|
||||
DSRemoved *time.Time `json:"ds_removed,omitempty"`
|
||||
// Files are the key's file names in the key directory.
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// SigningKey reports whether the parent's DS should point at the key.
|
||||
func (k *Key) SigningKey() bool { return k.Role == "CSK" || k.Role == "KSK" }
|
||||
|
||||
// Gone reports whether named has finished removing the key: every state
|
||||
// is hidden.
|
||||
func (k *Key) Gone() bool {
|
||||
for _, s := range []string{k.Goal, k.DNSKEYState, k.DSState, k.ZoneRRSIG, k.KeyRRSIG} {
|
||||
if s != "" && s != Hidden {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FilePrefix is how named's key files for zone (a name with the trailing
|
||||
// dot) begin.
|
||||
func FilePrefix(zone string) string { return "K" + strings.ToLower(zone) + "+" }
|
||||
|
||||
// Files lists the names of zone's key files in dir (every .key, .private
|
||||
// and .state file, and any other file named with the zone's key prefix).
|
||||
// A missing directory has none.
|
||||
func Files(dir, zone string) ([]string, error) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := FilePrefix(zone)
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() && strings.HasPrefix(strings.ToLower(e.Name()), strings.ToLower(prefix)) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Keys reads zone's keys from dir, ordered with the signing keys first.
|
||||
func Keys(dir, zone string) ([]*Key, error) {
|
||||
files, err := Files(dir, zone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byBase := map[string]*Key{}
|
||||
var order []string
|
||||
for _, f := range files {
|
||||
ext := filepath.Ext(f)
|
||||
base := strings.TrimSuffix(f, ext)
|
||||
k := byBase[base]
|
||||
if k == nil {
|
||||
k = &Key{}
|
||||
byBase[base] = k
|
||||
order = append(order, base)
|
||||
}
|
||||
k.Files = append(k.Files, f)
|
||||
switch ext {
|
||||
case ".key":
|
||||
if err := k.readPublic(filepath.Join(dir, f)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ".state":
|
||||
if err := k.readState(filepath.Join(dir, f)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []*Key
|
||||
for _, b := range order {
|
||||
k := byBase[b]
|
||||
if k.DNSKEY == "" && k.Goal == "" {
|
||||
continue // only a .private file: nothing to show
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SigningKey() != out[j].SigningKey() {
|
||||
return out[i].SigningKey()
|
||||
}
|
||||
return out[i].Tag < out[j].Tag
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *Key) readPublic(path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zp := dns.NewZoneParser(strings.NewReader(string(b)), "", path)
|
||||
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
|
||||
key, isKey := rr.(*dns.DNSKEY)
|
||||
if !isKey {
|
||||
continue
|
||||
}
|
||||
k.Tag = key.KeyTag()
|
||||
k.Algorithm = key.Algorithm
|
||||
k.AlgorithmName = dns.AlgorithmToString[key.Algorithm]
|
||||
k.DNSKEY = key.String()
|
||||
if k.Role == "" {
|
||||
switch {
|
||||
case key.Flags&dns.SEP != 0:
|
||||
k.Role = "KSK"
|
||||
default:
|
||||
k.Role = "ZSK"
|
||||
}
|
||||
}
|
||||
if key.Flags&dns.SEP != 0 {
|
||||
if ds := key.ToDS(dns.SHA256); ds != nil {
|
||||
k.DS = &DS{KeyTag: ds.KeyTag, Algorithm: ds.Algorithm, DigestType: ds.DigestType, Digest: strings.ToUpper(ds.Digest), Record: ds.String()}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := zp.Err(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return fmt.Errorf("%s: no DNSKEY record", path)
|
||||
}
|
||||
|
||||
func (k *Key) readState(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var ksk, zsk bool
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
name, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
t := func() *time.Time {
|
||||
// 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
f := strings.Fields(v)
|
||||
if len(f) == 0 {
|
||||
return nil
|
||||
}
|
||||
ts, err := time.Parse("20060102150405", f[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ts
|
||||
}
|
||||
switch name {
|
||||
case "Algorithm":
|
||||
if n, err := strconv.Atoi(v); err == nil && k.Algorithm == 0 {
|
||||
k.Algorithm = uint8(n)
|
||||
k.AlgorithmName = dns.AlgorithmToString[uint8(n)]
|
||||
}
|
||||
case "KSK":
|
||||
ksk = v == "yes"
|
||||
case "ZSK":
|
||||
zsk = v == "yes"
|
||||
case "Generated":
|
||||
k.Created = t()
|
||||
case "Published":
|
||||
k.Published = t()
|
||||
case "Active":
|
||||
k.Active = t()
|
||||
case "Retired":
|
||||
k.Retired = t()
|
||||
case "Removed":
|
||||
k.Removed = t()
|
||||
case "PublishCDS":
|
||||
k.PublishCDS = t()
|
||||
case "DSPublish":
|
||||
k.DSPublished = t()
|
||||
case "DSRemoved":
|
||||
k.DSRemoved = t()
|
||||
case "GoalState":
|
||||
k.Goal = v
|
||||
case "DNSKEYState":
|
||||
k.DNSKEYState = v
|
||||
case "DSState":
|
||||
k.DSState = v
|
||||
case "ZRRSIGState":
|
||||
k.ZoneRRSIG = v
|
||||
case "KRRSIGState":
|
||||
k.KeyRRSIG = v
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case ksk && zsk:
|
||||
k.Role = "CSK"
|
||||
case ksk:
|
||||
k.Role = "KSK"
|
||||
case zsk:
|
||||
k.Role = "ZSK"
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// ZoneFileSuffixes are the files named keeps next to a zone file: the
|
||||
// journal of the zone Jarl wrote (.jnl, and .jbk while it rewrites it) and,
|
||||
// for a signed zone, the signed copy and its journal.
|
||||
var ZoneFileSuffixes = []string{".jnl", ".jbk", ".signed", ".signed.jnl", ".signed.jbk"}
|
||||
|
||||
// DerivedFiles returns the files named made next to zoneFile that exist.
|
||||
func DerivedFiles(zoneFile string) []string {
|
||||
var out []string
|
||||
for _, s := range ZoneFileSuffixes {
|
||||
if _, err := os.Lstat(zoneFile + s); err == nil {
|
||||
out = append(out, zoneFile+s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testdata holds key files written by named 9.20 with dnssec-policy:
|
||||
// example.test just signed (default policy), fast.test turned insecure
|
||||
// after its DS was removed, fast2.test turned insecure before any DS.
|
||||
|
||||
func TestKeys(t *testing.T) {
|
||||
keys, err := Keys("testdata", "example.test.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("%d keys", len(keys))
|
||||
}
|
||||
k := keys[0]
|
||||
if k.Tag != 22642 || k.Role != "CSK" || k.Algorithm != 13 || k.AlgorithmName != "ECDSAP256SHA256" || len(k.Files) != 3 {
|
||||
t.Errorf("key: %+v", k)
|
||||
}
|
||||
if k.DS == nil || k.DS.KeyTag != 22642 || k.DS.DigestType != 2 || len(k.DS.Digest) != 64 || !strings.HasPrefix(k.DS.Record, "example.test.\t3600\tIN\tDS\t22642 13 2 ") {
|
||||
t.Errorf("DS: %+v", k.DS)
|
||||
}
|
||||
if k.DNSKEYState != Rumoured || k.Goal != Omnipresent || k.PublishCDS == nil || k.PublishCDS.Format("2006-01-02 15:04") != "2026-10-09 19:08" {
|
||||
t.Errorf("state: %+v", k)
|
||||
}
|
||||
// "fast.test." mustn't pick up fast2.test's keys, nor "test." anyone's.
|
||||
if keys, _ := Keys("testdata", "fast.test."); len(keys) != 1 || keys[0].Tag != 642 {
|
||||
t.Errorf("fast.test: %+v", keys)
|
||||
}
|
||||
if files, _ := Files("testdata", "test."); len(files) != 0 {
|
||||
t.Errorf("test.: %v", files)
|
||||
}
|
||||
if files, _ := Files("no-such-dir", "test."); files != nil {
|
||||
t.Errorf("missing dir: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssess(t *testing.T) {
|
||||
load := func(zone string) []*Key {
|
||||
keys, err := Keys("testdata", zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
check := func(name string, p Phase, code string) {
|
||||
t.Helper()
|
||||
if p.Code != code {
|
||||
t.Errorf("%s: %s (%s), want %s", name, p.Code, p.Summary, code)
|
||||
}
|
||||
}
|
||||
check("unsigned", Assess("", nil), PhaseOff)
|
||||
check("left over", Assess("", load("example.test.")), PhaseLeftover)
|
||||
check("no keys yet", Assess("default", nil), PhaseStarting)
|
||||
p := Assess("default", load("example.test."))
|
||||
check("new", p, PhaseSigning)
|
||||
if p.CanPublishDS || p.DSReadyAt == nil {
|
||||
t.Errorf("new: %+v", p)
|
||||
}
|
||||
check("DS removed", Assess("insecure", load("fast.test.")), PhaseDSExpiring)
|
||||
if Assess("insecure", load("fast.test.")).CanFinish {
|
||||
t.Error("DNSSEC mustn't be switched off while resolvers may still have the DS cached")
|
||||
}
|
||||
p = Assess("insecure", load("fast2.test."))
|
||||
check("no DS ever", p, PhaseRemovingKeys)
|
||||
if !p.CanFinish {
|
||||
t.Error("no DS: switching off is safe")
|
||||
}
|
||||
check("insecure, no keys", Assess("insecure", nil), PhaseUnsigned)
|
||||
|
||||
// The rest of the life cycle, from edited state files.
|
||||
edit := func(zone string, repl ...string) []*Key {
|
||||
dir := t.TempDir()
|
||||
files, _ := Files("testdata", zone)
|
||||
for _, f := range files {
|
||||
b, _ := os.ReadFile(filepath.Join("testdata", f))
|
||||
s := strings.NewReplacer(repl...).Replace(string(b))
|
||||
os.WriteFile(filepath.Join(dir, f), []byte(s), 0o644)
|
||||
}
|
||||
keys, err := Keys(dir, zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
ready := edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSState: hidden", "DSState: rumoured")
|
||||
p = Assess("default", ready)
|
||||
check("ready", p, PhaseReady)
|
||||
if !p.CanPublishDS || len(p.NeedsDS) != 1 || p.NeedsDS[0] != 22642 {
|
||||
t.Errorf("ready: %+v", p)
|
||||
}
|
||||
check("DS added", Assess("default", edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSChange:", "DSPublish: 20261010000000\nDSChange:")), PhaseDSPending)
|
||||
check("secure", Assess("default", edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSState: hidden", "DSState: omnipresent")), PhaseSecure)
|
||||
p = Assess("insecure", edit("fast.test.", "DSRemoved:", "XDSRemoved:"))
|
||||
check("turning off with the DS still there", p, PhaseRemovingDS)
|
||||
if p.CanFinish {
|
||||
t.Error("the DS is still at the parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zf := filepath.Join(dir, "example.com.zone")
|
||||
for _, f := range []string{zf, zf + ".jnl", zf + ".signed", zf + ".signed.jnl", zf + ".other"} {
|
||||
os.WriteFile(f, nil, 0o644)
|
||||
}
|
||||
got := DerivedFiles(zf)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phases of a zone, from unsigned to signed and back. Turning signing on:
|
||||
// off -> starting -> signing -> ready (add the DS at the parent) ->
|
||||
// ds_pending -> secure. Turning it off: secure -> removing_ds (remove the
|
||||
// DS at the parent) -> ds_expiring -> removing_keys -> unsigned -> off.
|
||||
const (
|
||||
PhaseOff = "off" // not signed, no keys
|
||||
PhaseLeftover = "leftover_keys" // not signed, but old keys are still in the key directory
|
||||
PhaseStarting = "starting" // signing is on in Jarl; named hasn't made keys yet
|
||||
PhaseSigning = "signing" // keys made, zone signed; not yet safe to add the DS
|
||||
PhaseReady = "ready" // safe to add the DS at the parent
|
||||
PhaseDSPending = "ds_pending" // the DS is at the parent; named waits for its TTL
|
||||
PhaseSecure = "secure" // signed, and the parent's DS is in place
|
||||
PhaseRemovingDS = "removing_ds" // turning off: the DS must go from the parent first
|
||||
PhaseDSExpiring = "ds_expiring" // the DS is gone; named waits until caches forget it
|
||||
PhaseRemovingKeys = "removing_keys" // named removes the keys and signatures
|
||||
PhaseUnsigned = "unsigned" // turning off has finished; switch DNSSEC off
|
||||
)
|
||||
|
||||
// Phase says where a zone is and what comes next.
|
||||
type Phase struct {
|
||||
Code string `json:"code"`
|
||||
Summary string `json:"summary"`
|
||||
Next string `json:"next,omitempty"`
|
||||
// DSReadyAt is when the DS can safely be added at the parent (phase
|
||||
// signing).
|
||||
DSReadyAt *time.Time `json:"ds_ready_at,omitempty"`
|
||||
// CanPublishDS: the zone is ready for (or already has) a DS at the
|
||||
// parent.
|
||||
CanPublishDS bool `json:"can_publish_ds"`
|
||||
// CanFinish: the zone is being turned off and the parent has no DS
|
||||
// any more (as far as named knows), so DNSSEC can be switched off.
|
||||
CanFinish bool `json:"can_finish"`
|
||||
// NeedsDS lists the signing keys whose DS should be at the parent,
|
||||
// and StaleDS those whose DS should be removed from it.
|
||||
NeedsDS []uint16 `json:"needs_ds,omitempty"`
|
||||
StaleDS []uint16 `json:"stale_ds,omitempty"`
|
||||
}
|
||||
|
||||
// Assess works out the phase of a zone signed with policy ("" when Jarl
|
||||
// doesn't sign it, "insecure" while signing is being removed) that has
|
||||
// keys.
|
||||
func Assess(policy string, keys []*Key) Phase {
|
||||
var live []*Key // signing keys named keeps (or is introducing)
|
||||
for _, k := range keys {
|
||||
if k.SigningKey() && k.Goal == Omnipresent {
|
||||
live = append(live, k)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case policy == "":
|
||||
if len(keys) == 0 {
|
||||
return Phase{Code: PhaseOff, Summary: "DNSSEC is off."}
|
||||
}
|
||||
return Phase{Code: PhaseLeftover, Summary: "DNSSEC is off, but keys from when the zone was signed are still in its key directory.",
|
||||
Next: "Wipe them (jarl zone dnssec wipe) once you're sure the parent zone has no DS record for them, or turn signing on again to reuse them."}
|
||||
case policy == "insecure":
|
||||
return assessOff(keys)
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return Phase{Code: PhaseStarting, Summary: "Signing is on; named hasn't created the zone's keys yet.",
|
||||
Next: "Wait a moment: named makes the keys when it loads the configuration."}
|
||||
}
|
||||
p := Phase{}
|
||||
var notReady []*Key
|
||||
for _, k := range live {
|
||||
if k.DNSKEYState != Omnipresent || k.KeyRRSIG != Omnipresent {
|
||||
notReady = append(notReady, k)
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.SigningKey() && k.Goal == Hidden && k.DSState != Hidden && k.DSRemoved == nil {
|
||||
p.StaleDS = append(p.StaleDS, k.Tag)
|
||||
}
|
||||
}
|
||||
if len(notReady) > 0 {
|
||||
p.Code = PhaseSigning
|
||||
p.Summary = "named is signing the zone. Resolvers still treat it as unsigned, because the parent zone has no DS record for it."
|
||||
for _, k := range notReady {
|
||||
if k.PublishCDS != nil && (p.DSReadyAt == nil || k.PublishCDS.After(*p.DSReadyAt)) {
|
||||
t := *k.PublishCDS
|
||||
p.DSReadyAt = &t
|
||||
}
|
||||
}
|
||||
if p.DSReadyAt != nil {
|
||||
p.Next = fmt.Sprintf("Wait until %s before adding the DS at the parent: until then some resolvers may still have unsigned answers cached, and would reject them once the DS appears.", p.DSReadyAt.UTC().Format("Mon 2 Jan 15:04 MST"))
|
||||
} else {
|
||||
p.Next = "Wait until named has published the keys long enough for caches to pick them up before adding the DS."
|
||||
}
|
||||
return p
|
||||
}
|
||||
p.CanPublishDS = true
|
||||
var pending, secure int
|
||||
for _, k := range live {
|
||||
switch {
|
||||
case k.DSState == Omnipresent:
|
||||
secure++
|
||||
case k.DSPublished != nil:
|
||||
pending++
|
||||
default:
|
||||
p.NeedsDS = append(p.NeedsDS, k.Tag)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(p.NeedsDS) > 0:
|
||||
p.Code = PhaseReady
|
||||
p.Summary = "The zone is signed and ready: add the DS record at the parent zone (your registrar) to turn validation on."
|
||||
p.Next = "Add the DS record (" + tags(p.NeedsDS) + ") at your registrar, then run the DS check so named knows it's there."
|
||||
case pending > 0:
|
||||
p.Code = PhaseDSPending
|
||||
p.Summary = "The parent zone has the DS record. named waits for the parent's TTL before it treats it as in place."
|
||||
default:
|
||||
p.Code = PhaseSecure
|
||||
p.Summary = "The zone is signed and the parent zone's DS record points at its key: validating resolvers check its answers."
|
||||
}
|
||||
if len(p.StaleDS) > 0 && p.Next == "" {
|
||||
p.Next = "Remove the DS record of the retired key " + tags(p.StaleDS) + " at your registrar, then run the DS check."
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func assessOff(keys []*Key) Phase {
|
||||
var dsUp, dsGoing, keysLeft bool
|
||||
for _, k := range keys {
|
||||
if !k.SigningKey() {
|
||||
if !k.Gone() {
|
||||
keysLeft = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case k.DSState == Hidden || k.DSState == "":
|
||||
case k.DSRemoved != nil:
|
||||
dsGoing = true
|
||||
default:
|
||||
dsUp = true
|
||||
}
|
||||
if !k.Gone() {
|
||||
keysLeft = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case dsUp:
|
||||
return Phase{Code: PhaseRemovingDS, Summary: "Turning DNSSEC off: named keeps the zone signed until the parent zone's DS record is gone.",
|
||||
Next: "Remove the DS record at your registrar, then run the DS check so named knows. Don't switch DNSSEC off before that: the zone would fail to validate."}
|
||||
case dsGoing:
|
||||
return Phase{Code: PhaseDSExpiring, Summary: "Turning DNSSEC off: the parent's DS record is gone; named keeps signing until resolvers' cached copies of it expire (the parent's DS TTL, often a day).",
|
||||
Next: "Wait: switching DNSSEC off now would make the zone fail to validate for resolvers that still have the DS cached."}
|
||||
case keysLeft:
|
||||
return Phase{Code: PhaseRemovingKeys, Summary: "Turning DNSSEC off: the parent has no DS any more, and named is removing the keys and signatures.",
|
||||
Next: "Wait until named has finished, then switch DNSSEC off.", CanFinish: true}
|
||||
}
|
||||
return Phase{Code: PhaseUnsigned, Summary: "Turning DNSSEC off has finished: the zone is no longer signed.",
|
||||
Next: "Switch DNSSEC off for the zone to finish (jarl zone dnssec disable), and wipe the old keys if you like.", CanFinish: true}
|
||||
}
|
||||
|
||||
func tags(ts []uint16) string {
|
||||
var s []string
|
||||
for _, t := range ts {
|
||||
s = append(s, fmt.Sprintf("key %d", t))
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
; This is a key-signing key, keyid 22642, for example.test.
|
||||
; Created: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; Publish: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; Activate: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; SyncPublish: 20261009190831 (Fri Oct 9 19:08:31 2026)
|
||||
example.test. 3600 IN DNSKEY 257 3 13 fiSDgJJGHyAp36ZNGOeb45gn+O1pF3hCbTUPHeJ3ztIuisGoywmo7Be3 +vUPyC6e9BmTCIRIeElB4f2o9HoPXg==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,19 @@
|
||||
; This is the state of key 22642, for example.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
Published: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
Active: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
PublishCDS: 20261009190831 (Fri Oct 9 19:08:31 2026)
|
||||
DNSKEYChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
ZRRSIGChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
KRRSIGChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
DSChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
DNSKEYState: rumoured
|
||||
ZRRSIGState: rumoured
|
||||
KRRSIGState: rumoured
|
||||
DSState: hidden
|
||||
GoalState: omnipresent
|
||||
@@ -0,0 +1,8 @@
|
||||
; This is a key-signing key, keyid 642, for fast.test.
|
||||
; Created: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Publish: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Activate: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Inactive: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
; Delete: 20261024213819 (Sat Oct 24 21:38:19 2026)
|
||||
; SyncPublish: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
fast.test. 3600 IN DNSKEY 257 3 13 D6O3u6ZWXwKPu/Y4acS2eRm1zKQ8LaN8zhvkr6KNWP+fEPgko6yLib94 J5KcUXpFcG6Y/jdxKM8FgeASRDq/ow==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,23 @@
|
||||
; This is the state of key 642, for fast.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Published: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Active: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Retired: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
Removed: 20261024213819 (Sat Oct 24 21:38:19 2026)
|
||||
DSPublish: 20261008203249 (Thu Oct 8 20:32:49 2026)
|
||||
DSRemoved: 20261008203329 (Thu Oct 8 20:33:29 2026)
|
||||
PublishCDS: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
DNSKEYChange: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
ZRRSIGChange: 20261008203232 (Thu Oct 8 20:32:32 2026)
|
||||
KRRSIGChange: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
DSChange: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
DNSKEYState: omnipresent
|
||||
ZRRSIGState: omnipresent
|
||||
KRRSIGState: omnipresent
|
||||
DSState: unretentive
|
||||
GoalState: hidden
|
||||
@@ -0,0 +1,8 @@
|
||||
; This is a key-signing key, keyid 28643, for fast2.test.
|
||||
; Created: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Publish: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Activate: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Inactive: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
; Delete: 20261024213844 (Sat Oct 24 21:38:44 2026)
|
||||
; SyncPublish: 20261008203354 (Thu Oct 8 20:33:54 2026)
|
||||
fast2.test. 3600 IN DNSKEY 257 3 13 6WkqPp3kxXxMJtC9clsF4zTnZGPXaFRikSdMLG1eFExa4d0Uo+PtMbqo KHR/Qe601tO7yZoY8itie0aZzPaMiA==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,21 @@
|
||||
; This is the state of key 28643, for fast2.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Published: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Active: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Retired: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
Removed: 20261024213844 (Sat Oct 24 21:38:44 2026)
|
||||
PublishCDS: 20261008203354 (Thu Oct 8 20:33:54 2026)
|
||||
DNSKEYChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
ZRRSIGChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
KRRSIGChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
DSChange: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
DNSKEYState: unretentive
|
||||
ZRRSIGState: unretentive
|
||||
KRRSIGState: unretentive
|
||||
DSState: hidden
|
||||
GoalState: hidden
|
||||
@@ -60,7 +60,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
n := to.Zones[k]
|
||||
s := n.SOA
|
||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, Comment: &n.Comment}
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment}
|
||||
if n.Type == model.ZonePrimary {
|
||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package engine
|
||||
|
||||
// DNSSEC signing (docs/dnssec.md). named signs zones itself (dnssec-policy
|
||||
// with inline signing) and keeps their keys in a key directory Jarl
|
||||
// chooses (render.KeyDir). Jarl's part is to keep the steps in order: a
|
||||
// zone must never become unsigned while the parent zone may still have a
|
||||
// DS record for it, or validating resolvers reject the whole domain.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// DNSSEC returns the signing status of zone z.
|
||||
func (e *Engine) DNSSEC(ctx context.Context, st *model.State, z *model.Zone) *dnssec.Status {
|
||||
s := &dnssec.Status{Policy: z.DNSSECPolicy, KeyDir: render.KeyDir(st, z, e.cfg.Paths), Keys: []*dnssec.Key{}, Policies: []string{model.DNSSECDefault}}
|
||||
for name := range validate.Policies(st) {
|
||||
s.Policies = append(s.Policies, name)
|
||||
}
|
||||
sort.Strings(s.Policies[1:])
|
||||
keys, err := dnssec.Keys(s.KeyDir, z.Name)
|
||||
if err != nil {
|
||||
s.KeyError = err.Error()
|
||||
} else if keys != nil {
|
||||
s.Keys = keys
|
||||
}
|
||||
s.Phase = dnssec.Assess(z.DNSSECPolicy, s.Keys)
|
||||
if z.Type == model.ZonePrimary && !e.cfg.NoReload && e.tools != nil {
|
||||
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(st, z))
|
||||
if err != nil {
|
||||
s.NamedError = err.Error()
|
||||
} else {
|
||||
s.Named = &dnssec.NamedSigning{Secure: zs.Secure, InlineSigning: zs.InlineSigning, Serial: zs.Serial, SignedSerial: zs.SignedSerial, NextKeyEvent: zs.NextKeyEvent}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// dnssecChanges checks the DNSSEC side of going from base to work: the
|
||||
// steps that would make a signed zone fail to validate, or give it new
|
||||
// keys its parent's DS doesn't match.
|
||||
func (e *Engine) dnssecChanges(base, work *model.State, opts ApplyOptions) []validate.Problem {
|
||||
var out []validate.Problem
|
||||
problem := func(sev validate.Severity, st *model.State, z *model.Zone, code, format string, args ...any) {
|
||||
out = append(out, validate.Problem{Severity: sev, Code: code, Message: fmt.Sprintf(format, args...), ObjectType: "zone", ObjectID: z.ID, Zone: z.Name, View: st.ViewName(z)})
|
||||
}
|
||||
for _, old := range base.SortedZones() {
|
||||
if !old.Signed() {
|
||||
continue
|
||||
}
|
||||
z := work.Zones[old.ID]
|
||||
if z == nil {
|
||||
problem(validate.Error, base, old, "dnssec_delete_signed", "the zone is signed: turn DNSSEC off before deleting it (jarl zone dnssec disable, and remove the DS record at your registrar), or the domain fails to validate while the parent still points at its keys")
|
||||
continue
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue // reported by validate (dnssec_zone_type)
|
||||
}
|
||||
if render.KeyDir(base, old, e.cfg.Paths) != render.KeyDir(work, z, e.cfg.Paths) {
|
||||
problem(validate.Error, work, z, "dnssec_keys_moved", "the zone is signed, and moving it to another view would make named sign it with new keys that the parent's DS record doesn't match; turn DNSSEC off first, or create the zone anew in the other view")
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case z.DNSSECPolicy == "" && opts.wipeZone == z.ID:
|
||||
// WipeDNSSEC: the caller checked that the parent has no DS.
|
||||
case z.DNSSECPolicy == "" && old.DNSSECPolicy != model.DNSSECInsecure:
|
||||
problem(validate.Error, work, z, "dnssec_off_unsafe", "switching DNSSEC off at once makes named serve the zone unsigned immediately, and if the parent zone has a DS record for it, validating resolvers then reject the whole domain. Turn it off in steps: 'jarl zone dnssec disable' (the insecure policy) keeps the zone signed while you remove the DS at your registrar, and Jarl says when it's safe to finish. If the zone never had a DS at its parent, wipe it instead ('jarl zone dnssec wipe')")
|
||||
case z.DNSSECPolicy == "":
|
||||
keys, err := dnssec.Keys(render.KeyDir(base, old, e.cfg.Paths), old.Name)
|
||||
if err != nil {
|
||||
problem(validate.Error, work, z, "dnssec_off_unknown", "can't tell whether turning DNSSEC off has finished, because the zone's keys can't be read: %v", err)
|
||||
continue
|
||||
}
|
||||
if ph := dnssec.Assess(model.DNSSECInsecure, keys); !ph.CanFinish {
|
||||
problem(validate.Error, work, z, "dnssec_off_too_early", "turning DNSSEC off hasn't finished yet: %s %s", ph.Summary, ph.Next)
|
||||
}
|
||||
case z.DNSSECPolicy != old.DNSSECPolicy && z.DNSSECPolicy != model.DNSSECInsecure && old.DNSSECPolicy != model.DNSSECInsecure:
|
||||
problem(validate.Warning, work, z, "dnssec_policy_change", "changing the zone's DNSSEC policy from %s to %s can make named replace its keys; when its key-signing key changes, the DS record at the parent must change too (Jarl shows when)", old.DNSSECPolicy, z.DNSSECPolicy)
|
||||
}
|
||||
}
|
||||
for _, z := range work.SortedZones() {
|
||||
if !z.Signed() || z.DNSSECPolicy == model.DNSSECInsecure {
|
||||
continue
|
||||
}
|
||||
if old := base.Zones[z.ID]; old != nil && old.Signed() {
|
||||
continue
|
||||
}
|
||||
if files, _ := dnssec.Files(render.KeyDir(work, z, e.cfg.Paths), z.Name); len(files) > 0 {
|
||||
problem(validate.Info, work, z, "dnssec_old_keys", "the zone's key directory still has keys from when it was signed before; named only reuses keys it hasn't retired, and makes new ones otherwise (wipe the old ones first for a clean start)")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// removeDerived deletes what named kept for zones that are gone after a
|
||||
// change: the journals and signed copies next to their files and, for a
|
||||
// deleted zone, keys left over from when it was signed (a signed zone
|
||||
// can't be deleted). Failures are only logged.
|
||||
func (e *Engine) removeDerived(pl *plan) {
|
||||
for _, path := range pl.removed {
|
||||
for _, f := range dnssec.DerivedFiles(path) {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
e.log.Warn("removing a file named kept for a deleted zone failed", "file", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, old := range pl.base.Zones {
|
||||
if old.Type != model.ZonePrimary || pl.work.Zones[id] != nil || pl.work.FindZone(old.Name, old.ViewID) != nil {
|
||||
continue
|
||||
}
|
||||
dir := render.KeyDir(pl.base, old, e.cfg.Paths)
|
||||
files, _ := dnssec.Files(dir, old.Name)
|
||||
for _, f := range files {
|
||||
if err := os.Remove(filepath.Join(dir, f)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
e.log.Warn("removing a deleted zone's old DNSSEC key failed", "file", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WipeResult is the outcome of WipeDNSSEC.
|
||||
type WipeResult struct {
|
||||
Result *Result `json:"result,omitempty"` // the change that switched signing off, if one was needed
|
||||
Removed []string `json:"removed"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// WipeDNSSEC switches signing off for a zone at once, skipping the
|
||||
// insecure policy, and deletes its keys and named's signed copy of it, so
|
||||
// that signing it again starts from scratch. The caller must make sure the
|
||||
// parent zone has no DS record for the zone: otherwise the domain stops
|
||||
// validating.
|
||||
func (e *Engine) WipeDNSSEC(ctx context.Context, zoneID int64, p *auth.Principal, via string, opts ApplyOptions) (*WipeResult, error) {
|
||||
st, err := e.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z := st.Zones[zoneID]
|
||||
if z == nil {
|
||||
return nil, errf(KindNotFound, "zone %d not found", zoneID)
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones are signed by Jarl")
|
||||
}
|
||||
if !p.Can(zoneAction(st, z, true)) {
|
||||
return nil, errf(KindForbidden, "permission denied: this needs write access to zone %s", zoneDesc(st, z))
|
||||
}
|
||||
out := &WipeResult{Removed: []string{}, DryRun: opts.DryRun}
|
||||
dir := render.KeyDir(st, z, e.cfg.Paths)
|
||||
keyFiles, err := dnssec.Files(dir, z.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the key directory: %w", err)
|
||||
}
|
||||
zoneFile := render.ZoneFilePath(st, z, e.cfg.Paths)
|
||||
var signed []string
|
||||
for _, f := range dnssec.DerivedFiles(zoneFile) {
|
||||
if strings.Contains(filepath.Base(f), ".signed") {
|
||||
signed = append(signed, f)
|
||||
}
|
||||
}
|
||||
if z.DNSSECPolicy == "" && len(keyFiles) == 0 && len(signed) == 0 {
|
||||
return nil, errf(KindConflict, "zone %s isn't signed and has no keys or signed files left: there is nothing to wipe", zoneDesc(st, z))
|
||||
}
|
||||
if z.DNSSECPolicy != "" {
|
||||
opts.wipeZone = z.ID
|
||||
res, err := e.Submit(ctx, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})}, p, via, "wipe DNSSEC from zone "+zoneDesc(st, z), opts)
|
||||
out.Result = res
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, f := range keyFiles {
|
||||
out.Removed = append(out.Removed, filepath.Join(dir, f))
|
||||
}
|
||||
out.Removed = append(out.Removed, signed...)
|
||||
if opts.DryRun {
|
||||
return out, nil
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
var failed []string
|
||||
for _, f := range out.Removed {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
failed = append(failed, err.Error())
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("%d files removed", len(out.Removed))
|
||||
if len(failed) > 0 {
|
||||
e.audit(ctx, p, "dnssec.wipe", "zone", z.ID, zoneDesc(st, z), 0, "failure", strings.Join(failed, "; "), nil)
|
||||
return out, fmt.Errorf("signing is off, but removing files failed: %s", strings.Join(failed, "; "))
|
||||
}
|
||||
e.audit(ctx, p, "dnssec.wipe", "zone", z.ID, zoneDesc(st, z), 0, "success", msg, nil)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkDS tells named that the parent zone now has (published) or no
|
||||
// longer has (withdrawn) the DS record of each key, as a DS check found.
|
||||
// named moves to the next step of signing or unsigning the zone once it
|
||||
// knows, and (unless it can query the parent itself) waits until told.
|
||||
func (e *Engine) MarkDS(ctx context.Context, st *model.State, z *model.Zone, p *auth.Principal, published, withdrawn []*dnssec.Key) ([]string, error) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return nil, errf(KindConflict, "jarld doesn't control named here (bind.no_reload)")
|
||||
}
|
||||
var out []string
|
||||
run := func(k *dnssec.Key, pub bool) error {
|
||||
msg, err := e.tools.CheckDS(ctx, z.Name, e.namedView(st, z), k.Tag, k.Algorithm, pub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, fmt.Sprintf("key %d: %s", k.Tag, strings.TrimSpace(msg)))
|
||||
return nil
|
||||
}
|
||||
for _, k := range published {
|
||||
if err := run(k, true); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, k := range withdrawn {
|
||||
if err := run(k, false); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
e.audit(ctx, p, "dnssec.checkds", "zone", z.ID, zoneDesc(st, z), 0, "success", strings.Join(out, "; "), nil)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// waitPhase polls the zone's DNSSEC status until ok accepts it.
|
||||
func (n *namedEnv) waitPhase(z *model.Zone, what string, ok func(*dnssec.Status) bool) *dnssec.Status {
|
||||
n.t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
st := n.state()
|
||||
s := n.e.DNSSEC(n.ctx, st, st.Zones[z.ID])
|
||||
if ok(s) {
|
||||
return s
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
n.t.Fatalf("waiting for %s: %+v (phase %+v)", what, s, s.Phase)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSECWithRealNamed(t *testing.T) {
|
||||
n := newNamedEnv(t)
|
||||
z := n.state().ZonesNamed("example.test")[0]
|
||||
zoneFile := filepath.Join(n.dir, "zones/_default/example.test.zone")
|
||||
|
||||
// Switching signing on for an existing zone: named makes a key and
|
||||
// signs the zone (inline signing, keeping Jarl's file as it is).
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
conf, _ := os.ReadFile(n.e.cfg.Paths.MainConfig())
|
||||
if !strings.Contains(string(conf), `dnssec-policy "default";`) || !strings.Contains(string(conf), `key-directory "`+filepath.Join(n.dir, "zones/dnssec-keys/_default")+`";`) {
|
||||
t.Errorf("named.conf:\n%s", conf)
|
||||
}
|
||||
s := n.waitPhase(z, "signing", func(s *dnssec.Status) bool { return len(s.Keys) == 1 && s.Named != nil && s.Named.Secure })
|
||||
k := s.Keys[0]
|
||||
if s.Phase.Code != dnssec.PhaseSigning || k.Role != "CSK" || k.Algorithm != 13 || k.DS == nil || s.Phase.DSReadyAt == nil {
|
||||
t.Errorf("status: %+v, key %+v", s.Phase, k)
|
||||
}
|
||||
if got := n.dig("example.test", "DNSKEY"); !strings.HasPrefix(got, "257 3 13 ") {
|
||||
t.Errorf("DNSKEY = %q", got)
|
||||
}
|
||||
// Changes still load (Jarl's serial is the unsigned zone's), and the
|
||||
// serial check sees the signed zone's serial.
|
||||
r := findRec(n.state(), "www.example.test.", "A")
|
||||
n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")}))
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.12" {
|
||||
t.Errorf("www = %q", got)
|
||||
}
|
||||
cur := n.state()
|
||||
served, ok, err := n.e.LoadedSerial(n.ctx, cur, cur.Zones[z.ID])
|
||||
if !ok || err != nil || !serialGreater(served, cur.Zones[z.ID].SOA.Serial) {
|
||||
t.Errorf("served serial %d (%v %v), Jarl's %d", served, ok, err, cur.Zones[z.ID].SOA.Serial)
|
||||
}
|
||||
|
||||
// Unsafe steps are refused.
|
||||
if e := n.submitErr(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})); !strings.Contains(e.Message, "switching DNSSEC off at once") {
|
||||
t.Errorf("switching off directly: %v", e)
|
||||
}
|
||||
if e := n.submitErr(n.admin, NewRequest(OpZoneDelete, z.ID, nil)); !strings.Contains(e.Message, "turn DNSSEC off before deleting") {
|
||||
t.Errorf("deleting: %v", e)
|
||||
}
|
||||
r = findRec(n.state(), "www.example.test.", "A")
|
||||
if e := n.submitErr(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{TTL: model.Some(uint32(172800))})); !strings.Contains(e.Message, "max-zone-ttl") {
|
||||
t.Errorf("long TTL: %v", e)
|
||||
}
|
||||
|
||||
// A restore of an older database: Jarl's serial is now below named's,
|
||||
// which named refuses for a signed zone. Resync raises it.
|
||||
before := n.state()
|
||||
older := before.Clone()
|
||||
older.Zones[z.ID].SOA.Serial -= 5
|
||||
findRec(older, "www.example.test.", "A").Data = "192.0.2.10"
|
||||
if err := n.st.InTx(n.ctx, func(tx *store.Tx) error { return tx.SaveState(before, older) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := n.e.Resync(n.ctx, n.admin, ApplyOptions{Force: true})
|
||||
if err != nil {
|
||||
t.Fatalf("resync: %v", err)
|
||||
}
|
||||
if len(res.Serials) != 1 || !serialGreater(res.Serials[0].New, before.Zones[z.ID].SOA.Serial) {
|
||||
t.Errorf("serials: %+v (named had %d)", res.Serials, before.Zones[z.ID].SOA.Serial)
|
||||
}
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www after restore = %q", got)
|
||||
}
|
||||
if n.state().Zones[z.ID].SOA.Serial != res.Serials[0].New {
|
||||
t.Error("raised serial not saved")
|
||||
}
|
||||
|
||||
// Turning off: insecure first. The DS was never at the parent, so
|
||||
// named removes the keys at once and finishing is allowed.
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("insecure")}))
|
||||
n.waitPhase(z, "removal", func(s *dnssec.Status) bool { return s.Phase.CanFinish })
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")}))
|
||||
if got := n.dig("example.test", "DNSKEY"); got != "" {
|
||||
t.Errorf("DNSKEY after switching off = %q", got)
|
||||
}
|
||||
s = n.waitPhase(z, "leftover keys", func(s *dnssec.Status) bool { return true })
|
||||
if s.Phase.Code != dnssec.PhaseLeftover {
|
||||
t.Errorf("after switching off: %+v", s.Phase)
|
||||
}
|
||||
|
||||
// Signing again makes a new key (the old one stays retired); a wipe
|
||||
// switches off at once and deletes keys and signed files.
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
s = n.waitPhase(z, "signing again", func(s *dnssec.Status) bool { return len(s.Keys) == 2 && s.Named != nil && s.Named.Secure })
|
||||
if s.Phase.Code != dnssec.PhaseSigning || s.Keys[0].Tag == k.Tag && s.Keys[0].Goal == dnssec.Omnipresent {
|
||||
t.Errorf("signing again: %+v %+v", s.Phase, s.Keys[0])
|
||||
}
|
||||
if _, err := os.Stat(zoneFile + ".signed"); err != nil {
|
||||
t.Errorf("signed copy: %v", err)
|
||||
}
|
||||
w, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("wipe: %v", err)
|
||||
}
|
||||
if len(w.Removed) < 4 || w.Result == nil || !w.Result.Applied {
|
||||
t.Errorf("wipe: %+v", w)
|
||||
}
|
||||
if files, _ := dnssec.Files(render.KeyDir(n.state(), z, n.e.cfg.Paths), z.Name); len(files) != 0 {
|
||||
t.Errorf("keys left: %v", files)
|
||||
}
|
||||
if _, err := os.Stat(zoneFile + ".signed"); !os.IsNotExist(err) {
|
||||
t.Errorf("signed copy left: %v", err)
|
||||
}
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www after wipe = %q", got)
|
||||
}
|
||||
if _, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{}); err == nil {
|
||||
t.Error("a second wipe found something")
|
||||
}
|
||||
// Deleting the zone also removes named's journal.
|
||||
n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if left, _ := filepath.Glob(zoneFile + "*"); len(left) != 0 {
|
||||
t.Errorf("left after deleting the zone: %v", left)
|
||||
}
|
||||
}
|
||||
+49
-11
@@ -122,6 +122,8 @@ type ApplyOptions struct {
|
||||
Force bool
|
||||
// NoReload writes the files without reloading named.
|
||||
NoReload bool
|
||||
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
|
||||
wipeZone int64
|
||||
}
|
||||
|
||||
func (e *Engine) replay(base *model.State, ops []model.Op, p *auth.Principal) (*model.State, []string, error) {
|
||||
@@ -397,11 +399,11 @@ func (pl *plan) files() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string) (*plan, error) {
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
||||
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
||||
before := e.validate(base)
|
||||
after := e.validate(work)
|
||||
pl.introduced = validate.Introduced(before, after)
|
||||
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
||||
introducedKeys := map[string]bool{}
|
||||
for _, p := range pl.introduced {
|
||||
introducedKeys[p.Key()] = true
|
||||
@@ -415,6 +417,12 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
return pl, nil
|
||||
}
|
||||
pl.serials = bumpSerials(base, work, e.Now(), user)
|
||||
guarded, problems := e.guardSerials(ctx, base, work, pl.serials, user)
|
||||
pl.serials = append(pl.serials, guarded...)
|
||||
if validate.HasErrors(problems) {
|
||||
pl.introduced = append(pl.introduced, problems...)
|
||||
return pl, nil
|
||||
}
|
||||
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
|
||||
deployed, err := e.store.DeployedFiles(ctx)
|
||||
if err != nil {
|
||||
@@ -498,6 +506,11 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
|
||||
defer os.RemoveAll(dir)
|
||||
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
|
||||
sout := render.Render(pl.work, sp, e.cfg.Extras)
|
||||
for _, d := range sout.Dirs {
|
||||
if err := os.MkdirAll(d, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, f := range sout.Files {
|
||||
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
|
||||
return err
|
||||
@@ -635,7 +648,7 @@ func (e *Engine) Preview(ctx context.Context, csID int64, p *auth.Principal) (*R
|
||||
res.Error = ee
|
||||
return res, nil
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), ApplyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -714,7 +727,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
cs.Description += fmt.Sprintf(" (+%d more)", len(cs.Ops)-1)
|
||||
}
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -782,6 +795,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
}
|
||||
res.ChangesetID = cs.ID
|
||||
res.Applied = true
|
||||
e.removeDerived(pl)
|
||||
e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description)
|
||||
return res, nil
|
||||
}
|
||||
@@ -956,15 +970,16 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
return nil, err
|
||||
}
|
||||
work := base.Clone()
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := pl.result()
|
||||
res.DryRun = opts.DryRun
|
||||
if validate.HasErrors(pl.existing) {
|
||||
if validate.HasErrors(pl.existing) || validate.HasErrors(pl.introduced) {
|
||||
// Resync deploys the stored state as is; it must be valid.
|
||||
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(pl.existing), Problems: validate.Errors(pl.existing)}
|
||||
ps := append(append([]validate.Problem{}, pl.existing...), pl.introduced...)
|
||||
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(ps), Problems: validate.Errors(ps)}
|
||||
}
|
||||
if pl.check != nil && !pl.check.OK {
|
||||
return res, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration: " + firstCheckError(pl.checkMsgs), Check: pl.checkMsgs}
|
||||
@@ -998,15 +1013,26 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
}
|
||||
deployed := deployedList(pl.out, 0, e.Now())
|
||||
err = e.store.InTx(ctx, func(tx *store.Tx) error {
|
||||
// Serials raised above named's (guardSerials) are kept.
|
||||
if len(pl.serials) > 0 {
|
||||
if err := tx.SaveState(base, work); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.SetDeployedFiles(deployed); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), nil))
|
||||
var details json.RawMessage
|
||||
if len(pl.serials) > 0 {
|
||||
details, _ = json.Marshal(map[string]any{"serials": pl.serials})
|
||||
}
|
||||
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), details))
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Applied = true
|
||||
e.removeDerived(pl)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -1058,7 +1084,7 @@ func (e *Engine) CheckCurrent(ctx context.Context) (*Result, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, base.Clone(), "")
|
||||
pl, err := e.makePlan(ctx, base, base.Clone(), "", ApplyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1192,8 +1218,19 @@ func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal
|
||||
}
|
||||
|
||||
// LoadedSerial returns the serial named serves for zone z, from rndc
|
||||
// zonestatus. ok is false when named isn't asked (no_reload) or doesn't
|
||||
// zonestatus: for a zone named signs, the signed zone's serial (which
|
||||
// secondaries and the rest of the world see), not that of the zone file
|
||||
// Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't
|
||||
// report a serial (the zone isn't loaded yet).
|
||||
// LoadedSerialCommand is the command LoadedSerial runs, or "" when it
|
||||
// doesn't ask named.
|
||||
func (e *Engine) LoadedSerialCommand(st *model.State, z *model.Zone) string {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return ""
|
||||
}
|
||||
return bind.ZoneCommand("zonestatus", z.Name, e.namedView(st, z))
|
||||
}
|
||||
|
||||
func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return 0, false, nil
|
||||
@@ -1202,5 +1239,6 @@ func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zon
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return zs.Serial, zs.HasSerial, nil
|
||||
serial, ok = zs.ServedSerial()
|
||||
return serial, ok, nil
|
||||
}
|
||||
@@ -87,7 +87,11 @@ type ZoneInput struct {
|
||||
SOA *SOAInput `json:"soa,omitempty"`
|
||||
DefaultTTL *uint32 `json:"default_ttl,omitempty"`
|
||||
SerialPolicy *string `json:"serial_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// DNSSECPolicy turns signing on ("default" or a custom policy's
|
||||
// name), starts turning it off ("insecure") or, once that has
|
||||
// finished, off ("").
|
||||
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
|
||||
// Create only: name servers for the apex NS records (default: the
|
||||
// configured default name servers), additional records, and a zone to
|
||||
|
||||
@@ -230,6 +230,16 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
||||
if in.SerialPolicy != nil {
|
||||
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
|
||||
}
|
||||
if in.DNSSECPolicy != nil {
|
||||
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
|
||||
switch strings.ToLower(p) {
|
||||
case "none", "off":
|
||||
p = ""
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
z.DNSSECPolicy = p
|
||||
}
|
||||
if in.Comment != nil {
|
||||
z.Comment = *in.Comment
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// serialGreater reports whether a > b in RFC 1982 serial number
|
||||
@@ -73,3 +76,59 @@ func bumpSerials(base, work *model.State, now time.Time, user string) []SerialCh
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// guardSerials makes sure every zone whose file is about to change gets
|
||||
// a serial above the one named has loaded. Normally Jarl's serial is
|
||||
// already higher, but not after restoring an older database or when the
|
||||
// files on disk were replaced: named refuses to load a signed zone's file
|
||||
// unless its serial went up (and keeps serving the old data), and
|
||||
// secondaries ignore a zone whose serial went down. Zones are bumped past
|
||||
// named's serial unless the user set the serial explicitly; an explicit
|
||||
// serial that a signed zone can't take is an error. named is only asked
|
||||
// when it controls the zone (not with no_reload).
|
||||
func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bumped []SerialChange, user string) ([]SerialChange, []validate.Problem) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return nil, nil
|
||||
}
|
||||
auto := map[int64]bool{}
|
||||
for _, c := range bumped {
|
||||
auto[c.ZoneID] = true
|
||||
}
|
||||
var out []SerialChange
|
||||
var problems []validate.Problem
|
||||
for _, z := range work.SortedZones() {
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue
|
||||
}
|
||||
old := base.Zones[z.ID]
|
||||
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
|
||||
continue // new to named: nothing loaded to compare with
|
||||
}
|
||||
path := render.ZoneFilePath(work, z, e.cfg.Paths)
|
||||
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
|
||||
continue // named keeps what it has
|
||||
}
|
||||
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(work, z))
|
||||
if err != nil || !zs.HasSerial || serialGreater(z.SOA.Serial, zs.Serial) {
|
||||
continue
|
||||
}
|
||||
explicit := z.SOA.Serial != old.SOA.Serial && !auto[z.ID]
|
||||
if explicit {
|
||||
if z.Signed() {
|
||||
problems = append(problems, validate.Problem{Severity: validate.Error, Code: "serial_not_higher", ObjectType: "zone", ObjectID: z.ID, Zone: z.Name, View: work.ViewName(z),
|
||||
Message: fmt.Sprintf("named only loads a signed zone's file when its serial goes up, and it has serial %d loaded; set a higher serial (or leave it to Jarl)", zs.Serial)})
|
||||
}
|
||||
continue
|
||||
}
|
||||
policy := z.SerialPolicy
|
||||
if policy == "" {
|
||||
policy = work.Settings.SerialPolicy
|
||||
}
|
||||
next := NextSerial(zs.Serial, policy, e.Now())
|
||||
out = append(out, SerialChange{ZoneID: z.ID, Zone: z.Name, View: work.ViewName(z), Old: z.SOA.Serial, New: next})
|
||||
e.log.Info("raising a zone's serial above the one named has loaded", "zone", z.Name, "view", work.ViewName(z), "jarl", z.SOA.Serial, "named", zs.Serial, "new", next)
|
||||
z.SOA.Serial = next
|
||||
z.Touch(user, e.Now())
|
||||
}
|
||||
return out, problems
|
||||
}
|
||||
@@ -61,6 +61,9 @@ type importer struct {
|
||||
dir string // the directory option, for relative zone file names
|
||||
seen map[string]bool
|
||||
ctrl map[string]bool // key names used by controls
|
||||
// policies are dnssec-policy statements of the options block (key
|
||||
// "options") and of views ("view:ID"), which Jarl sets per zone.
|
||||
policies map[string]string
|
||||
}
|
||||
|
||||
func (im *importer) warn(format string, args ...any) {
|
||||
@@ -90,7 +93,7 @@ func (im *importer) meta() model.Meta {
|
||||
|
||||
// Import reads the configuration starting at mainPath.
|
||||
func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *Report, error) {
|
||||
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}}
|
||||
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}, policies: map[string]string{}}
|
||||
stmts, err := im.readFile(mainPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -116,6 +119,7 @@ func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
im.dnssecZones()
|
||||
im.rep.Counts = map[string]int{"views": len(im.st.Views), "zones": len(im.st.Zones), "records": len(im.st.Records), "acls": len(im.st.ACLs), "tsig_keys": len(im.st.Keys), "options": len(im.st.Options)}
|
||||
sort.Slice(im.rep.Zones, func(i, j int) bool {
|
||||
a, b := im.rep.Zones[i], im.rep.Zones[j]
|
||||
@@ -246,6 +250,20 @@ func (im *importer) keepInclude(path string) {
|
||||
}
|
||||
|
||||
func (im *importer) addOption(scope string, scopeID int64, name, value, comment string) {
|
||||
switch {
|
||||
case name == "dnssec-policy" && scope == model.ScopeOptions:
|
||||
im.policies["options"] = value
|
||||
return
|
||||
case name == "dnssec-policy" && scope == model.ScopeView:
|
||||
im.policies[fmt.Sprintf("view:%d", scopeID)] = value
|
||||
return
|
||||
case name == "dnssec-policy" && scope == model.ScopeZone:
|
||||
im.policies[fmt.Sprintf("zone:%d", scopeID)] = value
|
||||
return
|
||||
case name == "key-directory" && scope == model.ScopeZone:
|
||||
// Jarl chooses signed zones' key directories (see dnssecKeys).
|
||||
return
|
||||
}
|
||||
id, err := im.id()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -259,6 +277,45 @@ func (im *importer) addOption(scope string, scopeID int64, name, value, comment
|
||||
im.st.Options[id] = &model.Option{ID: id, Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: pos, Comment: comment, Meta: im.meta()}
|
||||
}
|
||||
|
||||
// policyName turns a dnssec-policy value into a zone's DNSSECPolicy.
|
||||
func policyName(v string) string {
|
||||
v = strings.Trim(strings.TrimSpace(v), `"`)
|
||||
switch l := strings.ToLower(v); l {
|
||||
case "none":
|
||||
return ""
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
return l
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// dnssecZones gives primary zones the dnssec-policy they inherit from
|
||||
// their view or the options block (Jarl sets it per zone), and warns
|
||||
// about signed zones' keys: Jarl keeps them in its own key directory, and
|
||||
// unless the existing keys are moved there, named makes new ones that the
|
||||
// parent's DS record doesn't match.
|
||||
func (im *importer) dnssecZones() {
|
||||
for _, z := range im.st.SortedZones() {
|
||||
v, ok := im.policies[fmt.Sprintf("zone:%d", z.ID)]
|
||||
if !ok {
|
||||
v, ok = im.policies[fmt.Sprintf("view:%d", z.ViewID)]
|
||||
}
|
||||
if !ok {
|
||||
v = im.policies["options"]
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
if p := policyName(v); p != "" {
|
||||
im.warn("zone %s: named signs this %s zone (dnssec-policy %s); Jarl only signs primary zones, so the setting was dropped", dnsname.Display(z.Name), z.Type, p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
z.DNSSECPolicy = policyName(v)
|
||||
if z.DNSSECPolicy != "" {
|
||||
im.warn("zone %s is signed (dnssec-policy %s). Before 'jarld adopt', copy its key files (K%s+*) into the key directory Jarl uses for it ('jarl zone dnssec status %s' shows it), owned by named's user; otherwise named makes new keys, and if the parent zone has a DS record for the old ones, the zone stops validating (docs/dnssec.md#adopting-a-signed-zone)", dnsname.Display(z.Name), z.DNSSECPolicy, z.Name, dnsname.Display(z.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restText(s *namedconf.Stmt) string {
|
||||
return namedconf.Compact(s.Items[1:])
|
||||
}
|
||||
@@ -477,7 +534,7 @@ func (im *importer) zone(s *namedconf.Stmt, v *model.View) error {
|
||||
}
|
||||
report.Records, report.Skipped = n, skipped
|
||||
for _, o := range opts {
|
||||
if kw := o.Keyword(); kw == "allow-update" || kw == "update-policy" {
|
||||
if kw := o.Keyword(); kw == "update-policy" || kw == "allow-update" && !allowsNone(o) {
|
||||
im.warn("zone %s uses %s (dynamic updates); Jarl rewrites zone files and won't apply such zones until %s is removed", dnsname.Display(z.Name), kw, kw)
|
||||
}
|
||||
}
|
||||
@@ -735,3 +792,18 @@ func ParseZoneText(zone, text string) (*ParsedZone, error) {
|
||||
}
|
||||
return pz, nil
|
||||
}
|
||||
|
||||
// allowsNone says whether an address match list option is { none; }
|
||||
// (allow-update { none; } turns dynamic updates off).
|
||||
func allowsNone(o *namedconf.Stmt) bool {
|
||||
b := o.FirstBlock()
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
for _, s := range b.Stmts {
|
||||
if namedconf.Compact(s.Items) != "none" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -146,3 +146,62 @@ func TestParseTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportAllowUpdateWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"), 0o644)
|
||||
for conf, want := range map[string]bool{
|
||||
`allow-update { none; };`: false,
|
||||
`allow-update { 127.0.0.1; };`: true,
|
||||
`update-policy local;`: true,
|
||||
} {
|
||||
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; `+conf+` };`), 0o644)
|
||||
_, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(rep.Warnings) > 0; got != want {
|
||||
t.Errorf("%s: warnings = %v", conf, rep.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSignedZones(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zone := "$TTL 300\n@ IN SOA ns1 h 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"
|
||||
for _, n := range []string{"a", "b", "c"} {
|
||||
os.WriteFile(filepath.Join(dir, n+".db"), []byte(strings.ReplaceAll(zone, "ns1", "ns1."+n+".test.")), 0o644)
|
||||
}
|
||||
conf := `options { directory "` + dir + `"; dnssec-policy default; key-directory "/var/cache/bind/keys"; };
|
||||
view "external" {
|
||||
zone "a.test" { type primary; file "a.db"; key-directory "/elsewhere"; };
|
||||
zone "b.test" { type primary; file "b.db"; dnssec-policy none; };
|
||||
};
|
||||
view "internal" {
|
||||
dnssec-policy "none";
|
||||
zone "c.test" { type primary; file "c.db"; };
|
||||
};`
|
||||
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(conf), 0o644)
|
||||
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := map[string]string{}
|
||||
for _, z := range st.Zones {
|
||||
policy[z.Name] = z.DNSSECPolicy
|
||||
}
|
||||
if policy["a.test."] != "default" || policy["b.test."] != "" || policy["c.test."] != "" {
|
||||
t.Errorf("policies: %v", policy)
|
||||
}
|
||||
for _, o := range st.Options {
|
||||
if o.Name == "dnssec-policy" || o.Scope == model.ScopeZone && o.Name == "key-directory" {
|
||||
t.Errorf("option kept: %+v", o)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(rep.Warnings, "\n"), "zone a.test is signed (dnssec-policy default). Before 'jarld adopt', copy its key files (Ka.test.+*)") {
|
||||
t.Errorf("warnings: %v", rep.Warnings)
|
||||
}
|
||||
if ps := validate.Errors(validate.State(st, validate.Options{})); len(ps) != 0 {
|
||||
t.Errorf("imported state has errors: %v", ps)
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,17 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
// TOTPEnabledAt is when the user turned on two-factor login (nil:
|
||||
// off). The secrets never leave the server.
|
||||
TOTPEnabledAt *time.Time `json:"totp_enabled_at,omitempty"`
|
||||
TOTPSecret string `json:"-"`
|
||||
TOTPPending string `json:"-"`
|
||||
TOTPLastStep int64 `json:"-"`
|
||||
}
|
||||
|
||||
// HasTOTP reports whether the user logs in with a TOTP code.
|
||||
func (u *User) HasTOTP() bool { return u.TOTPSecret != "" }
|
||||
|
||||
// Token is an API key. The secret is shown once at creation and only its
|
||||
// hash is stored.
|
||||
type Token struct {
|
||||
@@ -125,6 +134,9 @@ type Session struct {
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
// TOTPAt is when the session last proved a TOTP code (at login or
|
||||
// since); nil for a session without two-factor login.
|
||||
TOTPAt *time.Time `json:"totp_at,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEvent records an action.
|
||||
|
||||
@@ -37,6 +37,10 @@ func (m *Meta) Touch(user string, now time.Time) {
|
||||
}
|
||||
|
||||
// View is a BIND view.
|
||||
// ViewColors are the colours the web UI can show a view in, besides the
|
||||
// default (the link colour).
|
||||
var ViewColors = []string{"green", "teal", "purple", "pink", "orange", "yellow", "red", "grey"}
|
||||
|
||||
type View struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -118,13 +122,28 @@ type Zone struct {
|
||||
SOA SOA `json:"soa"`
|
||||
DefaultTTL uint32 `json:"default_ttl"`
|
||||
SerialPolicy string `json:"serial_policy,omitempty"`
|
||||
// DNSSECPolicy is the dnssec-policy named signs the zone with (primary
|
||||
// zones only): "" for an unsigned zone, "default" (BIND's built-in
|
||||
// policy), the name of a dnssec-policy statement, or "insecure" while
|
||||
// signing is being removed safely (docs/dnssec.md).
|
||||
DNSSECPolicy string `json:"dnssec_policy,omitempty"`
|
||||
Comment string `json:"comment"`
|
||||
Meta
|
||||
}
|
||||
|
||||
// DNSSEC policies with a special meaning.
|
||||
const (
|
||||
DNSSECDefault = "default" // BIND's built-in policy
|
||||
DNSSECInsecure = "insecure" // BIND's built-in policy for removing signing
|
||||
)
|
||||
|
||||
// HasRecords reports whether Jarl manages the zone's records.
|
||||
func (z *Zone) HasRecords() bool { return z.Type == ZonePrimary }
|
||||
|
||||
// Signed reports whether named signs the zone (or is still removing its
|
||||
// signatures, under the insecure policy).
|
||||
func (z *Zone) Signed() bool { return z.Type == ZonePrimary && z.DNSSECPolicy != "" }
|
||||
|
||||
// Record is a resource record in a primary zone.
|
||||
type Record struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
+256
-46
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
)
|
||||
|
||||
// Finding is one conclusion of a delegation check. Level is "ok",
|
||||
@@ -28,8 +30,73 @@ type ParentAnswer struct {
|
||||
NS []string `json:"ns,omitempty"`
|
||||
// Glue is the addresses the parent hands out for in-zone name
|
||||
// servers.
|
||||
Glue map[string][]string `json:"glue,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Glue map[string][]string `json:"glue,omitempty"`
|
||||
// DS is the zone's DS records at this server ("" in DSError when it
|
||||
// couldn't be asked).
|
||||
DS []dnssec.DS `json:"ds,omitempty"`
|
||||
DSError string `json:"ds_error,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Signing is how Jarl signs a zone, for comparing with the parent's DS
|
||||
// records.
|
||||
type Signing struct {
|
||||
// Policy is the zone's dnssec-policy ("" when unsigned, "insecure"
|
||||
// while signing is being removed).
|
||||
Policy string
|
||||
// DS are the DS records of the zone's key-signing keys that named
|
||||
// still publishes.
|
||||
DS []dnssec.DS
|
||||
}
|
||||
|
||||
// ParentDSReport is what the parent zone's servers say about the zone's
|
||||
// DS records.
|
||||
type ParentDSReport struct {
|
||||
Zone string `json:"zone"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Resolver string `json:"resolver,omitempty"`
|
||||
// Delegated: at least one parent server delegates the zone. Reached
|
||||
// is how many answered.
|
||||
Delegated bool `json:"delegated"`
|
||||
Reached int `json:"reached"`
|
||||
Servers []ParentAnswer `json:"parent_servers"`
|
||||
// DS is every DS record any parent server has.
|
||||
DS []dnssec.DS `json:"ds"`
|
||||
// Consistent: every server that answered has the same DS records.
|
||||
Consistent bool `json:"consistent"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AllHaveDS reports whether every parent server that answered has a DS
|
||||
// record for key tag (with digest, unless "").
|
||||
func (r *ParentDSReport) AllHaveDS(tag uint16, digest string) bool {
|
||||
n := 0
|
||||
for _, a := range r.Servers {
|
||||
if a.Status != "delegated" || a.DSError != "" {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
found := false
|
||||
for _, d := range a.DS {
|
||||
if d.KeyTag == tag && (digest == "" || strings.EqualFold(d.Digest, digest)) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// HasDS reports whether a parent server has a DS record for key tag.
|
||||
func (r *ParentDSReport) HasDS(tag uint16, digest string) bool {
|
||||
for _, d := range r.DS {
|
||||
if d.KeyTag == tag && (digest == "" || strings.EqualFold(d.Digest, digest)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GlueCheck compares the parent's glue for an in-zone name server with
|
||||
@@ -52,6 +119,8 @@ type DelegationReport struct {
|
||||
JarlNS []string `json:"jarl_ns"`
|
||||
Parents []ParentAnswer `json:"parent_servers"`
|
||||
Glue []GlueCheck `json:"glue"`
|
||||
// ParentDS are the zone's DS records at the parent (DNSSEC).
|
||||
ParentDS []dnssec.DS `json:"parent_ds"`
|
||||
// Servers are the delegated name servers' SOA answers, as resolvers
|
||||
// on the internet would reach them.
|
||||
Servers []Result `json:"servers"`
|
||||
@@ -60,12 +129,14 @@ type DelegationReport struct {
|
||||
|
||||
// Delegation checks how the parent zone delegates zone: it finds the
|
||||
// parent through the resolver, asks each of the parent's servers for the
|
||||
// zone's NS records and glue, compares those with jarlNS and jarlAddrs
|
||||
// (Jarl's NS records and its addresses for in-zone name servers), and
|
||||
// asks every delegated server for the zone's serial (compared with ref).
|
||||
func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string, jarlAddrs map[string][]string, ref *uint32) *DelegationReport {
|
||||
// zone's NS records, glue and DS records, compares those with jarlNS and
|
||||
// jarlAddrs (Jarl's NS records and its addresses for in-zone name
|
||||
// servers) and with sig (how Jarl signs the zone; nil skips the DNSSEC
|
||||
// part), and asks every delegated server for the zone's serial (compared
|
||||
// with ref).
|
||||
func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string, jarlAddrs map[string][]string, ref *uint32, sig *Signing) *DelegationReport {
|
||||
zone = dns.CanonicalName(zone)
|
||||
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, Servers: []Result{}, Findings: []Finding{}}
|
||||
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, ParentDS: []dnssec.DS{}, Servers: []Result{}, Findings: []Finding{}}
|
||||
sort.Strings(rep.JarlNS)
|
||||
add := func(level, format string, args ...any) {
|
||||
for i, a := range args {
|
||||
@@ -78,42 +149,17 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
}
|
||||
rep.Findings = append(rep.Findings, Finding{level, fmt.Sprintf(format, args...)})
|
||||
}
|
||||
res, err := c.resolver()
|
||||
if err != nil {
|
||||
add("error", "No resolver to find the parent zone with: %v", err)
|
||||
pr := c.ParentDS(ctx, zone)
|
||||
rep.Resolver, rep.Parent, rep.Parents = pr.Resolver, pr.Parent, pr.Servers
|
||||
if pr.Error != "" {
|
||||
level := "error"
|
||||
if zone == "." {
|
||||
level = "info"
|
||||
}
|
||||
rep.Findings = append(rep.Findings, Finding{level, pr.Error})
|
||||
return rep
|
||||
}
|
||||
rep.Resolver = res
|
||||
if zone == "." {
|
||||
add("info", "The root zone has no parent to delegate it.")
|
||||
return rep
|
||||
}
|
||||
|
||||
// The parent zone: the zone that holds the name just above ours.
|
||||
parent, err := c.enclosingZone(ctx, res, parentName(zone))
|
||||
if err != nil {
|
||||
add("error", "Couldn't find the parent zone of %s through %s: %v", zone, res, err)
|
||||
return rep
|
||||
}
|
||||
rep.Parent = parent
|
||||
pns, err := c.nsNames(ctx, res, parent)
|
||||
if err != nil || len(pns) == 0 {
|
||||
add("error", "Couldn't find the name servers of the parent zone %s through %s: %v", parent, res, errOr(err, "none listed"))
|
||||
return rep
|
||||
}
|
||||
|
||||
// Ask every parent server, in parallel.
|
||||
var wg sync.WaitGroup
|
||||
answers := make([]ParentAnswer, len(pns))
|
||||
for i, name := range pns {
|
||||
wg.Add(1)
|
||||
go func(i int, name string) {
|
||||
defer wg.Done()
|
||||
answers[i] = c.askParent(ctx, zone, name)
|
||||
}(i, name)
|
||||
}
|
||||
wg.Wait()
|
||||
rep.Parents = answers
|
||||
parent, answers := pr.Parent, pr.Servers
|
||||
|
||||
parentNS := map[string]bool{}
|
||||
glue := map[string]map[string]bool{}
|
||||
@@ -141,10 +187,6 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
notDelegated++
|
||||
}
|
||||
}
|
||||
if reached == 0 {
|
||||
add("error", "None of the parent zone's servers (%s) answered; this server may not be able to reach them.", pns)
|
||||
return rep
|
||||
}
|
||||
if delegated == 0 {
|
||||
rep.Delegated = false
|
||||
where := "the parent zone " + show(parent)
|
||||
@@ -154,6 +196,7 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
add("info", "%s isn't delegated from %s, so the internet can't see it. That's expected for a zone used only on your own network. To publish it, register the domain and give your registrar these name servers: %s.", zone, where, rep.JarlNS)
|
||||
return rep
|
||||
}
|
||||
rep.ParentDS = pr.DS
|
||||
rep.Delegated = true
|
||||
if notDelegated > 0 {
|
||||
add("warning", "%d of the parent's %d servers don't know the delegation yet (a recent change at the registrar can take a while to reach them all).", notDelegated, reached)
|
||||
@@ -207,6 +250,12 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
}
|
||||
}
|
||||
|
||||
if sig != nil {
|
||||
for _, f := range dsFindings(zone, pr, sig) {
|
||||
rep.Findings = append(rep.Findings, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Every delegated server, reached the way resolvers reach it: by its
|
||||
// glue, or by looking its name up.
|
||||
var targets []Target
|
||||
@@ -262,7 +311,151 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
return rep
|
||||
}
|
||||
|
||||
// askParent asks one of the parent's servers (by name) for zone's NS.
|
||||
// ParentDS finds the parent of zone through the resolver and asks each of
|
||||
// its servers for the zone's delegation and DS records.
|
||||
func (c *Checker) ParentDS(ctx context.Context, zone string) *ParentDSReport {
|
||||
zone = dns.CanonicalName(zone)
|
||||
rep := &ParentDSReport{Zone: zone, Servers: []ParentAnswer{}, DS: []dnssec.DS{}}
|
||||
res, err := c.resolver()
|
||||
if err != nil {
|
||||
rep.Error = "No resolver to find the parent zone with: " + err.Error()
|
||||
return rep
|
||||
}
|
||||
rep.Resolver = res
|
||||
if zone == "." {
|
||||
rep.Error = "The root zone has no parent to delegate it."
|
||||
return rep
|
||||
}
|
||||
// The parent zone: the zone that holds the name just above ours.
|
||||
parent, err := c.enclosingZone(ctx, res, parentName(zone))
|
||||
if err != nil {
|
||||
rep.Error = fmt.Sprintf("Couldn't find the parent zone of %s through %s: %v", show(zone), res, err)
|
||||
return rep
|
||||
}
|
||||
rep.Parent = parent
|
||||
pns, err := c.nsNames(ctx, res, parent)
|
||||
if err != nil || len(pns) == 0 {
|
||||
rep.Error = fmt.Sprintf("Couldn't find the name servers of the parent zone %s through %s: %v", show(parent), res, errOr(err, "none listed"))
|
||||
return rep
|
||||
}
|
||||
// Ask every parent server, in parallel.
|
||||
var wg sync.WaitGroup
|
||||
answers := make([]ParentAnswer, len(pns))
|
||||
for i, name := range pns {
|
||||
wg.Add(1)
|
||||
go func(i int, name string) {
|
||||
defer wg.Done()
|
||||
answers[i] = c.askParent(ctx, zone, name)
|
||||
}(i, name)
|
||||
}
|
||||
wg.Wait()
|
||||
rep.Servers = answers
|
||||
all := map[string]dnssec.DS{}
|
||||
sets := map[string]bool{}
|
||||
for _, a := range answers {
|
||||
if a.Status != "delegated" && a.Status != "not_delegated" {
|
||||
continue
|
||||
}
|
||||
rep.Reached++
|
||||
if a.Status == "delegated" {
|
||||
rep.Delegated = true
|
||||
}
|
||||
if a.DSError != "" {
|
||||
continue
|
||||
}
|
||||
var set []string
|
||||
for _, d := range a.DS {
|
||||
k := dsKey(d)
|
||||
all[k] = d
|
||||
set = append(set, k)
|
||||
}
|
||||
sort.Strings(set)
|
||||
sets[strings.Join(set, " ")] = true
|
||||
}
|
||||
if rep.Reached == 0 {
|
||||
names := make([]string, len(pns))
|
||||
for i, n := range pns {
|
||||
names[i] = show(n)
|
||||
}
|
||||
rep.Error = fmt.Sprintf("None of the parent zone's servers (%s) answered; this server may not be able to reach them.", strings.Join(names, ", "))
|
||||
return rep
|
||||
}
|
||||
for _, k := range keys(boolSet(all)) {
|
||||
rep.DS = append(rep.DS, all[k])
|
||||
}
|
||||
rep.Consistent = len(sets) <= 1
|
||||
return rep
|
||||
}
|
||||
|
||||
func dsKey(d dnssec.DS) string {
|
||||
return fmt.Sprintf("%05d %d %d %s", d.KeyTag, d.Algorithm, d.DigestType, strings.ToUpper(d.Digest))
|
||||
}
|
||||
|
||||
func boolSet(m map[string]dnssec.DS) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for k := range m {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dsFindings compares the parent's DS records with how Jarl signs the
|
||||
// zone.
|
||||
func dsFindings(zone string, pr *ParentDSReport, sig *Signing) []Finding {
|
||||
var out []Finding
|
||||
add := func(level, format string, args ...any) {
|
||||
out = append(out, Finding{level, fmt.Sprintf(format, args...)})
|
||||
}
|
||||
z := show(zone)
|
||||
var tags []string
|
||||
for _, d := range pr.DS {
|
||||
tags = append(tags, fmt.Sprint(d.KeyTag))
|
||||
}
|
||||
if !pr.Consistent {
|
||||
add("warning", "The parent's servers have different DS records for %s (a change at the registrar may still be on its way to them all).", z)
|
||||
}
|
||||
matches := func(d dnssec.DS) bool {
|
||||
for _, k := range sig.DS {
|
||||
if k.KeyTag == d.KeyTag && k.Algorithm == d.Algorithm && k.DigestType == d.DigestType && strings.EqualFold(k.Digest, d.Digest) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var good, stale []string
|
||||
for _, d := range pr.DS {
|
||||
if matches(d) {
|
||||
good = append(good, fmt.Sprint(d.KeyTag))
|
||||
} else {
|
||||
stale = append(stale, fmt.Sprint(d.KeyTag))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(pr.DS) == 0 && sig.Policy == "":
|
||||
// Unsigned and no DS: nothing to say about DNSSEC.
|
||||
case len(pr.DS) == 0 && sig.Policy == "insecure":
|
||||
add("ok", "The parent has no DS record for %s any more, so turning DNSSEC off can go ahead.", z)
|
||||
case len(pr.DS) == 0:
|
||||
add("info", "%s is signed, but the parent has no DS record for it, so resolvers treat it as unsigned. Add the DS record at your registrar when the zone's DNSSEC page says it's ready.", z)
|
||||
case sig.Policy == "":
|
||||
add("error", "The parent has a DS record for %s (key %s), but the zone isn't signed: validating resolvers reject all of its answers. Remove the DS record at your registrar now.", z, strings.Join(tags, ", "))
|
||||
case len(good) == 0:
|
||||
add("error", "The parent's DS record for %s (key %s) matches none of the zone's keys: validating resolvers reject all of its answers. Replace it at your registrar with the zone's DS record.", z, strings.Join(tags, ", "))
|
||||
default:
|
||||
if sig.Policy == "insecure" {
|
||||
add("warning", "The parent still has the DS record for %s (key %s). DNSSEC is being turned off: remove it at your registrar, then run the DS check.", z, strings.Join(good, ", "))
|
||||
} else {
|
||||
add("ok", "The parent's DS record matches the zone's key %s: validating resolvers check its answers.", strings.Join(good, ", "))
|
||||
}
|
||||
if len(stale) > 0 {
|
||||
add("warning", "The parent also has a DS record for key %s, which the zone no longer has. Remove it at your registrar.", strings.Join(stale, ", "))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// askParent asks one of the parent's servers (by name) for zone's NS and
|
||||
// DS records.
|
||||
func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnswer {
|
||||
pa := ParentAnswer{Name: server}
|
||||
addrs, err := c.lookup(ctx, server)
|
||||
@@ -331,6 +524,23 @@ func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnsw
|
||||
for n := range pa.Glue {
|
||||
pa.Glue[n] = canonAddrs(pa.Glue[n])
|
||||
}
|
||||
// The parent itself answers DS queries for the zone.
|
||||
m = new(dns.Msg)
|
||||
m.SetQuestion(zone, dns.TypeDS)
|
||||
m.RecursionDesired = false
|
||||
resp, _, err = c.exchange(ctx, m, pa.Address, nil)
|
||||
switch {
|
||||
case err != nil:
|
||||
pa.DSError = err.Error()
|
||||
case resp.Rcode != dns.RcodeSuccess:
|
||||
pa.DSError = "answered " + dns.RcodeToString[resp.Rcode]
|
||||
default:
|
||||
for _, rr := range resp.Answer {
|
||||
if d, ok := rr.(*dns.DS); ok && dns.CanonicalName(d.Hdr.Name) == zone {
|
||||
pa.DS = append(pa.DS, dnssec.DS{KeyTag: d.KeyTag, Algorithm: d.Algorithm, DigestType: d.DigestType, Digest: strings.ToUpper(d.Digest), Record: d.String()})
|
||||
}
|
||||
}
|
||||
}
|
||||
return pa
|
||||
}
|
||||
|
||||
|
||||
+152
-5
@@ -20,8 +20,10 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -93,6 +95,134 @@ type Checker struct {
|
||||
// IsLocal reports whether an address belongs to this host (default:
|
||||
// its interface addresses and loopback).
|
||||
IsLocal func(netip.Addr) bool
|
||||
// Watch, if set, is told about each DNS query the checks send and
|
||||
// its outcome as it happens (the web UI shows them while it waits).
|
||||
// It is called from several goroutines at once.
|
||||
Watch func(Event)
|
||||
}
|
||||
|
||||
// Event is a query a check starts or finishes (Checker.Watch).
|
||||
type Event struct {
|
||||
// Kind is "start" or "end" (of query ID).
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
// Query describes what is asked, as a dig command line.
|
||||
Query string `json:"query,omitempty"`
|
||||
// End: Answer sums up the answer ("192.0.2.1, 2001:db8::1",
|
||||
// "NXDOMAIN", "referral to a.nic.lol., …"), or Error says why there
|
||||
// is none. Serials' SOA queries give the server's Result instead.
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RTTMs int64 `json:"rtt_ms,omitempty"`
|
||||
Result *Result `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// eventIDs numbers events; 0 is left for the caller's own steps.
|
||||
var eventIDs atomic.Int64
|
||||
|
||||
func (c *Checker) startEvent(query string) int64 {
|
||||
if c.Watch == nil {
|
||||
return 0
|
||||
}
|
||||
id := eventIDs.Add(1)
|
||||
c.Watch(Event{Kind: "start", ID: id, Query: query})
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *Checker) endEvent(e Event) {
|
||||
if c.Watch != nil {
|
||||
e.Kind = "end"
|
||||
c.Watch(e)
|
||||
}
|
||||
}
|
||||
|
||||
// quietKey in a context stops exchangeHostPort from reporting the query:
|
||||
// the caller reports it with more to say (Serials' SOA queries).
|
||||
type quietKey struct{}
|
||||
|
||||
// digQuery is the dig command line for m sent to hostport.
|
||||
func digQuery(m *dns.Msg, hostport string, key *Key) string {
|
||||
host, port, err := net.SplitHostPort(hostport)
|
||||
if err != nil {
|
||||
host, port = hostport, ""
|
||||
}
|
||||
if len(m.Question) == 0 {
|
||||
return "dig @" + host
|
||||
}
|
||||
q := m.Question[0]
|
||||
s := digLine(host, port, q.Name, dns.TypeToString[q.Qtype], !m.RecursionDesired, key)
|
||||
if o := m.IsEdns0(); o != nil && o.Do() {
|
||||
s += " +dnssec"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sumAnswer sums up a DNS answer in a few words.
|
||||
func sumAnswer(resp *dns.Msg) string {
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
return dns.RcodeToString[resp.Rcode]
|
||||
}
|
||||
var vals []string
|
||||
for _, rr := range resp.Answer {
|
||||
switch v := rr.(type) {
|
||||
case *dns.A:
|
||||
vals = append(vals, v.A.String())
|
||||
case *dns.AAAA:
|
||||
vals = append(vals, v.AAAA.String())
|
||||
case *dns.NS:
|
||||
vals = append(vals, v.Ns)
|
||||
case *dns.SOA:
|
||||
vals = append(vals, "serial "+strconv.FormatUint(uint64(v.Serial), 10))
|
||||
case *dns.DS:
|
||||
vals = append(vals, "DS "+strconv.Itoa(int(v.KeyTag)))
|
||||
case *dns.RRSIG:
|
||||
default:
|
||||
vals = append(vals, dns.TypeToString[rr.Header().Rrtype])
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
var ns []string
|
||||
for _, rr := range resp.Ns {
|
||||
switch v := rr.(type) {
|
||||
case *dns.NS:
|
||||
ns = append(ns, v.Ns)
|
||||
case *dns.SOA:
|
||||
return "no such record"
|
||||
}
|
||||
}
|
||||
if len(ns) > 0 {
|
||||
return "referral to " + shorten(ns)
|
||||
}
|
||||
return "empty answer"
|
||||
}
|
||||
s := shorten(vals)
|
||||
if !resp.Authoritative && !resp.RecursionAvailable {
|
||||
s += " (not authoritative)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func shorten(vals []string) string {
|
||||
if len(vals) > 3 {
|
||||
return strings.Join(vals[:3], ", ") + " and " + strconv.Itoa(len(vals)-3) + " more"
|
||||
}
|
||||
return strings.Join(vals, ", ")
|
||||
}
|
||||
|
||||
// digLine is a dig command line for a query (no TSIG secret).
|
||||
func digLine(server, port, name, types string, norec bool, key *Key) string {
|
||||
s := "dig @" + server
|
||||
if port != "" && port != "53" {
|
||||
s += " -p " + port
|
||||
}
|
||||
s += " " + name + " " + types
|
||||
if norec {
|
||||
s += " +norec"
|
||||
}
|
||||
if key != nil {
|
||||
s += " -y " + key.Algorithm + ":" + strings.TrimSuffix(key.Name, ".") + ":…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Checker) timeout() time.Duration {
|
||||
@@ -162,16 +292,25 @@ func (c *Checker) exchange(ctx context.Context, m *dns.Msg, addr string, key *Ke
|
||||
return c.exchangeHostPort(ctx, m, net.JoinHostPort(addr, c.port()), key)
|
||||
}
|
||||
|
||||
func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (*dns.Msg, time.Duration, error) {
|
||||
func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (resp *dns.Msg, rtt time.Duration, err error) {
|
||||
if c.Watch != nil && ctx.Value(quietKey{}) == nil {
|
||||
id := c.startEvent(digQuery(m, hostport, key))
|
||||
defer func() {
|
||||
e := Event{ID: id, RTTMs: rtt.Milliseconds()}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
} else {
|
||||
e.Answer = sumAnswer(resp)
|
||||
}
|
||||
c.endEvent(e)
|
||||
}()
|
||||
}
|
||||
cl := &dns.Client{Timeout: c.timeout()}
|
||||
if key != nil {
|
||||
name := dns.Fqdn(key.Name)
|
||||
cl.TsigSecret = map[string]string{name: key.Secret}
|
||||
m.SetTsig(name, dns.Fqdn(key.Algorithm), 300, time.Now().Unix())
|
||||
}
|
||||
var resp *dns.Msg
|
||||
var rtt time.Duration
|
||||
var err error
|
||||
for try := 0; try < 2; try++ {
|
||||
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
@@ -228,7 +367,7 @@ func (c *Checker) soa(ctx context.Context, zone string, key *Key, ref *uint32, r
|
||||
if port == "" {
|
||||
port = c.port()
|
||||
}
|
||||
resp, rtt, err := c.exchangeHostPort(ctx, m, net.JoinHostPort(r.Address, port), key)
|
||||
resp, rtt, err := c.exchangeHostPort(context.WithValue(ctx, quietKey{}, true), m, net.JoinHostPort(r.Address, port), key)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
switch {
|
||||
@@ -320,9 +459,17 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
port := r.Port
|
||||
if port == "" {
|
||||
port = c.port()
|
||||
}
|
||||
// Reported here rather than by exchange: the end carries the
|
||||
// server's status.
|
||||
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, t.Key))
|
||||
go func(r Result, key *Key) {
|
||||
defer wg.Done()
|
||||
c.soa(ctx, zone, key, ref, &r)
|
||||
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
|
||||
add(r)
|
||||
}(r, t.Key)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
)
|
||||
|
||||
// fake is a DNS server on one loopback address that answers with fn.
|
||||
@@ -187,7 +190,7 @@ func TestDelegationMismatch(t *testing.T) {
|
||||
ref := uint32(7)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.",
|
||||
[]string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
|
||||
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref)
|
||||
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref, nil)
|
||||
if rep.Parent != "lol." || !rep.Delegated {
|
||||
t.Fatalf("report: %+v", rep)
|
||||
}
|
||||
@@ -218,7 +221,7 @@ func TestDelegationGood(t *testing.T) {
|
||||
return m
|
||||
})
|
||||
ref := uint32(7)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, nil)
|
||||
l := levels(rep)
|
||||
if len(l["error"])+len(l["warning"])+len(l["info"]) != 0 || len(l["ok"]) != 2 {
|
||||
t.Errorf("findings: %v", l)
|
||||
@@ -234,7 +237,7 @@ func TestNotDelegated(t *testing.T) {
|
||||
m.Ns = []dns.RR{rr("lol. 300 IN SOA ns.nic.lol. hostmaster.lol. 1 3600 900 1209600 300")}
|
||||
return m
|
||||
})
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil, nil)
|
||||
l := levels(rep)
|
||||
if rep.Delegated || len(l["info"]) != 1 || len(l["warning"])+len(l["error"]) != 0 {
|
||||
t.Errorf("findings: %v", l)
|
||||
@@ -243,3 +246,126 @@ func TestNotDelegated(t *testing.T) {
|
||||
t.Errorf("note: %s", l["info"][0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationDS(t *testing.T) {
|
||||
const dsData = "22642 13 2 0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A"
|
||||
parent := func(withDS bool) fake {
|
||||
return func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
switch {
|
||||
case q.Qtype == dns.TypeNS && q.Name == "adbraun.lol.":
|
||||
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol.")}
|
||||
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
|
||||
case q.Qtype == dns.TypeDS && q.Name == "adbraun.lol.":
|
||||
m.Authoritative = true
|
||||
if withDS {
|
||||
m.Answer = []dns.RR{rr("adbraun.lol. 3600 IN DS " + dsData)}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
ours := dnssec.DS{KeyTag: 22642, Algorithm: 13, DigestType: 2, Digest: strings.Fields(dsData)[3]}
|
||||
other := ours
|
||||
other.KeyTag, other.Digest = 1111, strings.Repeat("AB", 32)
|
||||
ref := uint32(7)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
withDS bool
|
||||
sig Signing
|
||||
level string
|
||||
want string
|
||||
}{
|
||||
{"secure", true, Signing{Policy: "default", DS: []dnssec.DS{ours}}, "ok", "matches the zone's key 22642"},
|
||||
{"wrong key", true, Signing{Policy: "default", DS: []dnssec.DS{other}}, "error", "matches none of the zone's keys"},
|
||||
{"unsigned", true, Signing{}, "error", "but the zone isn't signed"},
|
||||
{"turning off", true, Signing{Policy: "insecure", DS: []dnssec.DS{ours}}, "warning", "remove it at your registrar"},
|
||||
{"no DS yet", false, Signing{Policy: "default", DS: []dnssec.DS{ours}}, "info", "the parent has no DS record for it"},
|
||||
{"DS gone", false, Signing{Policy: "insecure"}, "ok", "turning DNSSEC off can go ahead"},
|
||||
} {
|
||||
port, res := internet(t, parent(c.withDS))
|
||||
sig := c.sig
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, &sig)
|
||||
found := false
|
||||
for _, f := range rep.Findings {
|
||||
if f.Level == c.level && strings.Contains(f.Message, c.want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: no %s finding %q in %+v", c.name, c.level, c.want, rep.Findings)
|
||||
}
|
||||
if c.withDS && (len(rep.ParentDS) != 1 || rep.ParentDS[0].KeyTag != 22642) {
|
||||
t.Errorf("%s: parent DS %+v", c.name, rep.ParentDS)
|
||||
}
|
||||
}
|
||||
// Unsigned and no DS: nothing about DNSSEC.
|
||||
port, res := internet(t, parent(false))
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, &Signing{})
|
||||
for _, f := range rep.Findings {
|
||||
if strings.Contains(f.Message, "DS") {
|
||||
t.Errorf("unsigned zone: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatch: with Watch set, every query is reported as it starts and
|
||||
// ends, as a dig command line with a summary of the answer.
|
||||
func TestWatch(t *testing.T) {
|
||||
port, res := internet(t, func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
if q.Qtype == dns.TypeNS && q.Name == "adbraun.lol." {
|
||||
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol.")}
|
||||
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
|
||||
}
|
||||
return m
|
||||
})
|
||||
var mu sync.Mutex
|
||||
var events []Event
|
||||
c := checker(port, res)
|
||||
c.Watch = func(e Event) {
|
||||
mu.Lock()
|
||||
events = append(events, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
ref := uint32(7)
|
||||
c.Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, nil)
|
||||
open := map[int64]string{}
|
||||
var queries, answers []string
|
||||
for _, e := range events {
|
||||
switch e.Kind {
|
||||
case "start":
|
||||
if !strings.HasPrefix(e.Query, "dig @") {
|
||||
t.Errorf("query %q", e.Query)
|
||||
}
|
||||
open[e.ID] = e.Query
|
||||
queries = append(queries, e.Query)
|
||||
case "end":
|
||||
q, ok := open[e.ID]
|
||||
if !ok {
|
||||
t.Errorf("end without start: %+v", e)
|
||||
}
|
||||
delete(open, e.ID)
|
||||
a := e.Answer + e.Error
|
||||
if e.Result != nil {
|
||||
a = e.Result.Status
|
||||
}
|
||||
answers = append(answers, q+" => "+a)
|
||||
}
|
||||
}
|
||||
if len(open) != 0 {
|
||||
t.Errorf("never ended: %v", open)
|
||||
}
|
||||
all := strings.Join(answers, "\n")
|
||||
for _, want := range []string{
|
||||
" adbraun.lol. NS +norec => referral to ns1.adbraun.lol.", // asking a parent server
|
||||
"@127.0.0.12 -p " + port + " adbraun.lol. SOA +norec => in_sync", // asking the delegated server
|
||||
} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("no %q in\n%s", want, all)
|
||||
}
|
||||
}
|
||||
if len(queries) < 4 {
|
||||
t.Errorf("only %d queries:\n%s", len(queries), all)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Fake is an in-memory stand-in for the Porkbun API, for tests: it knows
|
||||
// one account's domains and checks the key like Porkbun does.
|
||||
type Fake struct {
|
||||
APIKey, Secret string
|
||||
mu sync.Mutex
|
||||
Domains map[string]*FakeDomain
|
||||
// Calls lists the paths called, in order.
|
||||
Calls []string
|
||||
}
|
||||
|
||||
// FakeDomain is a domain in a Fake.
|
||||
type FakeDomain struct {
|
||||
NS []string
|
||||
Glue map[string][]string // host -> addresses
|
||||
DS map[uint16]DS
|
||||
APIAccess bool
|
||||
}
|
||||
|
||||
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
||||
// own name servers, no glue or DS).
|
||||
func NewFake(key, secret string, domains ...string) *Fake {
|
||||
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
||||
for _, d := range domains {
|
||||
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
reply := func(status int, v map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
fail := func(status int, code, msg string) {
|
||||
reply(status, map[string]any{"status": "ERROR", "code": code, "message": msg})
|
||||
}
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/json/v3")
|
||||
f.Calls = append(f.Calls, path)
|
||||
if body["apikey"] != f.APIKey || body["secretapikey"] != f.Secret {
|
||||
fail(http.StatusForbidden, "INVALID_API_KEYS_001", "Invalid API key.")
|
||||
return
|
||||
}
|
||||
if path == "/ping" {
|
||||
reply(200, map[string]any{"status": "SUCCESS", "yourIp": "192.0.2.1", "credentialsValid": true, "account": "tester", "scopes": []string{"full"}})
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) < 3 {
|
||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||
return
|
||||
}
|
||||
op, domain := parts[0]+"/"+parts[1], parts[2]
|
||||
d := f.Domains[domain]
|
||||
if d == nil {
|
||||
fail(400, "DOMAIN_NOT_FOUND", "Domain not found in this account.")
|
||||
return
|
||||
}
|
||||
if !d.APIAccess {
|
||||
fail(403, "API_ACCESS_DISABLED", "The domain is not opted in to API access.")
|
||||
return
|
||||
}
|
||||
host := ""
|
||||
if len(parts) > 3 {
|
||||
host = parts[3] + "." + domain
|
||||
}
|
||||
ips := func() []string {
|
||||
var out []string
|
||||
if l, ok := body["ips"].([]any); ok {
|
||||
for _, v := range l {
|
||||
out = append(out, v.(string))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
switch op {
|
||||
case "domain/getNs":
|
||||
reply(200, map[string]any{"status": "SUCCESS", "ns": d.NS})
|
||||
case "domain/updateNs":
|
||||
var ns []string
|
||||
for _, v := range body["ns"].([]any) {
|
||||
n := v.(string)
|
||||
if strings.HasSuffix(n, "."+domain) && d.Glue[n] == nil {
|
||||
fail(400, "INVALID_NAMESERVER", "Host "+n+" doesn't exist at the registry.")
|
||||
return
|
||||
}
|
||||
ns = append(ns, n)
|
||||
}
|
||||
d.NS = ns
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/getGlue":
|
||||
hosts := []any{}
|
||||
for h, a := range d.Glue {
|
||||
v4, v6 := []string{}, []string{}
|
||||
for _, ip := range a {
|
||||
if strings.Contains(ip, ":") {
|
||||
v6 = append(v6, ip)
|
||||
} else {
|
||||
v4 = append(v4, ip)
|
||||
}
|
||||
}
|
||||
hosts = append(hosts, []any{h, map[string]any{"v4": v4, "v6": v6}})
|
||||
}
|
||||
reply(200, map[string]any{"status": "SUCCESS", "hosts": hosts})
|
||||
case "domain/createGlue":
|
||||
if d.Glue[host] != nil {
|
||||
fail(400, "GLUE_EXISTS", "Host already exists.")
|
||||
return
|
||||
}
|
||||
d.Glue[host] = ips()
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/updateGlue":
|
||||
if d.Glue[host] == nil {
|
||||
fail(400, "GLUE_NOT_FOUND", "Host not found.")
|
||||
return
|
||||
}
|
||||
d.Glue[host] = ips()
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/deleteGlue":
|
||||
for _, n := range d.NS {
|
||||
if n == host {
|
||||
fail(400, "GLUE_IN_USE", "Host is in use as a name server.")
|
||||
return
|
||||
}
|
||||
}
|
||||
delete(d.Glue, host)
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "dns/getDnssecRecords":
|
||||
if len(d.DS) == 0 {
|
||||
reply(200, map[string]any{"status": "SUCCESS", "records": []any{}})
|
||||
return
|
||||
}
|
||||
recs := map[string]any{}
|
||||
for tag, ds := range d.DS {
|
||||
recs[strconv.Itoa(int(tag))] = map[string]any{"keyTag": strconv.Itoa(int(tag)), "alg": strconv.Itoa(int(ds.Algorithm)), "digestType": strconv.Itoa(int(ds.DigestType)), "digest": strings.ToLower(ds.Digest)}
|
||||
}
|
||||
reply(200, map[string]any{"status": "SUCCESS", "records": recs})
|
||||
case "dns/createDnssecRecord":
|
||||
num := func(k string) int { n, _ := strconv.Atoi(body[k].(string)); return n }
|
||||
tag := uint16(num("keyTag"))
|
||||
d.DS[tag] = DS{KeyTag: tag, Algorithm: uint8(num("alg")), DigestType: uint8(num("digestType")), Digest: strings.ToUpper(body["digest"].(string))}
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "dns/deleteDnssecRecord":
|
||||
tag, _ := strconv.Atoi(parts[3])
|
||||
delete(d.DS, uint16(tag))
|
||||
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
||||
default:
|
||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Package porkbun is a small client for the parts of Porkbun's API v3
|
||||
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
||||
// a domain's delegation: its name servers, glue records (host objects at
|
||||
// the registry) and DNSSEC DS records.
|
||||
//
|
||||
// Every call is a POST with the API key and secret in the JSON body. API
|
||||
// access must be switched on for each domain in Porkbun's web interface,
|
||||
// and a key can be limited to some domains, permissions and source
|
||||
// addresses there.
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultURL is the API's base URL.
|
||||
const DefaultURL = "https://api.porkbun.com/api/json/v3"
|
||||
|
||||
// Client calls the Porkbun API.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Secret string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// Error is an error the API returned.
|
||||
type Error struct {
|
||||
Status int // HTTP status
|
||||
Code string // e.g. API_ACCESS_DISABLED
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
msg := "Porkbun: " + e.Message
|
||||
if e.Code != "" {
|
||||
msg += " (" + e.Code + ")"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Glue is a glue record (a host object at the registry).
|
||||
type Glue struct {
|
||||
Host string `json:"host"` // full host name, without a trailing dot
|
||||
V4 []string `json:"v4"`
|
||||
V6 []string `json:"v6"`
|
||||
}
|
||||
|
||||
// Addresses returns all of the glue's addresses.
|
||||
func (g Glue) Addresses() []string { return append(append([]string{}, g.V4...), g.V6...) }
|
||||
|
||||
// DS is a DS record at the registry.
|
||||
type DS struct {
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
DigestType uint8 `json:"digest_type"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
Warnings []string `json:"warnings"`
|
||||
NS []string `json:"ns"`
|
||||
Hosts json.RawMessage `json:"hosts"`
|
||||
Records json.RawMessage `json:"records"`
|
||||
YourIP string `json:"yourIp"`
|
||||
Valid bool `json:"credentialsValid"`
|
||||
Account string `json:"account"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
body["apikey"], body["secretapikey"] = c.APIKey, c.Secret
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := c.BaseURL
|
||||
if base == "" {
|
||||
base = DefaultURL
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(base, "/")+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "jarl")
|
||||
hc := c.HTTP
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: %w", err)
|
||||
}
|
||||
var r response
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, &Error{Status: resp.StatusCode, Message: fmt.Sprintf("unexpected answer (HTTP %d)", resp.StatusCode)}
|
||||
}
|
||||
if r.Status != "SUCCESS" {
|
||||
msg := r.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("request failed (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
return &r, &Error{Status: resp.StatusCode, Code: r.Code, Message: msg}
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func domainPath(op, domain string, rest ...string) string {
|
||||
p := op + "/" + url.PathEscape(strings.ToLower(strings.TrimSuffix(domain, ".")))
|
||||
for _, r := range rest {
|
||||
p += "/" + url.PathEscape(r)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Ping checks the credentials and returns the account name and the key's
|
||||
// permission scopes.
|
||||
func (c *Client) Ping(ctx context.Context) (account string, scopes []string, err error) {
|
||||
r, err := c.call(ctx, "/ping", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !r.Valid {
|
||||
return "", nil, &Error{Code: "INVALID_API_KEYS_001", Message: "the API key wasn't accepted"}
|
||||
}
|
||||
return r.Account, r.Scopes, nil
|
||||
}
|
||||
|
||||
// NS returns the domain's name servers at the registry (lowercase,
|
||||
// without trailing dots, sorted).
|
||||
func (c *Client) NS(ctx context.Context, domain string) ([]string, error) {
|
||||
r, err := c.call(ctx, domainPath("/domain/getNs", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []string{}
|
||||
for _, n := range r.NS {
|
||||
out = append(out, normName(n))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetNS replaces the domain's name servers at the registry.
|
||||
func (c *Client) SetNS(ctx context.Context, domain string, ns []string) error {
|
||||
list := make([]string, len(ns))
|
||||
for i, n := range ns {
|
||||
list[i] = normName(n)
|
||||
}
|
||||
_, err := c.call(ctx, domainPath("/domain/updateNs", domain), map[string]any{"ns": list})
|
||||
return err
|
||||
}
|
||||
|
||||
// Glue returns the domain's glue records.
|
||||
func (c *Client) Glue(ctx context.Context, domain string) ([]Glue, error) {
|
||||
r, err := c.call(ctx, domainPath("/domain/getGlue", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Glue{}
|
||||
if len(r.Hosts) == 0 || string(r.Hosts) == "null" {
|
||||
return out, nil
|
||||
}
|
||||
var tuples [][]json.RawMessage
|
||||
if err := json.Unmarshal(r.Hosts, &tuples); err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: unexpected glue records: %w", err)
|
||||
}
|
||||
for _, t := range tuples {
|
||||
if len(t) != 2 {
|
||||
continue
|
||||
}
|
||||
var g Glue
|
||||
var ips struct{ V4, V6 []string }
|
||||
if json.Unmarshal(t[0], &g.Host) != nil || json.Unmarshal(t[1], &ips) != nil {
|
||||
continue
|
||||
}
|
||||
g.Host, g.V4, g.V6 = normName(g.Host), nonNil(ips.V4), nonNil(ips.V6)
|
||||
out = append(out, g)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetGlue creates the glue record for host (a name inside domain) or, when
|
||||
// exists, replaces its addresses.
|
||||
func (c *Client) SetGlue(ctx context.Context, domain, host string, ips []string, exists bool) error {
|
||||
sub, err := subdomain(domain, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op := "/domain/createGlue"
|
||||
if exists {
|
||||
op = "/domain/updateGlue"
|
||||
}
|
||||
_, err = c.call(ctx, domainPath(op, domain, sub), map[string]any{"ips": ips})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteGlue deletes the glue record for host. The registry refuses while
|
||||
// the domain (or another one) uses it as a name server.
|
||||
func (c *Client) DeleteGlue(ctx context.Context, domain, host string) error {
|
||||
sub, err := subdomain(domain, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.call(ctx, domainPath("/domain/deleteGlue", domain, sub), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DS returns the domain's DS records at the registry.
|
||||
func (c *Client) DS(ctx context.Context, domain string) ([]DS, error) {
|
||||
r, err := c.call(ctx, domainPath("/dns/getDnssecRecords", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []DS{}
|
||||
type rec struct {
|
||||
KeyTag json.Number `json:"keyTag"`
|
||||
Alg json.Number `json:"alg"`
|
||||
DigestType json.Number `json:"digestType"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
var recs []rec
|
||||
// An object keyed by key tag; an empty one may come as [].
|
||||
var byTag map[string]rec
|
||||
if err := json.Unmarshal(r.Records, &byTag); err == nil {
|
||||
for _, v := range byTag {
|
||||
recs = append(recs, v)
|
||||
}
|
||||
} else if err := json.Unmarshal(r.Records, &recs); err != nil && len(r.Records) > 0 && string(r.Records) != "null" {
|
||||
return nil, fmt.Errorf("Porkbun: unexpected DNSSEC records: %w", err)
|
||||
}
|
||||
for _, v := range recs {
|
||||
tag, _ := strconv.ParseUint(v.KeyTag.String(), 10, 16)
|
||||
alg, _ := strconv.ParseUint(v.Alg.String(), 10, 8)
|
||||
dt, _ := strconv.ParseUint(v.DigestType.String(), 10, 8)
|
||||
out = append(out, DS{KeyTag: uint16(tag), Algorithm: uint8(alg), DigestType: uint8(dt), Digest: strings.ToUpper(v.Digest)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].KeyTag < out[j].KeyTag })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddDS adds a DS record at the registry. Warnings (a value the registry is
|
||||
// retiring) come back with a nil error.
|
||||
func (c *Client) AddDS(ctx context.Context, domain string, ds DS) ([]string, error) {
|
||||
r, err := c.call(ctx, domainPath("/dns/createDnssecRecord", domain), map[string]any{
|
||||
"keyTag": strconv.Itoa(int(ds.KeyTag)), "alg": strconv.Itoa(int(ds.Algorithm)),
|
||||
"digestType": strconv.Itoa(int(ds.DigestType)), "digest": ds.Digest,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Warnings, nil
|
||||
}
|
||||
|
||||
// DeleteDS deletes the DS record with key tag at the registry. Porkbun
|
||||
// warns that most registries delete every record with the same key data.
|
||||
func (c *Client) DeleteDS(ctx context.Context, domain string, keyTag uint16) error {
|
||||
_, err := c.call(ctx, domainPath("/dns/deleteDnssecRecord", domain, strconv.Itoa(int(keyTag))), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// subdomain returns host's label(s) below domain ("ns1" for
|
||||
// ns1.example.com in example.com).
|
||||
func subdomain(domain, host string) (string, error) {
|
||||
d, h := normName(domain), normName(host)
|
||||
sub, ok := strings.CutSuffix(h, "."+d)
|
||||
if !ok || sub == "" {
|
||||
return "", fmt.Errorf("%s isn't inside %s, so it can't have a glue record there", h, d)
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(n), ".")) }
|
||||
|
||||
func nonNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClient(t *testing.T) {
|
||||
f := NewFake("pk1_x", "sk1_y", "adbraun.lol")
|
||||
ts := httptest.NewServer(f)
|
||||
defer ts.Close()
|
||||
c := &Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_x", Secret: "sk1_y"}
|
||||
ctx := context.Background()
|
||||
|
||||
if acct, _, err := c.Ping(ctx); err != nil || acct != "tester" {
|
||||
t.Fatalf("ping: %q %v", acct, err)
|
||||
}
|
||||
ns, err := c.NS(ctx, "adbraun.lol.")
|
||||
if err != nil || strings.Join(ns, " ") != "curitiba.ns.porkbun.com fortaleza.ns.porkbun.com" {
|
||||
t.Errorf("NS: %v %v", ns, err)
|
||||
}
|
||||
// Two glue records, one with IPv4 and IPv6.
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns1.adbraun.lol.", []string{"203.0.113.1", "2001:db8::1"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns2.adbraun.lol", []string{"203.0.113.2"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns2.adbraun.lol", []string{"203.0.113.3"}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns.elsewhere.net", nil, false); err == nil {
|
||||
t.Error("glue outside the domain accepted")
|
||||
}
|
||||
glue, err := c.Glue(ctx, "adbraun.lol")
|
||||
if err != nil || len(glue) != 2 || glue[0].Host != "ns1.adbraun.lol" || strings.Join(glue[0].Addresses(), " ") != "203.0.113.1 2001:db8::1" || glue[1].V4[0] != "203.0.113.3" {
|
||||
t.Errorf("glue: %+v %v", glue, err)
|
||||
}
|
||||
if err := c.SetNS(ctx, "adbraun.lol", []string{"ns1.adbraun.lol.", "ns2.adbraun.lol"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.DeleteGlue(ctx, "adbraun.lol", "ns2.adbraun.lol"); err == nil {
|
||||
t.Error("deleted glue in use")
|
||||
}
|
||||
// DS records: empty comes as [], others keyed by tag.
|
||||
if ds, err := c.DS(ctx, "adbraun.lol"); err != nil || len(ds) != 0 {
|
||||
t.Errorf("no DS: %v %v", ds, err)
|
||||
}
|
||||
if _, err := c.AddDS(ctx, "adbraun.lol", DS{KeyTag: 22642, Algorithm: 13, DigestType: 2, Digest: "ABCDEF"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ds, err := c.DS(ctx, "adbraun.lol"); err != nil || len(ds) != 1 || ds[0].KeyTag != 22642 || ds[0].Algorithm != 13 || ds[0].Digest != "ABCDEF" {
|
||||
t.Errorf("DS: %+v %v", ds, err)
|
||||
}
|
||||
if err := c.DeleteDS(ctx, "adbraun.lol", 22642); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Errors carry Porkbun's code.
|
||||
f.Domains["adbraun.lol"].APIAccess = false
|
||||
_, err = c.NS(ctx, "adbraun.lol")
|
||||
var pe *Error
|
||||
if !errors.As(err, &pe) || pe.Code != "API_ACCESS_DISABLED" {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
bad := &Client{BaseURL: c.BaseURL, APIKey: "pk1_x", Secret: "wrong"}
|
||||
if _, _, err := bad.Ping(ctx); err == nil || !strings.Contains(err.Error(), "INVALID_API_KEYS") {
|
||||
t.Errorf("bad key: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@ type Span struct {
|
||||
// Output is the result of rendering.
|
||||
type Output struct {
|
||||
Files []*File
|
||||
// Dirs are directories named needs to exist (for secondary zone files).
|
||||
// Dirs are directories named must be able to write to (secondary zone
|
||||
// files, DNSSEC key directories).
|
||||
Dirs []string
|
||||
// Spans maps file paths to source spans.
|
||||
Spans map[string][]Span
|
||||
@@ -103,6 +104,22 @@ func ViewDir(viewName string) string {
|
||||
return dnsname.FileSafe(dnsname.Fqdn(strings.ToLower(viewName)))
|
||||
}
|
||||
|
||||
// KeysDir is the directory under which named keeps the DNSSEC keys of the
|
||||
// zones Jarl signs, one subdirectory per view.
|
||||
func (p Paths) KeysDir() string { return filepath.Join(p.ZoneDir, "dnssec-keys") }
|
||||
|
||||
// KeyDir returns the key-directory of zone z: one per view, because named
|
||||
// refuses one directory for zones of the same name in two views. It is
|
||||
// named after the view's ID, which unlike its name never changes: a new
|
||||
// directory would make named create new keys, which the parent's DS
|
||||
// record wouldn't match.
|
||||
func KeyDir(s *model.State, z *model.Zone, p Paths) string {
|
||||
if z.ViewID == 0 {
|
||||
return filepath.Join(p.KeysDir(), "_default")
|
||||
}
|
||||
return filepath.Join(p.KeysDir(), fmt.Sprintf("view-%d", z.ViewID))
|
||||
}
|
||||
|
||||
// ZoneFilePath returns the file named loads for zone z, or "" for zone
|
||||
// types without files.
|
||||
func ZoneFilePath(s *model.State, z *model.Zone, p Paths) string {
|
||||
@@ -387,6 +404,14 @@ func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Outp
|
||||
}
|
||||
}
|
||||
}
|
||||
if z.Signed() {
|
||||
// named signs the zone itself (inline signing): it keeps the
|
||||
// signed copy next to Jarl's file (.signed) and its keys in dir.
|
||||
dir := KeyDir(s, z, p)
|
||||
w.write(fmt.Sprintf("%s\tdnssec-policy %s;\n", tabs, namedconf.QuoteString(z.DNSSECPolicy)))
|
||||
w.write(fmt.Sprintf("%s\tkey-directory %s;\n", tabs, namedconf.QuoteString(dir)))
|
||||
out.Dirs = appendUnique(out.Dirs, dir)
|
||||
}
|
||||
for _, o := range s.ScopedOptions(model.ScopeZone, z.ID) {
|
||||
w.begin("option", o.ID, label+" option "+o.Name)
|
||||
w.write(optionText(o, indent+1))
|
||||
|
||||
@@ -266,6 +266,10 @@ func (s *Server) authed(h handler) http.HandlerFunc {
|
||||
// The CLI warns its user when it sees this.
|
||||
w.Header().Set(api.TokenExpiresHeader, p.Token.ExpiresAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
if s.setupOnly(p) && !setupOnlyAllowed(r) {
|
||||
s.writeErr(w, r, errSetupOnly, nil)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), keyPrincipal, p)
|
||||
if se != nil {
|
||||
ctx = context.WithValue(ctx, keySession, se)
|
||||
@@ -327,6 +331,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||
}
|
||||
if u.HasTOTP() {
|
||||
// The password was right; the session comes with the code
|
||||
// (POST /auth/login/totp).
|
||||
s.loginChallenge(w, u)
|
||||
return nil
|
||||
}
|
||||
s.limiter.reset(userKey)
|
||||
id, csrf := auth.NewSessionID()
|
||||
now := time.Now()
|
||||
@@ -338,7 +348,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
|
||||
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
|
||||
s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", "")
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
)
|
||||
|
||||
func TestDNSSECEndpoints(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
// Nothing answers DNS here, so the parent can't be checked.
|
||||
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Resolver: "127.0.0.1:1"}
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
e.do("GET", "/zones/example.com/dnssec?view=", e.token, nil, 200)
|
||||
|
||||
e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "default"}, 200)
|
||||
got := e.do("GET", "/zones/example.com/dnssec", e.token, nil, 200)
|
||||
if got["policy"] != "default" || got["phase"].(map[string]any)["code"] != dnssec.PhaseStarting || !strings.HasSuffix(got["key_dir"].(string), "zones/dnssec-keys/_default") {
|
||||
t.Errorf("status: %v", got)
|
||||
}
|
||||
// Switching off at once is refused; insecure is the way.
|
||||
out := e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "off"}, 422)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "switching DNSSEC off at once") {
|
||||
t.Errorf("switching off: %v", out)
|
||||
}
|
||||
cd := e.do("POST", "/zones/example.com/dnssec/check-ds", e.token, nil, 200)
|
||||
if cd["parent"].(map[string]any)["error"] == "" || len(cd["marked"].([]any)) != 0 {
|
||||
t.Errorf("check-ds without a resolver: %v", cd)
|
||||
}
|
||||
// Wipe: refused while the parent can't be checked, unless forced.
|
||||
out = e.do("POST", "/zones/example.com/dnssec/wipe", e.token, nil, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "wipe with force") {
|
||||
t.Errorf("wipe: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true, "dry_run": true}, 200)
|
||||
if e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"] != "default" {
|
||||
t.Error("a dry run changed the zone")
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 200)
|
||||
if p, ok := e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"]; ok && p != "" {
|
||||
t.Errorf("after wipe: %v", p)
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 409) // nothing left
|
||||
|
||||
// Secondary zones aren't signed by Jarl.
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "copy.example", "type": "secondary", "options": []map[string]any{{"name": "primaries", "value": "{ 192.0.2.1; }"}}}, 201)
|
||||
e.do("GET", "/zones/copy.example/dnssec", e.token, nil, 400)
|
||||
e.do("PATCH", "/zones/copy.example", e.token, map[string]any{"dnssec_policy": "default"}, 422)
|
||||
}
|
||||
|
||||
func TestDSChanges(t *testing.T) {
|
||||
ds := dnssec.DS{KeyTag: 1, Algorithm: 13, DigestType: 2, Digest: "AA"}
|
||||
key := func(goal, dsState string) *dnssec.Key {
|
||||
d := ds
|
||||
return &dnssec.Key{Tag: 1, Role: "CSK", DS: &d, Goal: goal, DSState: dsState}
|
||||
}
|
||||
with := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated", DS: []dnssec.DS{ds}}}, DS: []dnssec.DS{ds}, Consistent: true}
|
||||
half := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated"}}, DS: []dnssec.DS{ds}}
|
||||
without := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated"}}, Consistent: true}
|
||||
signed := &model.Zone{DNSSECPolicy: "default"}
|
||||
off := &model.Zone{DNSSECPolicy: "insecure"}
|
||||
|
||||
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, with); len(p) != 1 || len(w) != 0 {
|
||||
t.Errorf("DS added: %v %v", p, w)
|
||||
}
|
||||
if p, _ := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, half); len(p) != 0 {
|
||||
t.Error("published while not every parent server has it")
|
||||
}
|
||||
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, without); len(p)+len(w) != 0 {
|
||||
t.Error("a signed zone without a DS yet is just waiting")
|
||||
}
|
||||
if p, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, without); len(p) != 0 || len(w) != 1 {
|
||||
t.Errorf("DS removed: %v %v", p, w)
|
||||
}
|
||||
if _, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, half); len(w) != 0 {
|
||||
t.Error("withdrawn while a parent server still has it")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
se, _ := r.Context().Value(keySession).(*model.Session)
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -136,6 +136,11 @@ func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
// A token outlives the session: with two-factor login, making one
|
||||
// needs a recent code.
|
||||
if err := s.stepUp(r, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return badRequest("a token needs a name")
|
||||
}
|
||||
@@ -297,6 +302,9 @@ func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
if t.RevokedAt != nil {
|
||||
return conflict("token %d is revoked; create a new one", id)
|
||||
}
|
||||
if err := s.stepUp(r, p); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
var exp *time.Time
|
||||
switch {
|
||||
@@ -365,7 +373,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
||||
}
|
||||
out := []api.User{}
|
||||
for _, u := range us {
|
||||
out = append(out, presentUser(u))
|
||||
out = append(out, s.userOut(u))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
@@ -401,6 +409,9 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
if err := applyUserInput(u, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.stepUp(r, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return conflict("user %s already exists", u.Username)
|
||||
@@ -408,7 +419,7 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.create", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
|
||||
writeJSON(w, http.StatusCreated, presentUser(u))
|
||||
writeJSON(w, http.StatusCreated, s.userOut(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -467,7 +478,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
if u.ID != p.User.ID && !p.IsAdmin() {
|
||||
return forbidden("only administrators can see other users")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentUser(u))
|
||||
writeJSON(w, http.StatusOK, s.userOut(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -493,6 +504,9 @@ func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
if err := applyUserInput(u, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.stepUp(r, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if wasAdmin && (!u.Admin || u.Disabled) {
|
||||
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
||||
return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
|
||||
@@ -508,7 +522,7 @@ func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, "")
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.update", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
|
||||
writeJSON(w, http.StatusOK, presentUser(u))
|
||||
writeJSON(w, http.StatusOK, s.userOut(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -523,6 +537,9 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
if u.ID == p.User.ID {
|
||||
return conflict("you can't delete yourself")
|
||||
}
|
||||
if err := s.stepUp(r, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Admin && !u.Disabled {
|
||||
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
||||
return conflict("%s is the last administrator", u.Username)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/store"
|
||||
"jarl/internal/version"
|
||||
)
|
||||
|
||||
func toRequests(in []api.ChangeRequest) []engine.Request {
|
||||
@@ -273,9 +274,10 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
|
||||
resp := api.StatusResponse{Version: s.cfg.Version, Build: version.Info(), Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
|
||||
if p.IsAdmin() {
|
||||
resp.Backup = s.backupStatus(r.Context(), now)
|
||||
resp.TOTP = s.totpOverview(r.Context())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -46,10 +47,14 @@ func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colors, err := s.store.ViewColors(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.View{}
|
||||
for _, v := range st.OrderedViews() {
|
||||
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
||||
out = append(out, presentView(st, v))
|
||||
out = append(out, presentView(st, v, colors))
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
@@ -79,14 +84,58 @@ func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentView(st, v))
|
||||
colors, err := s.store.ViewColors(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentView(st, v, colors))
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewObject(st *model.State, id int64) any {
|
||||
if v := st.Views[id]; v != nil {
|
||||
return presentView(st, v)
|
||||
func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any {
|
||||
return func(st *model.State, id int64) any {
|
||||
if v := st.Views[id]; v != nil {
|
||||
colors, _ := s.store.ViewColors(r.Context())
|
||||
return presentView(st, v, colors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// setViewColor sets the colour the web UI shows a view in. It is not part
|
||||
// of the configuration, so it applies at once, outside changesets.
|
||||
func (s *Server) setViewColor(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := s.viewByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := require(p, auth.Action{Resource: auth.ResViews, Write: true, View: v.Name}); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.ViewColor
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Color != "" && !slices.Contains(model.ViewColors, in.Color) {
|
||||
return badRequest("unknown colour %q: use one of %s, or \"\" for the default", in.Color, strings.Join(model.ViewColors, ", "))
|
||||
}
|
||||
if err := s.store.SetViewColor(r.Context(), v.ID, in.Color); err != nil {
|
||||
return err
|
||||
}
|
||||
detail := in.Color
|
||||
if detail == "" {
|
||||
detail = "default"
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "view.color", "view", v.ID, v.Name, "success", detail)
|
||||
colors, err := s.store.ViewColors(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentView(st, v, colors))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,7 +144,7 @@ func (s *Server) createView(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewCreate, 0, body)}, viewObject)
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewCreate, 0, body)}, s.viewObject(r))
|
||||
}
|
||||
|
||||
func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
@@ -112,7 +161,7 @@ func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
return err
|
||||
}
|
||||
id := v.ID
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return viewObject(st, id) })
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return s.viewObject(r)(st, id) })
|
||||
}
|
||||
|
||||
func (s *Server) deleteView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package server
|
||||
|
||||
// DNSSEC signing (docs/dnssec.md): GET /zones/{zone}/dnssec,
|
||||
// POST /zones/{zone}/dnssec/check-ds and POST /zones/{zone}/dnssec/wipe.
|
||||
// Signing itself is switched with the zone's dnssec_policy field
|
||||
// (PATCH /zones/{zone}), which goes through a changeset like any change.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
)
|
||||
|
||||
func (s *Server) zoneDNSSEC(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return badRequest("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
|
||||
}
|
||||
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
|
||||
if pz := parentInJarl(st, z); pz != nil {
|
||||
resp.ParentZone = pz.Name
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parentInJarl returns the primary zone in z's view that z is delegated
|
||||
// from, if Jarl has it.
|
||||
func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
|
||||
var best *model.Zone
|
||||
for _, o := range st.ZonesInView(z.ViewID) {
|
||||
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
|
||||
continue
|
||||
}
|
||||
if best == nil || len(o.Name) > len(best.Name) {
|
||||
best = o
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
|
||||
func (s *Server) dsChecker(r *http.Request) (*nscheck.Checker, error) {
|
||||
c := s.checker()
|
||||
if v := r.URL.Query().Get("resolver"); v != "" {
|
||||
a, err := netip.ParseAddr(v)
|
||||
if err != nil {
|
||||
return nil, badRequest("resolver must be an IP address, e.g. 1.1.1.1 or 9.9.9.9")
|
||||
}
|
||||
c.Resolver = net.JoinHostPort(a.String(), "53")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Server) zoneCheckDS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := require(p, auth.Action{Resource: auth.ResZones, Write: true, Zone: z.Name, View: st.ViewName(z)}); err != nil {
|
||||
return err
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return badRequest("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
|
||||
}
|
||||
c, err := s.dsChecker(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
resp := api.CheckDSResponse{Parent: c.ParentDS(ctx, z.Name), Marked: []string{}, Notes: []string{}}
|
||||
status := s.eng.DNSSEC(ctx, st, z)
|
||||
if resp.Parent.Error == "" {
|
||||
pub, wd := dsChanges(z, status.Keys, resp.Parent)
|
||||
if len(pub)+len(wd) > 0 {
|
||||
marked, err := s.eng.MarkDS(ctx, st, z, p, pub, wd)
|
||||
resp.Marked = append(resp.Marked, marked...)
|
||||
if err != nil {
|
||||
resp.Notes = append(resp.Notes, "telling named failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
if !resp.Parent.Consistent {
|
||||
resp.Notes = append(resp.Notes, "The parent's servers don't all have the same DS records yet; named is only told once they agree. Check again in a while.")
|
||||
}
|
||||
if len(resp.Marked) == 0 {
|
||||
resp.Notes = append(resp.Notes, "Nothing new: named already knows what the parent has.")
|
||||
}
|
||||
status = s.eng.DNSSEC(ctx, st, z)
|
||||
} else {
|
||||
resp.Notes = append(resp.Notes, "The parent zone couldn't be checked, so named wasn't told anything.")
|
||||
}
|
||||
resp.Status = status
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dsChanges works out what named should be told after a DS check: keys
|
||||
// whose DS every parent server now has (published), and keys whose DS no
|
||||
// parent server has any more, when they're on their way out (withdrawn).
|
||||
// A signed zone without a DS yet is simply waiting for one: nothing is
|
||||
// withdrawn for it.
|
||||
func dsChanges(z *model.Zone, keys []*dnssec.Key, pr *nscheck.ParentDSReport) (published, withdrawn []*dnssec.Key) {
|
||||
for _, k := range keys {
|
||||
if !k.SigningKey() || k.DS == nil {
|
||||
continue
|
||||
}
|
||||
leaving := z.DNSSECPolicy == model.DNSSECInsecure || k.Goal == dnssec.Hidden
|
||||
switch {
|
||||
case !leaving && k.DSState != dnssec.Omnipresent && k.DSPublished == nil && pr.AllHaveDS(k.Tag, k.DS.Digest):
|
||||
published = append(published, k)
|
||||
case leaving && k.DSState != dnssec.Hidden && k.DSRemoved == nil && !pr.HasDS(k.Tag, k.DS.Digest):
|
||||
withdrawn = append(withdrawn, k)
|
||||
}
|
||||
}
|
||||
return published, withdrawn
|
||||
}
|
||||
|
||||
func (s *Server) zoneDNSSECWipe(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.DNSSECWipe
|
||||
if err := decodeOptional(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if queryBool(r, "dry_run") {
|
||||
in.DryRun = true
|
||||
}
|
||||
c, err := s.dsChecker(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
// The one thing that must not happen: the zone goes unsigned while
|
||||
// resolvers can still get a DS record for it.
|
||||
pr := c.ParentDS(ctx, z.Name)
|
||||
if len(pr.DS) > 0 {
|
||||
var tags []string
|
||||
for _, d := range pr.DS {
|
||||
tags = append(tags, fmt.Sprint(d.KeyTag))
|
||||
}
|
||||
return conflict("the parent zone %s still has a DS record for %s (key %s): wiping now would make validating resolvers reject the whole domain. Remove the DS at your registrar first and wait for its TTL to pass (or turn DNSSEC off step by step: jarl zone dnssec disable)", dnsname.Display(pr.Parent), dnsname.Display(z.Name), strings.Join(tags, ", "))
|
||||
}
|
||||
if pr.Error != "" && !in.Force {
|
||||
return conflict("couldn't check that the parent zone has no DS record for %s: %s. If the zone isn't on the internet (or you're sure its parent has no DS for it), wipe with force", dnsname.Display(z.Name), pr.Error)
|
||||
}
|
||||
if !in.Force {
|
||||
for _, k := range s.eng.DNSSEC(ctx, st, z).Keys {
|
||||
if k.SigningKey() && k.DSPublished != nil && k.DSState != dnssec.Hidden {
|
||||
return conflict("the parent's DS record for key %d is gone, but resolvers may still have it cached (named counts until its TTL has passed); wait until 'jarl zone dnssec status' no longer shows it, or wipe with force", k.Tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
res, err := s.eng.WipeDNSSEC(r.Context(), z.ID, p, via(r, p), engine.ApplyOptions{DryRun: in.DryRun})
|
||||
if err != nil {
|
||||
if res != nil && res.Result != nil {
|
||||
s.writeErr(w, r, err, res.Result)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
out := api.DNSSECWipeResponse{Removed: res.Removed, DryRun: res.DryRun}
|
||||
if pr.Error == "" {
|
||||
out.Parent = pr
|
||||
}
|
||||
if res.Result != nil {
|
||||
out.ChangesetID = res.Result.ChangesetID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
@@ -7,16 +7,19 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
"jarl/internal/nscheck"
|
||||
@@ -57,10 +60,17 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
resp.Serial, resp.SerialSource, err = s.localSerial(ctx, st, z)
|
||||
ck := s.checker()
|
||||
emit := s.watchChecks(w, r, ck)
|
||||
resp.Serial, resp.SerialSource, err = s.watchedLocalSerial(ctx, emit, st, z)
|
||||
if err != nil {
|
||||
resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error())
|
||||
}
|
||||
if z.Signed() && resp.SerialSource == "jarl" {
|
||||
// named gives the signed zone its own serial, ahead of Jarl's.
|
||||
resp.Notes = append(resp.Notes, "The zone is signed and named wasn't asked: other servers serve the signed zone's serial, which named sets and which is usually ahead of Jarl's.")
|
||||
resp.Serial = nil
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
if z.Type == model.ZonePrimary {
|
||||
resp.Notes = append(resp.Notes, "The zone has no NS records or also-notify targets to check.")
|
||||
@@ -71,15 +81,72 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
if resp.View != "" {
|
||||
resp.Notes = append(resp.Notes, fmt.Sprintf("The zone is in view %s. Other servers answer from the view that matches this server's address (or key), as they do for its zone transfers and NOTIFYs.", resp.View))
|
||||
}
|
||||
resp.Servers = s.checker().Serials(ctx, z.Name, resp.Serial, targets)
|
||||
resp.Servers = ck.Serials(ctx, z.Name, resp.Serial, targets)
|
||||
if len(resp.Servers) > 0 && !hasOther(resp.Servers) {
|
||||
resp.Notes = append(resp.Notes, "Only this server is listed, so there is nothing to compare with.")
|
||||
}
|
||||
resp.CheckedAt = time.Now()
|
||||
if emit != nil {
|
||||
emit(map[string]any{"kind": "done", "response": resp})
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchChecks: with ?stream=1, /serials and /delegation answer with
|
||||
// newline-delimited JSON events while they ask the servers (nscheck.Event,
|
||||
// kind start/end, one pair per DNS query), then {"kind":"done",
|
||||
// "response":...}. The web UI shows the queries as they go. It returns
|
||||
// the function that writes an event, or nil without ?stream=1.
|
||||
func (s *Server) watchChecks(w http.ResponseWriter, r *http.Request, ck *nscheck.Checker) func(any) {
|
||||
if !queryBool(r, "stream") {
|
||||
return nil
|
||||
}
|
||||
emit := s.streamJSON(w)
|
||||
ck.Watch = func(e nscheck.Event) { emit(e) }
|
||||
return emit
|
||||
}
|
||||
|
||||
// watchedLocalSerial is localSerial, reported as event 0 when streaming.
|
||||
func (s *Server) watchedLocalSerial(ctx context.Context, emit func(any), st *model.State, z *model.Zone) (*uint32, string, error) {
|
||||
cmd := s.eng.LoadedSerialCommand(st, z)
|
||||
if emit != nil && cmd != "" {
|
||||
emit(nscheck.Event{Kind: "start", ID: 0, Query: cmd})
|
||||
}
|
||||
serial, src, err := s.localSerial(ctx, st, z)
|
||||
if emit != nil && cmd != "" {
|
||||
e := nscheck.Event{Kind: "end", ID: 0}
|
||||
switch {
|
||||
case err != nil:
|
||||
e.Error = err.Error()
|
||||
case src == "named" && serial != nil:
|
||||
e.Answer = fmt.Sprintf("serial %d", *serial)
|
||||
}
|
||||
emit(e)
|
||||
}
|
||||
return serial, src, err
|
||||
}
|
||||
|
||||
// streamJSON starts a newline-delimited JSON response and returns a
|
||||
// function that writes one value and flushes it; it is safe for
|
||||
// concurrent use.
|
||||
func (s *Server) streamJSON(w http.ResponseWriter) func(any) {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // nginx: don't buffer
|
||||
w.WriteHeader(http.StatusOK)
|
||||
rc := http.NewResponseController(w)
|
||||
enc := json.NewEncoder(w)
|
||||
var mu sync.Mutex
|
||||
return func(v any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
enc.Encode(v)
|
||||
rc.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
@@ -115,15 +182,32 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
ref, _, _ := s.localSerial(ctx, st, z)
|
||||
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref), View: st.ViewName(z), Notes: []string{}, CheckedAt: time.Now()}
|
||||
emit := s.watchChecks(w, r, c)
|
||||
ref, _, _ := s.watchedLocalSerial(ctx, emit, st, z)
|
||||
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref, s.signing(ctx, st, z)), View: st.ViewName(z), Notes: []string{}, CheckedAt: time.Now()}
|
||||
if resp.View != "" {
|
||||
resp.Notes = append(resp.Notes, fmt.Sprintf("Compared with the zone in view %s. Resolvers on the internet get the view that matches their address; make sure that's this one.", resp.View))
|
||||
}
|
||||
if emit != nil {
|
||||
emit(map[string]any{"kind": "done", "response": resp})
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// signing describes how Jarl signs z, for comparing with the parent's DS
|
||||
// records: the DS of every key-signing key named still publishes.
|
||||
func (s *Server) signing(ctx context.Context, st *model.State, z *model.Zone) *nscheck.Signing {
|
||||
sig := &nscheck.Signing{Policy: z.DNSSECPolicy}
|
||||
for _, k := range s.eng.DNSSEC(ctx, st, z).Keys {
|
||||
if k.DS != nil && k.DNSKEYState != dnssec.Hidden {
|
||||
sig.DS = append(sig.DS, *k.DS)
|
||||
}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// localSerial is the serial this server serves for z: named's, or for a
|
||||
// primary zone Jarl's when named isn't asked.
|
||||
func (s *Server) localSerial(ctx context.Context, st *model.State, z *model.Zone) (*uint32, string, error) {
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
package server
|
||||
|
||||
// The registrar (docs/dnssec.md#porkbun): a domain's name servers, glue
|
||||
// records and DNSSEC DS records at Porkbun, next to what the zone in Jarl
|
||||
// says.
|
||||
//
|
||||
// GET /zones/{zone}/registrar
|
||||
// POST /zones/{zone}/registrar/glue create or update glue records
|
||||
// POST /zones/{zone}/registrar/ds publish the zone's DS record
|
||||
// DELETE /zones/{zone}/registrar/ds/{keytag} remove a DS record
|
||||
// PUT /zones/{zone}/registrar/ns set the domain's name servers
|
||||
//
|
||||
// All of them need an administrator: the API key can move the domain
|
||||
// anywhere. Every change first checks that it won't break the domain
|
||||
// (errors refuse it unless forced), and a dry run returns those checks
|
||||
// without changing anything.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
)
|
||||
|
||||
// registrarFor names the registrar Jarl can manage z's delegation at:
|
||||
// "porkbun" when a key is configured and allows the zone's name.
|
||||
func (s *Server) registrarFor(z *model.Zone) string {
|
||||
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
|
||||
return ""
|
||||
}
|
||||
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
|
||||
return ""
|
||||
}
|
||||
return "porkbun"
|
||||
}
|
||||
|
||||
func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State, *model.Zone, string, error) {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if s.registrarFor(z) == "" {
|
||||
if s.cfg.Porkbun == nil {
|
||||
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
|
||||
}
|
||||
return nil, nil, "", badRequest("Jarl may not manage %s at Porkbun (only primary zones listed in porkbun.domains in jarl.yaml)", dnsname.Display(z.Name))
|
||||
}
|
||||
return st, z, dnsname.Display(z.Name), nil
|
||||
}
|
||||
|
||||
func lowerNoDot(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
|
||||
|
||||
// zoneNS returns the zone's apex NS records, as lowercase names without
|
||||
// trailing dots.
|
||||
func zoneNS(st *model.State, z *model.Zone) []string {
|
||||
var out []string
|
||||
for _, rec := range st.ZoneRecords(z.ID) {
|
||||
if rec.Type == "NS" && rec.Name == z.Name {
|
||||
out = append(out, lowerNoDot(rec.Data))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sameSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
x := append([]string{}, a...)
|
||||
y := append([]string{}, b...)
|
||||
sort.Strings(x)
|
||||
sort.Strings(y)
|
||||
return strings.Join(x, " ") == strings.Join(y, " ")
|
||||
}
|
||||
|
||||
// registrarState asks the registrar about the domain and compares that
|
||||
// with the zone.
|
||||
func (s *Server) registrarState(ctx context.Context, st *model.State, z *model.Zone, domain string, sig *dnssec.Status) *api.RegistrarResponse {
|
||||
resp := &api.RegistrarResponse{Registrar: "porkbun", Domain: domain, NS: []string{}, JarlNS: zoneNS(st, z), Glue: []api.GlueStatus{}, DS: []api.RegistrarDS{}, Findings: []nscheck.Finding{}}
|
||||
if resp.JarlNS == nil {
|
||||
resp.JarlNS = []string{}
|
||||
}
|
||||
pb := s.cfg.Porkbun
|
||||
ns, err := pb.NS(ctx, domain)
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
return resp
|
||||
}
|
||||
glue, err := pb.Glue(ctx, domain)
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
return resp
|
||||
}
|
||||
ds, err := pb.DS(ctx, domain)
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
return resp
|
||||
}
|
||||
resp.NS = ns
|
||||
resp.NSMatch = sameSet(ns, resp.JarlNS)
|
||||
add := func(level, format string, args ...any) {
|
||||
resp.Findings = append(resp.Findings, nscheck.Finding{Level: level, Message: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
switch {
|
||||
case resp.NSMatch:
|
||||
add("ok", "Porkbun delegates %s to the zone's name servers: %s.", domain, strings.Join(ns, ", "))
|
||||
case len(resp.JarlNS) == 0:
|
||||
add("warning", "The zone has no NS records.")
|
||||
default:
|
||||
add("warning", "Porkbun delegates %s to %s, but the zone's NS records are %s.", domain, strings.Join(ns, ", "), strings.Join(resp.JarlNS, ", "))
|
||||
}
|
||||
|
||||
// Glue: every host at the registry, and every name server inside the
|
||||
// domain (which resolvers can only find through its glue).
|
||||
byHost := map[string]*api.GlueStatus{}
|
||||
var order []string
|
||||
get := func(host string) *api.GlueStatus {
|
||||
if g := byHost[host]; g != nil {
|
||||
return g
|
||||
}
|
||||
g := &api.GlueStatus{Host: host, Registry: []string{}, Jarl: []string{}}
|
||||
byHost[host] = g
|
||||
order = append(order, host)
|
||||
return g
|
||||
}
|
||||
for _, g := range glue {
|
||||
get(g.Host).Registry = canonAddrs(g.Addresses())
|
||||
}
|
||||
for _, n := range resp.JarlNS {
|
||||
if n == domain || !dnsname.IsSubdomain(dnsname.Fqdn(n), z.Name) {
|
||||
continue
|
||||
}
|
||||
g := get(n)
|
||||
g.Needed = true
|
||||
g.Jarl = canonAddrs(jarlAddresses(st, z.ViewID, dnsname.Fqdn(n)))
|
||||
switch {
|
||||
case len(g.Registry) == 0:
|
||||
g.Action = "create"
|
||||
case !sameSet(g.Registry, g.Jarl) && len(g.Jarl) > 0:
|
||||
g.Action = "update"
|
||||
}
|
||||
}
|
||||
sort.Strings(order)
|
||||
for _, h := range order {
|
||||
g := byHost[h]
|
||||
for _, n := range ns {
|
||||
if n == h {
|
||||
g.InUse = true
|
||||
}
|
||||
}
|
||||
resp.Glue = append(resp.Glue, *g)
|
||||
switch {
|
||||
case g.Needed && len(g.Jarl) == 0:
|
||||
add("error", "%s is one of the zone's name servers, inside the domain, but the zone has no A or AAAA record for it; add one, then sync the glue.", h)
|
||||
case g.Action == "create":
|
||||
add("warning", "%s has no glue record at Porkbun, so resolvers can't find it once the domain is delegated to it. Sync the glue (%s).", h, strings.Join(g.Jarl, ", "))
|
||||
case g.Action == "update":
|
||||
add("warning", "The glue for %s at Porkbun is %s, but the zone says %s. Sync the glue.", h, strings.Join(g.Registry, ", "), strings.Join(g.Jarl, ", "))
|
||||
case g.Needed:
|
||||
add("ok", "The glue for %s matches the zone (%s).", h, strings.Join(g.Jarl, ", "))
|
||||
case !g.InUse:
|
||||
add("info", "Porkbun has a glue record for %s (%s), which the zone's NS records don't use.", h, strings.Join(g.Registry, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// DS records.
|
||||
live := publishedDS(sig)
|
||||
var good, stale []string
|
||||
for _, d := range ds {
|
||||
rd := api.RegistrarDS{DS: d}
|
||||
for _, k := range live {
|
||||
if k.KeyTag == d.KeyTag && k.Algorithm == d.Algorithm && k.DigestType == d.DigestType && strings.EqualFold(k.Digest, d.Digest) {
|
||||
rd.Matches = true
|
||||
}
|
||||
}
|
||||
resp.DS = append(resp.DS, rd)
|
||||
if rd.Matches {
|
||||
good = append(good, strconv.Itoa(int(d.KeyTag)))
|
||||
} else {
|
||||
stale = append(stale, strconv.Itoa(int(d.KeyTag)))
|
||||
}
|
||||
}
|
||||
signed := sig != nil && sig.Policy != "" && sig.Policy != model.DNSSECInsecure
|
||||
switch {
|
||||
case len(ds) == 0 && signed && sig.Phase.CanPublishDS:
|
||||
add("info", "The zone is signed and ready: publish its DS record at Porkbun to turn validation on.")
|
||||
case len(ds) == 0 && signed:
|
||||
add("info", "The zone is signed; its DS record can be published once named says it's ready (DNSSEC tab).")
|
||||
case len(ds) > 0 && (sig == nil || sig.Policy == ""):
|
||||
add("error", "Porkbun has a DS record for %s (key %s), but the zone isn't signed: validating resolvers reject the domain. Remove the DS record.", domain, strings.Join(stale, ", "))
|
||||
case len(ds) > 0 && len(good) == 0:
|
||||
add("error", "Porkbun's DS record (key %s) matches none of the zone's keys: validating resolvers reject the domain. Replace it with the zone's DS record.", strings.Join(stale, ", "))
|
||||
case len(ds) > 0 && sig.Policy == model.DNSSECInsecure:
|
||||
add("warning", "DNSSEC is being turned off: remove the DS record (key %s) at Porkbun, then run the DS check.", strings.Join(good, ", "))
|
||||
case len(good) > 0:
|
||||
add("ok", "Porkbun's DS record matches the zone's key %s.", strings.Join(good, ", "))
|
||||
if len(stale) > 0 {
|
||||
add("warning", "Porkbun also has a DS record for key %s, which the zone doesn't have any more; remove it.", strings.Join(stale, ", "))
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// publishedDS returns the DS records of the key-signing keys named
|
||||
// publishes for the zone.
|
||||
func publishedDS(sig *dnssec.Status) []dnssec.DS {
|
||||
if sig == nil {
|
||||
return nil
|
||||
}
|
||||
var out []dnssec.DS
|
||||
for _, k := range sig.Keys {
|
||||
if k.DS != nil && k.DNSKEYState != dnssec.Hidden {
|
||||
out = append(out, *k.DS)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func canonAddrs(addrs []string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range addrs {
|
||||
if v, err := netip.ParseAddr(a); err == nil && !seen[v.String()] {
|
||||
seen[v.String()] = true
|
||||
out = append(out, v.String())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) zoneRegistrar(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, z, domain, err := s.registrarZone(r, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
writeJSON(w, http.StatusOK, s.registrarState(ctx, st, z, domain, s.eng.DNSSEC(ctx, st, z)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// changeCtx holds what a registrar change needs.
|
||||
type changeCtx struct {
|
||||
st *model.State
|
||||
z *model.Zone
|
||||
domain string
|
||||
in api.RegistrarChange
|
||||
sig *dnssec.Status
|
||||
cur *api.RegistrarResponse
|
||||
out api.RegistrarChangeResponse
|
||||
}
|
||||
|
||||
func (c *changeCtx) check(level, format string, args ...any) {
|
||||
c.out.Checks = append(c.out.Checks, nscheck.Finding{Level: level, Message: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
|
||||
// blocked returns the error that refuses the change when a check failed
|
||||
// (unless forced).
|
||||
func (c *changeCtx) blocked() error {
|
||||
if c.in.Force || c.in.DryRun {
|
||||
return nil
|
||||
}
|
||||
var errs []string
|
||||
for _, f := range c.out.Checks {
|
||||
if f.Level == "error" {
|
||||
errs = append(errs, f.Message)
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return conflict("not changed, because it could break the domain: %s (force overrides this check)", strings.Join(errs, " "))
|
||||
}
|
||||
|
||||
func (s *Server) registrarChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, body bool, run func(ctx context.Context, c *changeCtx) error) error {
|
||||
st, z, domain, err := s.registrarZone(r, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := &changeCtx{st: st, z: z, domain: domain, out: api.RegistrarChangeResponse{Done: []string{}, Checks: []nscheck.Finding{}, Warnings: []string{}}}
|
||||
if body {
|
||||
if err := decodeOptional(r, &c.in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if queryBool(r, "dry_run") {
|
||||
c.in.DryRun = true
|
||||
}
|
||||
if queryBool(r, "force") {
|
||||
c.in.Force = true
|
||||
}
|
||||
c.out.DryRun = c.in.DryRun
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
c.sig = s.eng.DNSSEC(ctx, st, z)
|
||||
c.cur = s.registrarState(ctx, st, z, domain, c.sig)
|
||||
if c.cur.Error != "" {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", c.cur.Error}
|
||||
}
|
||||
if err := run(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c.out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) audit(ctx context.Context, p *auth.Principal, c *changeCtx, action, msg string) {
|
||||
s.eng.Audit(ctx, p, action, "zone", c.z.ID, dnsname.Display(c.z.Name), "success", msg)
|
||||
}
|
||||
|
||||
// serving checks that the name servers answer authoritatively for the
|
||||
// zone with the serial this server serves (for a signed zone, the signed
|
||||
// serial: so they serve the signed zone).
|
||||
func (s *Server) serving(ctx context.Context, c *changeCtx, targets []nscheck.Target, why string) {
|
||||
ref, _, _ := s.localSerial(ctx, c.st, c.z)
|
||||
for _, res := range s.checker().Serials(ctx, c.z.Name, ref, targets) {
|
||||
where := dnsname.Display(res.Name)
|
||||
if res.Address != "" {
|
||||
where += " (" + res.Address + ")"
|
||||
}
|
||||
switch res.Status {
|
||||
case "self", "in_sync":
|
||||
case "answered":
|
||||
c.check("warning", "%s answers for the zone, but its serial couldn't be compared with this server's.", where)
|
||||
case "behind", "ahead":
|
||||
c.check("error", "%s serves serial %d, not this server's %d; %s.", where, *res.Serial, *ref, why)
|
||||
default:
|
||||
msg := res.Status
|
||||
if res.Error != "" {
|
||||
msg += ": " + res.Error
|
||||
}
|
||||
c.check("error", "%s doesn't serve the zone (%s); %s.", where, msg, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// targetsFor returns check targets for name servers: in-domain ones at
|
||||
// the given addresses, others looked up.
|
||||
func targetsFor(names []string, addrs map[string][]string) []nscheck.Target {
|
||||
var out []nscheck.Target
|
||||
for _, n := range names {
|
||||
out = append(out, nscheck.Target{Name: dnsname.Fqdn(n), Addresses: addrs[n], Source: "name server"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) registrarSyncGlue(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
|
||||
var todo []api.GlueStatus
|
||||
for _, g := range c.cur.Glue {
|
||||
if !g.Needed {
|
||||
continue
|
||||
}
|
||||
if len(g.Jarl) == 0 {
|
||||
c.check("error", "%s has no A or AAAA record in the zone, so there is no address to give its glue.", g.Host)
|
||||
continue
|
||||
}
|
||||
if g.Action == "" {
|
||||
continue
|
||||
}
|
||||
todo = append(todo, g)
|
||||
if g.Action == "update" && g.InUse {
|
||||
// Resolvers go to the new addresses at once.
|
||||
s.serving(ctx, c, []nscheck.Target{{Name: dnsname.Fqdn(g.Host), Addresses: g.Jarl, Source: "glue"}}, "the domain is delegated to "+g.Host+", so resolvers would go there")
|
||||
}
|
||||
}
|
||||
if len(todo) == 0 && len(c.out.Checks) == 0 {
|
||||
c.check("ok", "The glue records at Porkbun already match the zone.")
|
||||
}
|
||||
if err := c.blocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range todo {
|
||||
what := fmt.Sprintf("%s glue for %s: %s", map[string]string{"create": "create", "update": "update"}[g.Action], g.Host, strings.Join(g.Jarl, ", "))
|
||||
if !c.in.DryRun {
|
||||
if err := s.cfg.Porkbun.SetGlue(ctx, c.domain, g.Host, g.Jarl, g.Action == "update"); err != nil {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", fmt.Sprintf("%v (done before that: %s)", err, strings.Join(c.out.Done, "; "))}
|
||||
}
|
||||
s.audit(ctx, p, c, "registrar.glue", what)
|
||||
}
|
||||
c.out.Done = append(c.out.Done, what)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registrarPublishDS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
|
||||
sig := c.sig
|
||||
if sig.Policy == "" || sig.Policy == model.DNSSECInsecure {
|
||||
return conflict("the zone isn't signed (or DNSSEC is being turned off), so it has no DS record to publish")
|
||||
}
|
||||
var keys []*dnssec.Key
|
||||
for _, k := range sig.Keys {
|
||||
if k.DS != nil && k.Goal == dnssec.Omnipresent && (c.in.KeyTag == 0 || k.Tag == c.in.KeyTag) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return conflict("no key to publish a DS record for (named hasn't made the zone's key yet, or there is no key %d)", c.in.KeyTag)
|
||||
}
|
||||
if !sig.Phase.CanPublishDS {
|
||||
c.check("error", "Not yet: %s", sig.Phase.Next)
|
||||
}
|
||||
// The DS turns validation on: whatever the domain is delegated
|
||||
// to must serve this signed zone.
|
||||
if !c.cur.NSMatch {
|
||||
c.check("error", "Porkbun delegates the domain to %s, not to the zone's name servers (%s): validating resolvers would reject the answers of servers that don't serve this signed zone. Set the name servers first.", strings.Join(c.cur.NS, ", "), strings.Join(c.cur.JarlNS, ", "))
|
||||
} else {
|
||||
glue := map[string][]string{}
|
||||
for _, g := range c.cur.Glue {
|
||||
glue[g.Host] = g.Registry
|
||||
}
|
||||
s.serving(ctx, c, targetsFor(c.cur.NS, glue), "validating resolvers would reject its answers once the DS is published")
|
||||
}
|
||||
if err := c.blocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
already := false
|
||||
for _, d := range c.cur.DS {
|
||||
if d.Matches && d.KeyTag == k.Tag {
|
||||
already = true
|
||||
}
|
||||
}
|
||||
if already {
|
||||
c.out.Warnings = append(c.out.Warnings, fmt.Sprintf("Porkbun already has the DS record for key %d.", k.Tag))
|
||||
continue
|
||||
}
|
||||
what := fmt.Sprintf("publish the DS record for key %d (algorithm %d, digest type %d)", k.Tag, k.DS.Algorithm, k.DS.DigestType)
|
||||
if !c.in.DryRun {
|
||||
warns, err := s.cfg.Porkbun.AddDS(ctx, c.domain, porkbun.DS{KeyTag: k.DS.KeyTag, Algorithm: k.DS.Algorithm, DigestType: k.DS.DigestType, Digest: k.DS.Digest})
|
||||
if err != nil {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
|
||||
}
|
||||
c.out.Warnings = append(c.out.Warnings, warns...)
|
||||
s.audit(ctx, p, c, "registrar.ds.add", what)
|
||||
}
|
||||
c.out.Done = append(c.out.Done, what)
|
||||
}
|
||||
if len(c.out.Done) > 0 && !c.in.DryRun {
|
||||
c.out.Warnings = append(c.out.Warnings, "The registry publishes it in the parent zone within minutes to hours; then run the DS check (jarl zone dnssec check-ds) so named knows.")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registrarRemoveDS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
tag, err := strconv.ParseUint(r.PathValue("keytag"), 10, 16)
|
||||
if err != nil {
|
||||
return badRequest("invalid key tag %q", r.PathValue("keytag"))
|
||||
}
|
||||
return s.registrarChange(w, r, p, false, func(ctx context.Context, c *changeCtx) error {
|
||||
var found *api.RegistrarDS
|
||||
for i := range c.cur.DS {
|
||||
if c.cur.DS[i].KeyTag == uint16(tag) {
|
||||
found = &c.cur.DS[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return notFound("Porkbun has no DS record for key %d", tag)
|
||||
}
|
||||
if found.Matches && c.sig.Policy != "" && c.sig.Policy != model.DNSSECInsecure {
|
||||
c.check("warning", "This DS record points at the zone's key: without it, resolvers stop validating the domain (the zone stays signed). To turn DNSSEC off, start with 'Turn signing off' so named removes its signatures safely afterwards.")
|
||||
}
|
||||
c.check("info", "Porkbun notes that most registries delete every DS record with the same key data.")
|
||||
what := fmt.Sprintf("remove the DS record for key %d", tag)
|
||||
if !c.in.DryRun {
|
||||
if err := s.cfg.Porkbun.DeleteDS(ctx, c.domain, uint16(tag)); err != nil {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
|
||||
}
|
||||
s.audit(ctx, p, c, "registrar.ds.delete", what)
|
||||
c.out.Warnings = append(c.out.Warnings, "Resolvers may keep the DS cached for its TTL (often a day). If DNSSEC is being turned off, run the DS check once the parent zone no longer has it.")
|
||||
}
|
||||
c.out.Done = append(c.out.Done, what)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registrarSetNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
|
||||
target := c.cur.JarlNS
|
||||
if len(c.in.NS) > 0 {
|
||||
target = nil
|
||||
for _, n := range c.in.NS {
|
||||
n = lowerNoDot(n)
|
||||
if !dnsname.IsHostname(dnsname.Fqdn(n), false) {
|
||||
return badRequest("%q isn't a host name", n)
|
||||
}
|
||||
target = append(target, n)
|
||||
}
|
||||
}
|
||||
if len(target) == 0 {
|
||||
return badRequest("no name servers to set: the zone has no NS records")
|
||||
}
|
||||
if sameSet(target, c.cur.NS) {
|
||||
c.check("ok", "Porkbun already delegates the domain to %s.", strings.Join(target, ", "))
|
||||
return nil
|
||||
}
|
||||
if len(target) < 2 {
|
||||
c.check("warning", "Only one name server: when it can't be reached, the domain can't be resolved at all. Registries and RFC 1034 expect at least two (a secondary on another network).")
|
||||
}
|
||||
if !sameSet(target, c.cur.JarlNS) {
|
||||
c.check("warning", "These aren't the zone's NS records (%s); resolvers prefer the zone's own list once they reach it.", strings.Join(c.cur.JarlNS, ", "))
|
||||
}
|
||||
// In-domain servers need glue at the registry.
|
||||
addrs := map[string][]string{}
|
||||
for _, n := range target {
|
||||
if !dnsname.IsSubdomain(dnsname.Fqdn(n), c.z.Name) {
|
||||
continue
|
||||
}
|
||||
var g *api.GlueStatus
|
||||
for i := range c.cur.Glue {
|
||||
if c.cur.Glue[i].Host == n {
|
||||
g = &c.cur.Glue[i]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case g == nil || len(g.Registry) == 0:
|
||||
c.check("error", "%s is inside the domain but has no glue record at Porkbun, so resolvers couldn't find it. Sync the glue first.", n)
|
||||
case len(g.Jarl) > 0 && !sameSet(g.Registry, g.Jarl):
|
||||
c.check("error", "The glue for %s at Porkbun (%s) differs from the zone (%s). Sync the glue first.", n, strings.Join(g.Registry, ", "), strings.Join(g.Jarl, ", "))
|
||||
default:
|
||||
addrs[n] = g.Registry
|
||||
}
|
||||
if addrs[n] == nil {
|
||||
// Check the addresses the glue will have.
|
||||
addrs[n] = canonAddrs(jarlAddresses(c.st, c.z.ViewID, dnsname.Fqdn(n)))
|
||||
}
|
||||
}
|
||||
// Every new server must serve the zone...
|
||||
s.serving(ctx, c, targetsFor(target, addrs), "resolvers would be sent there")
|
||||
// ...and match the DS records at the registry, or the domain goes
|
||||
// dark for validating resolvers.
|
||||
if len(c.cur.DS) > 0 {
|
||||
matches := false
|
||||
for _, d := range c.cur.DS {
|
||||
matches = matches || d.Matches
|
||||
}
|
||||
if !matches {
|
||||
c.check("error", "Porkbun has DS records for the domain (key %s), and the zone's keys don't match them: validating resolvers would reject every answer from the new name servers. Remove the DS records first (or publish the zone's own).", dsTags(c.cur.DS))
|
||||
}
|
||||
}
|
||||
if err := c.blocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
what := "set the name servers to " + strings.Join(target, ", ")
|
||||
if !c.in.DryRun {
|
||||
if err := s.cfg.Porkbun.SetNS(ctx, c.domain, target); err != nil {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
|
||||
}
|
||||
s.audit(ctx, p, c, "registrar.ns", what+" (was "+strings.Join(c.cur.NS, ", ")+")")
|
||||
c.out.Warnings = append(c.out.Warnings, "Resolvers may keep using the old name servers for the parent's NS TTL (often a day or two); keep them serving the zone until then.")
|
||||
}
|
||||
c.out.Done = append(c.out.Done, what)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func dsTags(ds []api.RegistrarDS) string {
|
||||
var s []string
|
||||
for _, d := range ds {
|
||||
s = append(s, strconv.Itoa(int(d.KeyTag)))
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package server
|
||||
|
||||
// Two-factor login with TOTP (docs/permissions.md#two-factor-login).
|
||||
//
|
||||
// Logging in with a password is the first step for a user with TOTP: it
|
||||
// returns a short-lived challenge instead of a session, and
|
||||
// POST /auth/login/totp with the challenge and a code (or a recovery code)
|
||||
// creates the session. A session remembers when it last proved a code;
|
||||
// actions that would let a stolen password outlive the second factor
|
||||
// (creating or rotating API tokens, changing users, resetting someone's
|
||||
// TOTP) need one from the last stepUpWindow, or a code in the X-Jarl-TOTP
|
||||
// header. API tokens and the local socket are not affected: a token is
|
||||
// its own secret, and can't answer a prompt.
|
||||
//
|
||||
// totp.require in jarl.yaml makes TOTP compulsory for administrators or
|
||||
// for everyone. A user who must use it but hasn't set it up gets a session
|
||||
// that can only set it up (setupOnly).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/totp"
|
||||
)
|
||||
|
||||
const (
|
||||
// challengeLifetime is how long the second login step may take.
|
||||
challengeLifetime = 5 * time.Minute
|
||||
// challengeTries is how many wrong codes one challenge allows.
|
||||
challengeTries = 5
|
||||
// stepUpWindow is how long a proved code counts for actions that
|
||||
// ask for a recent one.
|
||||
stepUpWindow = 5 * time.Minute
|
||||
// TOTPHeader carries a code for an action that asks for a recent one.
|
||||
TOTPHeader = "X-Jarl-TOTP"
|
||||
)
|
||||
|
||||
// pendingLogins are logins waiting for their second step, by the hash of
|
||||
// the challenge.
|
||||
type pendingLogins struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*pendingLogin
|
||||
}
|
||||
|
||||
type pendingLogin struct {
|
||||
userID int64
|
||||
expires time.Time
|
||||
tries int
|
||||
}
|
||||
|
||||
func (c *pendingLogins) add(userID int64, now time.Time) string {
|
||||
id, _ := auth.NewSessionID()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.m == nil {
|
||||
c.m = map[string]*pendingLogin{}
|
||||
}
|
||||
for k, v := range c.m {
|
||||
if now.After(v.expires) {
|
||||
delete(c.m, k)
|
||||
}
|
||||
}
|
||||
c.m[auth.HashSecret(id)] = &pendingLogin{userID: userID, expires: now.Add(challengeLifetime)}
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *pendingLogins) get(id string, now time.Time) *pendingLogin {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ch := c.m[auth.HashSecret(id)]
|
||||
if ch == nil || now.After(ch.expires) {
|
||||
return nil
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// fail counts a wrong code; the challenge ends after challengeTries.
|
||||
func (c *pendingLogins) fail(id string) (left int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
h := auth.HashSecret(id)
|
||||
ch := c.m[h]
|
||||
if ch == nil {
|
||||
return 0
|
||||
}
|
||||
ch.tries++
|
||||
if ch.tries >= challengeTries {
|
||||
delete(c.m, h)
|
||||
return 0
|
||||
}
|
||||
return challengeTries - ch.tries
|
||||
}
|
||||
|
||||
func (c *pendingLogins) done(id string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.m, auth.HashSecret(id))
|
||||
}
|
||||
|
||||
// totpRequired reports whether totp.require makes TOTP compulsory for u.
|
||||
// Service accounts (no password) can't log in, so it never applies to
|
||||
// them.
|
||||
func (s *Server) totpRequired(u *model.User) bool {
|
||||
if u == nil || u.ID == 0 || u.PasswordHash == "" {
|
||||
return false
|
||||
}
|
||||
switch s.cfg.TOTPRequire {
|
||||
case "all":
|
||||
return true
|
||||
case "admins":
|
||||
return u.Admin
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setupOnly reports whether a session may only set up TOTP: the user
|
||||
// must use it and hasn't set it up yet.
|
||||
func (s *Server) setupOnly(p *auth.Principal) bool {
|
||||
return p.Method == "session" && s.totpRequired(p.User) && !p.User.HasTOTP()
|
||||
}
|
||||
|
||||
// setupOnlyAllowed are the requests a setup-only session may make.
|
||||
func setupOnlyAllowed(r *http.Request) bool {
|
||||
path := strings.TrimPrefix(r.URL.Path, api.Prefix)
|
||||
switch {
|
||||
case path == "/auth/whoami", path == "/auth/logout", path == "/me/password":
|
||||
return true
|
||||
case path == "/me/totp" || strings.HasPrefix(path, "/me/totp/"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var errSetupOnly = &httpError{http.StatusForbidden, "totp_setup_required", "this server requires two-factor login for your account: set it up under Account first"}
|
||||
|
||||
// checkCode checks a TOTP code, or with recovery set also a recovery
|
||||
// code, for u, and uses it up. It returns how it was proved ("totp" or
|
||||
// "recovery code").
|
||||
func (s *Server) checkCode(ctx context.Context, u *model.User, code string, recovery bool) (string, error) {
|
||||
if !u.HasTOTP() {
|
||||
return "", errors.New("two-factor login is not on")
|
||||
}
|
||||
if totp.IsCode(code) {
|
||||
step, ok := totp.Verify(u.TOTPSecret, code, time.Now(), u.TOTPLastStep)
|
||||
if ok {
|
||||
if ok, err := s.store.UseTOTPStep(ctx, u.ID, step); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
u.TOTPLastStep = step
|
||||
return "totp", nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("wrong or already used code")
|
||||
}
|
||||
if recovery && code != "" {
|
||||
ok, err := s.store.UseRecoveryCode(ctx, u.ID, auth.HashSecret(totp.NormalizeRecovery(code)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok {
|
||||
return "recovery code", nil
|
||||
}
|
||||
return "", errors.New("wrong or already used recovery code")
|
||||
}
|
||||
return "", errors.New("wrong code")
|
||||
}
|
||||
|
||||
// loginChallenge answers a correct password of a user with TOTP: the
|
||||
// challenge for the second step.
|
||||
func (s *Server) loginChallenge(w http.ResponseWriter, u *model.User) {
|
||||
writeJSON(w, http.StatusOK, api.LoginChallenge{TOTPRequired: true, Challenge: s.pendingLogins.add(u.ID, time.Now())})
|
||||
}
|
||||
|
||||
// loginTOTP is the second login step: POST /auth/login/totp.
|
||||
func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
|
||||
var in api.LoginTOTP
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
now := time.Now()
|
||||
ch := s.pendingLogins.get(in.Challenge, now)
|
||||
if ch == nil {
|
||||
return &httpError{http.StatusUnauthorized, "login_expired", "the login took too long or had too many wrong codes; log in again"}
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), ch.userID)
|
||||
if err != nil || u.Disabled {
|
||||
s.pendingLogins.done(in.Challenge)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "your account is not available"}
|
||||
}
|
||||
userKey := "user:" + strings.ToLower(u.Username)
|
||||
who := &auth.Principal{User: u, IP: ip, Agent: r.UserAgent(), Method: "session"}
|
||||
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
|
||||
s.eng.Audit(r.Context(), who, "auth.login_blocked", "user", u.ID, u.Username, "denied", "too many failed attempts")
|
||||
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
|
||||
}
|
||||
how, err := s.checkCode(r.Context(), u, in.Code, true)
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:"+ip, userKey)
|
||||
left := s.pendingLogins.fail(in.Challenge)
|
||||
s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error())
|
||||
msg := "wrong code: use the current code from your authenticator app, or a recovery code"
|
||||
if left == 0 {
|
||||
return &httpError{http.StatusUnauthorized, "login_expired", "too many wrong codes; log in again"}
|
||||
}
|
||||
return &httpError{http.StatusUnauthorized, "totp_invalid", msg}
|
||||
}
|
||||
s.pendingLogins.done(in.Challenge)
|
||||
s.limiter.reset(userKey)
|
||||
detail := "with an authenticator code"
|
||||
if how == "recovery code" {
|
||||
n, _ := s.store.RecoveryCodesLeft(r.Context(), u.ID)
|
||||
detail = fmt.Sprintf("with a recovery code (%d left)", n)
|
||||
}
|
||||
return s.startSession(w, r, u, &now, detail)
|
||||
}
|
||||
|
||||
// startSession creates a session for u after a successful login.
|
||||
// totpAt is when it proved a code (nil without TOTP).
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *model.User, totpAt *time.Time, detail string) error {
|
||||
ip := s.clientIP(r)
|
||||
id, csrf := auth.NewSessionID()
|
||||
now := time.Now()
|
||||
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent(), TOTPAt: totpAt}
|
||||
if err := s.store.CreateSession(r.Context(), se); err != nil {
|
||||
return err
|
||||
}
|
||||
s.store.TouchLogin(r.Context(), u.ID)
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
|
||||
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
|
||||
s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", detail)
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
|
||||
return nil
|
||||
}
|
||||
|
||||
// stepUp makes sure a session of a user with TOTP proved a code within
|
||||
// stepUpWindow, or proves one now from the X-Jarl-TOTP header. Tokens,
|
||||
// the local socket and users without TOTP pass.
|
||||
func (s *Server) stepUp(r *http.Request, p *auth.Principal) error {
|
||||
if p.Method != "session" || !p.User.HasTOTP() {
|
||||
return nil
|
||||
}
|
||||
se, _ := r.Context().Value(keySession).(*model.Session)
|
||||
if se == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
if se.TOTPAt != nil && now.Sub(*se.TOTPAt) < stepUpWindow {
|
||||
return nil
|
||||
}
|
||||
code := r.Header.Get(TOTPHeader)
|
||||
if code == "" {
|
||||
return &httpError{http.StatusForbidden, "totp_step_up", "this needs a current code from your authenticator app"}
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
userKey := "user:" + strings.ToLower(p.User.Username)
|
||||
if s.limiter.blocked(userKey, failsPerKey) {
|
||||
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many wrong codes; wait a few minutes and try again"}
|
||||
}
|
||||
if _, err := s.checkCode(r.Context(), p.User, code, false); err != nil {
|
||||
s.limiter.fail("ip:"+ip, userKey)
|
||||
s.eng.Audit(r.Context(), p, "auth.totp_failed", "user", p.User.ID, p.User.Username, "failure", "step-up: "+err.Error())
|
||||
return &httpError{http.StatusForbidden, "totp_invalid", "wrong or already used code; enter the current code from your authenticator app"}
|
||||
}
|
||||
s.store.SetSessionTOTP(r.Context(), se.IDHash, now)
|
||||
se.TOTPAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) totpStatus(ctx context.Context, u *model.User) api.TOTPStatus {
|
||||
st := api.TOTPStatus{Enabled: u.HasTOTP(), EnabledAt: u.TOTPEnabledAt, Required: s.totpRequired(u), Policy: s.cfg.TOTPRequire}
|
||||
if st.Enabled {
|
||||
st.RecoveryCodesLeft, _ = s.store.RecoveryCodesLeft(ctx, u.ID)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// needSession refuses TOTP changes made with an API token or on the
|
||||
// local socket: they belong to a person in the web UI.
|
||||
func needSession(p *auth.Principal) error {
|
||||
if p.Method != "session" {
|
||||
return forbidden("two-factor login is set up in the web UI (Account), not with an API token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GET /me/totp
|
||||
func (s *Server) getOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.User.ID == 0 {
|
||||
return badRequest("%s on the local socket isn't a Jarl user", p.Name())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.totpStatus(r.Context(), p.User))
|
||||
return nil
|
||||
}
|
||||
|
||||
// POST /me/totp/setup starts setting up (or moving to a new app): a new
|
||||
// secret, shown as a QR code, that becomes active once a code from it is
|
||||
// confirmed. It needs the password, and with TOTP already on a recent
|
||||
// code too.
|
||||
func (s *Server) setupOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := needSession(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TOTPSetupRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
u := p.User
|
||||
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Password) {
|
||||
s.limiter.fail("user:" + strings.ToLower(u.Username))
|
||||
s.eng.Audit(r.Context(), p, "user.totp_setup", "user", u.ID, u.Username, "failure", "wrong password")
|
||||
return &httpError{http.StatusForbidden, "wrong_password", "the password is wrong"}
|
||||
}
|
||||
if err := s.stepUp(r, p); err != nil {
|
||||
return err
|
||||
}
|
||||
secret := totp.NewSecret()
|
||||
if err := s.store.SetTOTPPending(r.Context(), u.ID, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
issuer := s.cfg.TOTPIssuer
|
||||
uri := totp.URI(issuer, u.Username, secret)
|
||||
qr, err := totp.QRDataURI(uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, api.TOTPSetup{Secret: secret, URI: uri, QR: qr, Issuer: issuer, Account: u.Username})
|
||||
return nil
|
||||
}
|
||||
|
||||
// POST /me/totp/enable confirms the secret from setup with a code from
|
||||
// the app. It returns the recovery codes (shown once) and logs out the
|
||||
// user's other sessions.
|
||||
func (s *Server) enableOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := needSession(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TOTPCode
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), p.User.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.TOTPPending == "" {
|
||||
return conflict("start the setup first (POST /me/totp/setup)")
|
||||
}
|
||||
userKey := "user:" + strings.ToLower(u.Username)
|
||||
if s.limiter.blocked(userKey, failsPerKey) {
|
||||
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many wrong codes; wait a few minutes and try again"}
|
||||
}
|
||||
step, ok := totp.Verify(u.TOTPPending, in.Code, time.Now(), 0)
|
||||
if !ok {
|
||||
s.limiter.fail(userKey)
|
||||
return &httpError{http.StatusBadRequest, "totp_invalid", "the code doesn't match: check that the app shows a 6-digit code for " + s.cfg.TOTPIssuer + " and that this device's clock is right, then try the current code"}
|
||||
}
|
||||
codes := totp.NewRecoveryCodes(totp.RecoveryCodes)
|
||||
var hashes []string
|
||||
for _, c := range codes {
|
||||
hashes = append(hashes, auth.HashSecret(totp.NormalizeRecovery(c)))
|
||||
}
|
||||
replaced := u.HasTOTP()
|
||||
if err := s.store.EnableTOTP(r.Context(), u.ID, u.TOTPPending, step, hashes); err != nil {
|
||||
return err
|
||||
}
|
||||
se, _ := r.Context().Value(keySession).(*model.Session)
|
||||
keep := ""
|
||||
if se != nil {
|
||||
keep = se.IDHash
|
||||
s.store.SetSessionTOTP(r.Context(), se.IDHash, time.Now())
|
||||
}
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
|
||||
detail := "turned on"
|
||||
if replaced {
|
||||
detail = "moved to a new authenticator"
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.totp_enable", "user", u.ID, u.Username, "success", detail)
|
||||
writeJSON(w, http.StatusOK, api.RecoveryCodes{Codes: codes})
|
||||
return nil
|
||||
}
|
||||
|
||||
// POST /me/totp/disable turns TOTP off, with the password and a code (or
|
||||
// a recovery code). Not when totp.require applies to the user.
|
||||
func (s *Server) disableOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := needSession(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TOTPDisable
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
u := p.User
|
||||
if !u.HasTOTP() {
|
||||
return conflict("two-factor login is not on")
|
||||
}
|
||||
if s.totpRequired(u) {
|
||||
return conflict("this server requires two-factor login for your account (totp.require in jarl.yaml); to move to a new phone, set it up again instead")
|
||||
}
|
||||
userKey := "user:" + strings.ToLower(u.Username)
|
||||
if s.limiter.blocked(userKey, failsPerKey) {
|
||||
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; wait a few minutes and try again"}
|
||||
}
|
||||
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Password) {
|
||||
s.limiter.fail(userKey)
|
||||
return &httpError{http.StatusForbidden, "wrong_password", "the password is wrong"}
|
||||
}
|
||||
if _, err := s.checkCode(r.Context(), u, in.Code, true); err != nil {
|
||||
s.limiter.fail(userKey)
|
||||
return &httpError{http.StatusForbidden, "totp_invalid", err.Error()}
|
||||
}
|
||||
if err := s.store.DisableTOTP(r.Context(), u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.totp_disable", "user", u.ID, u.Username, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// POST /me/totp/recovery-codes replaces the recovery codes (needs a
|
||||
// recent code).
|
||||
func (s *Server) newRecoveryCodes(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := needSession(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.User.HasTOTP() {
|
||||
return conflict("two-factor login is not on")
|
||||
}
|
||||
if err := s.stepUp(r, p); err != nil {
|
||||
return err
|
||||
}
|
||||
codes := totp.NewRecoveryCodes(totp.RecoveryCodes)
|
||||
var hashes []string
|
||||
for _, c := range codes {
|
||||
hashes = append(hashes, auth.HashSecret(totp.NormalizeRecovery(c)))
|
||||
}
|
||||
if err := s.store.ReplaceRecoveryCodes(r.Context(), p.User.ID, hashes); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.recovery_codes", "user", p.User.ID, p.User.Username, "success", "new recovery codes")
|
||||
writeJSON(w, http.StatusOK, api.RecoveryCodes{Codes: codes})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DELETE /users/{id}/totp: an administrator turns off someone's TOTP (a
|
||||
// lost phone without recovery codes). Their sessions end; at the next
|
||||
// login they log in with the password and, if totp.require applies, must
|
||||
// set it up again.
|
||||
func (s *Server) resetUserTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.stepUp(r, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if !u.HasTOTP() && u.TOTPPending == "" {
|
||||
return conflict("%s doesn't use two-factor login", u.Username)
|
||||
}
|
||||
if err := s.store.DisableTOTP(r.Context(), u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, "")
|
||||
s.eng.Audit(r.Context(), p, "user.totp_reset", "user", u.ID, u.Username, "success", "")
|
||||
u.TOTPSecret, u.TOTPEnabledAt = "", nil
|
||||
writeJSON(w, http.StatusOK, s.userOut(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
// totpOverview is the dashboard's view of two-factor login.
|
||||
func (s *Server) totpOverview(ctx context.Context) *api.TOTPOverview {
|
||||
us, err := s.store.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
o := &api.TOTPOverview{Policy: s.cfg.TOTPRequire, Missing: []string{}}
|
||||
admins, shared := 0, ""
|
||||
for _, u := range us {
|
||||
if u.Disabled {
|
||||
continue
|
||||
}
|
||||
if s.totpRequired(u) && !u.HasTOTP() {
|
||||
o.Missing = append(o.Missing, u.Username)
|
||||
}
|
||||
if u.Admin {
|
||||
admins++
|
||||
if strings.EqualFold(u.Username, "admin") && u.PasswordHash != "" {
|
||||
shared = u.Username
|
||||
}
|
||||
}
|
||||
}
|
||||
if shared != "" && admins > 1 {
|
||||
o.SharedAdmin = shared
|
||||
}
|
||||
return o
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -69,3 +71,59 @@ func TestZoneSerialsTargets(t *testing.T) {
|
||||
e.do("GET", "/zones/copy.example/delegation", e.token, nil, 400)
|
||||
e.do("GET", "/zones/example.com/delegation?resolver=not-an-ip", e.token, nil, 400)
|
||||
}
|
||||
|
||||
func TestZoneSerialsStream(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.21" }}
|
||||
e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201)
|
||||
e.do("POST", "/zones", e.token, map[string]any{
|
||||
"name": "example.com",
|
||||
"nameservers": []string{"ns1.example.com.", "ns2.example.com."},
|
||||
"records": []map[string]any{{"name": "ns1", "type": "A", "data": "127.0.0.21"}, {"name": "ns2", "type": "A", "data": "127.0.0.22"}},
|
||||
"options": []map[string]any{{"name": "also-notify", "value": "{ 127.0.0.24 port 5300 key xfer; }"}},
|
||||
}, 201)
|
||||
body, status := e.req("GET", "/zones/example.com/serials?stream=1", e.token, nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("stream = %d: %s", status, body)
|
||||
}
|
||||
var starts, ends []map[string]any
|
||||
var done map[string]any
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||
var ev map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
t.Fatalf("line %q: %v", line, err)
|
||||
}
|
||||
switch ev["kind"] {
|
||||
case "start":
|
||||
starts = append(starts, ev)
|
||||
case "end":
|
||||
ends = append(ends, ev)
|
||||
case "done":
|
||||
done = ev
|
||||
}
|
||||
}
|
||||
// .21 is this server (not asked); .22 and .24 are.
|
||||
queries := map[string]bool{}
|
||||
for _, s := range starts {
|
||||
queries[s["query"].(string)] = true
|
||||
}
|
||||
for _, q := range []string{
|
||||
"dig @127.0.0.22 -p 1 example.com. SOA +norec",
|
||||
"dig @127.0.0.24 -p 5300 example.com. SOA +norec -y hmac-sha256:xfer:…",
|
||||
} {
|
||||
if !queries[q] {
|
||||
t.Errorf("no query %q in %v", q, queries)
|
||||
}
|
||||
}
|
||||
if len(starts) != 2 || len(ends) != 2 {
|
||||
t.Errorf("starts %d, ends %d", len(starts), len(ends))
|
||||
}
|
||||
for _, e := range ends {
|
||||
if r, _ := e["result"].(map[string]any); r == nil || r["status"] != "unreachable" {
|
||||
t.Errorf("end event %v", e)
|
||||
}
|
||||
}
|
||||
if done == nil || len(done["response"].(map[string]any)["servers"].([]any)) != 3 {
|
||||
t.Errorf("done = %v", done)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/catalog"
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/rr"
|
||||
"jarl/internal/version"
|
||||
)
|
||||
|
||||
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone {
|
||||
@@ -17,6 +20,7 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]in
|
||||
if z.Type == model.ZonePrimary {
|
||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||
}
|
||||
out.Registrar = s.registrarFor(z)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -41,8 +45,8 @@ func canReadRecord(p *auth.Principal, st *model.State, r *model.Record) bool {
|
||||
return z != nil && p.Can(auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z), Name: r.Name, Type: r.Type})
|
||||
}
|
||||
|
||||
func presentView(st *model.State, v *model.View) api.View {
|
||||
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID))}
|
||||
func presentView(st *model.State, v *model.View, colors map[int64]string) api.View {
|
||||
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID)), Color: colors[v.ID]}
|
||||
}
|
||||
|
||||
func presentACL(st *model.State, a *model.ACL) api.ACL {
|
||||
@@ -131,8 +135,20 @@ func presentToken(t *model.Token) api.Token {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
|
||||
out := api.Whoami{User: presentUser(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version}
|
||||
// userOut is presentUser with what depends on the server's settings.
|
||||
func (s *Server) userOut(u *model.User) api.User {
|
||||
out := presentUser(u)
|
||||
out.TOTPRequired = s.totpRequired(u)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) whoamiOf(ctx context.Context, p *auth.Principal, se *model.Session) api.Whoami {
|
||||
out := api.Whoami{User: s.userOut(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version, Build: version.Info()}
|
||||
if p.Method == "session" {
|
||||
st := s.totpStatus(ctx, p.User)
|
||||
out.TOTP = &st
|
||||
out.TOTPSetupRequired = s.setupOnly(p)
|
||||
}
|
||||
if p.Token != nil {
|
||||
t := presentToken(p.Token)
|
||||
out.Token = &t
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
)
|
||||
|
||||
// soaServers answers SOA queries for zone with serial on each address
|
||||
// (all on one port, which it returns).
|
||||
func soaServers(t *testing.T, zone string, serial uint32, addrs ...string) string {
|
||||
t.Helper()
|
||||
port := "0"
|
||||
for _, a := range addrs {
|
||||
pc, err := net.ListenPacket("udp", net.JoinHostPort(a, port))
|
||||
if err != nil {
|
||||
t.Skipf("can't listen on %s: %v", a, err)
|
||||
}
|
||||
_, port, _ = net.SplitHostPort(pc.LocalAddr().String())
|
||||
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(req)
|
||||
m.Authoritative = true
|
||||
if req.Question[0].Qtype == dns.TypeSOA {
|
||||
rr, _ := dns.NewRR(zone + " 300 IN SOA ns1." + zone + " h." + zone + " " + strconv.FormatUint(uint64(serial), 10) + " 3600 900 1209600 300")
|
||||
m.Answer = []dns.RR{rr}
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
})}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func TestRegistrar(t *testing.T) {
|
||||
fake := porkbun.NewFake("pk1_a", "sk1_b", "adbraun.lol")
|
||||
pts := httptest.NewServer(fake)
|
||||
t.Cleanup(pts.Close)
|
||||
e := newAPIEnvConfig(t, Config{Version: "test", Porkbun: &porkbun.Client{BaseURL: pts.URL + "/api/json/v3", APIKey: "pk1_a", Secret: "sk1_b"},
|
||||
PorkbunAllows: func(d string) bool { return d == "adbraun.lol" }})
|
||||
e.do("POST", "/zones", e.token, map[string]any{
|
||||
"name": "adbraun.lol", "nameservers": []string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
|
||||
"records": []map[string]any{
|
||||
{"name": "ns1", "type": "A", "data": "127.0.0.41"}, {"name": "ns1", "type": "AAAA", "data": "::1"},
|
||||
{"name": "ns2", "type": "A", "data": "127.0.0.42"},
|
||||
},
|
||||
}, 201)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "other.lol"}, 201)
|
||||
e.do("GET", "/zones/other.lol/registrar", e.token, nil, 400)
|
||||
|
||||
st := e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
if st["ns_match"] != false || len(st["glue"].([]any)) != 2 {
|
||||
t.Fatalf("status: %v", st)
|
||||
}
|
||||
for _, g := range st["glue"].([]any) {
|
||||
if g.(map[string]any)["action"] != "create" {
|
||||
t.Errorf("glue: %v", g)
|
||||
}
|
||||
}
|
||||
|
||||
// Glue: a dry run changes nothing; then both hosts, IPv4 and IPv6.
|
||||
out := e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, map[string]any{"dry_run": true}, 200)
|
||||
if len(out["done"].([]any)) != 2 || len(fake.Domains["adbraun.lol"].Glue) != 0 {
|
||||
t.Errorf("dry run: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 200)
|
||||
g := fake.Domains["adbraun.lol"].Glue
|
||||
sort.Strings(g["ns1.adbraun.lol"])
|
||||
if strings.Join(g["ns1.adbraun.lol"], " ") != "127.0.0.41 ::1" || strings.Join(g["ns2.adbraun.lol"], " ") != "127.0.0.42" {
|
||||
t.Errorf("glue at Porkbun: %v", g)
|
||||
}
|
||||
|
||||
// NS: refused while the servers don't answer...
|
||||
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(netip.Addr) bool { return false }}
|
||||
out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "doesn't serve the zone") {
|
||||
t.Errorf("set-ns without servers: %v", out)
|
||||
}
|
||||
chk := e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"dry_run": true}, 200)
|
||||
if len(chk["checks"].([]any)) == 0 {
|
||||
t.Errorf("dry run checks: %v", chk)
|
||||
}
|
||||
// ...and allowed once they serve the zone.
|
||||
serial := uint32(e.do("GET", "/zones/adbraun.lol", e.token, nil, 200)["soa"].(map[string]any)["serial"].(float64))
|
||||
port := soaServers(t, "adbraun.lol.", serial, "127.0.0.41", "127.0.0.42", "::1")
|
||||
e.srv.nsChecker.Port = port
|
||||
e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 200)
|
||||
if ns := fake.Domains["adbraun.lol"].NS; strings.Join(ns, " ") != "ns1.adbraun.lol ns2.adbraun.lol" {
|
||||
t.Errorf("NS at Porkbun: %v", ns)
|
||||
}
|
||||
if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); st["ns_match"] != true {
|
||||
t.Errorf("after set-ns: %v", st)
|
||||
}
|
||||
|
||||
// A DS record for an unsigned zone: reported, removable; and setting
|
||||
// NS while a foreign DS is there is refused.
|
||||
fake.Domains["adbraun.lol"].DS[4242] = porkbun.DS{KeyTag: 4242, Algorithm: 13, DigestType: 2, Digest: "AB"}
|
||||
st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
found := false
|
||||
for _, f := range st["findings"].([]any) {
|
||||
if f.(map[string]any)["level"] == "error" && strings.Contains(f.(map[string]any)["message"].(string), "isn't signed") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("DS on an unsigned zone not reported: %v", st["findings"])
|
||||
}
|
||||
out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"ns": []string{"ns1.adbraun.lol"}}, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "DS records") {
|
||||
t.Errorf("set-ns with a foreign DS: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // not signed
|
||||
e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 200)
|
||||
if len(fake.Domains["adbraun.lol"].DS) != 0 {
|
||||
t.Error("DS not removed")
|
||||
}
|
||||
e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 404)
|
||||
|
||||
// A signed zone whose key named has published long enough: its DS
|
||||
// goes to Porkbun.
|
||||
e.do("PATCH", "/zones/adbraun.lol", e.token, map[string]any{"dnssec_policy": "default"}, 200)
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // no key yet
|
||||
tag := writeKey(t, filepath.Join(e.srv.eng.Config().Paths.KeysDir(), "_default"), "adbraun.lol.")
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200)
|
||||
if d, ok := fake.Domains["adbraun.lol"].DS[tag]; !ok || d.Algorithm != 13 || d.DigestType != 2 || len(d.Digest) != 64 {
|
||||
t.Errorf("DS at Porkbun: %+v", fake.Domains["adbraun.lol"].DS)
|
||||
}
|
||||
st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
if !st["ds"].([]any)[0].(map[string]any)["matches"].(bool) {
|
||||
t.Errorf("DS doesn't match: %v", st["ds"])
|
||||
}
|
||||
out = e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200)
|
||||
if len(out["done"].([]any)) != 0 {
|
||||
t.Errorf("published twice: %v", out)
|
||||
}
|
||||
|
||||
// Registrar errors are passed on.
|
||||
fake.Domains["adbraun.lol"].APIAccess = false
|
||||
if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); !strings.Contains(st["error"].(string), "API_ACCESS_DISABLED") {
|
||||
t.Errorf("error: %v", st)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 502)
|
||||
}
|
||||
|
||||
// writeKey writes the .key and .state files named would have for a key
|
||||
// of zone that is ready for its DS record, and returns its tag.
|
||||
func writeKey(t *testing.T, dir, zone string) uint16 {
|
||||
t.Helper()
|
||||
k := &dns.DNSKEY{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET, Ttl: 3600}, Flags: 257, Protocol: 3, Algorithm: dns.ECDSAP256SHA256}
|
||||
if _, err := k.Generate(256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := filepath.Join(dir, fmt.Sprintf("K%s+013+%05d", zone, k.KeyTag()))
|
||||
os.MkdirAll(dir, 0o755)
|
||||
os.WriteFile(base+".key", []byte(k.String()+"\n"), 0o644)
|
||||
os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644)
|
||||
return k.KeyTag()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -156,6 +157,21 @@ func readBody(r *http.Request) (json.RawMessage, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// decodeOptional reads a JSON body into v like decodeJSON, but an empty
|
||||
// body leaves v as it is.
|
||||
func decodeOptional(r *http.Request, v any) error {
|
||||
b, err := readBody(r)
|
||||
if err != nil || b == nil {
|
||||
return err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
v := r.PathValue(name)
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
|
||||
@@ -24,9 +24,15 @@ func (s *Server) routes() {
|
||||
})
|
||||
// Authentication
|
||||
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
||||
m.HandleFunc("POST "+p+"/auth/login/totp", s.public(s.loginTOTP))
|
||||
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
|
||||
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
|
||||
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
|
||||
m.HandleFunc("GET "+p+"/me/totp", s.authed(s.getOwnTOTP))
|
||||
m.HandleFunc("POST "+p+"/me/totp/setup", s.authed(s.setupOwnTOTP))
|
||||
m.HandleFunc("POST "+p+"/me/totp/enable", s.authed(s.enableOwnTOTP))
|
||||
m.HandleFunc("POST "+p+"/me/totp/disable", s.authed(s.disableOwnTOTP))
|
||||
m.HandleFunc("POST "+p+"/me/totp/recovery-codes", s.authed(s.newRecoveryCodes))
|
||||
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
|
||||
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
|
||||
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
|
||||
@@ -40,6 +46,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
|
||||
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
|
||||
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
|
||||
m.HandleFunc("DELETE "+p+"/users/{id}/totp", s.authed(s.resetUserTOTP))
|
||||
// Views
|
||||
m.HandleFunc("GET "+p+"/views", s.authed(s.listViews))
|
||||
m.HandleFunc("POST "+p+"/views", s.authed(s.createView))
|
||||
@@ -47,6 +54,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView))
|
||||
m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView))
|
||||
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
|
||||
m.HandleFunc("PUT "+p+"/views/{id}/color", s.authed(s.setViewColor))
|
||||
// ACLs
|
||||
m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs))
|
||||
m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL))
|
||||
@@ -67,6 +75,14 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}", s.authed(s.deleteZone))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/serials", s.authed(s.zoneSerials))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/delegation", s.authed(s.zoneDelegation))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/dnssec", s.authed(s.zoneDNSSEC))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/dnssec/check-ds", s.authed(s.zoneCheckDS))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/dnssec/wipe", s.authed(s.zoneDNSSECWipe))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/registrar", s.authed(s.zoneRegistrar))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/registrar/glue", s.authed(s.registrarSyncGlue))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/registrar/ds", s.authed(s.registrarPublishDS))
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/stats"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
@@ -47,6 +48,14 @@ type Config struct {
|
||||
// reports on (backup.dir and backup.warn_after).
|
||||
BackupDir string
|
||||
BackupWarnAfter time.Duration
|
||||
// Porkbun manages domains' delegation at the registrar Porkbun (nil
|
||||
// without an API key); PorkbunAllows says which domains it may touch.
|
||||
Porkbun *porkbun.Client
|
||||
PorkbunAllows func(domain string) bool
|
||||
// TOTPRequire is totp.require (none, admins or all) and TOTPIssuer the
|
||||
// name authenticator apps show (h_totp.go).
|
||||
TOTPRequire string
|
||||
TOTPIssuer string
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
@@ -64,6 +73,8 @@ type Server struct {
|
||||
oldSecretWarn throttle
|
||||
// expiryWarn limits warnings about expiring tokens to one a day each.
|
||||
expiryWarn throttle
|
||||
// pendingLogins are logins waiting for their TOTP code.
|
||||
pendingLogins pendingLogins
|
||||
// nsChecker, when set, replaces the default name server checker
|
||||
// (tests point it at local DNS servers).
|
||||
nsChecker *nscheck.Checker
|
||||
@@ -86,6 +97,12 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
||||
if cfg.CookieSecure == "" {
|
||||
cfg.CookieSecure = "auto"
|
||||
}
|
||||
if cfg.TOTPRequire == "" {
|
||||
cfg.TOTPRequire = "none"
|
||||
}
|
||||
if cfg.TOTPIssuer == "" {
|
||||
cfg.TOTPIssuer = "Jarl"
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
@@ -367,6 +384,10 @@ func (w *statusWriter) WriteHeader(code int) {
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Unwrap lets http.ResponseController reach the connection (to flush a
|
||||
// streamed response).
|
||||
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/totp"
|
||||
)
|
||||
|
||||
// browser is a web UI session: cookies plus the CSRF token.
|
||||
type browser struct {
|
||||
t *testing.T
|
||||
e *apiEnv
|
||||
c *http.Client
|
||||
csrf string
|
||||
}
|
||||
|
||||
func newBrowser(e *apiEnv) *browser {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &browser{t: e.t, e: e, c: &http.Client{Jar: jar}}
|
||||
}
|
||||
|
||||
// call sends a request; header is an optional X-Jarl-TOTP code.
|
||||
func (b *browser) call(method, path string, body any, code string) (map[string]any, int) {
|
||||
b.t.Helper()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
j, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(j)
|
||||
}
|
||||
r, _ := http.NewRequest(method, b.e.ts.URL+api.Prefix+path, rd)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if b.csrf != "" {
|
||||
r.Header.Set(csrfHeader, b.csrf)
|
||||
}
|
||||
if code != "" {
|
||||
r.Header.Set(TOTPHeader, code)
|
||||
}
|
||||
resp, err := b.c.Do(r)
|
||||
if err != nil {
|
||||
b.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&m)
|
||||
if c, ok := m["csrf_token"].(string); ok && c != "" {
|
||||
b.csrf = c
|
||||
}
|
||||
return m, resp.StatusCode
|
||||
}
|
||||
|
||||
func (b *browser) must(method, path string, body any, code string, want int) map[string]any {
|
||||
b.t.Helper()
|
||||
m, status := b.call(method, path, body, code)
|
||||
if status != want {
|
||||
b.t.Fatalf("%s %s = %d, want %d: %v", method, path, status, want, m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// sessionHash is the stored hash of the browser's session cookie.
|
||||
func (b *browser) sessionHash() string {
|
||||
u, _ := url.Parse(b.e.ts.URL)
|
||||
for _, c := range b.c.Jar.Cookies(u) {
|
||||
if c.Name == sessionCookie {
|
||||
return auth.HashSecret(c.Value)
|
||||
}
|
||||
}
|
||||
b.t.Fatal("no session cookie")
|
||||
return ""
|
||||
}
|
||||
|
||||
// codeAt is secret's code for a step. Tests count steps from a base
|
||||
// taken at their start, so crossing a step boundary mid-test changes
|
||||
// nothing (the server accepts one step either side).
|
||||
func codeAt(secret string, step int64) string {
|
||||
c, _ := totp.Code(secret, step)
|
||||
return c
|
||||
}
|
||||
|
||||
func bodyErrCode(m map[string]any) string {
|
||||
if e, ok := m["error"].(map[string]any); ok {
|
||||
s, _ := e["code"].(string)
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTOTPLogin(t *testing.T) {
|
||||
base := totp.Step(time.Now())
|
||||
e := newAPIEnv(t)
|
||||
const pw = "correct horse battery"
|
||||
b := newBrowser(e)
|
||||
b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
if st := b.must("GET", "/me/totp", nil, "", 200); st["enabled"] != false {
|
||||
t.Fatalf("status = %v", st)
|
||||
}
|
||||
// Setting up needs the password; the secret only counts once a code
|
||||
// confirms it.
|
||||
if m, status := b.call("POST", "/me/totp/setup", map[string]string{"password": "wrong"}, ""); status != 403 {
|
||||
t.Errorf("setup with a wrong password = %d %v", status, m)
|
||||
}
|
||||
setup := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)
|
||||
secret := setup["secret"].(string)
|
||||
if setup["qr"] == "" || setup["account"] != "admin" {
|
||||
t.Errorf("setup = %v", setup)
|
||||
}
|
||||
if m, status := b.call("POST", "/me/totp/enable", map[string]string{"code": "000000"}, ""); status != 400 || bodyErrCode(m) != "totp_invalid" {
|
||||
t.Errorf("enable with a wrong code = %d %v", status, m)
|
||||
}
|
||||
// The codes of three steps are accepted (the clock may be off by one);
|
||||
// each works once. Enable with the previous step's, log in with the
|
||||
// current one's, and step up with the next one's.
|
||||
en := b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base-1)}, "", 200)
|
||||
recovery := en["recovery_codes"].([]any)
|
||||
if len(recovery) != totp.RecoveryCodes {
|
||||
t.Fatalf("recovery codes = %v", en)
|
||||
}
|
||||
// Turning it on kept this session and recorded the proved code: a
|
||||
// token can be made right away.
|
||||
b.must("POST", "/me/tokens", map[string]any{"name": "laptop"}, "", 201)
|
||||
|
||||
// The password alone is not a session any more.
|
||||
b2 := newBrowser(e)
|
||||
ch := b2.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
if ch["totp_required"] != true || ch["challenge"] == "" {
|
||||
t.Fatalf("login = %v", ch)
|
||||
}
|
||||
if _, status := b2.call("GET", "/auth/whoami", nil, ""); status != 401 {
|
||||
t.Errorf("whoami after the password only = %d", status)
|
||||
}
|
||||
// The code used to turn it on can't be used again.
|
||||
if m, status := b2.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base-1)}, ""); status != 401 || bodyErrCode(m) != "totp_invalid" {
|
||||
t.Errorf("reused code = %d %v", status, m)
|
||||
}
|
||||
who := b2.must("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base)}, "", 200)
|
||||
if who["totp"].(map[string]any)["enabled"] != true {
|
||||
t.Errorf("whoami = %v", who)
|
||||
}
|
||||
if _, status := b2.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base)}, ""); status != 401 {
|
||||
t.Errorf("challenge used twice = %d", status)
|
||||
}
|
||||
|
||||
// Step-up: once the proved code is older than the window, making a
|
||||
// token needs a fresh one.
|
||||
old := time.Now().Add(-stepUpWindow - time.Minute)
|
||||
e.st.SetSessionTOTP(context.Background(), b2.sessionHash(), old)
|
||||
if m, status := b2.call("POST", "/me/tokens", map[string]any{"name": "ci"}, ""); status != 403 || bodyErrCode(m) != "totp_step_up" {
|
||||
t.Errorf("token without a recent code = %d %v", status, m)
|
||||
}
|
||||
if m, status := b2.call("POST", "/me/tokens", map[string]any{"name": "ci"}, "123456"); status != 403 || bodyErrCode(m) != "totp_invalid" {
|
||||
t.Errorf("token with a wrong code = %d %v", status, m)
|
||||
}
|
||||
b2.must("POST", "/me/tokens", map[string]any{"name": "ci"}, codeAt(secret, base+1), 201)
|
||||
// That proved a code again: the next action needs none.
|
||||
b2.must("POST", "/me/tokens", map[string]any{"name": "ci2"}, "", 201)
|
||||
// API tokens never need a code.
|
||||
e.do("POST", "/me/tokens", e.token, map[string]any{"name": "script"}, 201)
|
||||
|
||||
// A recovery code logs in once.
|
||||
rc := recovery[0].(string)
|
||||
for i, want := range []int{200, 401} {
|
||||
b3 := newBrowser(e)
|
||||
ch := b3.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
if _, status := b3.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": " " + rc + " "}, ""); status != want {
|
||||
t.Errorf("recovery code use %d = %d, want %d", i+1, status, want)
|
||||
}
|
||||
}
|
||||
if st := b2.must("GET", "/me/totp", nil, "", 200); st["recovery_codes_left"] != float64(totp.RecoveryCodes-1) {
|
||||
t.Errorf("recovery codes left = %v", st["recovery_codes_left"])
|
||||
}
|
||||
evs := e.list("/audit?action=user.totp", e.token)
|
||||
if len(evs) == 0 {
|
||||
t.Error("no user.totp audit events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPRequiredAndReset(t *testing.T) {
|
||||
base := totp.Step(time.Now())
|
||||
e := newAPIEnvConfig(t, Config{Version: "test", TOTPRequire: "admins", TOTPIssuer: "Jarl test"})
|
||||
const pw = "correct horse battery"
|
||||
// An administrator without TOTP gets a session that can only set it up.
|
||||
b := newBrowser(e)
|
||||
who := b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
if who["totp_setup_required"] != true {
|
||||
t.Fatalf("whoami = %v", who)
|
||||
}
|
||||
if m, status := b.call("GET", "/zones", nil, ""); status != 403 || bodyErrCode(m) != "totp_setup_required" {
|
||||
t.Errorf("zones before setup = %d %v", status, m)
|
||||
}
|
||||
// Tokens are not affected.
|
||||
e.do("GET", "/zones", e.token, nil, 200)
|
||||
setup := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)
|
||||
if setup["issuer"] != "Jarl test" {
|
||||
t.Errorf("issuer = %v", setup["issuer"])
|
||||
}
|
||||
secret := setup["secret"].(string)
|
||||
b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base)}, "", 200)
|
||||
b.must("GET", "/zones", nil, "", 200)
|
||||
// It can't be turned off while required.
|
||||
if _, status := b.call("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base+1)}, ""); status != 409 {
|
||||
t.Errorf("disable while required = %d", status)
|
||||
}
|
||||
// Users that aren't administrators may go without.
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "bob", "password": "bob-password-123", "role": "readonly"}, 201)
|
||||
bob := newBrowser(e)
|
||||
if who := bob.must("POST", "/auth/login", map[string]string{"username": "bob", "password": "bob-password-123"}, "", 200); who["totp_setup_required"] == true {
|
||||
t.Error("bob must set up TOTP")
|
||||
}
|
||||
bob.must("GET", "/zones", nil, "", 200)
|
||||
|
||||
// The dashboard: another administrator without two-factor login is
|
||||
// listed, and the first "admin" account looks shared.
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "carol", "password": "carol-password-123", "admin": true}, 201)
|
||||
st := e.do("GET", "/status", e.token, nil, 200)["totp"].(map[string]any)
|
||||
if missing := st["missing"].([]any); len(missing) != 1 || missing[0] != "carol" || st["shared_admin"] != "admin" || st["policy"] != "admins" {
|
||||
t.Errorf("status totp = %v", st)
|
||||
}
|
||||
|
||||
// An administrator resets someone's TOTP (with a token: no code).
|
||||
users := e.list("/users", e.token)
|
||||
var adminID string
|
||||
for _, u := range users {
|
||||
if u["username"] == "admin" {
|
||||
adminID = itoa(int64(u["id"].(float64)))
|
||||
if u["totp_required"] != true || u["totp_enabled_at"] == nil {
|
||||
t.Errorf("admin = %v", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.do("DELETE", "/users/"+adminID+"/totp", e.token, nil, 200)
|
||||
if _, status := b.call("GET", "/zones", nil, ""); status != 401 {
|
||||
t.Errorf("session after reset = %d", status)
|
||||
}
|
||||
e.do("DELETE", "/users/"+adminID+"/totp", e.token, nil, 409)
|
||||
// Next login: password only, and setup again.
|
||||
b = newBrowser(e)
|
||||
if who := b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200); who["totp_setup_required"] != true {
|
||||
t.Errorf("after reset = %v", who)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPDisable(t *testing.T) {
|
||||
base := totp.Step(time.Now())
|
||||
e := newAPIEnv(t)
|
||||
const pw = "correct horse battery"
|
||||
b := newBrowser(e)
|
||||
b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
|
||||
secret := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)["secret"].(string)
|
||||
b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base)}, "", 200)
|
||||
if _, status := b.call("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base)}, ""); status != 403 {
|
||||
t.Errorf("disable with a used code = %d", status)
|
||||
}
|
||||
b.must("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base+1)}, "", 204)
|
||||
// The password alone logs in again.
|
||||
b2 := newBrowser(e)
|
||||
if who := b2.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200); who["csrf_token"] == "" {
|
||||
t.Errorf("login = %v", who)
|
||||
}
|
||||
// A token can't set up TOTP.
|
||||
if _, status := e.req("POST", "/me/totp/setup", e.token, map[string]string{"password": pw}); status != 403 {
|
||||
t.Errorf("setup with a token = %d", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestViewColors(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
in := e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)["object"].(map[string]any)
|
||||
ex := e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)["object"].(map[string]any)
|
||||
inID, exID := int64(in["id"].(float64)), int64(ex["id"].(float64))
|
||||
applied := len(e.list("/changesets?status=applied", e.token))
|
||||
|
||||
v := e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": "green"}, 200)
|
||||
if v["color"] != "green" || v["name"] != "internal" {
|
||||
t.Errorf("set colour = %v", v)
|
||||
}
|
||||
e.do("PUT", "/views/"+itoa(exID)+"/color", e.token, map[string]any{"color": "purple"}, 200)
|
||||
got := map[string]any{}
|
||||
for _, v := range e.list("/views", e.token) {
|
||||
got[v["name"].(string)] = v["color"]
|
||||
}
|
||||
if got["internal"] != "green" || got["external"] != "purple" {
|
||||
t.Errorf("listed colours = %v", got)
|
||||
}
|
||||
if v := e.do("GET", "/views/internal", e.token, nil, 200); v["color"] != "green" {
|
||||
t.Errorf("get view colour = %v", v["color"])
|
||||
}
|
||||
// A colour is not configuration: no changeset.
|
||||
if n := len(e.list("/changesets?status=applied", e.token)); n != applied {
|
||||
t.Errorf("colour changes made changesets: %d -> %d", applied, n)
|
||||
}
|
||||
if v := e.do("PATCH", "/views/internal", e.token, map[string]any{"comment": "LAN"}, 200)["object"].(map[string]any); v["color"] != "green" {
|
||||
t.Errorf("colour after update = %v", v["color"])
|
||||
}
|
||||
if _, status := e.req("PUT", "/views/internal/color", e.token, map[string]any{"color": "#ff0000"}); status != 400 {
|
||||
t.Errorf("bad colour = %d", status)
|
||||
}
|
||||
if _, status := e.req("PUT", "/views/nope/color", e.token, map[string]any{"color": "red"}); status != 404 {
|
||||
t.Errorf("unknown view = %d", status)
|
||||
}
|
||||
|
||||
// Readers of views can't change colours.
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "reader", "password": "reader-password-123", "grant_strings": []string{"views:read"}}, 201)
|
||||
var readerID int64
|
||||
for _, u := range e.list("/users", e.token) {
|
||||
if u["username"] == "reader" {
|
||||
readerID = int64(u["id"].(float64))
|
||||
}
|
||||
}
|
||||
rt := e.do("POST", "/users/"+itoa(readerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
||||
if _, status := e.req("PUT", "/views/internal/color", rt, map[string]any{"color": "red"}); status != 403 {
|
||||
t.Errorf("reader set a colour: %d", status)
|
||||
}
|
||||
|
||||
// A view created in a draft can be given a colour before it is applied.
|
||||
cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "dmz"}, 201)["id"].(float64)))
|
||||
e.do("POST", "/views?changeset="+cs, e.token, map[string]any{"name": "dmz"}, 202)
|
||||
if _, status := e.req("PUT", "/views/dmz/color", e.token, map[string]any{"color": "orange"}); status != 404 {
|
||||
t.Errorf("draft view found outside its changeset: %d", status)
|
||||
}
|
||||
e.do("PUT", "/views/dmz/color?changeset="+cs, e.token, map[string]any{"color": "orange"}, 200)
|
||||
e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": "teal"}, 200)
|
||||
e.do("POST", "/changesets/"+cs+"/apply", e.token, nil, 200)
|
||||
if v := e.do("GET", "/views/dmz", e.token, nil, 200); v["color"] != "orange" {
|
||||
t.Errorf("draft view colour after apply = %v", v["color"])
|
||||
}
|
||||
|
||||
// "" goes back to the default.
|
||||
if v := e.do("PUT", "/views/internal/color", e.token, map[string]any{"color": ""}, 200); v["color"] != nil {
|
||||
t.Errorf("reset colour = %v", v["color"])
|
||||
}
|
||||
colors, err := e.st.ViewColors(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := colors[inID]; ok {
|
||||
t.Errorf("stored colours after reset = %v", colors)
|
||||
}
|
||||
}
|
||||
@@ -92,14 +92,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
z := &model.Zone{}
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -209,8 +209,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
||||
return err
|
||||
|
||||
@@ -238,6 +238,27 @@ CREATE TABLE deployed_files (
|
||||
ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
|
||||
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
|
||||
`,
|
||||
// 3: DNSSEC signing (the zone's dnssec-policy).
|
||||
`
|
||||
ALTER TABLE zones ADD COLUMN dnssec_policy TEXT NOT NULL DEFAULT '';
|
||||
`,
|
||||
// 4: two-factor login with TOTP. totp_pending holds a secret being set
|
||||
// up until a code confirms it; totp_last_step is the newest time step
|
||||
// used, so a code works only once. sessions.totp_at is when the
|
||||
// session last proved a code (for actions that ask for a recent one).
|
||||
`
|
||||
ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN totp_pending TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN totp_enabled_at TEXT;
|
||||
ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sessions ADD COLUMN totp_at TEXT;
|
||||
CREATE TABLE recovery_codes (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
PRIMARY KEY (user_id, code_hash)
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetTOTPPending stores a secret being set up (or clears it with ""). It
|
||||
// becomes the user's secret once a code confirms it (EnableTOTP).
|
||||
func (s *Store) SetTOTPPending(ctx context.Context, userID int64, secret string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_pending = ? WHERE id = ?`, secret, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableTOTP turns two-factor login on: the secret, the step of the code
|
||||
// that confirmed it (so that code can't be used again), and the hashes of
|
||||
// new recovery codes, replacing any old ones.
|
||||
func (s *Store) EnableTOTP(ctx context.Context, userID int64, secret string, step int64, codeHashes []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := ts(time.Now())
|
||||
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = ?, totp_pending = '', totp_enabled_at = ?, totp_last_step = ?, updated_at = ? WHERE id = ?`,
|
||||
secret, now, step, now, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DisableTOTP turns two-factor login off and deletes the recovery codes.
|
||||
func (s *Store) DisableTOTP(ctx context.Context, userID int64) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = '', totp_pending = '', totp_enabled_at = NULL, totp_last_step = 0, updated_at = ? WHERE id = ?`, ts(time.Now()), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UseTOTPStep records that the code of step was used. It returns false if
|
||||
// that step (or a later one) was used already: each code works once, even
|
||||
// with two requests at the same moment.
|
||||
func (s *Store) UseTOTPStep(ctx context.Context, userID, step int64) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_last_step = ? WHERE id = ? AND totp_last_step < ? AND totp_secret != ''`, step, userID, step)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// ReplaceRecoveryCodes gives a user new recovery codes (their hashes),
|
||||
// making the old ones useless.
|
||||
func (s *Store) ReplaceRecoveryCodes(ctx context.Context, userID int64, codeHashes []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func replaceRecoveryCodes(ctx context.Context, tx *sql.Tx, userID int64, hashes []string) error {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range hashes {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)`, userID, h); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseRecoveryCode marks an unused recovery code (by its hash) used. It
|
||||
// returns false if the user has no such unused code.
|
||||
func (s *Store) UseRecoveryCode(ctx context.Context, userID int64, codeHash string) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, ts(time.Now()), userID, codeHash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// RecoveryCodesLeft counts a user's unused recovery codes.
|
||||
func (s *Store) RecoveryCodesLeft(ctx context.Context, userID int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used_at IS NULL`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// SetSessionTOTP records that a session proved a TOTP code at at.
|
||||
func (s *Store) SetSessionTOTP(ctx context.Context, idHash string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET totp_at = ? WHERE id_hash = ?`, ts(at), idHash)
|
||||
return err
|
||||
}
|
||||
+10
-7
@@ -18,14 +18,14 @@ func isUnique(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at`
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step`
|
||||
|
||||
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||
u := &model.User{}
|
||||
var grants, ca, ua string
|
||||
var admin, disabled int
|
||||
var ll sql.NullString
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil {
|
||||
var ll, te sql.NullString
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Admin, u.Disabled = admin != 0, disabled != 0
|
||||
@@ -37,6 +37,7 @@ func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||
u.Grants = []model.Grant{}
|
||||
}
|
||||
u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll)
|
||||
u.TOTPEnabledAt = parseTSPtr(te)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -263,8 +264,8 @@ func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
|
||||
|
||||
// CreateSession stores a session.
|
||||
func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent, totp_at) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent, tsPtr(se.TOTPAt))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -272,8 +273,9 @@ func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
|
||||
func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) {
|
||||
se := &model.Session{}
|
||||
var ca, ea, ls string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash).
|
||||
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent)
|
||||
var ta sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent, totp_at FROM sessions WHERE id_hash = ?`, idHash).
|
||||
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent, &ta)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -281,6 +283,7 @@ func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session,
|
||||
return nil, err
|
||||
}
|
||||
se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls)
|
||||
se.TOTPAt = parseTSPtr(ta)
|
||||
return se, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// View colours only change how the web UI shows a view, so they are kept
|
||||
// outside the configuration: setting one needs no changeset or deploy.
|
||||
const viewColorsKey = "view_colors"
|
||||
|
||||
// ViewColors returns the colour of each view that has one, by view ID.
|
||||
func (s *Store) ViewColors(ctx context.Context) (map[int64]string, error) {
|
||||
out := map[int64]string{}
|
||||
raw, err := s.GetMeta(ctx, viewColorsKey)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range m {
|
||||
if id, err := strconv.ParseInt(k, 10, 64); err == nil {
|
||||
out[id] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetViewColor sets a view's colour; "" removes it. View IDs are never
|
||||
// reused, so the colour of a deleted view is harmless and is left alone
|
||||
// (the view may still exist in a changeset).
|
||||
func (s *Store) SetViewColor(ctx context.Context, id int64, color string) error {
|
||||
m, err := s.ViewColors(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if color == "" {
|
||||
delete(m, id)
|
||||
} else {
|
||||
m[id] = color
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, v := range m {
|
||||
out[strconv.FormatInt(k, 10)] = v
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return s.SetMeta(ctx, viewColorsKey, string(b))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Package totp implements time-based one-time passwords (RFC 6238) for
|
||||
// two-factor login, with the settings every authenticator app supports:
|
||||
// HMAC-SHA1, 6 digits, a 30-second step. It also makes the one-time
|
||||
// recovery codes and the QR code that sets up an app.
|
||||
package totp
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
const (
|
||||
// Digits is the length of a code.
|
||||
Digits = 6
|
||||
// Period is the length of a time step.
|
||||
Period = 30 * time.Second
|
||||
// Skew is how many steps before and after the current one are
|
||||
// accepted, for clocks that are a little off.
|
||||
Skew = 1
|
||||
// SecretBytes is the length of a secret (160 bits, as RFC 4226
|
||||
// recommends).
|
||||
SecretBytes = 20
|
||||
// RecoveryCodes is how many recovery codes a user gets.
|
||||
RecoveryCodes = 10
|
||||
)
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// NewSecret returns a new random secret, base32 encoded (the form
|
||||
// authenticator apps take).
|
||||
func NewSecret() string {
|
||||
b := make([]byte, SecretBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b32.EncodeToString(b)
|
||||
}
|
||||
|
||||
func decode(secret string) ([]byte, error) {
|
||||
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
|
||||
k, err := b32.DecodeString(strings.TrimRight(s, "="))
|
||||
if err != nil || len(k) == 0 {
|
||||
return nil, errors.New("invalid TOTP secret")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// Step returns the time step t falls in.
|
||||
func Step(t time.Time) int64 { return t.Unix() / int64(Period/time.Second) }
|
||||
|
||||
// Code returns the code for a step.
|
||||
func Code(secret string, step int64) (string, error) {
|
||||
k, err := decode(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], uint64(step))
|
||||
m := hmac.New(sha1.New, k)
|
||||
m.Write(msg[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
|
||||
return fmt.Sprintf("%0*d", Digits, v%1_000_000), nil
|
||||
}
|
||||
|
||||
// Normalize removes the spaces and dashes people type in codes.
|
||||
func Normalize(code string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '-' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
// IsCode reports whether s looks like a TOTP code (as opposed to a
|
||||
// recovery code).
|
||||
func IsCode(s string) bool {
|
||||
s = Normalize(s)
|
||||
if len(s) != Digits {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Verify checks a code against the steps around now and returns the step
|
||||
// it matched. Steps up to and including last (the newest step already
|
||||
// used) are refused, so a code works only once.
|
||||
func Verify(secret, code string, now time.Time, last int64) (int64, bool) {
|
||||
code = Normalize(code)
|
||||
if !IsCode(code) {
|
||||
return 0, false
|
||||
}
|
||||
cur := Step(now)
|
||||
for d := -Skew; d <= Skew; d++ {
|
||||
step := cur + int64(d)
|
||||
if step <= last {
|
||||
continue
|
||||
}
|
||||
want, err := Code(secret, step)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||
return step, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// URI is the otpauth:// URI that authenticator apps read from the QR code.
|
||||
func URI(issuer, account, secret string) string {
|
||||
label := url.PathEscape(issuer) + ":" + url.PathEscape(account)
|
||||
q := url.Values{}
|
||||
q.Set("secret", secret)
|
||||
q.Set("issuer", issuer)
|
||||
q.Set("algorithm", "SHA1")
|
||||
q.Set("digits", fmt.Sprint(Digits))
|
||||
q.Set("period", fmt.Sprint(int(Period/time.Second)))
|
||||
return "otpauth://totp/" + label + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// QRDataURI renders text as a QR code: an SVG image as a data: URI, ready
|
||||
// for an <img> tag.
|
||||
func QRDataURI(text string) (string, error) {
|
||||
c, err := qr.Encode(text, qr.M)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
const quiet = 4 // the white border scanners need, in modules
|
||||
n := c.Size + 2*quiet
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, n, n)
|
||||
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n)
|
||||
for y := 0; y < c.Size; y++ {
|
||||
for x := 0; x < c.Size; x++ {
|
||||
if c.Black(x, y) {
|
||||
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString(`"/></svg>`)
|
||||
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(b.String())), nil
|
||||
}
|
||||
|
||||
// recoveryAlphabet has no 0/o, 1/l/i, so codes are easy to read and type.
|
||||
const recoveryAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
|
||||
// NewRecoveryCodes returns n random recovery codes like "k7dm2-xq9pw"
|
||||
// (about 49 bits each).
|
||||
func NewRecoveryCodes(n int) []string {
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
var s strings.Builder
|
||||
for j := range 10 {
|
||||
if j == 5 {
|
||||
s.WriteByte('-')
|
||||
}
|
||||
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(recoveryAlphabet))))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s.WriteByte(recoveryAlphabet[v.Int64()])
|
||||
}
|
||||
out[i] = s.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizeRecovery puts a recovery code as typed into the form it is
|
||||
// stored in (lower case, no spaces or dashes).
|
||||
func NormalizeRecovery(code string) string {
|
||||
return strings.ToLower(Normalize(code))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package totp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The SHA-1 test vectors of RFC 6238 appendix B, cut to 6 digits.
|
||||
func TestCodeRFC6238(t *testing.T) {
|
||||
secret := b32.EncodeToString([]byte("12345678901234567890"))
|
||||
for _, c := range []struct {
|
||||
unix int64
|
||||
want string
|
||||
}{
|
||||
{59, "287082"},
|
||||
{1111111109, "081804"},
|
||||
{1111111111, "050471"},
|
||||
{1234567890, "005924"},
|
||||
{2000000000, "279037"},
|
||||
{20000000000, "353130"},
|
||||
} {
|
||||
got, err := Code(secret, Step(time.Unix(c.unix, 0)))
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("T=%d: %q, %v; want %q", c.unix, got, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
secret := NewSecret()
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
cur := Step(now)
|
||||
code := func(step int64) string { c, _ := Code(secret, step); return c }
|
||||
if step, ok := Verify(secret, code(cur), now, 0); !ok || step != cur {
|
||||
t.Fatal("current code refused")
|
||||
}
|
||||
if _, ok := Verify(secret, code(cur - 1)[:3]+" "+code(cur - 1)[3:], now, 0); !ok {
|
||||
t.Error("previous step (with a space) refused")
|
||||
}
|
||||
if _, ok := Verify(secret, code(cur+1), now, 0); !ok {
|
||||
t.Error("next step refused")
|
||||
}
|
||||
if _, ok := Verify(secret, code(cur-2), now, 0); ok {
|
||||
t.Error("code from a minute ago accepted")
|
||||
}
|
||||
// Replay: a step at or before the last used one is refused.
|
||||
if _, ok := Verify(secret, code(cur), now, cur); ok {
|
||||
t.Error("reused code accepted")
|
||||
}
|
||||
if _, ok := Verify(secret, code(cur+1), now, cur); !ok {
|
||||
t.Error("newer code refused after a used one")
|
||||
}
|
||||
if _, ok := Verify(secret, "abcdef", now, 0); ok {
|
||||
t.Error("letters accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestURIAndQR(t *testing.T) {
|
||||
u := URI("Jarl ns1", "adam", "JBSWY3DPEHPK3PXP")
|
||||
if !strings.HasPrefix(u, "otpauth://totp/Jarl%20ns1:adam?") || !strings.Contains(u, "secret=JBSWY3DPEHPK3PXP") || !strings.Contains(u, "issuer=Jarl+ns1") {
|
||||
t.Errorf("uri = %s", u)
|
||||
}
|
||||
img, err := QRDataURI(u)
|
||||
if err != nil || !strings.HasPrefix(img, "data:image/svg+xml;base64,") {
|
||||
t.Errorf("qr = %.60s, %v", img, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryCodes(t *testing.T) {
|
||||
codes := NewRecoveryCodes(RecoveryCodes)
|
||||
seen := map[string]bool{}
|
||||
for _, c := range codes {
|
||||
if len(c) != 11 || c[5] != '-' || seen[c] || IsCode(c) {
|
||||
t.Errorf("bad code %q", c)
|
||||
}
|
||||
seen[c] = true
|
||||
}
|
||||
if NormalizeRecovery(" K7DM2-XQ9PW ") != "k7dm2xq9pw" {
|
||||
t.Error("normalize")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
)
|
||||
|
||||
// defaultMaxZoneTTL is max-zone-ttl of BIND's built-in policies (and of
|
||||
// custom ones that don't set it): named refuses to load a signed zone
|
||||
// with a longer TTL.
|
||||
const defaultMaxZoneTTL = 86400
|
||||
|
||||
// Policies returns the custom dnssec-policy statements defined at the top
|
||||
// level, by name.
|
||||
func Policies(s *model.State) map[string]*namedconf.Stmt {
|
||||
out := map[string]*namedconf.Stmt{}
|
||||
for _, o := range s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if strings.ToLower(o.Name) != "dnssec-policy" {
|
||||
continue
|
||||
}
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil || stmt.Arg(0) == "" {
|
||||
continue
|
||||
}
|
||||
out[stmt.Arg(0)] = stmt
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *checker) checkDNSSEC() {
|
||||
policies := Policies(c.s)
|
||||
// A custom policy without keys crashes named 9.20 when it loads the
|
||||
// configuration (an assertion in kaspconf.c), although named-checkconf
|
||||
// accepts it. BIND's built-in "insecure" policy is the way to go
|
||||
// unsigned.
|
||||
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if strings.ToLower(o.Name) != "dnssec-policy" {
|
||||
continue
|
||||
}
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil {
|
||||
continue // reported by checkOptions
|
||||
}
|
||||
if b := stmt.FirstBlock(); b != nil {
|
||||
if keys := namedconf.FindOne(b.Stmts, "keys"); keys != nil {
|
||||
if kb := keys.FirstBlock(); kb != nil && len(kb.Stmts) == 0 {
|
||||
c.errf("dnssec_policy_no_keys", "option", o.ID, "dnssec-policy %s has an empty keys list, which makes named 9.20 crash when it loads the configuration; to remove signing from a zone, use BIND's built-in insecure policy (jarl zone dnssec disable)", stmt.Arg(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, z := range c.s.SortedZones() {
|
||||
if z.DNSSECPolicy == "" {
|
||||
continue
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
c.zoneProblem(z, Error, "dnssec_zone_type", "only primary zones can be signed by Jarl; a %s zone gets its signatures from its primary", z.Type)
|
||||
continue
|
||||
}
|
||||
maxTTL := uint32(defaultMaxZoneTTL)
|
||||
switch z.DNSSECPolicy {
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
default:
|
||||
stmt := policies[z.DNSSECPolicy]
|
||||
if stmt == nil {
|
||||
c.zoneProblem(z, Error, "dnssec_policy_unknown", "dnssec-policy %q doesn't exist: use default (BIND's built-in policy), or define it as a top-level dnssec-policy statement", z.DNSSECPolicy)
|
||||
continue
|
||||
}
|
||||
if b := stmt.FirstBlock(); b != nil {
|
||||
if m := namedconf.FindOne(b.Stmts, "max-zone-ttl"); m != nil {
|
||||
if v, ok := parseDuration(m.Arg(0)); ok && v > 0 {
|
||||
maxTTL = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if o := c.s.FindOption(model.ScopeZone, z.ID, "inline-signing"); o != nil && isNo(o.Value) {
|
||||
c.zoneProblem(z, Error, "dnssec_inline_signing", "a signed zone needs inline signing: named keeps its signatures in a separate copy of the zone, because Jarl rewrites the zone file (remove the inline-signing option)")
|
||||
}
|
||||
// named refuses to load a signed zone with a TTL above the
|
||||
// policy's max-zone-ttl, and keeps serving the old version.
|
||||
if z.DefaultTTL > maxTTL {
|
||||
c.zoneProblem(z, Error, "dnssec_ttl_too_long", "the default TTL (%d) is longer than the DNSSEC policy's max-zone-ttl (%d), so named would refuse to load the signed zone; lower it to %d or less", z.DefaultTTL, maxTTL, maxTTL)
|
||||
}
|
||||
for _, r := range c.zoneRecords[z.ID] {
|
||||
if r.TTL != nil && *r.TTL > maxTTL {
|
||||
p := c.zoneProblem(z, Error, "dnssec_ttl_too_long", "%s %s has TTL %d, longer than the DNSSEC policy's max-zone-ttl (%d), so named would refuse to load the signed zone; lower it to %d or less", strings.TrimSuffix(r.Name, "."), r.Type, *r.TTL, maxTTL, maxTTL)
|
||||
p.ObjectType, p.ObjectID, p.Name = "record", r.ID, r.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isNo(v string) bool {
|
||||
switch strings.ToLower(strings.Trim(strings.TrimSpace(v), `";`)) {
|
||||
case "no", "false", "0":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
isoDuration = regexp.MustCompile(`^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$`)
|
||||
ttlDuration = regexp.MustCompile(`^(?:\d+[wdhms])+$`)
|
||||
ttlPart = regexp.MustCompile(`(\d+)([wdhms])`)
|
||||
)
|
||||
|
||||
// parseDuration parses a BIND duration: ISO 8601 (P1D, PT12H), seconds, or
|
||||
// TTL style (1d, 12h30m).
|
||||
func parseDuration(s string) (uint32, bool) {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if n, err := strconv.ParseUint(s, 10, 32); err == nil {
|
||||
return uint32(n), true
|
||||
}
|
||||
if m := isoDuration.FindStringSubmatch(s); m != nil && s != "P" && s != "PT" {
|
||||
units := []uint64{31536000, 2592000, 604800, 86400, 3600, 60, 1}
|
||||
var total uint64
|
||||
for i, u := range units {
|
||||
if m[i+1] != "" {
|
||||
n, _ := strconv.ParseUint(m[i+1], 10, 32)
|
||||
total += n * u
|
||||
}
|
||||
}
|
||||
return uint32(min(total, 1<<32-1)), true
|
||||
}
|
||||
l := strings.ToLower(s)
|
||||
if ttlDuration.MatchString(l) {
|
||||
units := map[string]uint64{"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
|
||||
var total uint64
|
||||
for _, m := range ttlPart.FindAllStringSubmatch(l, -1) {
|
||||
n, _ := strconv.ParseUint(m[1], 10, 32)
|
||||
total += n * units[m[2]]
|
||||
}
|
||||
return uint32(min(total, 1<<32-1)), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func TestDNSSEC(t *testing.T) {
|
||||
b := newBuilder()
|
||||
ok := b.primary("signed.example", nil)
|
||||
ok.DNSSECPolicy = model.DNSSECDefault
|
||||
ps := State(b.s, Options{})
|
||||
if len(codes(ps, Error)) != 0 {
|
||||
t.Fatalf("a signed zone: %v", ps)
|
||||
}
|
||||
|
||||
sec := b.zone("copy.example", nil, model.ZoneSecondary)
|
||||
b.opt(model.ScopeZone, sec.ID, "primaries", "{ 192.0.2.1; }")
|
||||
sec.DNSSECPolicy = model.DNSSECDefault
|
||||
unknown := b.primary("custom.example", nil)
|
||||
unknown.DNSSECPolicy = "fast"
|
||||
long := b.primary("long.example", nil)
|
||||
long.DNSSECPolicy = model.DNSSECInsecure
|
||||
r := b.rec(long, "www", "A", "192.0.2.80")
|
||||
r.TTL = model.Ptr(uint32(172800))
|
||||
b.opt(model.ScopeZone, ok.ID, "inline-signing", "no")
|
||||
ps = State(b.s, Options{})
|
||||
for _, c := range []string{"dnssec_zone_type", "dnssec_policy_unknown", "dnssec_ttl_too_long", "dnssec_inline_signing"} {
|
||||
if !hasCode(ps, c) {
|
||||
t.Errorf("missing %s in %v", c, ps)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom policy: its max-zone-ttl applies; an empty keys list would
|
||||
// crash named.
|
||||
b2 := newBuilder()
|
||||
z := b2.primary("custom.example", nil)
|
||||
z.DNSSECPolicy = "fast"
|
||||
z.DefaultTTL = 600
|
||||
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `fast { keys { csk lifetime unlimited algorithm 13; }; max-zone-ttl PT5M; }`)
|
||||
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `"off" { keys { }; }`)
|
||||
ps = State(b2.s, Options{})
|
||||
if !hasCode(ps, "dnssec_ttl_too_long") || !hasCode(ps, "dnssec_policy_no_keys") || hasCode(ps, "dnssec_policy_unknown") {
|
||||
t.Errorf("custom policy: %v", ps)
|
||||
}
|
||||
|
||||
// dnssec-policy is set per zone in Jarl, not as an option.
|
||||
b3 := newBuilder()
|
||||
z = b3.primary("example.com", nil)
|
||||
b3.opt(model.ScopeZone, z.ID, "dnssec-policy", "default")
|
||||
b3.opt(model.ScopeOptions, 0, "dnssec-policy", "default")
|
||||
b3.opt(model.ScopeZone, z.ID, "key-directory", `"/tmp"`)
|
||||
if n := len(codes(State(b3.s, Options{}), Error)); n != 3 {
|
||||
t.Errorf("managed options: %v", State(b3.s, Options{}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
for in, want := range map[string]uint32{"86400": 86400, "P1D": 86400, "PT5M": 300, "P1W": 604800, "1d": 86400, "12h30m": 45000, "pt1h": 3600} {
|
||||
if got, ok := parseDuration(in); !ok || got != want {
|
||||
t.Errorf("parseDuration(%q) = %d %v, want %d", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "P", "unlimited", "1x"} {
|
||||
if _, ok := parseDuration(bad); ok {
|
||||
t.Errorf("parseDuration(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ func State(s *model.State, opts Options) []Problem {
|
||||
c.checkZones()
|
||||
c.checkOptions()
|
||||
c.checkSecurity()
|
||||
c.checkDNSSEC()
|
||||
sortProblems(c.problems)
|
||||
return c.problems
|
||||
}
|
||||
|
||||
+145
-2
@@ -1,7 +1,150 @@
|
||||
// Package version holds Jarl's version, set at build time with
|
||||
//
|
||||
// go build -ldflags "-X jarl/internal/version.Version=1.2.3"
|
||||
// go build -ldflags "-X jarl/internal/version.Version=1.2.3 -X jarl/internal/version.Tag=v1.2.3"
|
||||
//
|
||||
// The Makefile sets Version from the VERSION file, and Tag only when the
|
||||
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
|
||||
// is changed. Only such a build reports a version number. Other builds
|
||||
// are development builds, identified by their commit (which Go records
|
||||
// from git by itself) and by where they stand relative to the last tag
|
||||
// (Describe, from git describe).
|
||||
package version
|
||||
|
||||
// Version is the Jarl version.
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Version is the Jarl version (the VERSION file).
|
||||
var Version = "0.1.0-dev"
|
||||
|
||||
// Tag is the release tag the build is of ("" when it isn't a release).
|
||||
var Tag = ""
|
||||
|
||||
// Commit is the git commit the build is of. It is read from the build
|
||||
// information when not set at build time.
|
||||
var Commit = ""
|
||||
|
||||
// Describe is 'git describe --tags --long' of the build's commit
|
||||
// ("v0.2.0-rc.1-3-gca0f148b78b6"), set by the Makefile; it says how many
|
||||
// commits a development build is past the last tag.
|
||||
var Describe = ""
|
||||
|
||||
// Repo is Jarl's source repository.
|
||||
const Repo = "https://gitea.adbraun.lol/adam/jarl"
|
||||
|
||||
// Build describes what a binary was built from.
|
||||
type Build struct {
|
||||
Version string `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
|
||||
// Since is the last tag before a development build's commit, and
|
||||
// Ahead how many commits the build is past it (0: the tag's commit,
|
||||
// with uncommitted changes).
|
||||
Since string `json:"since,omitempty"`
|
||||
Ahead int `json:"ahead,omitempty"`
|
||||
// Label is the short form to show: the tag of a release, otherwise
|
||||
// the abbreviated commit (or the version when the commit is unknown).
|
||||
Label string `json:"label"`
|
||||
// URL is the release or commit in the repository.
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
var info = sync.OnceValue(func() Build {
|
||||
commit, modified := Commit, false
|
||||
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, s := range bi.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
if commit == "" {
|
||||
commit = s.Value
|
||||
}
|
||||
case "vcs.modified":
|
||||
modified = s.Value == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
b := describe(Version, Tag, commit, modified)
|
||||
b.Since, b.Ahead = parseDescribe(Describe, b.Commit)
|
||||
return b
|
||||
})
|
||||
|
||||
// parseDescribe splits git describe --long output, if it is of commit.
|
||||
func parseDescribe(d, commit string) (since string, ahead int) {
|
||||
m := describeRe.FindStringSubmatch(d)
|
||||
if m == nil || commit == "" || !strings.HasPrefix(commit, m[3]) {
|
||||
return "", 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
return m[1], n
|
||||
}
|
||||
|
||||
var describeRe = regexp.MustCompile(`^(v.+)-(\d+)-g([0-9a-f]+)$`)
|
||||
|
||||
func describe(version, tag, commit string, modified bool) Build {
|
||||
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
|
||||
switch {
|
||||
case tag != "":
|
||||
b.Modified = false // the Makefile only sets Tag for clean trees
|
||||
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
|
||||
case commit != "":
|
||||
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
|
||||
if modified {
|
||||
b.Label += "-modified"
|
||||
}
|
||||
default:
|
||||
b.Label, b.URL = version, Repo
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Info describes this build.
|
||||
func Info() Build { return info() }
|
||||
|
||||
// String is the version for logs and 'version' commands. Only a release
|
||||
// build gives a version ("0.2.0"); any other build says it's a
|
||||
// development build: "dev a1b2c3d4e5f6 (3 commits after v0.2.0-rc.1)".
|
||||
func String() string { return Info().String() }
|
||||
|
||||
func (b Build) String() string {
|
||||
switch {
|
||||
case b.Tag != "":
|
||||
return b.Version
|
||||
case b.Commit == "":
|
||||
// No git information (built from a source archive).
|
||||
return b.Version + " (source build)"
|
||||
}
|
||||
s := "dev " + b.Label
|
||||
switch {
|
||||
case b.Since == "":
|
||||
case b.Ahead == 1:
|
||||
s += " (1 commit after " + b.Since + ")"
|
||||
case b.Ahead > 1:
|
||||
s += " (" + strconv.Itoa(b.Ahead) + " commits after " + b.Since + ")"
|
||||
case b.Modified:
|
||||
s += " (" + b.Since + " with uncommitted changes)"
|
||||
default:
|
||||
s += " (at " + b.Since + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Short is the version for a User-Agent: the version of a release,
|
||||
// otherwise the label ("a1b2c3d4e5f6").
|
||||
func Short() string {
|
||||
if b := Info(); b.Tag == "" && b.Commit != "" {
|
||||
return b.Label
|
||||
}
|
||||
return Version
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 12 {
|
||||
return commit[:12]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package version
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDescribe(t *testing.T) {
|
||||
const c = "4d00be5e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c"
|
||||
for _, tc := range []struct {
|
||||
tag, commit string
|
||||
modified bool
|
||||
label, url string
|
||||
}{
|
||||
{"v0.1.0", c, true, "v0.1.0", Repo + "/releases/tag/v0.1.0"},
|
||||
{"", c, false, "4d00be5e1f0a", Repo + "/commit/" + c},
|
||||
{"", c, true, "4d00be5e1f0a-modified", Repo + "/commit/" + c},
|
||||
{"", "", false, "0.1.0", Repo},
|
||||
} {
|
||||
b := describe("0.1.0", tc.tag, tc.commit, tc.modified)
|
||||
if b.Label != tc.label || b.URL != tc.url {
|
||||
t.Errorf("describe(%q, %q, %v) = %q %q, want %q %q", tc.tag, tc.commit, tc.modified, b.Label, b.URL, tc.label, tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
const c = "ca0f148b78b6e1f0a9b8c7d6e5f4a3b2c1d0e9f8"
|
||||
build := func(tag, commit string, modified bool, desc string) Build {
|
||||
b := describe("0.2.0-rc.1", tag, commit, modified)
|
||||
b.Since, b.Ahead = parseDescribe(desc, b.Commit)
|
||||
return b
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
b Build
|
||||
want string
|
||||
}{
|
||||
{build("v0.2.0-rc.1", c, false, "v0.2.0-rc.1-0-gca0f148b78b6"), "0.2.0-rc.1"},
|
||||
{build("", c, false, "v0.2.0-rc.1-3-gca0f148b78b6"), "dev ca0f148b78b6 (3 commits after v0.2.0-rc.1)"},
|
||||
{build("", c, true, "v0.2.0-rc.1-1-gca0f148b78b6"), "dev ca0f148b78b6-modified (1 commit after v0.2.0-rc.1)"},
|
||||
{build("", c, true, "v0.2.0-rc.1-0-gca0f148b78b6"), "dev ca0f148b78b6-modified (v0.2.0-rc.1 with uncommitted changes)"},
|
||||
{build("", c, false, "v0.1.0-0-gca0f148b78b6"), "dev ca0f148b78b6 (at v0.1.0)"},
|
||||
{build("", c, false, ""), "dev ca0f148b78b6"},
|
||||
// A describe of another commit (stale ldflags) is ignored.
|
||||
{build("", c, false, "v0.2.0-rc.1-3-gdeadbeef0000"), "dev ca0f148b78b6"},
|
||||
{build("", "", false, ""), "0.2.0-rc.1 (source build)"},
|
||||
} {
|
||||
if got := tc.b.String(); got != tc.want {
|
||||
t.Errorf("String() = %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,27 @@ export class APIError extends Error {
|
||||
let onUnauthorized = () => {};
|
||||
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||
|
||||
// api calls the REST API. opts: {body, stage (bool), query}.
|
||||
// With two-factor login, some actions (API tokens, users) need a code
|
||||
// from the last few minutes. The server then answers totp_step_up (or
|
||||
// totp_invalid for a wrong code); askCode asks for one, and the request is
|
||||
// sent again with it. It returns null when the user cancels.
|
||||
let askCode = null;
|
||||
export function setCodePrompt(fn) { askCode = fn; }
|
||||
const stepUpCodes = new Set(["totp_step_up", "totp_invalid"]);
|
||||
|
||||
// api calls the REST API. opts: {body, stage (bool), query, code}.
|
||||
export async function api(method, path, opts = {}) {
|
||||
try {
|
||||
return await call(method, path, opts);
|
||||
} catch (e) {
|
||||
if (!askCode || e.status !== 403 || !stepUpCodes.has(e.code) || path.startsWith("/me/totp/disable")) throw e;
|
||||
const code = await askCode(e.code === "totp_invalid" ? e.message : "");
|
||||
if (!code) throw e;
|
||||
return api(method, path, { ...opts, code });
|
||||
}
|
||||
}
|
||||
|
||||
async function call(method, path, opts) {
|
||||
const headers = { "Accept": "application/json" };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
@@ -40,6 +59,7 @@ export async function api(method, path, opts = {}) {
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage);
|
||||
if (opts.code) headers["X-Jarl-TOTP"] = opts.code;
|
||||
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
|
||||
if (resp.status === 204) return null;
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
@@ -53,6 +73,34 @@ export async function api(method, path, opts = {}) {
|
||||
|
||||
export const get = (path, query) => api("GET", path, { query });
|
||||
|
||||
// stream GETs a newline-delimited JSON response (?stream=1 endpoints) and
|
||||
// calls onEvent with each value as it arrives. It resolves at the end.
|
||||
export async function stream(path, query, onEvent) {
|
||||
const q = new URLSearchParams(query || {});
|
||||
const resp = await fetch("/api/v1" + path + (q.toString() ? "?" + q : ""), { headers: { "Accept": "application/x-ndjson" }, credentials: "same-origin" });
|
||||
if (!resp.ok) {
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
||||
if (resp.status === 401) onUnauthorized();
|
||||
throw new APIError(resp.status, data);
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value) buf += dec.decode(value, { stream: true });
|
||||
let nl;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (line) onEvent(JSON.parse(line));
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
if (buf.trim()) onEvent(JSON.parse(buf));
|
||||
}
|
||||
|
||||
// draftQuery adds the draft changeset to a query, so reads show the
|
||||
// configuration as it will be once the pending changes are applied.
|
||||
export function draftQuery(query = {}) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Jarl web UI: router, layout and login.
|
||||
import { h, mount, toast, errorBox, plural } from "./ui.js";
|
||||
import { h, mount, toast, errorBox, plural, lastList } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate } from "./api.js";
|
||||
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt } from "./api.js";
|
||||
import * as totp from "./totp.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
import * as zones from "./pages/zones.js";
|
||||
import * as zone from "./pages/zone.js";
|
||||
@@ -49,7 +50,7 @@ const navItems = [
|
||||
];
|
||||
|
||||
const app = document.getElementById("app");
|
||||
let mainEl, navEl, barEl, current = { key: "" };
|
||||
let mainEl, navEl, barEl, current = { key: "" }, routeGen = 0;
|
||||
|
||||
function parseHash() {
|
||||
const raw = location.hash.replace(/^#\/?/, "");
|
||||
@@ -65,22 +66,81 @@ function renderLogin(message) {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
try {
|
||||
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token;
|
||||
await start();
|
||||
const r = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
|
||||
if (r.totp_required) { renderCodeStep(r.challenge); return; }
|
||||
await loggedIn(r);
|
||||
} catch (ex) {
|
||||
mount(err, errorBox(ex));
|
||||
pass.select();
|
||||
}
|
||||
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
|
||||
mount(app, h("div", { class: "login panel" },
|
||||
mount(app, loginPanel(message, form));
|
||||
user.focus();
|
||||
}
|
||||
|
||||
function loginPanel(message, ...body) {
|
||||
return h("div", { class: "login panel" },
|
||||
logo(104, "login-logo"),
|
||||
h("h1", null, wordmark()),
|
||||
h("div", { class: "tag" }, "Rule your DNS holds"),
|
||||
message ? h("div", { class: "msg warn" }, message) : null,
|
||||
form));
|
||||
user.focus();
|
||||
...body);
|
||||
}
|
||||
|
||||
// renderCodeStep is the second login step for users with two-factor login.
|
||||
function renderCodeStep(challenge) {
|
||||
const code = h("input", { name: "code", autocomplete: "one-time-code", inputmode: "numeric", placeholder: "6-digit code", required: true, class: "totp-code" });
|
||||
const err = h("div");
|
||||
let recovery = false;
|
||||
const hint = h("div", { class: "small muted" });
|
||||
const toggle = h("a", { href: "#", class: "small", onclick: (e) => {
|
||||
e.preventDefault();
|
||||
recovery = !recovery;
|
||||
code.placeholder = recovery ? "Recovery code, e.g. k7dm2-xq9pw" : "6-digit code";
|
||||
code.inputMode = recovery ? "text" : "numeric";
|
||||
toggle.textContent = recovery ? "Use the authenticator app" : "Lost your phone? Use a recovery code";
|
||||
code.value = "";
|
||||
code.focus();
|
||||
} }, "Lost your phone? Use a recovery code");
|
||||
mount(hint, "Enter the code from your authenticator app.");
|
||||
const form = h("form", { onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
try {
|
||||
const me = await api("POST", "/auth/login/totp", { body: { challenge, code: code.value } });
|
||||
await loggedIn(me);
|
||||
} catch (ex) {
|
||||
if (ex.code === "login_expired") { renderLogin(ex.message); return; }
|
||||
mount(err, errorBox(ex));
|
||||
code.select();
|
||||
}
|
||||
} }, hint, code, h("button", { class: "primary", type: "submit" }, "Verify"), toggle, err);
|
||||
mount(app, loginPanel(null, form));
|
||||
code.focus();
|
||||
}
|
||||
|
||||
async function loggedIn(me) {
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
if (me.totp_setup_required) renderSetupRequired();
|
||||
else await start();
|
||||
}
|
||||
|
||||
// renderSetupRequired: the server requires two-factor login for this
|
||||
// user (totp.require), and it isn't set up yet. Nothing else works until
|
||||
// it is.
|
||||
function renderSetupRequired() {
|
||||
const box = h("div");
|
||||
mount(app, h("div", { class: "login panel wide" },
|
||||
logo(64, "login-logo"),
|
||||
h("h1", null, "Set up two-factor login"),
|
||||
h("p", null, "This server requires two-factor login for your account. Set up an authenticator app to continue."),
|
||||
box,
|
||||
h("div", { class: "small", style: { marginTop: "12px" } }, h("a", { href: "#", onclick: (e) => { e.preventDefault(); logout(); } }, "Log out"))));
|
||||
totp.setup(box, state.me, async () => {
|
||||
const me = await get("/auth/whoami");
|
||||
await loggedIn(me);
|
||||
});
|
||||
}
|
||||
|
||||
function layout() {
|
||||
@@ -93,8 +153,7 @@ function layout() {
|
||||
const header = h("header", { class: "top" },
|
||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||
logo(30),
|
||||
wordmark({ rule: false, height: 19, cls: "brand-name" }),
|
||||
h("span", { class: "brand-version" }, me.version)),
|
||||
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
||||
navEl,
|
||||
h("div", { class: "spacer" }),
|
||||
modeEl = h("span"),
|
||||
@@ -102,7 +161,7 @@ function layout() {
|
||||
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { class: "small", onclick: logout }, "Log out"));
|
||||
mount(app, header, barEl, mainEl);
|
||||
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
||||
modeHolder = modeEl;
|
||||
renderNav();
|
||||
renderBar();
|
||||
@@ -110,6 +169,19 @@ function layout() {
|
||||
|
||||
let modeHolder;
|
||||
|
||||
// footer names the Jarl build: the release, or the commit for other
|
||||
// builds, linked to it in the repository.
|
||||
function footer(b) {
|
||||
if (!b) return null;
|
||||
let title = b.tag ? "Jarl " + b.version + " (release " + b.tag + ")" : "Jarl " + b.version + " (built without git)";
|
||||
if (!b.tag && b.commit) {
|
||||
title = "Development build of commit " + b.commit + (b.modified ? " with uncommitted changes" : "");
|
||||
if (b.since) title += b.ahead ? ", " + b.ahead + (b.ahead === 1 ? " commit" : " commits") + " after " + b.since : ", at " + b.since;
|
||||
}
|
||||
return h("footer", { class: "site" }, "Powered by ",
|
||||
h("a", { href: b.url, target: "_blank", rel: "noopener", title }, "Jarl " + (b.tag || b.commit ? b.label : b.version)));
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
||||
}
|
||||
@@ -151,12 +223,21 @@ async function route() {
|
||||
const params = {};
|
||||
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
|
||||
current = { key: path, nav: r.nav, page: r.page, params, query };
|
||||
if (!r.params && r.nav) lastList[r.nav] = location.hash;
|
||||
renderNav();
|
||||
mount(mainEl, h("div", { class: "muted" }, "Loading…"));
|
||||
// Keep the old page until the new one is drawn (switching a zone's
|
||||
// tabs shouldn't blank the page); say "Loading…" only when it's slow.
|
||||
// (Only while the new page has drawn nothing yet: pages that draw
|
||||
// their frame first fill it in afterwards.)
|
||||
const gen = ++routeGen;
|
||||
const old = mainEl.firstChild;
|
||||
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
|
||||
try {
|
||||
await r.page.render(mainEl, params, query);
|
||||
} catch (e) {
|
||||
mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
|
||||
if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
|
||||
} finally {
|
||||
clearTimeout(slow);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -183,12 +264,11 @@ setUnauthorizedHandler(() => {
|
||||
}
|
||||
});
|
||||
|
||||
setCodePrompt(totp.askCode);
|
||||
|
||||
(async function boot() {
|
||||
try {
|
||||
const me = await get("/auth/whoami");
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
await start();
|
||||
await loggedIn(await get("/auth/whoami"));
|
||||
} catch (e) {
|
||||
if (e.status === 401) renderLogin();
|
||||
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e)));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h, mount, field, input, toast, errorBox } from "../ui.js";
|
||||
import { h, mount, field, input, toast, errorBox, time, modal, badge } from "../ui.js";
|
||||
import { api, get } from "../api.js";
|
||||
import * as totp from "../totp.js";
|
||||
import { tokenTable, newTokenForm, grantHelp } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
@@ -18,6 +19,7 @@ export async function render(el) {
|
||||
h("h1", null, "Account: " + me.user.username),
|
||||
h("div", { class: "panel" }, h("h2", null, "Permissions"),
|
||||
h("div", { class: "grants" }, (me.permissions || []).map((p) => h("div", null, p)))),
|
||||
me.method === "session" ? twoFactorPanel(me) : null,
|
||||
h("div", { class: "panel" }, h("h2", null, "Change password"),
|
||||
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)),
|
||||
err,
|
||||
@@ -36,3 +38,64 @@ export async function render(el) {
|
||||
grantHelp(grants));
|
||||
await drawTokens();
|
||||
}
|
||||
|
||||
// twoFactorPanel shows and changes the user's two-factor login.
|
||||
function twoFactorPanel(me) {
|
||||
const el = h("div", { class: "panel" });
|
||||
const draw = async () => {
|
||||
const st = await get("/me/totp");
|
||||
const body = h("div");
|
||||
const redraw = () => draw();
|
||||
if (!st.enabled) {
|
||||
mount(el, h("h2", null, "Two-factor login"),
|
||||
h("p", null, "Log in with your password and a code from an authenticator app on your phone, so a stolen password alone isn't enough.",
|
||||
st.required ? h("strong", null, " This server requires it for your account.") : null),
|
||||
body);
|
||||
totp.setup(body, me, redraw);
|
||||
return;
|
||||
}
|
||||
const actions = h("div", { class: "actions" });
|
||||
mount(el, h("h2", null, "Two-factor login"),
|
||||
h("p", null, badge("on", "ok"), " since ", time(st.enabled_at), ". ",
|
||||
st.recovery_codes_left + " of 10 recovery codes left.",
|
||||
st.required ? h("span", { class: "muted" }, " Required on this server.") : null),
|
||||
st.recovery_codes_left <= 3 ? h("div", { class: "msg warn" }, "Only " + st.recovery_codes_left + " recovery codes are left: make new ones.") : null,
|
||||
body, actions);
|
||||
mount(actions,
|
||||
h("button", { onclick: () => {
|
||||
mount(actions);
|
||||
totp.setup(body, me, redraw, { again: true, cancel: redraw });
|
||||
} }, "Move to a new phone"),
|
||||
h("button", { onclick: async () => {
|
||||
try {
|
||||
const r = await api("POST", "/me/totp/recovery-codes");
|
||||
mount(actions);
|
||||
mount(body, totp.recoveryBox(r.recovery_codes, me.user.username),
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: redraw }, "I have saved the codes")));
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
} }, "New recovery codes"),
|
||||
st.required ? null : h("button", { class: "danger", onclick: () => disable(me, redraw) }, "Turn off"));
|
||||
};
|
||||
draw();
|
||||
return el;
|
||||
}
|
||||
|
||||
async function disable(me, done) {
|
||||
const pw = input({ type: "password", autocomplete: "current-password" });
|
||||
const code = input({ autocomplete: "one-time-code", placeholder: "123456 or a recovery code" });
|
||||
const err = h("div");
|
||||
const ok = await modal("Turn off two-factor login", h("div", null,
|
||||
h("p", null, "You will log in with your password alone."),
|
||||
h("div", { class: "form-grid" },
|
||||
me.user.has_password ? field("Password", pw) : null,
|
||||
field("Code", code, "From your authenticator app, or a recovery code.")), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Turn off", kind: "danger", onclick: async () => {
|
||||
try {
|
||||
await api("POST", "/me/totp/disable", { body: { password: pw.value, code: code.value } });
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
]);
|
||||
if (ok) { toast("Two-factor login is off"); done(); }
|
||||
}
|
||||
@@ -40,6 +40,17 @@ export async function render(el) {
|
||||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||||
}
|
||||
const tf = st.totp;
|
||||
if (tf && tf.missing && tf.missing.length) {
|
||||
parts.push(h("div", { class: "msg warn" },
|
||||
h("strong", null, "Two-factor login is required (" + tf.policy + ") but not set up by: "), tf.missing.join(", "), ". ",
|
||||
"They can only set it up when they next log in. ", h("a", { href: "#/users" }, "Users")));
|
||||
}
|
||||
if (tf && tf.shared_admin) {
|
||||
parts.push(h("div", { class: "msg info" },
|
||||
h("strong", null, "The first account, " + tf.shared_admin + ", is still active "), "next to personal administrator accounts. If it is shared, disable it under ",
|
||||
h("a", { href: "#/users" }, "Users"), ": everyone then logs in as themselves, with their own two-factor login, and the audit log shows who did what. Root on the server can always get back in with 'jarld user passwd'."));
|
||||
}
|
||||
const exp = st.expiring_tokens || [];
|
||||
if (exp.length) {
|
||||
const now = new Date();
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user