Compare commits

...
14 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 fa8feb6bd9 Release 0.6.1-rc.1
release / release (push) Successful in 4m41s
0.6.1's first release candidate: certificates for other hosts (jarl cert
get, cert schedule, the certificates grant), certificate export, the
missing PTR list, links to a zone in other views, one-view tokens with
linked zones, PTR records and overrides in linked zones per view, the
registrar and DS guidance for subdomain and reverse zones, the stats
heatmap and cname_chain fixes. Upgrading a 0.6.0 database was tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:00:20 +01:00
adamandClaude Opus 5.5 12013b12d5 Stats heatmap: empty hours same height; cname_chain only for long chains
The "Busiest times" grid's hours without data used the class "empty",
which picked up the padding of empty messages. One CNAME to another no
longer warns; three or more get an info note showing the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00
adamandClaude Opus 5.5 9db76a113c Certificates for other hosts: jarl cert get
jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.

- Certificates have an owner user; another user can't take the name.
  An existing one is obtained again only when a name is added or
  removed. A new certificates grant (names=) says which names a user
  may have; it is never part of "*". Users may own
  certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
  delete theirs; keys only over HTTPS, the socket or loopback; every
  download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
  saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00
adamandClaude Opus 5.5 b4bb99b482 Certificate export, missing PTR list, simpler "Used for"
- Certificates can be exported for use outside Jarl: Export… on the
  Certificates page (download or copy), jarl cert export, and
  GET /certificates/{name}/pem?part=fullchain|cert|key|bundle.
  Administrators only; key exports are audited.
- A reverse zone's missing-PTR banner lists the addresses and names
  (ptr_missing_list in the zone API).
- "Used for" shows DoT/DoH for named, and "not used by Jarl" instead of
  "nothing yet".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:07:26 +01:00
adamandClaude Opus 5.5 57012fece8 One-view tokens: local records in linked zones, zone lookup, view list
A token for one view of a linked zone (an ACME token for external)
couldn't add records: a new record was shared and reached the other
view. A new name and type is now local when the user may not change it
in the other views. A zone name in several views resolves to the only
one the user can see, and the error no longer names views they can't.
GET /views lists the views a user sees zones in (name, colour, count;
limited) without views:read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:57:06 +01:00
adamandClaude Opus 5.5 4cc2ab2287 Zone page: links to the same zone in the other views
The header says "also in VIEW" for every zone of the same name in
another view, linked or not, in the view's colour, keeping the open tab
where the other zone has it. The zone API gains in_views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:45:41 +01:00
adamandClaude Opus 5.5 a36c54ce60 DNSSEC: say where the DS record really goes
The DNSSEC tab said "give this to your registrar" for every zone whose
parent wasn't in Jarl in the same view. The DNSSEC response now has
ds_at/ds_place/ds_note: the parent zone in Jarl (any view, its own
first, then a public one), the registrar for a registered domain, the
parent zone wherever it is hosted for a subdomain, the ISP or RIR for a
reverse zone, and nowhere for a private name. The page and the CLI use
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:40:58 +01:00
adamandClaude Opus 5.5 82c282cd32 Registrar: only for zones that are a registered domain
With porkbun.domains empty the Registrar tab was offered for every zone
and always failed for subdomain zones, reverse zones and private names.
dnsname.RegisteredDomain (public suffix list) now decides; the others
get a message naming what delegates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:36:39 +01:00
adamandClaude Opus 5.5 65086e314a Linked zones: PTR records follow each view, deleting an override stays deleted
Auto-PTR (and --ptr) changed PTR records in every linked view, whatever
view the address record was in. ptrSync now compares each touched A/AAAA
set before and after, per view, and changes only that view's reverse
zone. PTR records in linked reverse zones are local (fill too); a shared
PTR set is made local in each view before one view changes it.

Deleting a linked zone's local records no longer brings the shared ones
back into the view: the name and type are gone from that view and the
other views keep theirs, local to each. Share still restores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:29:12 +01:00
adamandClaude Opus 5.5 e38390df7d Zone settings: no empty changes, keep an outside name server absolute
- A zone update that changes nothing stages nothing: saving a zone's
  settings unchanged says "Nothing changed" instead of staging an empty
  change. Staging operations that change nothing is a 400 "nothing to
  change".
- The settings form showed the SOA MNAME without its final dot, which the
  server reads as relative to the zone, so saving wg.adbraun.lol turned
  ns1.adbraun.lol. into ns1.adbraun.lol.wg.adbraun.lol. Names outside the
  zone keep the dot, and MNAME is only sent when edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:13:01 +01:00
adamandClaude Opus 5.5 0e663ab36c New token form: only templates within what the token's user may do
A token never does more than its user, so offering wider templates only
misled. On the Users page the templates follow the token's user, not the
administrator making it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:37 +01:00
adamandClaude Opus 5.5 5cd22f52d4 Compare views: start with a view the zone is linked to
The tab picked the first other view in view order, so a zone linked with
one view opened compared with an unrelated one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:09:52 +01:00
adamandClaude Opus 5.5 aca0abc780 Add the jarld.service alias for jarl.service
systemctl restart/status jarld now work too. The package relinks a unit
enabled by an older version on upgrade, so the alias appears without
re-enabling; make uninstall removes the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:54:57 +01:00
adamandClaude Opus 5.5 9175d75d61 Add AGENTS.md and CLAUDE.md for coding agents
Ground rules, building and testing with real BIND, where things live,
web UI rules, BIND gotchas, and the git and release workflow.
docs/development.md lists the packages its layout was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:14 +01:00
68 changed files with 3246 additions and 335 deletions

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
# Working on Jarl
Notes for coding agents (and people) changing this repository. Read this
first. For more depth, see [docs/development.md](docs/development.md) for
layout, building, tests and releasing, and [docs/concepts.md](docs/concepts.md)
for how Jarl works from the outside.
Jarl manages a BIND 9.20 server. It has three parts:
* `jarld`, a daemon with an HTTP API, an embedded web UI and SQLite storage;
* `jarl`, a CLI for that API;
* a Debian package.
It's all Go: module path `jarl`, version in `go.mod`, `CGO_ENABLED=0`. The
SQLite driver is `modernc.org/sqlite`, so there is no cgo. The target is
Debian/Ubuntu with BIND 9.20, using Debian's paths (`/etc/bind`, the `bind`
group, AppArmor).
## Ground rules
* **The database is the source of truth.** Jarl renders `named.conf`,
`keys.conf` and the zone files from it. Never hand-edit rendered output or
add code that reads BIND's files back as state; the importer is the only
exception.
* **Every change goes through the engine** (`internal/engine`). An API
request becomes ops in `expand.go`/`ops.go`. The engine then runs
replay → validate → render → `named-checkconf`/`named-checkzone` →
atomic install → `rndc reload` → verify serials, and rolls back on
failure. Going through the engine gives a change permissions, dry runs,
changesets, history and undo for free, so don't write to the store
directly from handlers.
* **Every user-visible change updates `CHANGELOG.md`** under
`## [Unreleased]`, and the matching page in `docs/` in the same commit.
Docs that may need it:
* `cli.md`, `api.md` and `configuration.md`;
* `validation.md`, for new validation codes;
* `metrics.md`;
* the feature's own page.
Write docs in plain, direct English, like the existing pages.
* **Error messages** say what is wrong and how to fix it, naming the object.
For example: `view external is reachable from the internet …; set
minimal-any yes`.
* **New validation rules** get a stable `code`, a test, and a row in
`docs/validation.md`.
* **Schema changes** append a migration to the `migrations` slice in
`internal/store/store.go`, and never edit an old one. The statistics
database has its own list, `schema` in `internal/stats/store.go`. Add a note
under "Upgrading" in the CHANGELOG when a downgrade would break.
* `make test` runs `go vet` and a `gofmt -l` check, and both must pass.
`goimports` isn't installed, so fix imports by hand.
## Building and testing
```sh
make # bin/jarld, bin/jarl, bash completion
make test # vet + gofmt check + go test ./...
JARL_BIND_ROOT=DIR make test # also runs the integration tests against real BIND
JARL_BIND_ROOT=DIR make e2e # scripts/e2e.sh: real named + jarld, drives every CLI command
```
* **Real BIND without root.** Get the packages with `apt-get download` and
unpack them with `dpkg -x` into a directory; that directory is
`JARL_BIND_ROOT`. The package list is in docs/development.md, under "Tests
against real BIND". Without `JARL_BIND_ROOT` the integration tests skip,
so a green `make test` alone proves less than it looks.
* **Run e2e after any engine, render, CLI or server change.** It prints a
pass count, such as `606/606`; a failure prints the command and its
output.
* It uses fixed ports: DNS 25353, rndc 25953, HTTP 28053, lists 28054 and
metrics 28055. Stop anything left running on them first.
* `KEEP=1` leaves named and jarld running; the last line of the log has
their PIDs.
* `WORK=DIR` must name an empty directory.
* **Certificate tests** run against Pebble in Docker, unless you set
`PEBBLE=0`. If `docker info` fails, the Pebble block is silently skipped;
on this machine it needs `DOCKER_HOST=unix:///var/run/docker.sock`.
* **Unix socket paths** are limited to 107 bytes. Put test sockets under
`/run/user/$UID/`, not under a deep temporary directory.
* **The systemd sandbox isn't tested.** e2e runs outside systemd, so bugs
that only appear in the sandbox (`RestrictAddressFamilies`,
capabilities, read-only paths) need a package test. Run `make deb`, then
install it in a `debian:trixie` container. That container has BIND 9.20;
run it with `--privileged /sbin/init` when you need the real unit.
## Layout and where things live
docs/development.md has the full layout. Some places that are easy to miss:
* **Engine** (`internal/engine`):
* `expand.go` turns requests into ops.
* `linked.go`: linked zones, kept in step across views by `linkSync`.
* `reverse.go`: PTR fill, auto-PTR and reverse zone suggestions.
* `compare.go`: compare views.
* `exposure.go`: fixes for internet exposure.
* **Validation**: `internal/validate` holds all checks and lints.
`internal/catalog` is the BIND 9.20 grammar and ARM text for every
statement. Use it rather than hard-coding option knowledge.
* **Rendering**: `internal/render` writes the configuration.
`generated.go` holds the statements Jarl adds itself, shown to users as
"Added by Jarl".
* **Server**: `internal/server` has one `h_*.go` file per API area; routes
are in `routes.go`. The JSON types are in `internal/api`, shared with the
Go client in `internal/client`.
* **CLI**: `cmd/jarl` is built on cobra. `complete.go` completes the
placeholders in usage lines (`ZONE`, `VIEW`, …), so name them
consistently.
* **Statistics**: `internal/stats` is query statistics, kept in a
**separate** SQLite file, `stats.db`, with its own migrations.
* **Feature packages**: each has its own doc page.
* `encdns`: encrypted DNS;
* `certs` and `acme`: certificates and ACME;
* `blocking`: response policy zones;
* `dnssec`;
* `nscheck`: queries to other name servers;
* `mailcheck`: mail reverse DNS;
* `totp`: two-factor login;
* `backup`;
* `porkbun`: registrar API, with an in-memory `Fake`. Tests never call
the real API.
## Web UI rules
`internal/web/static/` holds the UI: plain ES modules, no framework, no
build step. The files are embedded with `go:embed`.
* **Rebuild jarld after editing them**, or you'll be testing the old files.
* **Build DOM only with `h()`** from `assets/ui.js`. Never use `innerHTML`
or `insertAdjacentHTML`. That's how record data can't inject markup.
* **The CSP is `self` only.** That rules out:
* inline scripts;
* `style="…"` attributes and inline style strings (`el.setAttribute("style", …)`);
* external fonts and CDNs.
Use a CSS class in `style.css`, or set individual properties with
`el.style.prop = …` (CSSOM is allowed).
* **Colors are CSS custom properties** with light and dark values. Reuse the
existing tokens.
* **Pages live in `assets/pages/`**, one file per route. The router is in
`app.js` and the API calls are in `api.js`.
* **What the user wants:** functionality and ease of use matter more than
visual polish. In pick lists, leave every checkbox unticked by default.
There's no JS test suite. To catch syntax and import errors without a
browser, bundle `assets/app.js` with esbuild, or load the page in headless
Chrome.
## BIND facts that bit us
* named 9.20 crashes on an empty `keys {}` in a `dnssec-policy`. Validation
refuses it.
* `named-checkconf` doesn't catch a TTL above `max-zone-ttl`, but named then
refuses to load the zone.
* `responselog` only takes effect when named starts; use `rndc responselog
on|off` instead.
* `named -g` sends all logging to stderr, so tests use `-f`.
* RPZ client-IP rules see IPv4 clients as v4-mapped (`::ffff:0:0/96`), so
IPv6 prefix covers must leave that range out.
* named serves DoH in RFC 8484 wire format only, not the JSON API. Test it
with BIND's `dig +https` / `dig +tls`, not curl or kdig.
* named can't forward over DoH, only over DoT (`forwarders port 853 tls NAME`).
* `rndc reconfig` makes named reload renewed TLS certificate files.
## Git and releases
These are the maintainer's rules; follow them unless told otherwise:
* **Commit only when asked. Never push**; the maintainer pushes, to Gitea
at gitea.adbraun.lol.
* **Work on `dev-X.Y.Z` branches** (for example `dev-0.6.1`) off `main`.
Don't name a branch like a tag (`v0.5.2`): `git push` then fails with
"src refspec matches more than one".
* **A release** is a commit titled `Release X`.
* It sets `VERSION`.
* It updates the status line in the README.
* For a final release, it moves `[Unreleased]` to
`## [X] - YYYY-MM-DD`. A release candidate (`X-rc.N`) may leave the
notes under `[Unreleased]`.
* Tag it annotated: `git tag -a vX -m "Jarl X"`.
* Before tagging, `make test` and the e2e run must pass.
* Check the notes with `VERSION=X scripts/gitea-release.sh notes`.
* **Pushing a `v*` tag** runs `.gitea/workflows/release.yml`. It builds the
tarballs, the CLI for each OS and the `.deb`s, and publishes them.
* **Before a minor release**, test an upgrade from the previous release's
`.deb`s in a container. Check data, history, answers from named, and that
an old backup restores.
## Shell hygiene
* **Never kill processes by pattern** with `pkill -f` or
`pgrep -f … | xargs kill` in the same shell command. The pattern matches
the shell running it, and kills that shell. Find the PID with `ps` and
kill it by number.
* **Start long-running test servers in the background** (your tool's
background mode, not `&` in a shell that exits), so they outlive the
command.
+128
View File
@@ -6,6 +6,134 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased]
### Added
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
--hook CMD` on a mail server or proxy has jarld obtain the certificate
(and renew it), waits, downloads it when it is newer than the host's
copy, and runs the hook with the file paths in `JARL_CERT_*`
variables. Its settings are saved, so `jarl cert get` alone refreshes
them all; `jarl cert schedule` writes a daily systemd timer for that;
`jarl cert saved` lists them with their expiry; `jarl cert forget`
stops one. A certificate belongs to the user that asked for it (other
users can't take the name), is obtained again only when a name is
added or removed, and the Certificates page shows its owner and who
downloaded it, flagging hosts that haven't fetched a renewal. A new
`certificates` grant (`certificates:write names=*.adbraun.lol`) says
which names a user may have; it is never part of `*`. Users may own
`certificates.max_per_user` certificates (10), or their own
`--cert-limit`. API: `POST /certificates/{name}/request`.
- Certificates can be exported for use outside Jarl: **Export…** on the
Certificates page (download, or copy to the clipboard),
`jarl cert export NAME [--dir DIR | --part PART]`
and `GET /certificates/{name}/pem?part=fullchain|cert|key|bundle`.
Administrators only; every export of the private key is audited
(`certificate.export_key`).
- A reverse zone's "addresses have no PTR record" banner lists them, with
the name each would get, instead of only counting them
(`ptr_missing_list` in the zone API).
- A zone's page links to the same zone in the other views ("also in
external"), in each view's colour, keeping the tab you are on. It
works for zones that aren't linked too, and the zone API's new
`in_views` lists them.
- `jarl.service` has the alias `jarld.service`, so `systemctl restart
jarld` works too. The alias is linked when the unit is enabled; the
package relinks a unit enabled by an older version on upgrade.
### Changed
- `cname_chain` no longer warns about one CNAME pointing at another (a
service name at a host alias is common, and named answers the chain at
once). A chain of three or more CNAMEs gets an info note that shows
it.
- A certificate's private key is only exported over HTTPS, the local
socket, or to the server itself (plain HTTP from another host is
refused with `https_required`).
- The Certificates page's "Used for" column says DoT and DoH for named,
with the addresses in the tooltip, instead of listing every address. A
certificate with no use says "not used by Jarl" rather than "nothing
yet": it may well be used elsewhere.
- Deleting a linked zone's local records (an override) removes that name
and type from that view only. It used to bring the shared records back
into the view, so a delete looked undone. The other views keep their
records, local to each now; **Share** one view's version (`jarl record
update ID --shared`) to have it everywhere again.
- A linked zone's Compare views tab starts with a view it is linked to,
not the first other view.
- The New token form only offers templates within what the token's user
may do. A token never does more than its user, so offering more only
misled.
- A zone update that changes nothing is no change: saving a zone's
settings without changing them says "Nothing changed" instead of
staging an empty change. Staging operations that change nothing
(`POST /changesets/{id}/ops`, or `?stage=`) is a 400 "nothing to
change".
### Fixed
- The statistics' "Busiest times" grid drew hours without data taller
than the others (they picked up the page's padding for empty
messages).
- A token for one view of a linked zone (such as an ACME token with
`views=external`) couldn't add records: a new record is shared, and
that reached the other view. A new name and type is now local to the
view when the user may not change it in the other views.
- A zone name in several views had to be given with `--view` even when
the user could see it in only one (`jarl record list adbraun.lol` with
a one-view token), and the error named views the user can't see. Now
the one they can see is used.
- `jarl view list` was empty for a user without `views:read`, though
they can see zones in views. It now lists those views by name, colour
and visible zone count (marked `limited` in the API), without their
settings.
- The DNSSEC tab told you to give the DS record to your registrar for
every zone whose parent isn't in Jarl in the same view. Now it (and
`jarl zone dnssec status`, and the API's new `ds_at`, `ds_place` and
`ds_note`) says where it really goes: the parent zone in Jarl (in any
view now, the zone's own first), the registrar for a registered
domain, the parent zone wherever it is hosted for a subdomain, your
ISP or RIR for a reverse zone, and nowhere for a private name such as
`home.lan` (signing it has no effect).
- The Registrar tab (and `jarl zone registrar`, and the DNSSEC page's
registrar button) was offered for every zone when `porkbun.domains` is
empty, and always failed for subdomain zones (`lab.adbraun.lol`),
reverse zones and private names (`home.lan`): Porkbun only knows
registered domains. Only zones that are a registered domain (by the
public suffix list) get it now; the others say what delegates them
instead.
- Auto-PTR in linked zones changed PTR records in every linked view, not
just where the address record is. A record local to one view got PTR
records in the other views too; deleting or changing the address in
one view deleted or moved the PTR records of views that still had it.
PTR records now follow each view's own address records, are local to
their view in a linked reverse zone, and each zone's Reverse DNS
setting counts for its own view. **Fill** also adds local PTR records
now, not shared ones. The same applies to **Update PTR** (`--ptr`).
- Saving the settings of a zone whose primary name server is outside it
(`ns1.adbraun.lol.` for `wg.adbraun.lol`) changed the name server to
`ns1.adbraun.lol.wg.adbraun.lol.`. The form showed the name without its
final dot, which the server reads as relative to the zone. Names outside
the zone now keep the dot, and the name server is only sent when it was
changed.
### Upgrading
- Tested: a 0.6.0 install with views, linked and reverse zones and
statistics, opened by 0.6.1-rc.1: every zone, record and view kept,
named answered as before, and the new grant and limits worked.
- The database gains a migration (certificate owners, per-user
certificate limits, certificate downloads). Going back to an older
version afterwards means restoring a backup made before the upgrade.
## [0.6.0] - 2026-10-13
### Added
+8
View File
@@ -0,0 +1,8 @@
@AGENTS.md
## Claude Code specifics
- End commit messages with a `Co-Authored-By:` trailer naming the model,
e.g. `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Keep temporary scripts, extracted BIND trees, screenshots and test
servers' work directories in the session scratchpad, not in the repo.
+1
View File
@@ -114,6 +114,7 @@ install-files:
uninstall:
rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \
$(DESTDIR)$(UNITDIR)/jarld.service \
$(DESTDIR)$(UNITDIR)/jarl-backup.service $(DESTDIR)$(UNITDIR)/jarl-backup.timer \
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
$(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf
+7 -1
View File
@@ -202,7 +202,13 @@ review step.
## Status
Version 0.6.0 is the current release. It makes reverse zones easy (suggested from your records,
Version 0.6.0 is the current release, and 0.6.1 is in testing
(0.6.1-rc.1): it gets certificates onto your other hosts (`jarl cert
get`: Jarl obtains and renews them, a host downloads them and runs a
hook, a daily timer keeps them current), exports certificates, fixes
PTR records and overrides in linked zones, and makes one-view tokens
(such as ACME tokens for the external view) work with linked zones.
0.6.0 makes reverse zones easy (suggested from your records,
created in the right view and filled, PTR records kept in step), rebuilds
comparing a zone's views, lets you explore the statistics down to each
client and name (a query log), shows the databases' size, and gives
+1 -1
View File
@@ -1 +1 @@
0.6.0
0.6.1-rc.1
+8
View File
@@ -378,6 +378,9 @@ func printUser(w io.Writer, u api.User) {
if u.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", u.Comment)
}
if u.CertLimit != nil {
fmt.Fprintf(w, "Certificates:\tat most %d (jarl cert get)\n", *u.CertLimit)
}
fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt))
}
@@ -444,6 +447,7 @@ func userCmd() *cobra.Command {
}
var admin, noPassword, disabled bool
var role, comment, rename string
var certLimit int
var grants []string
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
var in api.UserInput
@@ -466,6 +470,9 @@ func userCmd() *cobra.Command {
if f.Changed("rename") {
in.Username = &rename
}
if f.Changed("cert-limit") {
in.CertLimit = &certLimit
}
return in, nil
}
addUserFlags := func(c *cobra.Command) {
@@ -473,6 +480,7 @@ func userCmd() *cobra.Command {
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
c.Flags().StringVar(&comment, "comment", "", "comment")
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
}
create := &cobra.Command{
Use: "create NAME",
+718
View File
@@ -0,0 +1,718 @@
package main
// jarl cert get: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts).
// jarld obtains and renews the certificate; this host asks for it, waits,
// downloads it when it is newer than its copy, and runs a hook. The
// settings of each certificate are saved, so a daily timer (jarl cert
// schedule) can run 'jarl cert get' for all of them.
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"jarl/internal/client"
)
// savedCert is a certificate's settings, saved by jarl cert get.
type savedCert struct {
Name string `json:"name"`
Domains []string `json:"domains"`
KeyType string `json:"key_type,omitempty"`
Dir string `json:"dir"`
Hook string `json:"hook,omitempty"`
Conn connection `json:"connection"`
SavedAt time.Time `json:"saved_at"`
}
// localMeta is what this host has of a certificate, kept next to its
// files as jarl-cert.json.
type localMeta struct {
Name string `json:"name"`
Domains []string `json:"domains"`
Serial string `json:"serial"`
NotAfter time.Time `json:"not_after"`
Fetched time.Time `json:"fetched"`
// HookFailed is set when the hook failed for these files: the next
// run runs it again.
HookFailed string `json:"hook_failed,omitempty"`
}
const metaFile = "jarl-cert.json"
// certConfigDir is where saved settings live: /etc/jarl/cert-get for
// root, ~/.config/jarl/cert-get otherwise.
func certConfigDir(flag string) string {
if flag != "" {
return flag
}
if os.Geteuid() == 0 {
return "/etc/jarl/cert-get"
}
if d, err := os.UserConfigDir(); err == nil {
return filepath.Join(d, "jarl", "cert-get")
}
return ".jarl-cert-get"
}
// defaultCertDir is where a certificate's files go without --dir.
func defaultCertDir(name string) string {
if os.Geteuid() == 0 {
return filepath.Join("/etc/jarl/certs", name)
}
return name
}
func loadSaved(dir string) ([]savedCert, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []savedCert
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
var sc savedCert
if err := json.Unmarshal(b, &sc); err != nil {
return nil, fmt.Errorf("%s: %w", filepath.Join(dir, e.Name()), err)
}
out = append(out, sc)
}
return out, nil
}
// save writes a certificate's settings, readable only by this user: they
// may hold the API token.
func (sc savedCert) save(dir string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
b, _ := json.MarshalIndent(sc, "", " ")
return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600)
}
func readMeta(dir string) (*localMeta, error) {
b, err := os.ReadFile(filepath.Join(dir, metaFile))
if err != nil {
return nil, err
}
var m localMeta
return &m, json.Unmarshal(b, &m)
}
func (m *localMeta) write(dir string) error {
b, _ := json.MarshalIndent(m, "", " ")
return writeAtomic(filepath.Join(dir, metaFile), append(b, '\n'), 0o644)
}
// writeAtomic replaces path with data, so a program never reads half a
// file.
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// requestAnswer is POST /certificates/{name}/request.
type requestAnswer struct {
Certificate certInfo `json:"certificate"`
Created bool `json:"created"`
Changed bool `json:"changed"`
Warnings []string `json:"warnings"`
}
// getResult is what one jarl cert get did.
type getResult struct {
Name string `json:"name"`
Dir string `json:"dir"`
Serial string `json:"serial"`
NotAfter time.Time `json:"not_after"`
Updated bool `json:"updated"` // new files written
Hook string `json:"hook,omitempty"`
}
// fetchCert asks jarld for the certificate, waits while it is obtained,
// downloads it when it is newer than this host's copy (or force) and runs
// the hook.
func fetchCert(c *client.Client, sc savedCert, force bool, wait time.Duration) (*getResult, error) {
var ans requestAnswer
err := c.Do(ctx(), "POST", certPath(sc.Name)+"/request", nil, map[string]any{"domains": sc.Domains, "key_type": sc.KeyType}, &ans)
if err != nil {
return nil, err
}
for _, w := range ans.Warnings {
fmt.Fprintf(os.Stderr, "%s: %s\n", sc.Name, w)
}
ci := ans.Certificate
if ci.IssuingSince != nil {
tty := !flagQuiet && flagOutput == "table"
if tty {
what := "Obtaining"
if ans.Changed {
what = "Obtaining (its names changed)"
}
fmt.Fprintf(os.Stderr, "%s %s from the CA on jarld", what, sc.Name)
}
deadline := time.Now().Add(wait)
for ci.IssuingSince != nil && time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
if tty {
fmt.Fprint(os.Stderr, ".")
}
ci = certInfo{}
if err := c.Get(ctx(), certPath(sc.Name), nil, &ci); err != nil {
return nil, err
}
}
if tty {
fmt.Fprintln(os.Stderr)
}
if ci.IssuingSince != nil {
return nil, fmt.Errorf("%s: jarld is still obtaining it; run jarl cert get again later", sc.Name)
}
}
if ci.NotAfter == nil {
msg := firstNonEmpty(ci.LastError, ci.Status.Message, "jarld has no certificate for it yet")
return nil, fmt.Errorf("%s: %s", sc.Name, msg)
}
if ci.LastError != "" && ci.LastAttempt != nil && (ci.LastSuccess == nil || ci.LastAttempt.After(*ci.LastSuccess)) {
fmt.Fprintf(os.Stderr, "%s: jarld's last attempt failed (%s); this is the certificate it has\n", sc.Name, ci.LastError)
}
res := &getResult{Name: sc.Name, Dir: sc.Dir, Serial: ci.Serial, NotAfter: *ci.NotAfter}
meta, _ := readMeta(sc.Dir)
have := meta != nil && meta.Serial == ci.Serial && filesPresent(sc.Dir)
if have && !force && meta.HookFailed == "" {
return res, nil
}
renewed := meta != nil && meta.Serial != "" && meta.Serial != ci.Serial
if !have || force {
if err := downloadCert(c, sc, ci); err != nil {
return nil, err
}
res.Updated = true
meta = &localMeta{Name: sc.Name, Domains: ci.Domains, Serial: ci.Serial, NotAfter: *ci.NotAfter, Fetched: time.Now().UTC()}
}
if sc.Hook != "" {
res.Hook = "ok"
if err := runHook(sc, ci, renewed); err != nil {
meta.HookFailed = err.Error()
res.Hook = "failed: " + err.Error()
} else {
meta.HookFailed = ""
}
}
if err := meta.write(sc.Dir); err != nil {
return nil, err
}
if meta.HookFailed != "" {
return res, fmt.Errorf("%s: the hook failed (%s); the next jarl cert get runs it again", sc.Name, meta.HookFailed)
}
return res, nil
}
var certFiles = []string{"cert.pem", "chain.pem", "fullchain.pem", "privkey.pem"}
func filesPresent(dir string) bool {
for _, f := range certFiles {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
return false
}
}
return true
}
// downloadCert writes the certificate's files: cert.pem, chain.pem (the
// intermediates), fullchain.pem and privkey.pem (mode 600).
func downloadCert(c *client.Client, sc savedCert, ci certInfo) error {
get := func(part string) ([]byte, error) {
var out string
err := c.Get(ctx(), certPath(sc.Name)+"/pem", url.Values{"part": {part}}, &out)
return []byte(out), err
}
var full, key []byte
// Twice at most: a renewal between the two downloads gives a key
// that doesn't match.
for try := 0; ; try++ {
var err error
if full, err = get("fullchain"); err != nil {
return err
}
if key, err = get("key"); err != nil {
return err
}
if _, err = tls.X509KeyPair(full, key); err == nil {
break
} else if try == 1 {
return fmt.Errorf("%s: the key jarld sent doesn't match the certificate: %w", sc.Name, err)
}
}
block, rest := pem.Decode(full)
if block == nil {
return fmt.Errorf("%s: jarld sent no certificate", sc.Name)
}
if err := os.MkdirAll(sc.Dir, 0o755); err != nil {
return err
}
files := []struct {
name string
data []byte
mode os.FileMode
}{
{"cert.pem", pem.EncodeToMemory(block), 0o644},
{"chain.pem", []byte(strings.TrimLeft(string(rest), "\n")), 0o644},
{"fullchain.pem", full, 0o644},
{"privkey.pem", key, 0o600},
}
// The key first: a program that reads both never pairs a new
// certificate with the old key for long.
slices.Reverse(files)
for _, f := range files {
if err := writeAtomic(filepath.Join(sc.Dir, f.name), f.data, f.mode); err != nil {
return err
}
}
return nil
}
// runHook runs the certificate's hook with sh, its files in the
// environment.
func runHook(sc savedCert, ci certInfo, renewed bool) error {
c, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(c, "sh", "-c", sc.Hook)
ren := "0"
if renewed {
ren = "1"
}
cmd.Env = append(os.Environ(),
"JARL_CERT_NAME="+sc.Name,
"JARL_CERT_DIR="+sc.Dir,
"JARL_CERT_CERT="+filepath.Join(sc.Dir, "cert.pem"),
"JARL_CERT_CHAIN="+filepath.Join(sc.Dir, "chain.pem"),
"JARL_CERT_FULLCHAIN="+filepath.Join(sc.Dir, "fullchain.pem"),
"JARL_CERT_KEY="+filepath.Join(sc.Dir, "privkey.pem"),
"JARL_CERT_DOMAINS="+strings.Join(ci.Domains, " "),
"JARL_CERT_SERIAL="+ci.Serial,
"JARL_CERT_NOT_AFTER="+ci.NotAfter.UTC().Format(time.RFC3339),
"JARL_CERT_RENEWED="+ren,
)
cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr
return cmd.Run()
}
func certGetCmd() *cobra.Command {
var domains []string
var dir, hook, keyType, configDir string
var noSave, force bool
var wait time.Duration
cmd := &cobra.Command{
Use: "get [NAME]",
Short: "Get a certificate for this host: jarld obtains it, this host downloads it",
Long: `Gets a certificate for this host (a mail server, a reverse proxy). jarld
obtains it from the CA and renews it, answering the challenges in its own
zones; this command asks for it, waits until it is there, downloads it
when it is newer than this host's copy, and runs --hook.
The token needs a certificates grant for the names, and the certificate
belongs to the token's user: another user can't take the name. Asking
again with the same names returns the certificate jarld has; adding or
removing a name gets a new one.
Files in --dir (default /etc/jarl/certs/NAME as root, ./NAME otherwise):
cert.pem, chain.pem (the intermediates), fullchain.pem, privkey.pem (mode
600), and jarl-cert.json (serial, expiry, when it was fetched).
The settings (names, --dir, --hook, the server and token) are saved in
/etc/jarl/cert-get/NAME.json (as root; ~/.config/jarl/cert-get otherwise,
readable only by you). 'jarl cert get' without a name then does every
saved certificate: run it daily (jarl cert schedule does that with a
systemd timer). Nothing is downloaded until jarld has renewed one.
--hook runs with sh after new files are written (and again on the next
run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT,
JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS,
JARL_CERT_SERIAL, JARL_CERT_NOT_AFTER and JARL_CERT_RENEWED (1 when it
replaced an older certificate).`,
Example: ` jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix dovecot'
jarl cert get proxy -d adbraun.lol -d '*.adbraun.lol' --dir /etc/haproxy/certs/proxy \
--hook 'cat "$JARL_CERT_KEY" "$JARL_CERT_FULLCHAIN" > /etc/haproxy/proxy.pem && systemctl reload haproxy'
jarl cert get # every saved certificate (what the timer runs)
jarl cert saved # what this host has, and when it expires`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
saved, err := loadSaved(cdir)
if err != nil {
return err
}
var todo []savedCert
f := cmd.Flags()
switch {
case len(args) == 0:
if len(domains) > 0 {
return errors.New("give the certificate a name: jarl cert get NAME -d DOMAIN")
}
if len(saved) == 0 {
return fmt.Errorf("no saved certificates in %s: get one first (jarl cert get NAME -d DOMAIN)", cdir)
}
todo = saved
default:
name := args[0]
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == name })
var sc savedCert
if i >= 0 {
sc = saved[i]
} else if len(domains) == 0 {
return fmt.Errorf("%s isn't saved here: give its names (-d DOMAIN)", name)
}
sc.Name = name
if len(domains) > 0 {
sc.Domains = domains
}
if f.Changed("dir") || sc.Dir == "" {
sc.Dir = firstNonEmpty(dir, defaultCertDir(name))
}
if f.Changed("hook") {
sc.Hook = hook
}
if f.Changed("key-type") {
sc.KeyType = keyType
}
if abs, err := filepath.Abs(sc.Dir); err == nil {
sc.Dir = abs
}
// The server and token given now (flags, environment, the
// CLI configuration) replace the saved ones.
if i < 0 || f.Changed("server") || cmd.Root().PersistentFlags().Changed("server") || cmd.Root().PersistentFlags().Changed("token") || os.Getenv("JARL_TOKEN") != "" || os.Getenv("JARL_SERVER") != "" {
cn, err := resolveConnection()
if err != nil {
return err
}
sc.Conn = cn
}
todo = []savedCert{sc}
}
var results []*getResult
var failed []string
var lastErr error
for _, sc := range todo {
c, err := sc.Conn.open()
if err == nil {
var r *getResult
r, err = fetchCert(c, sc, force, wait)
if r != nil {
results = append(results, r)
}
}
if err != nil {
if len(todo) > 1 {
fmt.Fprintln(os.Stderr, "error:", err)
}
failed = append(failed, sc.Name)
lastErr = err
continue
}
if len(args) == 1 && !noSave {
sc.SavedAt = time.Now().UTC()
if err := sc.save(cdir); err != nil {
return fmt.Errorf("saving the settings: %w", err)
}
}
}
if err := output(results, func(w io.Writer) {
for _, r := range results {
what := "up to date"
if r.Updated {
what = "new files"
}
fmt.Fprintf(w, "%s\t%s\t%s, expires %s\t%s\n", r.Name, r.Dir, what, r.NotAfter.Local().Format("2006-01-02"), hookText(r.Hook))
}
}); err != nil {
return err
}
switch {
case len(failed) == 1 && len(todo) == 1:
return lastErr // its own exit code (3 forbidden, 6 conflict, ...)
case len(failed) > 0:
return fmt.Errorf("failed: %s", strings.Join(failed, ", "))
}
return nil
},
}
f := cmd.Flags()
f.StringArrayVarP(&domains, "domain", "d", nil, "a name for the certificate (repeatable; a wildcard as '*.adbraun.lol')")
f.StringVar(&dir, "dir", "", "directory for the files (default /etc/jarl/certs/NAME as root, ./NAME otherwise)")
f.StringVar(&hook, "hook", "", "shell command to run after new files are written")
f.StringVar(&keyType, "key-type", "", "key type of a new certificate: ec256 (default), ec384, rsa2048, rsa3072, rsa4096")
f.BoolVar(&force, "force", false, "download and run the hook even when this host has the current certificate")
f.BoolVar(&noSave, "no-save", false, "don't save the settings for later runs")
f.DurationVar(&wait, "wait", 15*time.Minute, "how long to wait while jarld obtains it")
f.StringVar(&configDir, "config-dir", "", "where the settings are saved (default /etc/jarl/cert-get as root, ~/.config/jarl/cert-get otherwise)")
_ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp))
return cmd
}
func hookText(h string) string {
if h == "" {
return ""
}
return "hook " + h
}
func certSavedCmd() *cobra.Command {
var configDir string
cmd := &cobra.Command{
Use: "saved",
Short: "The certificates this host gets with jarl cert get, and when they expire",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
saved, err := loadSaved(cdir)
if err != nil {
return err
}
type row struct {
savedCert
Local *localMeta `json:"local,omitempty"`
}
var rows []row
for _, sc := range saved {
sc.Conn.Token = "" // never print it
m, _ := readMeta(sc.Dir)
rows = append(rows, row{sc, m})
}
return output(rows, func(w io.Writer) {
if len(rows) == 0 {
fmt.Fprintf(w, "No saved certificates in %s.\n", cdir)
return
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tNAMES\tDIR\tEXPIRES\tFETCHED\tSERVER\tHOOK")
for _, r := range rows {
exp, fetched := "-", "-"
if r.Local != nil {
exp = r.Local.NotAfter.Local().Format("2006-01-02") + fmt.Sprintf(" (%dd)", int(time.Until(r.Local.NotAfter).Hours()/24))
fetched = r.Local.Fetched.Local().Format("2006-01-02 15:04")
if r.Local.HookFailed != "" {
fetched += " HOOK FAILED"
}
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Name, strings.Join(r.Domains, ","), r.Dir, exp, fetched, r.Conn.Server, firstNonEmpty(r.Hook, "-"))
}
tw.Flush()
})
},
}
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
return cmd
}
func certForgetCmd() *cobra.Command {
var configDir string
var del, yes bool
cmd := &cobra.Command{
Use: "forget NAME",
Short: "Stop getting a certificate on this host (its files stay)",
Long: `Removes a certificate's saved settings, so 'jarl cert get' (and the timer)
no longer fetches it. Its files stay where they are. With --delete, the
certificate is deleted on jarld too, so it is no longer renewed.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cdir := certConfigDir(configDir)
path := filepath.Join(cdir, args[0]+".json")
saved, err := loadSaved(cdir)
if err != nil {
return err
}
i := slices.IndexFunc(saved, func(s savedCert) bool { return s.Name == args[0] })
if i < 0 {
return fmt.Errorf("%s isn't saved in %s", args[0], cdir)
}
if del {
if !confirm("Delete certificate "+args[0]+" on jarld too?", yes) {
return errors.New("aborted")
}
c, err := saved[i].Conn.open()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil {
return err
}
}
if err := os.Remove(path); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("forgot %s (its files stay in %s)\n", args[0], saved[i].Dir)
}
return nil
},
}
cmd.Flags().StringVar(&configDir, "config-dir", "", "where the settings are saved")
cmd.Flags().BoolVar(&del, "delete", false, "delete the certificate on jarld too")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
// Units written by jarl cert schedule.
const (
scheduleUnit = "jarl-cert-get"
unitDir = "/etc/systemd/system"
)
func scheduleUnits(bin, configDir string) (service, timer string) {
service = fmt.Sprintf(`# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
[Unit]
Description=Fetch certificates from Jarl (jarl cert get)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=%s cert get --config-dir %s
`, bin, configDir)
timer = `# Written by 'jarl cert schedule'; 'jarl cert schedule --remove' removes it.
[Unit]
Description=Fetch certificates from Jarl daily
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
`
return service, timer
}
func certScheduleCmd() *cobra.Command {
var remove, noSystemctl bool
var dir string
cmd := &cobra.Command{
Use: "schedule",
Short: "Run jarl cert get daily with a systemd timer (Linux, as root)",
Long: `Writes /etc/systemd/system/jarl-cert-get.service and .timer, which run
'jarl cert get' once a day for every certificate saved in /etc/jarl/cert-get,
and enables the timer. jarld renews certificates well before they expire;
the daily run downloads a renewed one and runs its hook.
Linux with systemd only, as root. Elsewhere, run 'jarl cert get' daily
yourself (cron, launchd, Task Scheduler).`,
Example: ` sudo jarl cert schedule
systemctl list-timers jarl-cert-get.timer
sudo jarl cert schedule --remove`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("jarl cert schedule writes systemd units, so it only works on Linux; run 'jarl cert get' daily with this system's scheduler")
}
if dir == unitDir && os.Geteuid() != 0 {
return errors.New("jarl cert schedule writes to /etc/systemd/system: run it as root (sudo)")
}
service := filepath.Join(dir, scheduleUnit+".service")
timer := filepath.Join(dir, scheduleUnit+".timer")
systemctl := func(a ...string) error {
if noSystemctl {
return nil
}
out, err := exec.Command("systemctl", a...).CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(a, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
if remove {
_ = systemctl("disable", "--now", scheduleUnit+".timer")
for _, f := range []string{timer, service} {
if err := os.Remove(f); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := systemctl("daemon-reload"); err != nil {
return err
}
if !flagQuiet {
fmt.Println("removed the timer; saved certificates stay in", certConfigDir(""))
}
return nil
}
bin, err := os.Executable()
if err != nil {
return err
}
if b, err := filepath.EvalSymlinks(bin); err == nil {
bin = b
}
cdir := certConfigDir("")
saved, err := loadSaved(cdir)
if err != nil {
return err
}
if len(saved) == 0 {
fmt.Fprintf(os.Stderr, "note: no certificates are saved in %s yet; get one with: jarl cert get NAME -d DOMAIN\n", cdir)
}
svc, tmr := scheduleUnits(bin, cdir)
if err := writeAtomic(service, []byte(svc), 0o644); err != nil {
return err
}
if err := writeAtomic(timer, []byte(tmr), 0o644); err != nil {
return err
}
if err := systemctl("daemon-reload"); err != nil {
return err
}
if err := systemctl("enable", "--now", scheduleUnit+".timer"); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("wrote %s and %s; the timer runs jarl cert get daily (systemctl list-timers %s.timer)\n", service, timer, scheduleUnit)
}
return nil
},
}
cmd.Flags().BoolVar(&remove, "remove", false, "disable and remove the timer")
cmd.Flags().StringVar(&dir, "unit-dir", unitDir, "where to write the units")
cmd.Flags().BoolVar(&noSystemctl, "no-systemctl", false, "only write the units")
_ = cmd.Flags().MarkHidden("unit-dir")
_ = cmd.Flags().MarkHidden("no-systemctl")
return cmd
}
+109 -2
View File
@@ -50,6 +50,13 @@ type certInfo struct {
Chain string `json:"chain"`
Key string `json:"key"`
} `json:"files,omitempty"`
Owner string `json:"owner,omitempty"`
PulledBy []struct {
Who string `json:"who"`
Serial string `json:"serial"`
IP string `json:"ip"`
At time.Time `json:"at"`
} `json:"pulled_by,omitempty"`
}
type certAccount struct {
@@ -99,9 +106,10 @@ used too (--cert-file and --key-file): Jarl rereads it when it changes.`,
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
jarl cert list
jarl cert issue ns1 # renew now
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)`,
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)
jarl cert export ns1 --dir /etc/ssl/ns1 # fullchain.pem and privkey.pem, for another program`,
}
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certDeleteCmd(), certAccountCmd())
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certGetCmd(), certSavedCmd(), certForgetCmd(), certScheduleCmd(), certDeleteCmd(), certAccountCmd())
return cmd
}
@@ -180,6 +188,20 @@ func printCert(w io.Writer, ci certInfo) {
if ci.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
}
if ci.Owner != "" {
fmt.Fprintf(w, "Owner:\t%s (jarl cert get)\n", ci.Owner)
}
for i, p := range ci.PulledBy {
label := ""
if i == 0 {
label = "Pulled by:"
}
cur := "current"
if p.Serial != ci.Serial {
cur = "an older one: not fetched since the renewal"
}
fmt.Fprintf(w, "%s\t%s from %s, %s (%s)\n", label, p.Who, dash(p.IP), fmtTime(&p.At), cur)
}
state := ci.Status.State
if ci.Status.Message != "" {
state += ": " + ci.Status.Message
@@ -493,6 +515,91 @@ is published and the CA isn't asked.`,
return cmd
}
func certExportCmd() *cobra.Command {
var dir, part string
var noKey, force bool
cmd := &cobra.Command{
Use: "export NAME",
Short: "Save a certificate (and its key) to use in another program",
Long: `Saves a certificate's current files, to use it outside Jarl (a mail
server, a reverse proxy): fullchain.pem and privkey.pem in --dir (the
key readable only by you), or one part on standard output with --part:
fullchain the certificate and its chain
cert the certificate alone
key the private key
bundle the key, then the chain (one file, as HAProxy wants)
Administrators only; every export of the key is audited. Jarl renews the
certificate, but not your copy: export it again after a renewal (the
'renews' date in 'jarl cert show'), or point the program at the files Jarl
writes for named (use dns) instead.`,
Example: ` jarl cert export ns1 --dir /etc/ssl/ns1
jarl cert export ns1 --part bundle > /etc/haproxy/ns1.pem
jarl cert export ns1 --no-key --dir .`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
get := func(p string) (string, error) {
var out string
err := c.Get(ctx(), certPath(args[0])+"/pem", url.Values{"part": {p}}, &out)
return out, err
}
if part != "" {
out, err := get(part)
if err != nil {
return err
}
_, err = os.Stdout.WriteString(out)
return err
}
files := []struct{ part, name string }{{"fullchain", "fullchain.pem"}, {"key", "privkey.pem"}}
if noKey {
files = files[:1]
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, f := range files {
path := dir + string(os.PathSeparator) + f.name
if _, err := os.Stat(path); err == nil && !force {
return fmt.Errorf("%s exists (--force replaces it)", path)
}
}
for _, f := range files {
out, err := get(f.part)
if err != nil {
return err
}
path := dir + string(os.PathSeparator) + f.name
mode := os.FileMode(0o644)
if f.part == "key" {
mode = 0o600
}
if err := os.WriteFile(path, []byte(out), mode); err != nil {
return err
}
if err := os.Chmod(path, mode); err != nil {
return err
}
if !flagQuiet {
fmt.Println("wrote", path)
}
}
return nil
},
}
cmd.Flags().StringVar(&dir, "dir", ".", "directory for fullchain.pem and privkey.pem")
cmd.Flags().StringVar(&part, "part", "", "write one part to standard output: fullchain, cert, key or bundle")
cmd.Flags().BoolVar(&noKey, "no-key", false, "only fullchain.pem")
cmd.Flags().BoolVar(&force, "force", false, "replace existing files")
_ = cmd.RegisterFlagCompletionFunc("part", cobra.FixedCompletions([]string{"fullchain", "cert", "key", "bundle"}, cobra.ShellCompDirectiveNoFileComp))
return cmd
}
func certDeleteCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
+6 -1
View File
@@ -30,7 +30,12 @@ func viewCmd() *cobra.Command {
return output(views, func(w io.Writer) {
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\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color))
clients, dests := orAny(v.MatchClients), orAny(v.MatchDestinations)
if v.Limited {
// No views:read: only the zones they can see.
clients, dests = "(not shown)", "(not shown)"
}
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, clients, dests, v.Zones, orDefault(v.Color))
}
})
},
+20 -9
View File
@@ -23,8 +23,8 @@ 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
the zone's DS record at the parent zone (your registrar for a domain, the
parent zone for a subdomain), and 'status' says when that is safe and where. 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
@@ -71,7 +71,7 @@ DS after that.`,
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))
fmt.Printf("\nnamed is signing %s now. Add its DS record to the parent zone only when\n'jarl zone dnssec status %s' says the zone is ready; it also says where it goes.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
}
return nil
},
@@ -87,8 +87,8 @@ DS after that.`,
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
signed and publishes a CDS "delete" record. Remove the DS record at the
parent ('status' says where), 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
@@ -137,7 +137,7 @@ A zone whose DS never reached the parent can be wiped at once instead
if next == "insecure" && st.ParentZone != "" {
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen 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), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else 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))
fmt.Printf("\nNext: remove the DS record for %s %s, 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), dsPlace(st), 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').")
}
@@ -153,7 +153,7 @@ A zone whose DS never reached the parent can be wiped at once instead
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
added it there) 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
@@ -345,9 +345,12 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
}
}
if len(ds) > 0 && r.Policy != "insecure" {
where := "at your registrar"
if r.DSAt == "none" {
fmt.Fprintf(w, "\n%s\n", r.DSNote)
}
where := dsPlace(r)
if r.ParentZone != "" {
where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there"
where += ", which is 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 {
@@ -368,6 +371,14 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
}
}
// dsPlace says where the zone's DS record goes ("at your registrar").
func dsPlace(r *api.DNSSECResponse) string {
if r.DSPlace != "" {
return r.DSPlace
}
return "at the parent zone"
}
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
// record in its parent zone in Jarl.
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
+36 -9
View File
@@ -103,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
return os.WriteFile(flagConfig, b, 0o600)
}
func newClient() (*client.Client, error) {
// connection is the server a command talks to and how: Token may be
// "@FILE" (read by open).
type connection struct {
Server string `json:"server"`
Token string `json:"token,omitempty"`
Insecure bool `json:"insecure,omitempty"`
CACert string `json:"ca_cert,omitempty"`
// Name is the named server's name ("" for one given by address).
Name string `json:"-"`
}
// resolveConnection works out the connection from the flags, the
// environment and the CLI configuration.
func resolveConnection() (connection, error) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
return connection{}, fmt.Errorf("reading %s: %w", flagConfig, err)
}
ts, err := resolveTargets(cfg, targetSpec(cfg))
if err != nil {
return nil, err
return connection{}, err
}
if len(ts) > 1 {
return nil, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
return connection{}, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
}
t := ts[0]
// A named server's own token wins: a token from the environment
@@ -125,15 +138,21 @@ func newClient() (*client.Client, error) {
sock, isSocket := client.SocketPath(t.URL)
if isSocket {
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
return nil, err
return connection{}, err
}
}
if token == "" && !isSocket {
if t.Name != "" {
return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
return connection{}, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
}
return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
return connection{}, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
}
return connection{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert), Name: t.Name}, nil
}
// open returns a client for the connection.
func (cn connection) open() (*client.Client, error) {
token := cn.Token
if strings.HasPrefix(token, "@") {
b, err := os.ReadFile(token[1:])
if err != nil {
@@ -141,7 +160,15 @@ func newClient() (*client.Client, error) {
}
token = strings.TrimSpace(string(b))
}
c, err := client.New(client.Options{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert)})
return client.New(client.Options{Server: cn.Server, Token: token, Insecure: cn.Insecure, CACert: cn.CACert})
}
func newClient() (*client.Client, error) {
cn, err := resolveConnection()
if err != nil {
return nil, err
}
c, err := cn.open()
if err != nil {
return nil, err
}
@@ -167,7 +194,7 @@ func newClient() (*client.Client, error) {
}
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
if !c.Preflight {
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
}
return c, nil
}
+1
View File
@@ -318,6 +318,7 @@ func serveCmd() *cobra.Command {
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
Resolvers: a.cfg.ACME.Resolvers, CABundle: a.cfg.ACME.CABundle, CheckPropagation: a.cfg.ACME.CheckPropagation,
Dir: a.cfg.CertDir(), Version: version.String(), Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
MaxPerUser: a.cfg.Certificates.MaxPerUser,
}, a.store, a.eng, a.log)
cm.Start(ctx)
if a.cfg.ACME.Account.EABHMAC != "" {
+7
View File
@@ -38,6 +38,13 @@ if [ "$1" = configure ]; then
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
systemctl start jarl-backup.timer || true
fi
# The jarld.service alias is linked when jarl.service is enabled;
# relink a unit enabled before the alias existed (this neither
# starts nor stops jarld).
if [ -n "$2" ] && systemctl is-enabled --quiet jarl.service 2>/dev/null \
&& [ ! -e /etc/systemd/system/jarld.service ]; then
systemctl reenable jarl.service >/dev/null 2>&1 || true
fi
# An upgrade restarts a running jarld (its database is upgraded
# when it starts). A first install starts nothing: Jarl needs
# 'jarld init' and 'jarld adopt' first.
+2
View File
@@ -65,3 +65,5 @@ SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
# 'systemctl restart jarld' works too (the daemon's binary is jarld).
Alias=jarld.service
+3 -1
View File
@@ -48,7 +48,9 @@ jarl acme unset DOMAIN [VALUE...] # remove these values, or all of them
* The zone is found automatically: the longest matching primary zone.
* If the zone exists in several **views** (split DNS), the record is written
into every view, so the CA (on the internet, in your external view) and
your internal resolvers see the same thing. `--view NAME` limits it to one.
your internal resolvers see the same thing. `--view NAME` limits it to one,
and so does a token for one view (`views=external`): in a linked zone its
records are then local to that view.
* All values and views go into one changeset. Setting a value that is
already there succeeds, so a retried hook doesn't fail, and removing a
value that isn't there succeeds as well.
+21 -5
View File
@@ -294,11 +294,20 @@ view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)).
},
"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
"ds_at": "registrar", // parent_zone, registrar, parent, reverse or none
"ds_place": "at your registrar", // completes "add the DS record ..."
"ds_note": "", // for ds_at none: why it can't have one
"parent_zone": "", // set when the parent zone is a primary zone in Jarl (any view, its own first)
"registrar": "porkbun" // when the DS can be published through Jarl
}
```
`ds_at` says where the DS record goes ([dnssec.md](dnssec.md#should-you-sign)):
`parent_zone` (a zone in Jarl: `parent_zone`, `parent_zone_id`,
`parent_view`), `registrar` (the zone is a registered domain), `parent` (a
subdomain whose parent zone is hosted elsewhere), `reverse` (whoever
delegates the addresses) or `none` (nothing on the internet delegates it).
`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)).
@@ -354,7 +363,10 @@ In a linked zone ([views.md](views.md#linked-zones)), records carry
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
true` (this view only, an override) or `false` (shared; on an update or a
set it makes this view's version every view's). Zones carry `link_group`
and `linked_with` (the other views).
and `linked_with` (the other views). Every zone in a view carries
`in_views`: the zones of the same name in the other views, linked or not
and of any type, in view order (`[{"id": 289, "view": "external", "type":
"primary", "linked": true}]`).
| Method | Path | Body |
|--------|------|------|
@@ -411,7 +423,9 @@ meantime stops it (and gives a `disabled_conflict` warning until then).
`PUT /rrsets` makes the RRset exactly the given data: a disabled record
with one of the values is enabled, other disabled records are left alone.
Disabling a record that is already disabled is a 409; with `missing_ok=1`
(by match) it's "nothing to do".
(by match) it's "nothing to do". A zone update that leaves the zone as it
is (the same SOA, TTL, comment...) changes nothing, so applying it is
"nothing to do" and staging it is "nothing to change".
### Views
@@ -510,7 +524,7 @@ written in Jarl's fixed order (docs/concepts.md).
| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` |
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` |
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations |
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations; 400 "nothing to change" if they change nothing |
| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation |
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
@@ -590,6 +604,8 @@ Administrators only. See [certificates.md](certificates.md#api).
| GET, PATCH, DELETE | `/certificates/{name}` | |
| POST | `/certificates/{name}/issue` | 202: being obtained (poll until `issuing_since` is gone); a file certificate is reread (200) |
| POST | `/certificates/{name}/check` | the routes of a certificate's names |
| POST | `/certificates/{name}/request` | `{"domains": [...], "key_type": "ec256"}`: a certificate for another host, owned by the caller's user (needs `certificates:write` for every name). New: created and obtained, 202 (poll `GET /certificates/{name}` until `issuing_since` is gone). Existing: must be the caller's (else 409); obtained again only when its names change. Answers `certificate`, `created`, `changed` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
| GET | `/certificates/{name}/pem?part=PART` | the certificate as PEM: `fullchain` (default), `cert`, `key`, `bundle`; key exports audited ([certificates.md](certificates.md#using-a-certificate-outside-jarl)) |
`GET /status` includes `certificates` for administrators: `total`,
`problems` (failing, expiring, expired), `next` and `next_after`,
@@ -616,7 +632,7 @@ 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`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, `username` to rename, and `cert_limit` (certificates the user may own through `jarl cert get`; negative: the `certificates.max_per_user` default). 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 |
+96 -1
View File
@@ -216,11 +216,105 @@ and the last good one stays in use. jarld runs as user `jarl`, which must
be able to read both files (e.g. `chgrp jarl` and `chmod 640`, or have
your renewal hook copy them somewhere it can read).
## Using a certificate outside Jarl
A certificate needn't be used by Jarl at all ("not used by Jarl" on the
Certificates page): Jarl obtains and renews it, and you export it for
another program, such as a mail server or a reverse proxy. **Export…** on
the Certificates page downloads each file or copies it to the clipboard
(copying needs the web UI over HTTPS, or from localhost). Or:
```sh
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem, and privkey.pem (mode 600)
jarl cert export mail --part bundle > /etc/haproxy/mail.pem
```
The parts are `fullchain` (the certificate and its chain), `cert` (the
certificate alone), `key` and `bundle` (the key, then the chain, in one
file, as HAProxy wants). Administrators can export any certificate, and
the owner of a certificate for another host (below) their own. Every
export of the key is in the audit log (`certificate.export_key`), and a key
only goes over HTTPS, the local socket, or to this server itself.
Jarl renews the certificate, not your copy: export it again after each
renewal (`jarl cert show` says when that is). For another host, `jarl
cert get` (below) does that for you. A program on this server can instead
read the files Jarl writes for named (use `dns`), which Jarl keeps current.
## Certificates for other hosts
`jarl cert get` gets a certificate onto another host (a mail server, a
reverse proxy) with one command: jarld obtains it from the CA and renews
it, answering the challenges in its own zones as for its own certificates;
the host asks for it, waits until it is there, downloads it and runs a
hook. The host needs only a token, not an ACME client or access to DNS.
**On Jarl**, as an administrator, give the hosts a user and a token each:
```sh
jarl user create hosts --no-password --grant "certificates:write names=*.adbraun.lol"
jarl token create mx --user hosts # one token per host
```
The `certificates` grant says which names the user may have certificates
for. It is never part of `*`, so `operator` and `readonly` users can't
get certificates (or download keys) unless you add one. A certificate
belongs to the user that asked for it: its tokens can download it, other
users can't take its name or see it, and the certificates Jarl uses
itself are the administrators'. Hosts that should share certificates use
the same user (each with its own token, so you can revoke one host
alone); hosts that shouldn't, use different users. A user may own
`certificates.max_per_user` certificates (10 by default), or what `jarl
user update NAME --cert-limit N` (or the user's form) sets.
**On the host:**
```sh
export JARL_SERVER=https://jarl.adbraun.lol:8053 JARL_TOKEN=@/root/jarl.token
jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot'
sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get'
jarl cert saved # what this host has, and when it expires
```
* **Asking again** with the same names returns the certificate jarld has:
nothing is obtained, nothing is downloaded until jarld renews it.
**Adding or removing a name** gets a new certificate. A different
`--key-type` alone doesn't; delete the certificate for that.
* **Files**, in `--dir` (default `/etc/jarl/certs/NAME` as root, `./NAME`
otherwise): `cert.pem`, `chain.pem` (the intermediates),
`fullchain.pem`, `privkey.pem` (mode 600) and `jarl-cert.json` (serial,
expiry, when it was fetched). Each file is replaced at once, never
half written. The key is checked against the certificate before
anything is written.
* **Saved settings**: the names, `--dir`, `--hook`, the server and the
token are saved in `/etc/jarl/cert-get/NAME.json` (as root;
`~/.config/jarl/cert-get` otherwise), readable only by their owner.
`jarl cert get` without a name does every saved certificate. A token
given as `@FILE` is saved as that reference, not copied. `jarl cert
forget NAME` stops getting one (`--delete` deletes it on jarld too).
* **The hook** runs with `sh` after new files are written, with
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`,
`JARL_CERT_SERIAL`, `JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when
it replaced an older certificate). If it fails, `jarl cert get` exits
non-zero and runs it again next time. It has 5 minutes.
* **`jarl cert schedule`** (Linux with systemd, as root) writes
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
`jarl cert get` daily at a random time within the hour after midnight,
and catch up after the host was off. `--remove` takes them away. On
other systems, run `jarl cert get` daily with their scheduler.
**On the Certificates page**, the administrators see each certificate's
owner and who downloaded it: the token, from where, when, and whether
that host has the current certificate or hasn't fetched the renewed one
yet. `jarl cert show NAME` says the same.
## Security notes
- Certificates Jarl obtains, their keys and the ACME account key are kept
in Jarl's database, so they are in its backups (like TSIG secrets). The
API never returns a key.
API returns a key only to an administrator who exports it, and records
that in the audit log.
- Every publicly trusted certificate is published in Certificate
Transparency logs, with all its names. A name like `ns1.adbraun.lol`
used for both the web UI and DNS reveals less than separate names. Avoid
@@ -240,6 +334,7 @@ your renewal hook copy them somewhere it can read).
| GET, PATCH, DELETE | `/certificates/{name}` | `domains` and `uses` replace the whole list |
| POST | `/certificates/{name}/issue` | obtain now: 202, then poll `GET /certificates/{name}` until `issuing_since` is gone; a file certificate is reread (200) |
| POST | `/certificates/{name}/check` | the routes for an existing certificate |
| GET | `/certificates/{name}/pem?part=fullchain` | the certificate as PEM, to save: `fullchain` (default), `cert`, `key` or `bundle` (key and chain). Exports of the key are audited |
## Testing with Pebble
+9 -1
View File
@@ -318,7 +318,8 @@ host name (a bare `nas` is refused, since it would mean
keeps the PTR record in the reverse zone in step (when that zone is managed
by Jarl); `jarl record update ID --ptr` with nothing else adds the PTR record
if it is missing, e.g. after creating the reverse zone. In linked zones, the
reverse zones of every linked view count. Every record type BIND supports can be used. Jarl knows the
reverse zone of each linked view that has the record counts
([reverse.md](reverse.md#keeping-ptr-records-in-step)). Every record type BIND supports can be used. Jarl knows the
syntax of the common ones and checks them strictly, and the rest are
validated by parsing.
@@ -445,6 +446,13 @@ jarl cert show ns1
jarl cert issue ns1 # renew now (--no-wait to return at once)
jarl cert update ns1 --challenge porkbun # auto, jarl or porkbun
jarl cert update ns1 --use dns # --use and --domain replace the list (--use none)
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey.pem, for another program
jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle
jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook
jarl cert get # every saved certificate (what the timer runs)
jarl cert saved # this host's certificates and their expiry
jarl cert forget mail [--delete] # stop getting it here
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
jarl cert delete ns1
jarl cert account # the ACME account (set in jarl.yaml)
```
+2 -1
View File
@@ -101,6 +101,7 @@ jarld restarts.
| `acme.ca_bundle` | `""` | PEM file of extra CA certificates to trust for the ACME server (a private CA). |
| `acme.check_propagation` | `true` | Wait until every name server of the zone serves a challenge before the CA is told to look (they are asked directly, on port 53). |
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the `dns` use are written for named: `<dir>/<name>/fullchain.pem` and `privkey.pem`. |
| `certificates.max_per_user` | `10` | How many certificates a user may own through `jarl cert get` ([certificates.md](certificates.md#certificates-for-other-hosts)). A user's own limit (`jarl user update NAME --cert-limit N`) replaces it. |
### ACME (lego httpreq endpoint)
@@ -163,7 +164,7 @@ check` says so).
|-----|---------|---------|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). |
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). Subdomain and reverse zones never get the registrar features ([dnssec.md](dnssec.md#porkbun)). |
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
+6
View File
@@ -32,6 +32,12 @@ internal/
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
(the downloads are in engine/blocklists.go)
config/ jarld's YAML configuration
certs/ certificates: Jarl as an ACME client (lego), renewal, files for named
encdns/ encrypted DNS (DoT/DoH) setup, status and health checks
mailcheck/ reverse DNS check of the addresses mail is sent from
totp/ two-factor login codes and recovery codes
version/ what a build reports (release tag, commit, commits since a tag)
testutil/ test helpers, chiefly finding BIND's tools (JARL_BIND_ROOT)
deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
contrib/acme.sh/ acme.sh DNS API hook
+23
View File
@@ -30,6 +30,17 @@ 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.
**Where the DS record goes** depends on the zone. The DNSSEC tab and
`jarl zone dnssec status` say which:
| Zone | The DS record goes |
|---|---|
| a registered domain (`adbraun.lol`) | at your registrar ([Porkbun](#porkbun) can do it for you) |
| a subdomain whose parent zone is in Jarl (`lab.adbraun.lol`) | in the parent zone, as a DS record named `lab`: one button, or the `jarl record add` command shown. The parent in the zone's own view counts first, else one in another view (a public one first). |
| a subdomain whose parent zone is hosted elsewhere | in that parent zone, wherever it is hosted (at Porkbun: the domain's DNS records) |
| a reverse zone | with whoever delegates it to you: your ISP, or your RIR for an address block of your own. Many ISPs don't take DS records. |
| a private name (`home.lan`) | nowhere: nothing on the internet delegates it, so signing it has no effect |
**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
@@ -204,6 +215,18 @@ 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.
Only a zone that is a registered domain gets the registrar features
(`adbraun.lol`, `example.co.uk`, judged by the public suffix list). Their
parent zone delegates the others, not a registrar:
* a subdomain zone such as `lab.adbraun.lol`: the NS and DS records in
`adbraun.lol`;
* a reverse zone: whoever holds the addresses (your ISP, or the RIR);
* a zone under a suffix that isn't public (`home.lan`, `home.arpa`):
nobody.
The zone's **Name servers** tab (Check delegation) checks all of them.
**Using it**: the zone's **Registrar** tab, or `jarl zone registrar`:
| | |
+4
View File
@@ -317,6 +317,10 @@ systemctl status jarl
journalctl -u jarl -f
```
The unit also answers to `jarld` (`Alias=jarld.service`), so
`systemctl restart jarld` and `journalctl -u jarl` work alike once it
is enabled.
Open `https://<management-address>:8053/` and log in as `admin`.
## 9. Set up the CLI
+15 -3
View File
@@ -118,7 +118,8 @@ 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,
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
whose key can move the domain), and manage TLS certificates (whose keys
can impersonate the server; [certificates.md](certificates.md)).
can impersonate the server; [certificates.md](certificates.md)). The one
exception is certificates for other hosts, below.
A grant is written as:
@@ -137,7 +138,8 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
| `stats` | query statistics, including client addresses and the names they look up (read only) |
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
| `*` | all of the above |
| `certificates` | certificates for other hosts (`jarl cert get`), by the names in them: `write` gets one obtained, `read` only downloads it. Never part of `*` |
| `*` | all of the above, except `certificates` |
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
Each takes comma separated patterns where `*` and `?` are wildcards, and a
@@ -146,6 +148,8 @@ missing filter matches everything:
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
* `views=internal`: view names (`_default` means zones outside any view)
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
(for `certificates`: the names in the certificate; `*.adbraun.lol` also
allows the wildcard certificate `*.adbraun.lol`)
* `types=TXT,CNAME`: record types
Examples:
@@ -157,10 +161,18 @@ zones:write zones=*.lab.adbraun.lol create/delete zones be
records:write zones=adbraun.lol views=internal only the internal copy of a split zone
records:write names=_acme-challenge.* types=TXT ACME challenges anywhere
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
certificates:write names=mail.adbraun.lol,smtp.adbraun.lol certificates for a mail server (jarl cert get)
```
A user who may see some records of a zone may also see that the zone exists
(its name and type), but not its other records.
(its name and type), but not its other records. They also see the zone's
view in `jarl view list` (`GET /views`): its name, colour and how many
zones they can see there, marked `limited`, but not its settings
(`match-clients` and the rest need `views:read`).
A zone name that is in several views needs a view (`--view`), unless the
user can see the zone in only one of them: a token for
`views=external` finds the external copy without being told.
### Roles
+22 -5
View File
@@ -132,8 +132,10 @@ view's A and AAAA records use. An address with several names gets one PTR:
3. the shortest name, then the first alphabetically.
Jarl says which name it chose, and which it didn't, so you can change it.
A reverse zone's page shows how many addresses still lack a PTR, with a
button to add them. `jarl zone show` shows it as `Missing PTRs`.
A reverse zone's page shows the addresses that still lack a PTR record and
the name each would get (the first 25), with a button to add them. `jarl
zone show` shows how many as `Missing PTRs`; the API lists them in
`ptr_missing_list`.
## Keeping PTR records in step
@@ -147,9 +149,24 @@ jarl zone update adbraun.lol --view internal --auto-ptr # off: --auto-p
(Zone settings → **Reverse DNS** in the web UI.) Then adding, changing,
disabling, enabling or deleting an A or AAAA record in the zone does the
same to its PTR record, in the reverse zones of the zone's view and of the
views it is linked with ([views.md](views.md#linked-zones)). A whole set
replaced at once (`jarl record set`, a batch) works too.
same to its PTR record, in the reverse zone of each view that has the
record. A whole set replaced at once (`jarl record set`, a batch) works
too.
**Linked zones** ([views.md](views.md#linked-zones)). PTR records follow
each view's own address records:
* A shared record gets a PTR record in every linked view that has it; a
record local to one view gets one in that view only.
* An address that goes from one view (deleted, changed, or overridden by
a local record) loses its PTR record there only. Views that still have
the address keep theirs.
* Each zone's own **Reverse DNS** setting counts: a linked zone without
it doesn't get PTR records from its partner's changes.
* In a linked reverse zone, these PTR records (and those **Fill** adds)
are local to their view. A PTR record that is shared becomes local in
each view the first time one of them changes it, so that a change in
one view doesn't reach the others.
* **One name per address.** When an address's PTR record names another
host, it stays: a second A record for the same address doesn't add a
+1 -1
View File
@@ -34,7 +34,7 @@ showing them.
| `cname_multiple` | error | A name can have only one CNAME |
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
| `cname_loop` | error | CNAMEs that point back to themselves |
| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution |
| `cname_chain` | info | a chain of three or more CNAMEs (one CNAME to another says nothing): works, but each is another lookup for some resolvers |
| `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone |
| `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) |
| `apex_ns_missing` | error | A zone needs NS records at its apex |
+15 -3
View File
@@ -168,6 +168,10 @@ shows one (it needs `tsig:write` and is audited). Key names starting with
`jarl-view-` are reserved, and the view keys can be used in your own
options (`allow-transfer { key jarl-view-external; }`).
**Moving between views.** A zone's page lists the other views that have a
zone of the same name ("also in external", in each view's colour). Each
one links straight to that zone, on the tab you are on.
## Linked zones
Most split-horizon zones are the same in every view apart from a few
@@ -183,7 +187,9 @@ own and its own zone options.
the web UI). A local record overrides the shared records of the same name
and type in that view: `www A 10.0.10.5` local to internal, while the
external view keeps the shared `www A 203.0.113.20`. Delete the local
records and the view gets the shared ones back.
records and the name and type are gone from that view only: the other
views keep their records, each local to its view now. To give the view
the shared version back instead, **Share** it from a view that has it.
* A new record joins the set of the same name and type: local if that set
is local in its view, shared otherwise.
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
@@ -216,7 +222,10 @@ anyway), so scripts don't need to know; `--shared` there is refused.
a grant for one view only (`views=internal`) can change that view's local
records and make overrides, but not a shared record, since that would
change the other views too: Jarl says so and suggests a local record. A
grant without a view (the usual `zones=adbraun.lol`) covers every view.
new name and type it adds is local to its view without being asked (an
ACME token for `views=external` adds its `_acme-challenge` TXT records to
the external view only). A grant without a view (the usual
`zones=adbraun.lol`) covers every view.
**Importing a zone file** into a linked zone (`jarl zone import`) adds
the records like any others: shared, unless the name and type is local
@@ -235,7 +244,10 @@ before it).
## Comparing views
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
same zone in another view (or all of them), from the view you're on:
same zone in another view (or all of them), from the view you're on.
The tab starts with a view the zone is linked to, if any, else the
first other view; `jarl zone diff` compares with every other view unless
`--with` names some:
```
$ jarl zone diff adbraun.lol --view internal
+40 -1
View File
@@ -34,6 +34,9 @@ type Zone struct {
LinkedWith []string `json:"linked_with,omitempty"`
// Links are the zones this one is linked with, with their views.
Links []ZoneLink `json:"links,omitempty"`
// InViews are the zones of this name in the other views (any type),
// in view order: for going straight to one.
InViews []ZoneLink `json:"in_views,omitempty"`
// OtherViews are the other views with a primary zone of this name
// (linked or not): what GET /zones/{zone}/compare compares.
OtherViews []string `json:"other_views,omitempty"`
@@ -42,6 +45,9 @@ type Zone struct {
// (POST /zones/{zone}/fill-ptr adds them).
Prefix string `json:"prefix,omitempty"`
PTRMissing int `json:"ptr_missing,omitempty"`
// PTRMissingList are the first 25 of them: the PTR records adding
// them would create.
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"`
// Pending is set when listing with a changeset: add, change or delete.
Pending string `json:"pending,omitempty"`
}
@@ -67,6 +73,21 @@ type View struct {
model.View
Zones int `json:"zone_count"`
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
// Limited is set for a view listed only because the principal can see
// zones in it (no views:read): only its name, colour and the zones
// they can see are given.
Limited bool `json:"limited,omitempty"`
}
// PTRFill is a PTR record a reverse zone lacks: Name (the PTR owner) for
// Address would give Target. Others are its other names; From the view
// whose record names it, when the zone's own view has none.
type PTRFill struct {
Address string `json:"address"`
Name string `json:"name"`
Target string `json:"target"`
Others []string `json:"others,omitempty"`
From string `json:"from,omitempty"`
}
// ViewColor sets the colour the web UI shows a view in.
@@ -399,6 +420,9 @@ type UserInput struct {
Role *string `json:"role,omitempty"` // a role template name
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
// CertLimit is how many certificates the user may own (jarl cert
// get); a negative number goes back to certificates.max_per_user.
CertLimit *int `json:"cert_limit,omitempty"`
}
// TokenInput creates a token.
@@ -465,8 +489,20 @@ type DNSSECResponse struct {
View string `json:"view,omitempty"`
Type string `json:"type"`
*dnssec.Status
// DSAt says where the zone's DS record goes: "parent_zone" (a primary
// zone in Jarl, below), "registrar" (the zone is a registered domain),
// "parent" (a subdomain whose parent zone is hosted elsewhere),
// "reverse" (whoever delegates the addresses) or "none" (nothing on
// the internet delegates the zone, so it can't have one).
DSAt string `json:"ds_at"`
// DSPlace completes "add the DS record …" ("at your registrar"); ""
// for "none".
DSPlace string `json:"ds_place,omitempty"`
// DSNote explains DSAt "none".
DSNote string `json:"ds_note,omitempty"`
// 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.
// (in the zone's own view, else preferably a public one): the DS
// record then goes there, as a record.
ParentZone string `json:"parent_zone,omitempty"`
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
ParentView string `json:"parent_view,omitempty"`
@@ -616,6 +652,9 @@ type BlockRule struct {
type ZoneLink struct {
ID int64 `json:"id"`
View string `json:"view"`
// Type and Linked are set in Zone.InViews.
Type string `json:"type,omitempty"`
Linked bool `json:"linked,omitempty"`
}
// EncDNSSummary is what the dashboard says about encrypted DNS.
+20
View File
@@ -134,3 +134,23 @@ func TestRoleTemplatesValid(t *testing.T) {
}
}
}
func TestCertificateGrants(t *testing.T) {
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
if err != nil {
t.Fatal(err)
}
if !GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.adbraun.lol"}) || GrantAllows(g, Action{Resource: ResCerts, Write: true, Name: "mail.example.org"}) {
t.Error("names filter")
}
if _, err := ParseGrant("certificates:read types=TXT"); err == nil {
t.Error("types on certificates accepted")
}
if _, err := ParseGrant("certificates:read zones=adbraun.lol"); err == nil {
t.Error("zones on certificates accepted")
}
// "*" never covers certificates: they hand out private keys.
if GrantAllows(model.Grant{Resource: ResAll, Access: Write}, Action{Resource: ResCerts, Name: "mail.adbraun.lol"}) {
t.Error("* covers certificates")
}
}
+12 -3
View File
@@ -21,11 +21,14 @@ const (
ResAudit = "audit" // audit log and everyone's changesets
ResStats = "stats" // query statistics
ResBlocking = "blocking" // blocklists and block/allow rules
// ResCerts is certificates for other hosts (jarl cert get), by the
// names in them. It is never part of "*": it hands out private keys.
ResCerts = "certificates"
ResAll = "*"
)
// Resources lists the resources in display order.
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking, ResCerts}
// Access levels.
const (
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
for i, t := range g.Types {
g.Types[i] = strings.ToUpper(t)
}
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
return fmt.Errorf("names and types filters only apply to the records resource")
if len(g.Types) > 0 && g.Resource != ResRecords {
return fmt.Errorf("the types filter only applies to the records resource")
}
if len(g.Names) > 0 && g.Resource != ResRecords && g.Resource != ResCerts {
return fmt.Errorf("the names filter only applies to the records and certificates resources")
}
if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews {
return fmt.Errorf("zone and view filters only apply to the zones, records and views resources")
@@ -228,6 +234,9 @@ func GrantAllows(g model.Grant, a Action) bool {
if g.Resource != ResAll && g.Resource != a.Resource {
return false
}
if g.Resource == ResAll && a.Resource == ResCerts {
return false // only an explicit certificates grant
}
if a.Write && g.Access != Write {
return false
}
+130
View File
@@ -52,6 +52,9 @@ type Config struct {
PropagationTimeout time.Duration
// SkipPropagationCheck is !CheckPropagation (set by New).
SkipPropagationCheck bool
// MaxPerUser is how many certificates a user may own (Ensure), unless
// the user has a limit of their own.
MaxPerUser int
}
// Manager keeps certificates current and serves jarld's own.
@@ -132,6 +135,8 @@ type Input struct {
Challenge *string `json:"challenge"`
KeyType *string `json:"key_type"`
Comment *string `json:"comment"`
// Owner sets the owning user (Ensure only).
Owner *int64 `json:"-"`
}
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
@@ -210,6 +215,9 @@ func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
if in.Comment != nil {
c.Comment = *in.Comment
}
if in.Owner != nil {
c.OwnerID = *in.Owner
}
switch c.Source {
case model.CertSourceACME:
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
@@ -295,6 +303,128 @@ func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, err
return c, err
}
// PEM returns a certificate's current chain and private key, for
// exporting it: from Jarl's store, or from the files of one you renew
// yourself.
func (m *Manager) PEM(ctx context.Context, name string) (*model.Certificate, []byte, []byte, error) {
c, err := m.Get(ctx, name)
if err != nil {
return nil, nil, nil, err
}
if c.Source == model.CertSourceFile {
cert, err := os.ReadFile(c.CertFile)
if err != nil {
return nil, nil, nil, fileErr(err)
}
key, err := os.ReadFile(c.KeyFile)
if err != nil {
return nil, nil, nil, fileErr(err)
}
return c, cert, key, nil
}
if c.CertPEM == "" {
return nil, nil, nil, &InputError{Msg: "certificate " + name + " hasn't been obtained yet, so there is nothing to export"}
}
return c, []byte(c.CertPEM), []byte(c.KeyPEM), nil
}
// ErrTaken is Ensure's answer for a name another user's certificate (or
// the administrators') has.
var ErrTaken = errors.New("certificate name taken")
// LimitFor is how many certificates u may own.
func (m *Manager) LimitFor(u *model.User) int {
if u != nil && u.CertLimit != nil {
return *u.CertLimit
}
return m.cfg.MaxPerUser
}
// EnsureResult is what Ensure did.
type EnsureResult struct {
Certificate *model.Certificate
Created bool // a new certificate
Changed bool // its names changed, so it is obtained again
// Ignored says what of the request an existing certificate keeps as
// it is (a different key type).
Ignored string
}
// Ensure is jarl cert get: the certificate name, owned by p's user, for
// domains. A new one is created (within the user's limit) and obtained
// at once. An existing one must be the user's: it is obtained again only
// when its names change (a name added or removed); otherwise it is
// returned as it is, renewed by the loop as usual. The caller checks that
// p may have these names.
func (m *Manager) Ensure(ctx context.Context, name string, domains []string, keyType string, p *auth.Principal) (*EnsureResult, error) {
owner := p.User
want, err := normDomains(domains)
if err != nil {
return nil, err
}
c, err := m.Get(ctx, name)
switch {
case errors.Is(err, ErrNotFound):
all, err := m.List(ctx)
if err != nil {
return nil, err
}
n := 0
for _, o := range all {
if o.OwnerID == owner.ID {
n++
}
}
if limit := m.LimitFor(owner); n >= limit {
return nil, inputErr("user %s has %d certificates, its limit (an administrator can raise it: jarl user update %s --cert-limit N, or certificates.max_per_user in jarl.yaml)", owner.Username, n, owner.Username)
}
in := Input{Name: &name, Domains: &want, Owner: &owner.ID, Comment: model.Ptr("for " + owner.Username + " (jarl cert get)")}
if keyType != "" {
in.KeyType = &keyType
}
c, err = m.Create(ctx, in, p)
if errors.Is(err, ErrConflict) {
return nil, ErrTaken // created meanwhile
}
if err != nil {
return nil, err
}
if _, err := m.IssueAsync(ctx, name, p); err != nil {
return nil, err
}
c, err = m.Get(ctx, name)
return &EnsureResult{Certificate: c, Created: true}, err
case err != nil:
return nil, err
}
if c.OwnerID != owner.ID && !(p.IsAdmin() && c.OwnerID == 0) {
return nil, ErrTaken
}
if c.Source != model.CertSourceACME {
return nil, inputErr("certificate %s is read from files on the server, so Jarl can't obtain it for you", name)
}
res := &EnsureResult{Certificate: c}
if keyType != "" && keyType != c.KeyType {
res.Ignored = "it keeps its key type " + c.KeyType + " (changing it means a new certificate: delete this one, or ask an administrator)"
}
have := slices.Clone(c.Domains)
slices.Sort(have)
sorted := slices.Clone(want)
slices.Sort(sorted)
if !slices.Equal(have, sorted) {
if c, err = m.Update(ctx, name, Input{Domains: &want}, p); err != nil {
return nil, err
}
res.Certificate, res.Changed = c, true
}
if res.Changed || c.CertPEM == "" && m.Issuing(name).IsZero() && c.Failures == 0 {
if _, err := m.IssueAsync(ctx, name, p); err != nil {
return nil, err
}
}
return res, nil
}
// Create adds a certificate. A file-source certificate is read at once;
// one Jarl obtains is left for Issue (or the background loop).
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
+5
View File
@@ -51,6 +51,10 @@ type Certificates struct {
// HTTPS): one directory per certificate, readable by bind.file_group.
// "" is <bind.config_dir>/tls.
Dir string `yaml:"dir"`
// MaxPerUser is how many certificates a user may own through jarl
// cert get (certificates for other hosts), unless the user has a
// limit of their own (jarl user update --cert-limit). Default 10.
MaxPerUser int `yaml:"max_per_user"`
}
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
@@ -467,6 +471,7 @@ func Default() *Config {
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 24 * time.Hour)},
TopN: 100,
},
Certificates: Certificates{MaxPerUser: 10},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
+23
View File
@@ -11,6 +11,7 @@ import (
"github.com/miekg/dns"
"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)
// Root is the root domain name.
@@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string {
return local + "@" + strings.Join(labels[1:], ".")
}
// RegisteredDomain returns the domain registered at a registrar that name
// is in, without a trailing dot: adbraun.lol for lab.adbraun.lol, from
// the public suffix list. ok is false when no registrar is involved:
// reverse zones and the rest of .arpa (delegated by whoever holds the
// addresses), names under a suffix the list doesn't know (home.lan,
// internal) and public suffixes themselves (co.uk).
func RegisteredDomain(name string) (string, bool) {
n := strings.ToLower(strings.TrimSuffix(name, "."))
if n == "" || n == "arpa" || strings.HasSuffix(n, ".arpa") {
return "", false
}
suffix, icann := publicsuffix.PublicSuffix(n)
if !icann || suffix == n {
return "", false
}
d, err := publicsuffix.EffectiveTLDPlusOne(n)
if err != nil {
return "", false
}
return d, true
}
// IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
func IsReverse(name string) bool {
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
+20
View File
@@ -249,3 +249,23 @@ func TestPrefixFromReverse(t *testing.T) {
}
}
}
func TestRegisteredDomain(t *testing.T) {
for name, want := range map[string]string{
"adbraun.lol.": "adbraun.lol",
"lab.adbraun.lol.": "adbraun.lol",
"Example.CO.UK": "example.co.uk",
"co.uk.": "",
"lol.": "",
"10.0.10.in-addr.arpa.": "",
"d.f.ip6.arpa.": "",
"home.arpa.": "",
"nas.home.lan.": "",
"": "",
} {
got, ok := RegisteredDomain(name)
if got != want || ok != (want != "") {
t.Errorf("RegisteredDomain(%q) = %q, %v; want %q", name, got, ok, want)
}
}
}
+3
View File
@@ -330,6 +330,9 @@ func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.
if err != nil {
return nil, err
}
if len(ops) == 0 {
return nil, errf(KindBadRequest, "nothing to change")
}
intro := validate.Introduced(before, e.validate(work))
if validate.HasErrors(intro) {
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
+36
View File
@@ -462,3 +462,39 @@ func TestParseWarningsReachTheResult(t *testing.T) {
t.Errorf("ops = %+v", res.Ops)
}
}
func TestUnchangedZoneUpdateStagesNothing(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("wg.example.com")}))
z := env.state().FindZone("wg.example.com.", 0)
if z.SOA.MName != "ns1.example.com." {
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{MName: model.Ptr("ns1.example.com.")}}))
z = env.state().FindZone("wg.example.com.", 0)
}
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
if err != nil {
t.Fatal(err)
}
// The settings form saved as it was.
same := ZoneInput{Comment: model.Ptr(z.Comment), DefaultTTL: model.Ptr(z.DefaultTTL), SerialPolicy: model.Ptr(z.SerialPolicy),
SOA: &SOAInput{RName: model.Ptr("hostmaster@example.com"), Refresh: model.Ptr(z.SOA.Refresh), Retry: model.Ptr(z.SOA.Retry),
Expire: model.Ptr(z.SOA.Expire), Minimum: model.Ptr(z.SOA.Minimum)}}
if z.SOA.RName != "hostmaster.example.com." {
same.SOA.RName = nil
}
_, err = env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, same)}, env.admin)
if e, ok := AsError(err); !ok || e.Kind != KindBadRequest || e.Message != "nothing to change" {
t.Fatalf("unchanged update: %v", err)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, same)); e.Message != "nothing to do" {
t.Errorf("unchanged update applied: %+v", e)
}
// A real change still stages, and an absolute MNAME stays absolute.
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{Comment: model.Ptr("the VPN")})}, env.admin); err != nil {
t.Fatal(err)
}
if got := env.state().FindZone("wg.example.com.", 0).SOA.MName; got != "ns1.example.com." {
t.Errorf("mname = %q", got)
}
}
+277 -200
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/netip"
"reflect"
"slices"
"strings"
"time"
@@ -31,6 +32,12 @@ type expander struct {
// exposures classifies the views as public or private (nil in tests:
// every view private).
exposures func(*model.State) []validate.Exposure
// ptrForced are the address sets whose PTR records the request being
// expanded asked to update (ptrForce).
ptrForced map[rrKey]bool
// sharing are the local sets the request deletes so that the shared
// version reaches them (dropOverrides).
sharing map[rrKey]bool
}
func (x *expander) ids(n int) ([]int64, error) {
@@ -69,11 +76,13 @@ func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, er
if zone == "" {
return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)")
}
return FindZoneRef(x.st, zone, view)
return FindZoneRef(x.st, zone, view, func(z *model.Zone) bool { return x.p.CanSeeZone(z.Name, x.st.ViewName(z)) })
}
// FindZoneRef finds a zone by name and optional view name.
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
// FindZoneRef finds a zone by name and optional view name. Without a
// view, a name in several views needs one, counting only the zones sees
// allows (nil: all): a token for one view finds that view's zone.
func FindZoneRef(st *model.State, zone, view string, sees func(*model.Zone) bool) (*model.Zone, error) {
name, err := ParseZoneName(zone)
if err != nil {
return nil, errf(KindBadRequest, "%v", err)
@@ -90,6 +99,16 @@ func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
return z, nil
}
zs := st.ZonesNamed(name)
if sees != nil && len(zs) > 1 {
var vis []*model.Zone
for _, z := range zs {
if sees(z) {
vis = append(vis, z)
}
}
zs = vis // none: not found, without naming the views
}
st.SortByView(zs)
switch len(zs) {
case 0:
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
@@ -135,13 +154,20 @@ func dateSerial(now time.Time) uint32 {
}
func (x *expander) expand(req Request) ([]model.Op, error) {
x.ptrForced, x.sharing = nil, nil
ops, err := x.expandOne(req)
if err != nil {
return nil, err
}
switch req.Op {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable, OpZoneFillPTR:
// Changes to linked zones reach the other zones of the link.
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
// Changes to linked zones reach the other zones of the link, then
// PTR records follow the addresses in each view.
if ops, err = x.linkSync(ops); err != nil {
return nil, err
}
return x.ptrSync(ops)
case OpZoneFillPTR:
return x.linkSync(ops)
}
return ops, nil
@@ -193,6 +219,11 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
if in.LinkWith != "" {
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
}
// An update that leaves the zone as it is stages nothing (a
// settings form saved without changes).
if zoneUnchanged(x.st, z, &in) {
return nil, nil
}
}
return ops, nil
case OpRecordCreate:
@@ -751,55 +782,218 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
return netip.Addr{}, false
}
// ptrOps returns operations creating the PTR record for an address record.
//
// auto is set when the zone's auto_ptr asked rather than the request: a
// missing reverse zone isn't worth a warning then (most addresses have
// none), and an address whose PTR record names another host keeps it.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) {
addr, ok := addrOf(p)
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
// PTR records follow address records per view (docs/reverse.md): after a
// request's operations (and those keeping linked zones in step), ptrSync
// compares each touched A or AAAA set before and after, in every view it
// is in, and changes the PTR records of that view's reverse zone to match.
// So a record local to one view of a linked zone changes PTR records in
// that view only, and an address that goes from one view keeps its PTR
// record in the views that still have it.
// ptrForce marks a name and type whose PTR records the request asked to
// update (ptr: true), in its zone and the zones linked with it.
func (x *expander) ptrForce(z *model.Zone, name, typ string) {
if x.ptrForced == nil {
x.ptrForced = map[rrKey]bool{}
}
var ops []model.Op
found := false
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
x.ptrForced[rrKey{z.ID, name, typ}] = true
for _, o := range x.st.LinkedWith(z) {
x.ptrForced[rrKey{o.ID, name, typ}] = true
}
found = true
more, err := x.ptrCreateIn(rz, ptrName, owner, auto)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
if !found && !auto {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
}
return ops, nil
}
// ptrViews are the views whose reverse zones get the PTR records of zone
// z's address records: z's own, and, for a linked zone, the other views
// of the link (the record is in all of them).
func ptrViews(st *model.State, z *model.Zone) []int64 {
out := []int64{z.ViewID}
for _, o := range st.LinkedWith(z) {
if !slices.Contains(out, o.ViewID) {
out = append(out, o.ViewID)
// Whether a set has an address: none, only in disabled records, enabled.
const (
addrNone = iota
addrDisabled
addrEnabled
)
// addrStates returns the addresses of a zone's A or AAAA set.
func addrStates(st *model.State, k rrKey) map[netip.Addr]int {
out := map[netip.Addr]int{}
for _, r := range st.RRset(k.zone, k.name, k.typ) {
a, err := netip.ParseAddr(r.Data)
if err != nil {
continue
}
s := addrDisabled
if !r.Disabled {
s = addrEnabled
}
out[a.Unmap()] = max(out[a.Unmap()], s)
}
return out
}
// ptrSync returns ops followed by the PTR changes they call for: for the
// zone's auto_ptr, or because the request asked (ptrForce).
func (x *expander) ptrSync(ops []model.Op) ([]model.Op, error) {
forced := x.ptrForced
x.ptrForced = nil
work := x.st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range ops {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
keys := affectedKeys(x.st, work, ops)
for _, k := range sortedForced(forced) {
if !slices.Contains(keys, k) {
keys = append(keys, k)
}
}
var extra []model.Op
for _, k := range keys {
z := work.Zones[k.zone]
if z == nil || (k.typ != "A" && k.typ != "AAAA") || strings.HasPrefix(k.name, "*.") {
continue
}
force := forced[k]
if !force && !z.AutoPTR {
continue
}
before, after := addrStates(x.st, k), addrStates(work, k)
var addrs []netip.Addr
for _, m := range []map[netip.Addr]int{before, after} {
for a := range m {
if !slices.Contains(addrs, a) {
addrs = append(addrs, a)
}
}
}
slices.SortFunc(addrs, func(a, b netip.Addr) int { return a.Compare(b) })
for _, a := range addrs {
was, is := before[a], after[a]
if !force && (was == is || was == addrNone && is == addrDisabled) {
continue
}
rz, ptrName := reverseZoneFor(work, z.ViewID, a)
if rz == nil {
if force && is == addrEnabled {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in %s, so no PTR record was added there (create one: jarl zone create %s/%d%s)",
a, viewOrServer(work, z.ViewID), a, a.BitLen(), viewFlag(work, z.ViewID)))
}
continue
}
more, err := x.ptrChange(work, rz, ptrName, k.name, is, !force)
if err != nil {
return nil, err
}
for _, op := range more {
if err := applyOp(work, op, c); err != nil {
return nil, err
}
}
extra = append(extra, more...)
}
}
if len(extra) == 0 {
return ops, nil
}
// The PTR records are local (see ptrChange), so this only keeps a
// linked reverse zone consistent.
extra, err := x.linkSync(extra)
if err != nil {
return nil, err
}
return append(ops, extra...), nil
}
func sortedForced(m map[rrKey]bool) []rrKey {
var out []rrKey
for k := range m {
out = append(out, k)
}
slices.SortFunc(out, func(a, b rrKey) int {
if a.zone != b.zone {
return int(a.zone - b.zone)
}
if a.name != b.name {
return strings.Compare(a.name, b.name)
}
return strings.Compare(a.typ, b.typ)
})
return out
}
func viewFlag(st *model.State, viewID int64) string {
if v := st.Views[viewID]; v != nil {
return " --view " + v.Name
}
return ""
}
// ptrChange returns the operations that give reverse zone rz's PTR
// records at ptrName for host owner the state of its address: none
// (deleted), disabled, or enabled (added when missing).
//
// In a linked reverse zone the PTR records are local to each view, since
// each view's follow its own address records: a shared set at ptrName
// is made local in every view of the link first, as each view has it.
func (x *expander) ptrChange(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
nwarn := len(x.warnings)
ops, err := x.ptrChangeIn(st, rz, ptrName, owner, state, auto)
if err != nil || len(ops) == 0 || rz.LinkGroup == 0 {
return ops, err
}
var local []model.Op
for _, m := range st.LinkMembers(rz.LinkGroup) {
for _, r := range st.RRset(m.ID, ptrName, "PTR") {
if r.SharedID != 0 {
local = append(local, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
fmt.Sprintf("linked: %s PTR %s becomes local to %s (PTR records follow each view's own address records)", r.Name, r.Data, zoneDesc(st, m))))
}
}
}
if len(local) == 0 {
return ops, nil
}
// Again on the local records (their versions changed).
scratch := st.Clone()
c := &applyCtx{p: x.p, now: x.now}
for _, op := range local {
if err := applyOp(scratch, op, c); err != nil {
return nil, err
}
}
x.warnings = x.warnings[:nwarn]
ops, err = x.ptrChangeIn(scratch, rz, ptrName, owner, state, auto)
if err != nil {
return nil, err
}
return append(local, ops...), nil
}
func (x *expander) ptrChangeIn(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
var ops []model.Op
switch state {
case addrNone:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) {
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("delete %s PTR %s", r.Name, r.Data)))
}
}
case addrDisabled:
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) && !r.Disabled {
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{Disabled: model.Ptr(true)}, fmt.Sprintf("disable %s PTR %s", r.Name, r.Data)))
}
}
default:
return x.ptrCreateIn(st, rz, ptrName, owner, auto)
}
return ops, nil
}
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled).
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
// auto is set when the zone's auto_ptr asked rather than the request: an
// address whose PTR record names another host keeps it then.
func (x *expander) ptrCreateIn(st *model.State, rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
var other string
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
if strings.EqualFold(r.Data, owner) {
if r.Disabled {
in := RecordInput{Disabled: model.Ptr(false)}
@@ -811,7 +1005,6 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool)
other = r.Data
}
}
}
if other != "" {
if auto {
// One name per address: the one it has stays.
@@ -824,38 +1017,12 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool)
return nil, err
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
if rz.LinkGroup != 0 {
in.SharedID = model.Ptr(int64(0))
}
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
}
// ptrSetDisabled returns operations disabling or enabling the PTR record
// of an address record.
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
var ops []model.Op
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
}
return ops
}
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
var ops []model.Op
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
in := RecordInput{Disabled: model.Ptr(disabled)}
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data)))
}
}
return ops
}
func enableWord(disabled bool) string {
if disabled {
return "disable"
@@ -863,23 +1030,6 @@ func enableWord(disabled bool) string {
return "enable"
}
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
addr, ok := addrOf(p)
if !ok {
return nil
}
var ops []model.Op
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
}
return ops
}
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
// The stored operation holds the canonical data, so applying it doesn't
// notice again.
@@ -906,7 +1056,12 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
return nil, err
}
x.parseWarnings(owner, p)
ptr, auto := in.PTR, !in.PTR && autoPTR(z, owner, p)
if in.PTR {
if _, ok := addrOf(p); !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
x.ptrForce(z, owner, p.Type)
}
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
ids, err := x.ids(1)
@@ -934,15 +1089,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
}
// local on a zone that isn't linked: its records are local anyway.
in.Local = nil
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
if ptr || auto && (in.Disabled == nil || !*in.Disabled) {
more, err := x.ptrCreate(z, owner, p, auto)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, nil
}
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
@@ -1063,67 +1210,22 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if string(mustJSON(in)) != "{}" || !ptr {
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
}
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
}
moved := p.Data != r.Data || owner != r.Name || p.Type != r.Type
if !ptr && z.AutoPTR {
// The zone keeps PTR records in step (docs/reverse.md): as if
// asked, for whichever of the old and new record is an address.
old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data)
oldAuto := err == nil && autoPTR(z, r.Name, old)
newAuto := autoPTR(z, owner, p)
switch {
case moved:
if oldAuto {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if newAuto && !disabled {
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
case toggled && newAuto && disabled:
ops = append(ops, x.ptrSetDisabled(z, owner, p, true)...)
case toggled && newAuto:
// Enables the PTR record, or adds it when it is missing.
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
if ptr && moved {
if ptr {
// The PTR records of the old and the new record, whichever is an
// address (ptrSync).
_, isAddr := addrOf(p)
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if !disabled {
more, err := x.ptrCreate(z, owner, p, false)
if err != nil {
return nil, err
}
ops = append(ops, more...)
if _, ok := addrOf(old); ok {
isAddr = true
x.ptrForce(z, r.Name, r.Type)
}
}
if ptr && !moved && toggled {
if _, ok := addrOf(p); !ok {
if !isAddr {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
if _, ok := addrOf(p); ok {
x.ptrForce(z, owner, p.Type)
}
if ptr && !moved && !toggled && !disabled {
// Nothing else changes: make sure the PTR record is there (the
// reverse zone may be newer than the record).
more, err := x.ptrCreate(z, owner, p, false)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
@@ -1146,9 +1248,8 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
base = r.Version
}
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
z := x.st.Zones[r.ZoneID]
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
if in.PTR {
x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
}
return ops, nil
}
@@ -1184,25 +1285,12 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
if in.MissingOK {
what += " (if present)"
}
ptr := in.PTR
if in.PTR && parsed != nil {
x.ptrForce(z, owner, typ)
}
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type = &owner, &typ
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
switch {
case ptr && parsed != nil:
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
case z.AutoPTR:
// Every matching record's PTR record goes too.
for _, r := range x.st.RRset(z.ID, owner, typ) {
if parsed != nil && r.Data != parsed.Data {
continue
}
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && autoPTR(z, r.Name, p) {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
}
return ops, nil
return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, nil
}
// recordSetDisabled expands record.disable and record.enable: the record
@@ -1336,32 +1424,7 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
}
ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary))
if z.AutoPTR && (typ == "A" || typ == "AAAA") && !strings.HasPrefix(owner, "*.") {
// PTR records follow the addresses that go and come.
old := map[string]bool{}
for _, r := range x.st.RRset(z.ID, owner, typ) {
old[r.Data] = true
if !slices.Contains(canon, r.Data) {
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
}
for _, d := range canon {
if old[d] {
continue
}
if _, p, err := parseRecord(z, owner, typ, d); err == nil {
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
}
}
return ops, nil
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
}
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
@@ -1555,3 +1618,17 @@ func firstNonEmptyStr(a, b string) string {
func notLinked(st *model.State, z *model.Zone) error {
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
}
// zoneUnchanged reports whether an update would leave the zone as it is.
// Errors are left for applying the update to report.
func zoneUnchanged(st *model.State, z *model.Zone, in *ZoneInput) bool {
if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 {
return false
}
c := st.Clone()
n := c.Zones[z.ID]
if err := setZoneFields(c, n, in, false); err != nil {
return false
}
return reflect.DeepEqual(n, z)
}
+53 -15
View File
@@ -106,8 +106,8 @@ func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
//
// - z overrides it (local records): z's shared copies go; the others
// keep theirs.
// - z's override was just removed: z gets the shared records back from
// a zone that has them.
// - z's override was just removed: z goes without the name and type,
// and the other zones' shared copies become local to each.
// - otherwise z's shared records are the truth: every zone that doesn't
// override the name and type gets the same (created, updated or
// deleted to match).
@@ -123,16 +123,10 @@ func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]
}
truth := st.SharedRecords(z.ID, name, typ)
members := st.LinkedWith(z)
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
// The override was removed: back to the shared records.
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) && x.sharing[rrKey{z.ID, name, typ}] {
// Another view's version was shared (dropOverrides): z gets it.
for _, o := range members {
if st.HasLocal(o.ID, name, typ) {
continue
}
src := st.SharedRecords(o.ID, name, typ)
if len(src) == 0 {
continue
}
if src := st.SharedRecords(o.ID, name, typ); len(src) > 0 && !st.HasLocal(o.ID, name, typ) {
for _, r := range src {
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
if err != nil {
@@ -142,6 +136,28 @@ func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]
}
break
}
}
return ops, nil
}
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
// z's local records are gone, and with them the name and type from
// z: the other views keep theirs, each its own now (a shared set
// would be in z too). Share sets one version for all again.
for _, o := range members {
if st.HasLocal(o.ID, name, typ) {
continue
}
var more []model.Op
for _, r := range st.SharedRecords(o.ID, name, typ) {
more = append(more, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
fmt.Sprintf("linked: %s %s %s stays in %s, local to it now (%s has none)", name, typ, r.Data, where(o), where(z))))
}
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
return nil, errf(KindForbidden, "deleting %s %s from %s makes it local in %s, which you may not change; ask someone who may to delete it, or share %s's version (jarl record update ID --shared)",
dnsname.Display(name), typ, where(z), where(o), where(o))
}
ops = append(ops, more...)
}
return ops, nil
}
for _, o := range members {
@@ -440,13 +456,31 @@ func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
// localFor works out whether a new record (or set) in zone z is local:
// as asked, or like the records of the same name and type already there.
// A new name and type is shared, unless the principal may not change it
// in every linked view (an ACME token for the external view only): then
// it is local to z's view, which is all they may change.
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
has := x.st.HasLocal(z.ID, name, typ)
if want == nil {
return has, nil
}
if want != nil {
return *want, nil
}
if x.st.HasLocal(z.ID, name, typ) {
return true, nil
}
if len(x.st.SharedRecords(z.ID, name, typ)) > 0 {
return false, nil
}
var not []string
for _, o := range x.st.LinkedWith(z) {
if !x.p.Can(recordAction(x.st, o, name, typ, true)) {
not = append(not, x.st.ViewName(o))
}
}
if len(not) > 0 {
x.warnings = append(x.warnings, fmt.Sprintf("%s %s is local to view %s: you may not change it in %s", dnsname.Display(name), typ, x.st.ViewName(z), strings.Join(not, ", ")))
return true, nil
}
return false, nil
}
// shareAllOps returns the operations that start a link with zone z: it
// joins group and all its records become shared.
@@ -468,6 +502,10 @@ func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
for _, o := range x.st.LinkedWith(z) {
for _, r := range x.st.RRset(o.ID, name, typ) {
if r.SharedID == 0 {
if x.sharing == nil {
x.sharing = map[rrKey]bool{}
}
x.sharing[rrKey{o.ID, name, typ}] = true
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
}
}
+64 -3
View File
@@ -130,10 +130,15 @@ func TestLinkedZones(t *testing.T) {
if !slices.Contains(env.view("external"), "www A 192.0.2.11 S") || slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external"))
}
// Removing the override brings the shared records back.
// Removing the override removes www A from internal only: external
// keeps its record, local now. Sharing it brings it back to internal.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
t.Errorf("override removed: internal %q", env.view("internal"))
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "www A ") }) || !slices.Contains(env.view("external"), "www A 192.0.2.11 L") {
t.Errorf("override removed: internal %q external %q", env.view("internal"), env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "www", "A"), RecordInput{Local: model.Ptr(false)}))
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") || !slices.Contains(env.view("external"), "www A 192.0.2.11 S") {
t.Errorf("shared again: internal %q external %q", env.view("internal"), env.view("external"))
}
// Make a shared set local, then share another view's version.
@@ -386,3 +391,59 @@ func TestCompareReport(t *testing.T) {
t.Errorf("mail A: %+v", rep)
}
}
// A shared record made local in one view, then deleted there, is gone from
// that view only; the other view keeps it, local now.
func TestDeleteLocalOverride(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, recIn("internal", "a", "A", "192.0.2.50"))
if !slices.Contains(env.view("external"), "a A 192.0.2.50 S") {
t.Fatalf("not shared: %q", env.view("external"))
}
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Local: model.Ptr(true)}))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil))
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "a A ") }) {
t.Errorf("internal still has a: %q", env.view("internal"))
}
if !slices.Contains(env.view("external"), "a A 192.0.2.50 L") {
t.Errorf("external: %q", env.view("external"))
}
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
t.Errorf("validation: %+v", probs)
}
}
// An ACME token for one view of a linked zone: its challenge records are
// local to that view, the zone is found without naming the view, and they
// go again without touching the other view.
func TestLinkedACMEToken(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
u := &model.User{Username: "certbot", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Zones: []string{"example.com"}, Views: []string{"external"}, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}}
if err := env.st.CreateUser(env.ctx, u); err != nil {
t.Fatal(err)
}
bot := &auth.Principal{User: u, Method: "token"}
name, typ, data := "_acme-challenge", "TXT", "token-value"
res := env.submit(bot, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: &name, Type: &typ, Data: &data}))
if !slices.Contains(env.view("external"), "_acme-challenge TXT \"token-value\" L") || slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "_acme-challenge") }) {
t.Errorf("challenge: internal %q external %q", env.view("internal"), env.view("external"))
}
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "local to view external") }) {
t.Errorf("warnings %q", res.Warnings)
}
env.submit(bot, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: &name, Type: &typ, Data: &data}))
if slices.ContainsFunc(env.view("external"), func(s string) bool { return strings.HasPrefix(s, "_acme-challenge") }) {
t.Errorf("not deleted: %q", env.view("external"))
}
// The admin, who may change both, still gets a shared record.
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("both"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.7")}))
if !slices.Contains(env.view("external"), "both A 192.0.2.7 S") {
t.Errorf("admin: %q", env.view("external"))
}
// Without a view, the admin is still asked which.
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.8")})); !strings.Contains(e.Message, "several views (internal, external)") {
t.Errorf("admin without a view: %v", e)
}
}
+3 -12
View File
@@ -8,7 +8,6 @@ import (
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
"jarl/internal/validate"
)
@@ -227,9 +226,11 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
}
var ops []model.Op
for i, f := range fills {
// Local to the view in a linked zone, like the PTR records that
// follow address records (ptrChange).
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
if z.LinkGroup != 0 {
ri.SharedID = model.Ptr(ids[i])
ri.SharedID = model.Ptr(int64(0))
}
ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
}
@@ -531,16 +532,6 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
// autoPTR reports whether a record's PTR is kept in step by its zone's
// auto_ptr setting.
func autoPTR(z *model.Zone, owner string, p *rr.Parsed) bool {
if z == nil || !z.AutoPTR || strings.HasPrefix(owner, "*.") {
return false
}
_, ok := addrOf(p)
return ok
}
func viewName(st *model.State, id int64) string {
if v := st.Views[id]; v != nil {
return v.Name
+99
View File
@@ -303,3 +303,102 @@ func TestReverseFromOtherView(t *testing.T) {
t.Errorf("warnings %q", res.Warnings)
}
}
// ptrsMarked is ptrs with S (shared) or L (local) after each record.
func (env *testEnv) ptrsMarked(view, zone string) []string {
env.t.Helper()
s := env.state()
z := s.FindZone(zone, s.ViewByName(view).ID)
var out []string
for _, r := range s.ZoneRecords(z.ID) {
if r.Type == "PTR" {
mark := " L"
if r.SharedID != 0 {
mark = " S"
}
out = append(out, strings.TrimSuffix(r.Name, ".10.0.10.in-addr.arpa.")+" "+strings.TrimSuffix(r.Data, ".example.com.")+mark)
}
}
return out
}
// Auto-PTR with linked forward and reverse zones: PTR records follow each
// view's own address records, and are local to their view.
func TestAutoPTRLinked(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("external"), LinkWith: "internal"}))
st := env.state()
for _, v := range []string{"internal", "external"} {
env.submit(env.admin, NewRequest(OpZoneUpdate, st.FindZone("example.com.", st.ViewByName(v).ID).ID, ZoneInput{AutoPTR: model.Ptr(true)}))
}
rz := "10.0.10.in-addr.arpa."
local := func(view, name, data string) Request {
r := recIn(view, name, "A", data)
var in RecordInput
_ = decode(r.Data, &in)
in.Local = model.Ptr(true)
return NewRequest(OpRecordCreate, 0, in)
}
// A local record: a local PTR record in its own view only.
env.submit(env.admin, local("internal", "a", "10.0.10.1"))
want(t, "local a internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "local a external", env.ptrsMarked("external", rz))
// The other view adds the same name (shared, as it has no local
// one): its own PTR record; deleting it in one view leaves the other.
env.submit(env.admin, recIn("external", "a", "A", "10.0.10.1"))
want(t, "both internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "both external", env.ptrsMarked("external", rz), "1 a L")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil))
want(t, "deleted in external: internal", env.ptrsMarked("internal", rz), "1 a L")
want(t, "deleted in external: external", env.ptrsMarked("external", rz))
// Both local; one changes its address, the other keeps its PTR.
env.submit(env.admin, local("external", "a", "10.0.10.1"))
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.10.2")}))
want(t, "moved internal", env.ptrsMarked("internal", rz), "2 a L")
want(t, "moved external", env.ptrsMarked("external", rz), "1 a L")
// Disabling in one view disables there only.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "a", "A"), RecordInput{Disabled: model.Ptr(true)}))
want(t, "disabled external", env.ptrs("external", rz), "1.10.0.10.in-addr.arpa. a.example.com. disabled")
want(t, "disabled: internal", env.ptrsMarked("internal", rz), "2 a L")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "a", "A"), nil))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil))
want(t, "gone internal", env.ptrsMarked("internal", rz))
want(t, "gone external", env.ptrsMarked("external", rz))
// A shared record: a local PTR in each view; deleting it removes both.
env.submit(env.admin, recIn("internal", "b", "A", "10.0.10.3"))
want(t, "shared internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "shared external", env.ptrsMarked("external", rz), "3 b L")
// Overridden in one view with another address: that view's PTR moves.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")}))
want(t, "override internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "override external", env.ptrsMarked("external", rz), "4 b L")
// Sharing internal's version: external gets it back, and its PTR.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "b", "A"), RecordInput{Local: model.Ptr(false)}))
want(t, "shared again internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "shared again external", env.ptrsMarked("external", rz), "3 b L")
// The override deleted: external goes without b (and its PTR);
// internal keeps it, local now.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "b", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.4")}))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("external", "b", "A"), nil))
want(t, "override gone internal", env.ptrsMarked("internal", rz), "3 b L")
want(t, "override gone external", env.ptrsMarked("external", rz))
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "b", "A"), nil))
want(t, "b gone internal", env.ptrsMarked("internal", rz))
// A shared PTR record from before (shared by hand) becomes local in
// each view when one view's address goes.
env.submit(env.admin, recIn("internal", "c", "A", "10.0.10.5"))
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR")}))
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: rz, View: "external", Name: model.Ptr("5"), Type: model.Ptr("PTR")}))
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: rz, View: "internal", Name: model.Ptr("5"), Type: model.Ptr("PTR"), Data: model.Ptr("c.example.com."), Local: model.Ptr(false)}))
want(t, "shared ptr external", env.ptrsMarked("external", rz), "5 c S")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "c", "A"), RecordInput{Local: model.Ptr(true), Data: model.Ptr("10.0.10.6")}))
want(t, "unshared internal", env.ptrsMarked("internal", rz), "5 c L")
want(t, "unshared external", env.ptrsMarked("external", rz), "6 c L")
}
+18 -1
View File
@@ -39,7 +39,8 @@ type Certificate struct {
Comment string `json:"comment"`
// The current certificate: acme source only (a file source's is read
// from its files). The key never leaves the server through the API.
// from its files). The key leaves the server only through the
// administrators' export (GET /certificates/{name}/pem), audited.
CertPEM string `json:"-"`
KeyPEM string `json:"-"`
@@ -61,11 +62,27 @@ type Certificate struct {
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
LastRoutes []string `json:"last_routes,omitempty"`
// OwnerID is the user a certificate for another host belongs to
// (jarl cert get); 0 for the administrators' own.
OwnerID int64 `json:"owner_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
}
// CertificatePull records that someone downloaded a certificate: who
// ("token NAME" or "user NAME"), which one (Serial), when and from where.
type CertificatePull struct {
CertID int64 `json:"-"`
Who string `json:"who"`
UserID int64 `json:"user_id,omitempty"`
TokenID int64 `json:"token_id,omitempty"`
Serial string `json:"serial"`
IP string `json:"ip,omitempty"`
At time.Time `json:"at"`
}
// HasUse reports whether c is used for use.
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
+3
View File
@@ -76,6 +76,9 @@ type User struct {
TOTPSecret string `json:"-"`
TOTPPending string `json:"-"`
TOTPLastStep int64 `json:"-"`
// CertLimit is how many certificates the user may own (jarl cert get);
// nil: certificates.max_per_user in jarl.yaml.
CertLimit *int `json:"cert_limit,omitempty"`
}
// HasTOTP reports whether the user logs in with a TOTP code.
+173
View File
@@ -0,0 +1,173 @@
package server
import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
"os"
"strings"
"testing"
"time"
"jarl/internal/auth"
"jarl/internal/model"
)
// certUser creates a service user with grants and returns a token.
func certUser(t *testing.T, e *apiEnv, name string, grants ...string) (string, *model.User) {
t.Helper()
gs := []model.Grant{}
for _, s := range grants {
g, err := auth.ParseGrant(s)
if err != nil {
t.Fatal(err)
}
gs = append(gs, g)
}
u := &model.User{Username: name, Grants: gs}
if err := e.st.CreateUser(context.Background(), u); err != nil {
t.Fatal(err)
}
tok, pid, sh := auth.NewToken()
if err := e.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: name + "-host", PublicID: pid, SecretHash: sh}); err != nil {
t.Fatal(err)
}
return tok, u
}
// obtained gives a certificate a current certificate, as if the CA had
// signed it.
func obtained(t *testing.T, e *apiEnv, name string, domains ...string) {
t.Helper()
certFile, keyFile := selfSigned(t, t.TempDir(), domains...)
cb, _ := os.ReadFile(certFile)
kb, _ := os.ReadFile(keyFile)
ctx := context.Background()
for i := 0; i < 50 && e.srv.cfg.Certs.Issuing(name) != (time.Time{}); i++ {
time.Sleep(20 * time.Millisecond) // the failing attempt (no CA) ends
}
c, err := e.st.CertificateByName(ctx, name)
if err != nil {
t.Fatal(err)
}
na, nb := time.Now().Add(30*24*time.Hour), time.Now()
c.CertPEM, c.KeyPEM, c.NotAfter, c.NotBefore, c.Serial, c.Failures, c.LastError = string(cb), string(kb), &na, &nb, "s-"+name, 0, ""
if err := e.st.UpdateCertificate(ctx, c); err != nil {
t.Fatal(err)
}
}
func TestCertGet(t *testing.T) {
e := newAPIEnv(t)
withCerts(e)
mx, mxUser := certUser(t, e, "mx", "certificates:write names=*.adbraun.lol")
other, _ := certUser(t, e, "other", "certificates:write names=*.adbraun.lol")
wide, _ := certUser(t, e, "wide", "*:write")
req := func(tok, name string, domains ...string) (map[string]any, int) {
b, status := e.req("POST", "/certificates/"+name+"/request", tok, map[string]any{"domains": domains})
var out map[string]any
_ = jsonUnmarshal(b, &out)
return out, status
}
// "*" doesn't cover certificates.
if _, st := req(wide, "mail", "mail.adbraun.lol"); st != 403 {
t.Errorf("*:write: %d", st)
}
// Names outside the grant.
if out, st := req(mx, "mail", "mail.example.org"); st != 403 || !strings.Contains(errMsg(out), "mail.example.org") {
t.Errorf("other names: %d %v", st, out)
}
// A new certificate, owned by mx.
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 && st != 202 || out["created"] != true {
t.Fatalf("create: %d %v", st, out)
}
c, _ := e.st.CertificateByName(context.Background(), "mail")
if c.OwnerID != mxUser.ID || len(c.Uses) != 0 {
t.Errorf("owner %d uses %v", c.OwnerID, c.Uses)
}
// Another user can't have the name, or see it.
if out, st := req(other, "mail", "mail.adbraun.lol"); st != 409 || !strings.Contains(errMsg(out), "taken") {
t.Errorf("taken: %d %v", st, out)
}
e.do("GET", "/certificates/mail", other, nil, 404)
if l := e.do("GET", "/certificates", other, nil, 200)["certificates"].([]any); len(l) != 0 {
t.Errorf("other's list: %v", l)
}
// Nothing to download until it is obtained.
e.do("GET", "/certificates/mail/pem", mx, nil, 400)
obtained(t, e, "mail", "mail.adbraun.lol")
// The same names: the same certificate.
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 || out["created"] != false || out["changed"] != false {
t.Errorf("again: %d %v", st, out)
}
if c2, _ := e.st.CertificateByName(context.Background(), "mail"); c2.Serial != "s-mail" {
t.Errorf("replaced: %v", c2.Serial)
}
// Download: recorded, with the token.
if b, st := e.req("GET", "/certificates/mail/pem?part=key", mx, nil); st != 200 || !strings.Contains(string(b), "PRIVATE KEY") {
t.Errorf("key: %d", st)
}
got := e.do("GET", "/certificates/mail", e.token, nil, 200)
pulls, _ := got["pulled_by"].([]any)
if got["owner"] != "mx" || len(pulls) != 1 || !strings.Contains(pulls[0].(map[string]any)["who"].(string), "mx-host") || pulls[0].(map[string]any)["serial"] != "s-mail" {
t.Errorf("owner %v pulled_by %v", got["owner"], got["pulled_by"])
}
// A name added: obtained again.
if out, st := req(mx, "mail", "mail.adbraun.lol", "smtp.adbraun.lol"); out["changed"] != true || st != 200 && st != 202 {
t.Errorf("SAN added: %d %v", st, out)
}
// The owner's own list.
if l := e.do("GET", "/certificates", mx, nil, 200)["certificates"].([]any); len(l) != 1 {
t.Errorf("mx's list: %v", l)
}
// Jarl's own certificates are the administrators'.
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.adbraun.lol")
e.do("POST", "/certificates", e.token, map[string]any{"name": "web", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
if _, st := req(mx, "web", "jarl.adbraun.lol"); st != 409 {
t.Errorf("Jarl's own: %d", st)
}
e.do("GET", "/certificates/web/pem", mx, nil, 404)
// The limit: one more for mx, then no more.
limit := 2
mxUser.CertLimit = &limit
if err := e.st.UpdateUser(context.Background(), mxUser); err != nil {
t.Fatal(err)
}
if out, st := req(mx, "www", "www.adbraun.lol"); st != 200 && st != 202 {
t.Errorf("second: %d %v", st, out)
}
if out, st := req(mx, "third", "x.adbraun.lol"); st != 400 || !strings.Contains(errMsg(out), "limit") {
t.Errorf("over the limit: %d %v", st, out)
}
// The owner may delete theirs.
e.do("DELETE", "/certificates/www", mx, nil, 200)
}
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// errMsg is an error answer's message.
func errMsg(out map[string]any) string {
e, _ := out["error"].(map[string]any)
m, _ := e["message"].(string)
return m
}
// A private key goes back only over HTTPS or to this server itself.
func TestKeyTransport(t *testing.T) {
e := newAPIEnv(t)
for addr, want := range map[string]bool{"127.0.0.1:5000": true, "[::1]:5000": true, "192.0.2.7:5000": false} {
r, _ := http.NewRequest("GET", "/x", nil)
r.RemoteAddr = addr
if ok, why := e.srv.keyTransport(r); ok != want {
t.Errorf("%s: %v (%s)", addr, ok, why)
}
}
r, _ := http.NewRequest("GET", "/x", nil)
r.RemoteAddr, r.TLS = "192.0.2.7:5000", &tls.ConnectionState{}
if ok, _ := e.srv.keyTransport(r); !ok {
t.Error("HTTPS refused")
}
}
+37 -1
View File
@@ -45,7 +45,7 @@ func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile st
func withCerts(env *apiEnv) *certs.Manager {
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir", MaxPerUser: 10}, env.st, env.srv.eng, quiet)
env.srv.cfg.Certs = m
return m
}
@@ -165,3 +165,39 @@ func TestHTTPSAndPlainOnOnePort(t *testing.T) {
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
}
}
// A certificate's files can be exported for use elsewhere; the key only
// by an administrator, audited.
func TestCertificateExport(t *testing.T) {
env := newAPIEnv(t)
withCerts(env)
certFile, keyFile := selfSigned(t, t.TempDir(), "mail.example.com")
env.do("POST", "/certificates", env.token, map[string]any{"name": "mail", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
chain, _ := os.ReadFile(certFile)
key, _ := os.ReadFile(keyFile)
for part, want := range map[string]string{"": string(chain), "fullchain": string(chain), "cert": string(chain), "key": string(key), "bundle": string(key) + "\n" + string(chain)} {
b, status := env.req("GET", "/certificates/mail/pem?part="+part, env.token, nil)
if status != 200 || string(b) != want {
t.Errorf("part %q: %d %q", part, status, b)
}
}
if _, status := env.req("GET", "/certificates/mail/pem?part=nope", env.token, nil); status != 400 {
t.Errorf("bad part: %d", status)
}
if _, status := env.req("GET", "/certificates/none/pem", env.token, nil); status != 404 {
t.Errorf("no such certificate: %d", status)
}
// Not for a user who isn't an administrator.
hash, _ := auth.HashPassword("pw-pw-pw-pw")
u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}}
env.st.CreateUser(context.Background(), u)
tok, pid, sh := auth.NewToken()
env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh})
if _, status := env.req("GET", "/certificates/mail/pem?part=key", tok, nil); status != 403 {
t.Errorf("non-admin: %d", status)
}
audit, _ := env.req("GET", "/audit?action=certificate.export_key", env.token, nil)
if n := strings.Count(string(audit), "certificate.export_key"); n != 2 {
t.Errorf("audited key exports: %d (%s)", n, audit)
}
}
+95
View File
@@ -0,0 +1,95 @@
package server
import (
"strings"
"testing"
)
// The DNSSEC page says where each zone's DS record goes.
func TestDNSSECWhereDSGoes(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
zone := func(name, view string) {
e.do("POST", "/zones", e.token, map[string]any{"name": name, "view": view, "nameservers": []string{"ns1.example.net."}}, 201)
}
zone("adbraun.lol", "external")
zone("lab.adbraun.lol", "external")
zone("dev.adbraun.lol", "internal") // its parent only in external
zone("sub.example.com", "external")
zone("10.0.10.0/24", "internal")
zone("home.lan", "internal")
for _, c := range []struct{ zone, at, place, parent string }{
{"adbraun.lol?view=external", "registrar", "at your registrar", ""},
{"lab.adbraun.lol?view=external", "parent_zone", "in the parent zone adbraun.lol", "adbraun.lol."},
{"dev.adbraun.lol?view=internal", "parent_zone", "in the parent zone adbraun.lol (view external)", "adbraun.lol."},
{"sub.example.com?view=external", "parent", "in its parent zone (most likely example.com)", ""},
{"10.0.10.in-addr.arpa?view=internal", "reverse", "your ISP", ""},
{"home.lan?view=internal", "none", "", ""},
} {
r := e.do("GET", "/zones/"+strings.Replace(c.zone, "?", "/dnssec?", 1), e.token, nil, 200)
place, _ := r["ds_place"].(string)
parent, _ := r["parent_zone"].(string)
if r["ds_at"] != c.at || !strings.Contains(place, c.place) || (c.place == "" && place != "") || parent != c.parent {
t.Errorf("%s: ds_at %v, ds_place %q, parent_zone %q", c.zone, r["ds_at"], place, parent)
}
}
}
// A zone lists the zones of its name in the other views.
func TestZoneInViews(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."}}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "external", "nameservers": []string{"ns1.example.net."}}, 201)
z := e.do("GET", "/zones/adbraun.lol?view=internal", e.token, nil, 200)
in, _ := z["in_views"].([]any)
if len(in) != 1 || in[0].(map[string]any)["view"] != "external" || in[0].(map[string]any)["type"] != "primary" {
t.Errorf("in_views = %v", z["in_views"])
}
}
// A token for records in one view of a zone that is in two: the zone is
// found without naming the view, and view list names the views it can
// see zones in, without their settings.
func TestOneViewToken(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
e.do("POST", "/views", e.token, map[string]any{"name": "external"}, 201)
for _, v := range []string{"internal", "external"} {
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": v, "nameservers": []string{"ns1.example.net."}}, 201)
}
e.do("POST", "/users", e.token, map[string]any{"username": "certbot", "password": "", "grant_strings": []string{"records:write zones=adbraun.lol views=external names=_acme-challenge.* types=TXT"}}, 201)
var uid int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "certbot" {
uid = int64(u["id"].(float64))
}
}
tok := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "certbot"}, 201)["token"].(string)
if z := e.do("GET", "/zones/adbraun.lol", tok, nil, 200); z["view"] != "external" {
t.Errorf("zone without a view: %v", z["view"])
}
e.do("GET", "/zones/adbraun.lol/records", tok, nil, 200)
views := e.list("/views", tok)
if len(views) != 1 || views[0]["name"] != "external" || views[0]["limited"] != true || views[0]["match_clients"] != nil {
t.Errorf("views = %v", views)
}
// The admin still has to say which.
e.do("GET", "/zones/adbraun.lol", e.token, nil, 400)
}
// A reverse zone lists the PTR records it lacks, not just how many.
func TestPTRMissingList(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "nameservers": []string{"ns1.example.net."},
"records": []map[string]any{{"name": "nas", "type": "A", "data": "10.60.60.5"}}}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "10.60.60.0/24", "nameservers": []string{"ns1.example.net."}}, 201)
z := e.do("GET", "/zones/60.60.10.in-addr.arpa", e.token, nil, 200)
list, _ := z["ptr_missing_list"].([]any)
if z["ptr_missing"] != float64(1) || len(list) != 1 || list[0].(map[string]any)["address"] != "10.60.60.5" || list[0].(map[string]any)["target"] != "nas.adbraun.lol." {
t.Errorf("ptr_missing %v, list %v", z["ptr_missing"], z["ptr_missing_list"])
}
}
+6
View File
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
if in.Comment != nil {
u.Comment = *in.Comment
}
if in.CertLimit != nil {
u.CertLimit = in.CertLimit
if *in.CertLimit < 0 {
u.CertLimit = nil
}
}
if in.Password != nil {
if *in.Password == "" {
u.PasswordHash = "" // service account: tokens only
+239 -5
View File
@@ -2,8 +2,13 @@ package server
import (
"context"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"time"
"jarl/internal/api"
@@ -27,6 +32,10 @@ type certView struct {
NamedUse []string `json:"named_use,omitempty"`
// IssuingSince is set while Jarl is obtaining it.
IssuingSince *time.Time `json:"issuing_since,omitempty"`
// Owner is the user a certificate for another host belongs to; PulledBy
// who downloaded it (list and get, administrators only).
Owner string `json:"owner,omitempty"`
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
}
type certFiles struct {
@@ -61,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
return nil
}
// certsAccess lets in administrators and users with a certificates grant
// (certificates for other hosts: their own only).
func (s *Server) certsAccess(p *auth.Principal) error {
if s.cfg.Certs == nil {
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
}
if p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResCerts}) {
return nil
}
return forbidden("certificates need an administrator, or a certificates grant for their names (certificates:write names=*.example.com)")
}
// certAllowed reports whether p may see (or, with write, change) c: an
// administrator any; anyone else their own, while their grants cover all
// its names.
func certAllowed(p *auth.Principal, c *model.Certificate, write bool) bool {
if p.IsAdmin() {
return true
}
if p.User == nil || c.OwnerID == 0 || c.OwnerID != p.User.ID {
return false
}
for _, d := range c.Domains {
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: write, Name: d}) {
return false
}
}
return true
}
// certFor returns the certificate in the path, if p may see it (else not
// found, so names of others' certificates don't leak).
func (s *Server) certFor(r *http.Request, p *auth.Principal, write bool) (*model.Certificate, error) {
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
if err != nil {
return nil, certErr(err)
}
if !certAllowed(p, c, write) {
if write && certAllowed(p, c, false) {
return nil, forbidden("you may only download certificate %s", c.Name)
}
return nil, notFound("no such certificate")
}
return c, nil
}
// withOwners adds owners and downloads to views (for administrators).
func (s *Server) withOwners(ctx context.Context, views []certView) error {
pulls, err := s.store.CertificatePulls(ctx)
if err != nil {
return err
}
users, err := s.store.ListUsers(ctx)
if err != nil {
return err
}
names := map[int64]string{}
for _, u := range users {
names[u.ID] = u.Username
}
for i := range views {
if id := views[i].OwnerID; id != 0 {
views[i].Owner = names[id]
if views[i].Owner == "" {
views[i].Owner = fmt.Sprintf("deleted user %d", id)
}
}
views[i].PulledBy = pulls[views[i].ID]
}
return nil
}
func certErr(err error) error {
var ie *certs.InputError
switch {
@@ -70,13 +151,15 @@ func certErr(err error) error {
return notFound("no such certificate")
case errors.Is(err, certs.ErrConflict):
return conflict("a certificate with that name exists")
case errors.Is(err, certs.ErrTaken):
return conflict("the certificate name is taken by another user's certificate (or one Jarl uses): choose another name")
}
return err
}
// certList: GET /certificates.
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsReady(p); err != nil {
if err := s.certsAccess(p); err != nil {
return err
}
all, err := s.cfg.Certs.List(r.Context())
@@ -89,22 +172,32 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
}
out := []certView{}
for _, c := range all {
if !certAllowed(p, c, false) {
continue
}
v := s.presentCert(c)
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
out = append(out, v)
}
if !p.IsAdmin() {
writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
return nil
}
if err := s.withOwners(r.Context(), out); err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
return nil
}
// certGet: GET /certificates/{name}.
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsReady(p); err != nil {
if err := s.certsAccess(p); err != nil {
return err
}
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
c, err := s.certFor(r, p, false)
if err != nil {
return certErr(err)
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
@@ -112,10 +205,123 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
}
v := s.presentCert(c)
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
if p.IsAdmin() {
vs := []certView{v}
if err := s.withOwners(r.Context(), vs); err != nil {
return err
}
v = vs[0]
}
writeJSON(w, http.StatusOK, v)
return nil
}
// certRequest: POST /certificates/{name}/request {"domains": [...],
// "key_type": "ec256"}, for jarl cert get: the certificate for another
// host, owned by the caller's user. A new one is created and obtained
// (202 while that runs; poll GET /certificates/{name} until
// issuing_since is gone). An existing one is the caller's, obtained again
// only when its names change; otherwise it is returned as it is (200).
func (s *Server) certRequest(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsAccess(p); err != nil {
return err
}
if p.User == nil || p.User.ID == 0 {
return forbidden("certificates for other hosts belong to a user: use an API token, not the local socket as root")
}
var in struct {
Domains []string `json:"domains"`
KeyType string `json:"key_type"`
}
if err := decodeJSON(r, &in); err != nil {
return err
}
for _, d := range in.Domains {
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: true, Name: d}) {
return forbidden("you may not get a certificate for %s: that needs certificates:write with a names pattern that matches it", d)
}
}
res, err := s.cfg.Certs.Ensure(r.Context(), r.PathValue("name"), in.Domains, in.KeyType, p)
if err != nil {
return certErr(err)
}
v := s.presentCert(res.Certificate)
out := map[string]any{"certificate": v, "created": res.Created, "changed": res.Changed}
if res.Ignored != "" {
out["warnings"] = []string{res.Ignored}
}
status := http.StatusOK
if v.IssuingSince != nil {
status = http.StatusAccepted
}
writeJSON(w, status, out)
return nil
}
// certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle,
// the certificate as PEM for use elsewhere: the chain (default), the
// certificate alone, the private key, or the key followed by the chain
// (one file, as HAProxy wants). Anything with the key is audited.
func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsAccess(p); err != nil {
return err
}
if _, err := s.certFor(r, p, false); err != nil {
return err
}
part := r.URL.Query().Get("part")
if part == "" {
part = "fullchain"
}
if part == "key" || part == "bundle" {
if ok, why := s.keyTransport(r); !ok {
return &httpError{http.StatusForbidden, "https_required", "a private key is only handed out over HTTPS (or to this server itself), and this request came by " + why}
}
}
c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name"))
if err != nil {
return certErr(err)
}
var out []byte
file := c.Name + "-" + part + ".pem"
switch part {
case "fullchain":
out = chain
case "cert":
if b, _ := pem.Decode(chain); b != nil {
out = pem.EncodeToMemory(b)
}
case "key":
out = key
case "bundle":
out = append(append(append([]byte{}, key...), '\n'), chain...)
default:
return badRequest("part must be fullchain, cert, key or bundle")
}
if len(out) == 0 {
return badRequest("certificate %s has no %s", c.Name, part)
}
if part == "key" || part == "bundle" {
s.eng.Audit(r.Context(), p, "certificate.export_key", "certificate", c.ID, c.Name, "success", "exported the private key ("+part+")")
}
pull := &model.CertificatePull{CertID: c.ID, Who: pullWho(p), Serial: c.Serial, IP: s.clientIP(r), At: time.Now().UTC()}
if p.User != nil {
pull.UserID = p.User.ID
}
if p.Token != nil {
pull.TokenID = p.Token.ID
}
if err := s.store.RecordCertificatePull(r.Context(), pull); err != nil {
s.log.Warn("recording a certificate download failed", "certificate", c.Name, "err", err)
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
w.Header().Set("Cache-Control", "no-store")
_, err = w.Write(out)
return err
}
// certCreate: POST /certificates. With "issue": true (the default for
// certificates Jarl obtains) it is obtained before the answer.
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
@@ -162,7 +368,10 @@ func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Prin
// certDelete: DELETE /certificates/{name}.
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.certsReady(p); err != nil {
if err := s.certsAccess(p); err != nil {
return err
}
if _, err := s.certFor(r, p, true); err != nil {
return err
}
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
@@ -257,3 +466,28 @@ func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
}
return out
}
// keyTransport reports whether a private key may go back over r: HTTPS
// (also through a trusted proxy), the local socket, or loopback.
func (s *Server) keyTransport(r *http.Request) (bool, string) {
https, why := s.transport(r)
if https {
return true, why
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if a, err := netip.ParseAddr(host); err == nil && a.Unmap().IsLoopback() && !s.trusted(a.Unmap()) {
return true, "plain HTTP from this server"
}
return false, why
}
// pullWho names a downloader: its token (and user), or the user.
func pullWho(p *auth.Principal) string {
switch {
case p.Token != nil:
return "token " + p.Token.Name + " (" + p.Name() + ")"
case p.User != nil && p.Method != "":
return p.Method + " " + p.Name()
}
return p.Name()
}
+2
View File
@@ -57,6 +57,8 @@ func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Princ
for _, v := range st.OrderedViews() {
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
out = append(out, presentView(st, v, colors))
} else if lv := limitedView(st, v, colors, p); lv != nil {
out = append(out, *lv)
}
}
writeJSON(w, http.StatusOK, out)
+45 -6
View File
@@ -36,24 +36,49 @@ func (s *Server) zoneDNSSEC(w http.ResponseWriter, r *http.Request, p *auth.Prin
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 {
public := map[int64]bool{}
for _, e := range s.eng.Exposures(st) {
public[e.ViewID] = e.Public
}
name := dnsname.Display(z.Name)
reg, regOK := dnsname.RegisteredDomain(z.Name)
switch pz := parentInJarl(st, z, public); {
case pz != nil:
resp.DSAt = "parent_zone"
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
for _, rec := range st.ServedRecords(pz.ID) {
if rec.Name == z.Name && rec.Type == "DS" {
resp.ParentDS = append(resp.ParentDS, rec.Data)
}
}
resp.DSPlace = "in the parent zone " + dnsname.Display(pz.Name)
if pz.ViewID != z.ViewID {
resp.DSPlace += " (view " + st.ViewName(pz) + ")"
}
case dnsname.IsReverse(z.Name):
resp.DSAt = "reverse"
resp.DSPlace = "with whoever delegates " + name + " to you (your ISP, or your RIR for an address block of your own; many ISPs don't take DS records)"
case !regOK:
resp.DSAt = "none"
resp.DSNote = "No zone on the internet delegates " + name + ", so it can't get a DS record and resolvers never validate it. Signing only matters for a zone on the internet."
case reg == name:
resp.DSAt, resp.DSPlace = "registrar", "at your registrar"
default:
resp.DSAt = "parent"
resp.DSPlace = "in its parent zone (most likely " + reg + "), wherever that zone's DNS is hosted (at Porkbun: " + reg + "'s DNS records, as a DS record named " + strings.TrimSuffix(name, "."+reg) + ")"
}
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 {
// parentInJarl returns the primary zone in Jarl that z is delegated from:
// in z's own view, else in another view, a public one first (the one the
// internet asks).
func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone {
in := func(viewID int64) *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) {
for _, o := range st.ZonesInView(viewID) {
if o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
continue
}
if best == nil || len(o.Name) > len(best.Name) {
@@ -62,6 +87,20 @@ func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
}
return best
}
if pz := in(z.ViewID); pz != nil {
return pz
}
var other *model.Zone
for _, v := range st.OrderedViews() {
if v.ID == z.ViewID {
continue
}
if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) {
other = pz
}
}
return other
}
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
func (s *Server) dsChecker(r *http.Request) (*nscheck.Checker, error) {
+16 -1
View File
@@ -35,11 +35,16 @@ import (
)
// registrarFor names the registrar Jarl can manage z's delegation at:
// "porkbun" when a key is configured and allows the zone's name.
// "porkbun" when a key is configured and allows the zone's name, and the
// zone is a registered domain (not a subdomain or a reverse zone, which
// their parent zone delegates).
func (s *Server) registrarFor(z *model.Zone) string {
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
return ""
}
if d, ok := dnsname.RegisteredDomain(z.Name); !ok || d != dnsname.Display(z.Name) {
return ""
}
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
return ""
}
@@ -59,6 +64,16 @@ func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State
return nil, nil, "", err
}
if s.registrarFor(z) == "" {
d, ok := dnsname.RegisteredDomain(z.Name)
switch {
case s.cfg.Porkbun == nil:
case dnsname.IsReverse(z.Name):
return nil, nil, "", badRequest("%s is a reverse zone: whoever holds the addresses (your ISP, or the RIR) delegates it, not a registrar. Check its delegation on the zone's Name servers tab (jarl zone delegation ZONE)", dnsname.Display(z.Name))
case !ok:
return nil, nil, "", badRequest("%s isn't a domain registered at a registrar (its suffix isn't a public one), so there is nothing to manage at Porkbun", dnsname.Display(z.Name))
case d != dnsname.Display(z.Name):
return nil, nil, "", badRequest("%s is a subdomain of %s: the NS (and DS) records in the zone %s delegate it, not the registrar. Manage %s at Porkbun instead", dnsname.Display(z.Name), d, d, d)
}
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)")
}
+1 -1
View File
@@ -34,7 +34,7 @@ func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal)
name = "."
}
var err error
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"))
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"), func(z *model.Zone) bool { return p.CanSeeZone(z.Name, st.ViewName(z)) })
if err != nil {
return nil, err
}
+30 -1
View File
@@ -29,10 +29,23 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
}
}
}
if z.ViewID != 0 {
others := st.ZonesNamed(z.Name)
st.SortByView(others)
for _, o := range others {
if o.ID != z.ID {
out.InViews = append(out.InViews, api.ZoneLink{ID: o.ID, View: st.ViewName(o), Type: o.Type, Linked: z.LinkGroup != 0 && o.LinkGroup == z.LinkGroup})
}
}
}
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
out.Prefix = pf.String()
if z.Type == model.ZonePrimary {
out.PTRMissing = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID)))
fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID))
out.PTRMissing = len(fills)
for _, f := range fills[:min(len(fills), 25)] {
out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f))
}
}
}
for _, o := range st.LinkedWith(z) {
@@ -73,6 +86,22 @@ func presentView(st *model.State, v *model.View, colors map[int64]string) api.Vi
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID)), Color: colors[v.ID]}
}
// limitedView presents a view to a principal without views:read who can
// see zones in it (the zone list names the view anyway): its name and
// colour, and how many of its zones they can see. nil when they see none.
func limitedView(st *model.State, v *model.View, colors map[int64]string, p *auth.Principal) *api.View {
n := 0
for _, z := range st.ZonesInView(v.ID) {
if p.CanSeeZone(z.Name, v.Name) {
n++
}
}
if n == 0 {
return nil
}
return &api.View{View: model.View{ID: v.ID, Name: v.Name, Position: v.Position}, Zones: n, Color: colors[v.ID], Limited: true}
}
func presentACL(st *model.State, a *model.ACL) api.ACL {
used := engine.FindACLRefs(st, a.Name)
if used == nil {
+26
View File
@@ -170,3 +170,29 @@ func writeKey(t *testing.T, dir, zone string) uint16 {
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()
}
// With no porkbun.domains, only zones that are registered domains get the
// registrar: not subdomains or reverse zones, which their parent delegates.
func TestRegistrarOnlyRegisteredDomains(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"}})
for _, z := range []string{"adbraun.lol", "lab.adbraun.lol", "10.0.10.0/24", "home.lan"} {
e.do("POST", "/zones", e.token, map[string]any{"name": z, "nameservers": []string{"ns1.example.net."}}, 201)
}
for zone, want := range map[string]string{"adbraun.lol": "porkbun", "lab.adbraun.lol": "", "10.0.10.in-addr.arpa": "", "home.lan": ""} {
got, _ := e.do("GET", "/zones/"+zone, e.token, nil, 200)["registrar"].(string)
if got != want {
t.Errorf("%s: registrar %q, want %q", zone, got, want)
}
}
for zone, msg := range map[string]string{"lab.adbraun.lol": "subdomain of adbraun.lol", "10.0.10.in-addr.arpa": "reverse zone", "home.lan": "isn't a domain registered"} {
out := e.do("GET", "/zones/"+zone+"/registrar", e.token, nil, 400)
errObj, _ := out["error"].(map[string]any)
if m, _ := errObj["message"].(string); !strings.Contains(m, msg) {
t.Errorf("%s: %v", zone, out)
}
}
e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
}
+2
View File
@@ -174,6 +174,8 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM))
m.HandleFunc("POST "+p+"/certificates/{name}/request", s.authed(s.certRequest))
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
+35 -5
View File
@@ -12,7 +12,7 @@ import (
const certCols = `id, name, source, domains, cert_file, key_file, uses, challenge, key_type, comment, cert_pem, key_pem, names,
not_before, not_after, issuer, serial, renew_at, ari_checked_at, last_attempt, last_success, last_error, failures, last_routes,
created_at, updated_at, created_by, updated_by`
created_at, updated_at, created_by, updated_by, owner_id`
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
c := &model.Certificate{}
@@ -20,7 +20,7 @@ func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
var nb, na, ra, ac, la, ls sql.NullString
if err := sc.Scan(&c.ID, &c.Name, &c.Source, &domains, &c.CertFile, &c.KeyFile, &uses, &c.Challenge, &c.KeyType, &c.Comment,
&c.CertPEM, &c.KeyPEM, &names, &nb, &na, &c.Issuer, &c.Serial, &ra, &ac, &la, &ls, &c.LastError, &c.Failures, &routes,
&ca, &ua, &c.CreatedBy, &c.UpdatedBy); err != nil {
&ca, &ua, &c.CreatedBy, &c.UpdatedBy, &c.OwnerID); err != nil {
return nil, err
}
for _, f := range []struct {
@@ -71,7 +71,7 @@ func (s *Store) CertificateByName(ctx context.Context, name string) (*model.Cert
func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error {
now := time.Now().UTC()
c.CreatedAt, c.UpdatedAt = now, now
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(27)+`)`, certArgs(c)...)
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(28)+`)`, certArgs(c)...)
if isUnique(err) {
return ErrConflict
}
@@ -87,7 +87,7 @@ func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) err
c.UpdatedAt = time.Now().UTC()
res, err := s.db.ExecContext(ctx, `UPDATE certificates SET name=?, source=?, domains=?, cert_file=?, key_file=?, uses=?, challenge=?, key_type=?,
comment=?, cert_pem=?, key_pem=?, names=?, not_before=?, not_after=?, issuer=?, serial=?, renew_at=?, ari_checked_at=?, last_attempt=?,
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=? WHERE id=?`,
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=?, owner_id=? WHERE id=?`,
append(certArgs(c), c.ID)...)
if isUnique(err) {
return ErrConflict
@@ -123,5 +123,35 @@ func certArgs(c *model.Certificate) []any {
return []any{c.Name, c.Source, list(c.Domains), c.CertFile, c.KeyFile, list(c.Uses), c.Challenge, c.KeyType, c.Comment,
c.CertPEM, c.KeyPEM, list(c.Names), tsPtr(c.NotBefore), tsPtr(c.NotAfter), c.Issuer, c.Serial, tsPtr(c.RenewAt),
tsPtr(c.ARICheckedAt), tsPtr(c.LastAttempt), tsPtr(c.LastSuccess), c.LastError, c.Failures, list(c.LastRoutes),
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy}
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy, c.OwnerID}
}
// RecordCertificatePull notes a download of a certificate, replacing the
// same downloader's earlier one.
func (s *Store) RecordCertificatePull(ctx context.Context, p *model.CertificatePull) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO certificate_pulls (cert_id, who, user_id, token_id, serial, ip, at) VALUES (?,?,?,?,?,?,?)
ON CONFLICT (cert_id, who) DO UPDATE SET user_id = excluded.user_id, token_id = excluded.token_id, serial = excluded.serial, ip = excluded.ip, at = excluded.at`,
p.CertID, p.Who, p.UserID, p.TokenID, p.Serial, p.IP, ts(p.At))
return err
}
// CertificatePulls returns who downloaded each certificate, newest first,
// by certificate ID.
func (s *Store) CertificatePulls(ctx context.Context) (map[int64][]model.CertificatePull, error) {
rows, err := s.db.QueryContext(ctx, `SELECT cert_id, who, user_id, token_id, serial, ip, at FROM certificate_pulls ORDER BY at DESC`)
if err != nil {
return nil, err
}
out := map[int64][]model.CertificatePull{}
for rows.Next() {
var p model.CertificatePull
var at string
if err := rows.Scan(&p.CertID, &p.Who, &p.UserID, &p.TokenID, &p.Serial, &p.IP, &at); err != nil {
rows.Close()
return nil, err
}
p.At = parseTS(at)
out[p.CertID] = append(out[p.CertID], p)
}
return out, closeRows(rows)
}
+16
View File
@@ -384,6 +384,22 @@ ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
`
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
`,
// 14: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts):
// an owner, a per-user limit, and who downloaded them.
`
ALTER TABLE certificates ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN cert_limit INTEGER;
CREATE TABLE certificate_pulls (
cert_id INTEGER NOT NULL REFERENCES certificates(id) ON DELETE CASCADE,
who TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
token_id INTEGER NOT NULL DEFAULT 0,
serial TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
at TEXT NOT NULL,
PRIMARY KEY (cert_id, who)
);
`,
}
+11 -6
View File
@@ -18,16 +18,21 @@ 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, totp_secret, totp_pending, totp_enabled_at, totp_last_step`
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, cert_limit`
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u := &model.User{}
var grants, ca, ua string
var admin, disabled int
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 {
var limit sql.NullInt64
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, &limit); err != nil {
return nil, err
}
if limit.Valid {
n := int(limit.Int64)
u.CertLimit = &n
}
u.Admin, u.Disabled = admin != 0, disabled != 0
u.HasPassword = u.PasswordHash != ""
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
@@ -48,8 +53,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
if u.Grants == nil {
u.Grants = []model.Grant{}
}
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at, cert_limit) VALUES (?,?,?,?,?,?,?,?,?)`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now), u.CertLimit)
if isUnique(err) {
return ErrConflict
}
@@ -64,8 +69,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
// UpdateUser saves a user.
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
u.UpdatedAt = time.Now()
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID)
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ?, cert_limit = ? WHERE id = ?`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.CertLimit, u.ID)
if isUnique(err) {
return ErrConflict
}
+12 -4
View File
@@ -385,8 +385,16 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
if !ix.inZoneTarget(target) {
return
}
// Follow the chain inside the zone to detect loops.
// Follow the chain inside the zone to detect loops. One CNAME to
// another is common and costs nothing (named answers the chain in
// one response); a longer chain gets a note.
seen := map[string]bool{r.Name: true}
chain := []string{disp(r.Name), disp(target)}
note := func() {
if len(chain) > 3 {
c.recProblem(z, r, Info, "cname_chain", "%s reaches its address through %d CNAMEs (%s); fine, but each one is another lookup for resolvers that don't get the chain at once", disp(r.Name), len(chain)-1, strings.Join(chain, " → "))
}
}
cur := target
for hops := 0; hops < 20; hops++ {
if seen[cur] {
@@ -398,18 +406,18 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
if len(next) == 0 {
break
}
if hops == 0 {
c.recProblem(z, r, Warning, "cname_chain", "%s is a CNAME to %s, which is itself a CNAME; chains slow down resolution", disp(r.Name), disp(target))
}
p := ix.parsed[next[0].ID]
if p == nil {
break
}
cur = p.RR.(*dns.CNAME).Target
chain = append(chain, disp(cur))
if !ix.inZoneTarget(cur) {
note()
return
}
}
note()
if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) {
c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target))
}
+16 -2
View File
@@ -161,8 +161,9 @@ func TestRecordConflicts(t *testing.T) {
}, "cname_loop", Error},
{"cname chain", func(b *builder, z *model.Zone) {
b.rec(z, "a", "CNAME", "b")
b.rec(z, "b", "CNAME", "ns1")
}, "cname_chain", Warning},
b.rec(z, "b", "CNAME", "c")
b.rec(z, "c", "CNAME", "ns1")
}, "cname_chain", Info},
{"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning},
{"null mx not alone", func(b *builder, z *model.Zone) {
b.rec(z, "@", "MX", "0 .")
@@ -380,3 +381,16 @@ func TestProblemStringDisplaysZone(t *testing.T) {
t.Errorf("String() = %q, want %q", got, want)
}
}
// One CNAME to another is common and says nothing.
func TestShortCNAMEChain(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
b.rec(z, "songshare", "CNAME", "navidrome")
b.rec(z, "navidrome", "CNAME", "ns1")
for _, p := range State(b.s, Options{}) {
if p.Code == "cname_chain" {
t.Errorf("one extra CNAME: %s %s", p.Severity, p.Message)
}
}
}
+2 -2
View File
@@ -283,7 +283,7 @@ export function heatmap(hm, unit) {
const n = hm.samples[d][hr];
const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" :
day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)");
const cell = h("div", { class: "hm-cell " + (v < 0 ? "empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
const cell = h("div", { class: "hm-cell " + (v < 0 ? "hm-empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
const showTip = () => {
tip.textContent = text;
tip.hidden = false;
@@ -313,7 +313,7 @@ export function heatmap(hm, unit) {
h("span", { class: "muted small" }, "0"),
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"),
h("span", { class: "hm-swatch empty" }), h("span", { class: "muted small" }, "no data"));
h("span", { class: "hm-swatch hm-empty" }), h("span", { class: "muted small" }, "no data"));
return h("div", { class: "heatmap-wrap" }, grid, tip, scale);
}
+1 -1
View File
@@ -9,7 +9,7 @@ export async function render(el) {
const tokensEl = h("div");
const drawTokens = async () => {
const tokens = await get("/me/tokens");
mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens));
mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens, { admin: me.admin, grants: me.user.grants }));
};
const cur = input({ type: "password", autocomplete: "current-password" });
const pw1 = input({ type: "password", autocomplete: "new-password" });
@@ -32,10 +32,10 @@ export async function render(el) {
{ label: "Name", render: (c) => h("a", { href: "#", onclick: (e) => { e.preventDefault(); details(c); } }, h("strong", null, c.name)) },
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
{ label: "Used for", render: (c) => [
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
c.uses.includes("dns") ? [badge("named", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))),
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
c.uses.includes("web") ? [badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : ""), " "] : null,
c.uses.includes("dns") ? namedUse(c) : null,
!c.uses.length ? h("span", { class: "muted", title: "Export it to use it in another program" }, "not used by Jarl") : null] },
{ label: "Owner", render: (c) => c.owner ? h("span", null, c.owner, pulledBy(c)) : h("span", { class: "muted" }, "Jarl") },
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
{ label: "Status", render: (c) => {
@@ -45,6 +45,7 @@ export async function render(el) {
{ label: "", render: (c) => h("span", null,
h("button", { class: "small", disabled: !!c.issuing_since, title: c.source === "acme" ? "Obtain a new certificate now" : "Reread the files now", onclick: () => issue(c) }, c.source === "acme" ? "Renew" : "Reread"), " ",
h("button", { class: "small", onclick: () => editDialog(c, a) }, "Edit"), " ",
c.not_after ? [h("button", { class: "small", title: "Download it to use in another program", onclick: () => exportDialog(c) }, "Export…"), " "] : null,
h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" },
], r.certificates, { empty: "No certificates yet." })));
@@ -64,6 +65,24 @@ export async function render(el) {
}
}
// pulledBy says who downloaded a certificate for another host, and
// whether they have the current one.
function pulledBy(c) {
const ps = c.pulled_by || [];
if (!ps.length) return h("div", { class: "small muted" }, "not downloaded yet");
return ps.map((p) => h("div", { class: "small " + (p.serial === c.serial ? "muted" : "warn-text"), title: p.who + " from " + (p.ip || "?") + ", " + fmtTime(p.at) },
p.who.replace(/^token /, "") + ", " + fmtTime(p.at) + (p.serial === c.serial ? "" : " (old: not fetched since the renewal)")));
}
// namedUse is what named uses a certificate for: DoT and DoH, with the
// addresses in the tooltip (the details list them).
function namedUse(c) {
const uses = c.named_use || [];
if (!uses.length) return h("span", { class: "muted", title: "Encrypted DNS sets up named's DNS over TLS and HTTPS" }, "named: not set up");
const kinds = [["DNS over TLS", "DoT"], ["DNS over HTTPS", "DoH"]].filter(([long]) => uses.some((u) => u.startsWith(long)));
return h("span", { title: uses.join("\n") }, kinds.map(([, short]) => [badge(short, ""), " "]));
}
function details(c) {
const rows = [
["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file],
@@ -85,6 +104,62 @@ function details(c) {
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
}
// exportDialog downloads a certificate's files to use elsewhere.
function exportDialog(c) {
const parts = [
["fullchain", "fullchain.pem", "the certificate and its chain: what most programs want"],
["cert", "cert.pem", "the certificate alone"],
["key", "privkey.pem", "the private key"],
["bundle", "bundle.pem", "the key, then the chain, in one file (HAProxy)"],
];
modal("Export certificate " + c.name, h("div", null,
c.source === "acme"
? h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). Jarl renews the certificate, but not your copy: export it again after each renewal",
c.next_attempt ? " (next: " + fmtTime(c.next_attempt) + ")" : "", ", or have the program read the files Jarl writes for named (use it for named).")
: h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). You renew this one yourself, so a program on this server can read your files directly: ", h("code", null, c.cert_file), "."),
h("table", { class: "small" }, parts.map(([part, file, what]) => h("tr", null,
h("td", null, h("button", { class: "small" + (part === "fullchain" ? " primary" : ""), title: "Download " + file, onclick: () => download(c, part, file) }, file), " ",
h("button", { class: "small", title: "Copy " + file + " to the clipboard", onclick: () => copyPEM(c, part, file) }, "Copy")),
h("td", { class: part === "key" || part === "bundle" ? "" : "muted" }, what, part === "key" || part === "bundle" ? h("span", { class: "muted" }, " (audited)") : null)))),
h("p", { class: "small muted" }, "CLI: ", h("code", null, "jarl cert export " + c.name + " --dir DIR"))), [{ label: "Close", value: null }]);
}
// fetchPEM gets one part of a certificate, after asking when it holds the
// private key (verb: "Download" or "Copy"). null when not confirmed.
async function fetchPEM(c, part, verb) {
if ((part === "key" || part === "bundle") && !await confirmDialog(verb + " the private key",
"Whoever has the key of " + c.name + " can pose as " + (c.names || c.domains || []).join(", ") + " until the certificate expires. Keep it as safe as the server keeps it" +
(verb === "Copy" ? ", and clear it from the clipboard when you're done (clipboard managers keep a history)" : "") + ". This is recorded in the audit log.", verb)) return null;
const resp = await fetch("/api/v1/certificates/" + encodeURIComponent(c.name) + "/pem?part=" + part, { credentials: "same-origin" });
if (!resp.ok) {
let msg = "HTTP " + resp.status;
try { msg = (await resp.json()).error.message; } catch { /* not JSON */ }
throw new Error(msg);
}
return resp.text();
}
async function copyPEM(c, part, file) {
try {
const text = await fetchPEM(c, part, "Copy");
if (text === null) return;
if (!navigator.clipboard) throw new Error("the browser allows copying only over HTTPS (or from localhost); download it instead");
await navigator.clipboard.writeText(text);
toast("Copied " + file);
} catch (e) { toast("Copy failed: " + e.message, "err", 8000); }
}
async function download(c, part, file) {
try {
const text = await fetchPEM(c, part, "Download");
if (text === null) return;
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "application/x-pem-file" })), download: c.name + "-" + file });
document.body.append(a);
a.click();
a.remove();
} catch (e) { toast(e.message, "err", 8000); }
}
async function issue(c) {
try {
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
+12 -5
View File
@@ -44,7 +44,8 @@ export async function dnssecTab(el, zone) {
s.named_error ? h("div", { class: "msg warn small" }, "named: " + s.named_error) : null,
s.key_error ? h("div", { class: "msg err small" }, "The keys can't be read: " + s.key_error) : null,
!s.policy && !s.keys.length ? h("p", { class: "muted small" },
"Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record in the parent zone (at your registrar), and resolvers on your own network don't validate zones this server answers itself.") : null,
"Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record " + (s.ds_place || "in the parent zone") + ", and resolvers on your own network don't validate zones this server answers itself.") : null,
s.ds_at === "none" ? h("div", { class: "msg warn small" }, s.ds_note) : null,
out)];
const live = s.keys.filter((k) => k.ds && k.goal === "omnipresent");
@@ -98,8 +99,12 @@ async function addToParent(zone, s, k) {
function dsPanel(zone, s, keys) {
const ph = s.phase;
const where = s.parent_zone
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
: "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields.";
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)),
s.parent_view && s.parent_view !== zone.view ? " (view " + s.parent_view + ")" : "",
" is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
: s.ds_at === "registrar"
? "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields."
: s.ds_at === "none" ? s.ds_note : "Add this " + s.ds_place + ".";
return h("div", { class: "panel" },
h("h2", null, "DS record for the parent zone"),
ph.can_publish_ds
@@ -153,7 +158,7 @@ async function enableDialog(zone, s, others) {
"The server's clock must be right (NTP): signatures are valid for a fixed time window.",
"TTLs in the zone must be at most a day; Jarl refuses longer ones.",
"Secondaries get the signed zone by zone transfer; they need nothing else.",
"With the default policy the key is never replaced, so the DS at your registrar never has to change. Keep backups: the daily backup includes the keys.",
"With the default policy the key is never replaced, so the DS record at the parent never has to change. Keep backups: the daily backup includes the keys.",
]),
split,
s.policies.length > 1 ? h("div", { class: "form-grid" }, h("label", null, "Policy"), pol) : null,
@@ -175,7 +180,9 @@ async function disableDialog(zone, s) {
[h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."],
s.parent_zone
? [h("strong", null, "You: "), "remove the DS record from ", display(s.parent_zone), " (", h("code", null, jarlCmd(zone, s, "delete", "")), ", or on its Records page), then click Check DS at parent so named knows it's gone."]
: [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."],
: s.ds_at === "registrar"
? [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."]
: [h("strong", null, "You: "), "remove the DS record ", s.ds_place || "from the parent zone", " (if it was ever added), then click Check DS at parent so named knows it's gone."],
[h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."],
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
]),
+21 -4
View File
@@ -54,6 +54,7 @@ async function userDialog(u, grants) {
tmpl.value = "";
});
const comment = input({ value: u ? u.comment : "" });
const certLimit = input({ type: "number", min: "0", value: u && u.cert_limit != null ? String(u.cert_limit) : "", placeholder: "certificates.max_per_user" });
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
field("Username", name),
field("Password", pw),
@@ -62,6 +63,7 @@ async function userDialog(u, grants) {
u ? h("span") : null, u ? disabled : null,
field("Permissions", gr, "One grant per line (ignored for administrators)."),
h("span"), tmpl,
field("Certificates", certLimit, "How many certificates this user may own through jarl cert get (needs a certificates grant). Empty: the default from jarl.yaml."),
field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
@@ -69,6 +71,8 @@ async function userDialog(u, grants) {
if (service.input.checked) data.password = "";
else if (pw.value) data.password = pw.value;
if (u) data.disabled = disabled.input.checked;
if (certLimit.value.trim() !== "") data.cert_limit = Number(certLimit.value);
else if (u && u.cert_limit != null) data.cert_limit = -1;
try {
if (u) await api("PATCH", "/users/" + u.id, { body: data });
else await api("POST", "/users", { body: data });
@@ -104,7 +108,7 @@ export async function tokensDialog(u, grants, own) {
const base = own ? "/me/tokens" : "/users/" + u.id + "/tokens";
const draw = async () => {
const tokens = await get(base);
mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw));
mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw, u));
};
await draw();
await modal("API tokens of " + u.username, box, [{ label: "Close", value: null }], { wide: true });
@@ -185,13 +189,26 @@ async function rotateDialog(t, redraw) {
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
}
export function newTokenForm(base, grants, redraw) {
// coveredBy reports whether a user (admin, or grants) may do what a grant
// string ("records:write names=..." ) allows, by resource and access. A
// filter on either side only narrows the token further, so it is ignored.
function coveredBy(user, grant) {
if (!user || user.admin) return true;
const [res, acc] = grant.split(/\s+/)[0].split(":");
return (user.grants || []).some((g) => (g.resource === "*" || g.resource === res) && (g.access === "write" || acc === "read"));
}
// newTokenForm makes a token for owner (the user it belongs to). Its
// templates are only the roles within what owner may do: a token never
// does more than its user, so offering more would only mislead.
export function newTokenForm(base, grants, redraw, owner) {
const err = h("div");
const out = h("div");
const name = input({ placeholder: "e.g. certbot on web01" });
const exp = select([["", "never expires"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "1 year"]], "90d");
const gr = textarea("", { class: "mono", rows: 3, placeholder: "optional restriction, e.g.\nrecords:write zones=example.com names=_acme-challenge.* types=TXT" });
const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], "");
const roles = Object.keys(grants.roles).filter((r) => grants.roles[r].every((g) => coveredBy(owner, g)));
const tmpl = select([["", "Insert a template…"], ...roles.map((r) => [r, r])], "");
tmpl.addEventListener("change", () => {
if (!tmpl.value) return;
gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n");
@@ -203,7 +220,7 @@ export function newTokenForm(base, grants, redraw) {
field("Name", name),
field("Expires", exp),
field("Restrict to", gr, "Leave empty for all of the user's permissions. A token can never do more than its user."),
h("span"), tmpl),
roles.length ? [h("span"), tmpl] : null),
err,
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
mount(err); mount(out);
+44 -7
View File
@@ -9,6 +9,15 @@ import { registrarTab } from "./registrar.js";
let renderSeq = 0;
// tabIn is the "/tab" part of the address of zone o (an in_views entry)
// that keeps the tab open now, when o has that tab.
function tabIn(o, tab) {
if (!tab || tab === "registrar") return "";
if (["records", "file", "dnssec", "compare"].includes(tab) && o.type !== "primary") return "";
if (tab === "servers" && !["primary", "secondary", "mirror"].includes(o.type)) return "";
return "/" + tab;
}
export async function render(el, params, query) {
const id = params.id;
const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]);
@@ -33,12 +42,19 @@ export async function render(el, params, query) {
badge(zone.type, "accent"), " ",
zone.pending ? [pendingBadge(zone.pending), " "] : null,
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.in_views ? [h("span", { title: "The zone " + zone.display_name + " in another view" }, "also in ",
zone.in_views.map((o, i) => {
const v = views.find((x) => x.name === o.view);
return [i ? ", " : "", h("a", { href: "#/zones/" + o.id + tabIn(o, params.tab), class: v && v.color ? "vc-" + v.color : null,
title: "Go to " + zone.display_name + " in view " + o.view + (o.type !== zone.type ? " (" + o.type + ")" : "") + (o.linked ? ", linked with this one" : "") }, o.view),
o.type !== zone.type ? h("span", { class: "muted" }, " (" + o.type + ")") : null];
})), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null,
zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "ok")), " "] : null,
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
zone.links ? [h("span", { class: "badge accent" }, "linked with ",
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null,
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + tabIn({ type: zone.type }, params.tab), title: "The zone in view " + l.view }, l.view)])), " "] : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" },
zone.disabled
@@ -51,7 +67,12 @@ export async function render(el, params, query) {
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.")
: zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" },
plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ",
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them"))) : null;
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them")),
h("ul", { class: "small ptr-missing" }, (zone.ptr_missing_list || []).map((f) => h("li", null,
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null,
f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)),
zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null)) : null;
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
// The header and tabs show at once. Below them, switching tabs keeps the
// previous tab's content (faded) until the new one is loaded, rather
@@ -88,7 +109,10 @@ export async function render(el, params, query) {
// the same. ?name= (and &type=) narrows it to one name.
async function compareTab(el, zone, query) {
const others = zone.other_views || [];
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || others[0] || "");
// A linked zone starts with a view it is linked to: that is the
// comparison that matters (its differences are its own records).
const linked = (zone.linked_with || []).filter((v) => others.includes(v));
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || linked[0] || others[0] || "");
const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } });
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
let showSame = false;
@@ -801,12 +825,21 @@ async function recordsTab(el, zone, query) {
// ---- settings
// inZone reports whether an absolute name is the zone or inside it.
function inZone(name, zoneName) {
if (!name) return true;
return name === zoneName || name.endsWith("." + zoneName);
}
async function settingsTab(el, zone) {
const views = await get("/views");
const err = h("div");
const rows = [];
const s = zone.soa || {};
const mname = input({ value: display(s.mname), class: "mono" });
// The server reads a name without a final dot as relative to the zone,
// so a name outside it keeps its dot.
const mnameText = inZone(s.mname, zone.name) ? display(s.mname) : (s.mname || "");
const mname = input({ value: mnameText, class: "mono" });
const rname = input({ value: zone.rname_email || emailOf(s.rname) });
const refresh = input({ type: "number", value: s.refresh });
const retry = input({ type: "number", value: s.retry });
@@ -820,7 +853,7 @@ async function settingsTab(el, zone) {
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
if (zone.type === "primary") {
rows.push(
field("Primary name server", mname, "SOA MNAME."),
field("Primary name server", mname, "SOA MNAME. A name outside " + zone.display_name + " ends with '.'; without it, the name is taken as relative to the zone."),
field("Contact", rname, "SOA RNAME, as an email address."),
field("Refresh", refresh, "Seconds between secondaries checking for updates."),
field("Retry", retry, "Seconds before a failed refresh is retried."),
@@ -838,7 +871,8 @@ async function settingsTab(el, zone) {
mount(err);
const body = { comment: comment.value };
if (zone.type === "primary") {
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
body.soa = { rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
if (mname.value.trim() !== mnameText) body.soa.mname = mname.value.trim();
body.default_ttl = +ttl.value;
body.serial_policy = policy.value;
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
@@ -849,7 +883,10 @@ async function settingsTab(el, zone) {
const r = await change("PATCH", "/zones/" + zone.id, body);
toast(describeResult(r));
rerender();
} catch (e) { mount(err, errorBox(e)); }
} catch (e) {
if (e.status === 400 && /^nothing to (change|do)/.test(e.message)) toast("Nothing changed");
else mount(err, errorBox(e));
}
};
mount(el, h("div", { class: "panel" }, h("div", { class: "form-grid" }, rows), err,
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: save }, "Save settings"))));
+1 -1
View File
@@ -398,7 +398,7 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.hm-row { display: flex; align-items: center; }
.hm-cell { height: 22px; border-radius: 3px; }
.hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; }
.hm-cell.empty, .hm-swatch.empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
.hm-empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
.heat-0 { background: var(--heat-0); } .heat-1 { background: var(--heat-1); } .heat-2 { background: var(--heat-2); }
.heat-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); }
.heat-6 { background: var(--heat-6); }
+52
View File
@@ -896,6 +896,58 @@ if [ -n "$PEBBLE" ]; then
run "a second certificate for the web UI is refused (exit 6)" 6 $J cert create other --domain nas.adbraun.test --use web
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
# Certificates for other hosts: jarl cert get (docs/certificates.md#certificates-for-other-hosts).
run "user for other hosts' certificates" 0 $J user create hosts --no-password --grant "certificates:write names=*.adbraun.test"
run "its token" 0 sh -c "$J token create mx --user hosts -q > $E/hosts.token"
run "a second user with the same grant" 0 $J user create hosts2 --no-password --grant "certificates:write names=*.adbraun.test"
run "and its token" 0 sh -c "$J token create web --user hosts2 -q > $E/hosts2.token"
run "a user without a certificates grant" 0 $J user create nocert --no-password --role acme
run "and its token too" 0 sh -c "$J token create nocert --user nocert -q > $E/nocert.token"
CG="--token @$E/hosts.token"; CGD=$E/certget
mkdir -p $CGD
HOOK='echo "$JARL_CERT_NAME $JARL_CERT_RENEWED $(basename $JARL_CERT_KEY)" >> '$CGD/hook.log
run "cert get: obtained by jarld, downloaded here" 0 $J $CG cert get mail -d mail.adbraun.test --dir $CGD/mail --hook "$HOOK" --config-dir $CGD/conf
contains "mail .*new files"
run "the files" 0 ls $CGD/mail
contains "^chain.pem$"
contains "^fullchain.pem$"
contains "^jarl-cert.json$"
run "the key is mode 600" 0 stat -c %a $CGD/mail/privkey.pem
contains "^600$"
run "the key matches" 0 sh -c "openssl x509 -in $CGD/mail/cert.pem -noout -pubkey | cmp - \$(openssl pkey -in $CGD/mail/privkey.pem -pubout > $CGD/pub.pem; echo $CGD/pub.pem)"
run "the hook ran" 0 cat $CGD/hook.log
contains "^mail 0 privkey.pem$"
run "the settings are saved, readable only by their owner" 0 stat -c %a $CGD/conf/mail.json
contains "^600$"
run "cert get again: up to date, no hook" 0 $J cert get --config-dir $CGD/conf
contains "mail .*up to date"
if [ "$(wc -l < $CGD/hook.log)" = 1 ]; then ok "the hook didn't run again"; else bad "hook ran again: $(cat $CGD/hook.log)"; fi
run "cert saved" 0 $J cert saved --config-dir $CGD/conf
contains "mail *mail.adbraun.test .*$CGD/mail"
run "another user can't take the name (exit 5)" 5 $J --token @$E/hosts2.token cert get mail -d mail.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
contains "taken"
run "an ACME token can't get certificates (exit 3)" 3 $J --token @$E/nocert.token cert get x -d x.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
run "a name outside the grant (exit 3)" 3 $J $CG cert get other -d mail.example.org --dir $CGD/other --no-save --config-dir $CGD/conf
run "the admin sees the owner and who downloaded it" 0 $J cert show mail
contains "Owner: *hosts"
contains "Pulled by: *token mx (hosts)"
run "jarld renews it" 0 $J cert issue mail
run "cert get: the renewed one, hook told" 0 $J cert get --config-dir $CGD/conf
contains "mail .*new files"
run "the hook ran for the renewal" 0 tail -1 $CGD/hook.log
contains "^mail 1 privkey.pem$"
run "a name added: obtained again" 0 $J cert get mail -d mail.adbraun.test -d smtp.adbraun.test --config-dir $CGD/conf
run "the new certificate has both names" 0 openssl x509 -in $CGD/mail/cert.pem -noout -ext subjectAltName
contains "smtp.adbraun.test"
run "cert schedule writes the units" 0 $J cert schedule --unit-dir $CGD --no-systemctl
run "the service runs cert get" 0 cat $CGD/jarl-cert-get.service
contains "^ExecStart=.* cert get --config-dir"
run "the timer runs daily" 0 cat $CGD/jarl-cert-get.timer
contains "^OnCalendar=daily$"
run "cert forget" 0 $J cert forget mail --config-dir $CGD/conf
run "nothing saved now" 0 $J cert saved --config-dir $CGD/conf
contains "No saved certificates"
run "the owner may delete it" 0 $J $CG cert delete mail --yes
# Encrypted DNS set up by Jarl (docs/encrypted-dns.md), next to the
# hand-written listener above.
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}