Compare commits
14
Commits
v0.6.0
...
v0.6.1-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8feb6bd9 | ||
|
|
12013b12d5 | ||
|
|
9db76a113c | ||
|
|
b4bb99b482 | ||
|
|
57012fece8 | ||
|
|
4cc2ab2287 | ||
|
|
a36c54ce60 | ||
|
|
82c282cd32 | ||
|
|
65086e314a | ||
|
|
e38390df7d | ||
|
|
0e663ab36c | ||
|
|
5cd22f52d4 | ||
|
|
aca0abc780 | ||
|
|
9175d75d61 |
No files matched your search
@@ -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
@@ -6,6 +6,134 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.6.0] - 2026-10-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -114,6 +114,7 @@ install-files:
|
|||||||
|
|
||||||
uninstall:
|
uninstall:
|
||||||
rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \
|
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)$(UNITDIR)/jarl-backup.service $(DESTDIR)$(UNITDIR)/jarl-backup.timer \
|
||||||
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
|
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
|
||||||
$(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf
|
$(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf
|
||||||
|
|||||||
@@ -202,7 +202,13 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## 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
|
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
|
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
|
client and name (a query log), shows the databases' size, and gives
|
||||||
|
|||||||
@@ -378,6 +378,9 @@ func printUser(w io.Writer, u api.User) {
|
|||||||
if u.Comment != "" {
|
if u.Comment != "" {
|
||||||
fmt.Fprintf(w, "Comment:\t%s\n", 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))
|
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 admin, noPassword, disabled bool
|
||||||
var role, comment, rename string
|
var role, comment, rename string
|
||||||
|
var certLimit int
|
||||||
var grants []string
|
var grants []string
|
||||||
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
||||||
var in api.UserInput
|
var in api.UserInput
|
||||||
@@ -466,6 +470,9 @@ func userCmd() *cobra.Command {
|
|||||||
if f.Changed("rename") {
|
if f.Changed("rename") {
|
||||||
in.Username = &rename
|
in.Username = &rename
|
||||||
}
|
}
|
||||||
|
if f.Changed("cert-limit") {
|
||||||
|
in.CertLimit = &certLimit
|
||||||
|
}
|
||||||
return in, nil
|
return in, nil
|
||||||
}
|
}
|
||||||
addUserFlags := func(c *cobra.Command) {
|
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().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().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
||||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
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{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
|
|||||||
@@ -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
@@ -50,6 +50,13 @@ type certInfo struct {
|
|||||||
Chain string `json:"chain"`
|
Chain string `json:"chain"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
} `json:"files,omitempty"`
|
} `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 {
|
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 create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
|
||||||
jarl cert list
|
jarl cert list
|
||||||
jarl cert issue ns1 # renew now
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +188,20 @@ func printCert(w io.Writer, ci certInfo) {
|
|||||||
if ci.Comment != "" {
|
if ci.Comment != "" {
|
||||||
fmt.Fprintf(w, "Comment:\t%s\n", 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
|
state := ci.Status.State
|
||||||
if ci.Status.Message != "" {
|
if ci.Status.Message != "" {
|
||||||
state += ": " + ci.Status.Message
|
state += ": " + ci.Status.Message
|
||||||
@@ -493,6 +515,91 @@ is published and the CA isn't asked.`,
|
|||||||
return cmd
|
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 {
|
func certDeleteCmd() *cobra.Command {
|
||||||
var yes bool
|
var yes bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ func viewCmd() *cobra.Command {
|
|||||||
return output(views, func(w io.Writer) {
|
return output(views, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
||||||
for i, v := range views {
|
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
@@ -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.
|
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
|
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
|
the zone's DS record at the parent zone (your registrar for a domain, the
|
||||||
when that is safe. Turning it off goes through BIND's insecure policy, which
|
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.`,
|
keeps the zone signed until the DS is gone from the parent.`,
|
||||||
}
|
}
|
||||||
var view string
|
var view string
|
||||||
@@ -71,7 +71,7 @@ DS after that.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !flagQuiet {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
@@ -87,8 +87,8 @@ DS after that.`,
|
|||||||
parent zone still has its DS record:
|
parent zone still has its DS record:
|
||||||
|
|
||||||
1. The first run switches the zone to BIND's insecure policy. named keeps it
|
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
|
signed and publishes a CDS "delete" record. Remove the DS record at the
|
||||||
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
|
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
|
waits until resolvers have forgotten the DS (the parent's DS TTL, often a
|
||||||
day) and then removes the keys and signatures.
|
day) and then removes the keys and signatures.
|
||||||
2. Run it again when 'status' says turning off has finished: DNSSEC is then
|
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 != "" {
|
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))
|
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" {
|
} 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 {
|
} else {
|
||||||
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
|
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",
|
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
|
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
|
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.
|
moves to the next step only once it knows.
|
||||||
|
|
||||||
The parent is found through jarld's resolver, or --resolver. Exits with 2
|
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" {
|
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 != "" {
|
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)
|
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
|
||||||
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
|
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
|
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
|
||||||
// record in its parent zone in Jarl.
|
// record in its parent zone in Jarl.
|
||||||
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
|
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
|
||||||
|
|||||||
+36
-9
@@ -103,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
|
|||||||
return os.WriteFile(flagConfig, b, 0o600)
|
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()
|
cfg, err := loadCLIConfig()
|
||||||
if err != nil {
|
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))
|
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return connection{}, err
|
||||||
}
|
}
|
||||||
if len(ts) > 1 {
|
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]
|
t := ts[0]
|
||||||
// A named server's own token wins: a token from the environment
|
// 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)
|
sock, isSocket := client.SocketPath(t.URL)
|
||||||
if isSocket {
|
if isSocket {
|
||||||
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
||||||
return nil, err
|
return connection{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token == "" && !isSocket {
|
if token == "" && !isSocket {
|
||||||
if t.Name != "" {
|
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, "@") {
|
if strings.HasPrefix(token, "@") {
|
||||||
b, err := os.ReadFile(token[1:])
|
b, err := os.ReadFile(token[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,7 +160,15 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
token = strings.TrimSpace(string(b))
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,7 +194,7 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
||||||
if !c.Preflight {
|
if !c.Preflight {
|
||||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ func serveCmd() *cobra.Command {
|
|||||||
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
|
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,
|
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,
|
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)
|
}, a.store, a.eng, a.log)
|
||||||
cm.Start(ctx)
|
cm.Start(ctx)
|
||||||
if a.cfg.ACME.Account.EABHMAC != "" {
|
if a.cfg.ACME.Account.EABHMAC != "" {
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ if [ "$1" = configure ]; then
|
|||||||
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
|
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
|
||||||
systemctl start jarl-backup.timer || true
|
systemctl start jarl-backup.timer || true
|
||||||
fi
|
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
|
# An upgrade restarts a running jarld (its database is upgraded
|
||||||
# when it starts). A first install starts nothing: Jarl needs
|
# when it starts). A first install starts nothing: Jarl needs
|
||||||
# 'jarld init' and 'jarld adopt' first.
|
# 'jarld init' and 'jarld adopt' first.
|
||||||
|
|||||||
@@ -65,3 +65,5 @@ SystemCallErrorNumber=EPERM
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
# 'systemctl restart jarld' works too (the daemon's binary is jarld).
|
||||||
|
Alias=jarld.service
|
||||||
+3
-1
@@ -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.
|
* The zone is found automatically: the longest matching primary zone.
|
||||||
* If the zone exists in several **views** (split DNS), the record is written
|
* 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
|
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
|
* 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
|
already there succeeds, so a retried hook doesn't fail, and removing a
|
||||||
value that isn't there succeeds as well.
|
value that isn't there succeeds as well.
|
||||||
|
|||||||
+21
-5
@@ -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": "..."},
|
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
|
||||||
"policies": ["default"],
|
"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
|
"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`,
|
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
|
||||||
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
|
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
|
||||||
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
|
`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":
|
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
|
||||||
true` (this view only, an override) or `false` (shared; on an update or a
|
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`
|
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 |
|
| 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
|
`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.
|
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`
|
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
|
### 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` |
|
| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` |
|
||||||
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
|
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
|
||||||
| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` |
|
| 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 |
|
| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation |
|
||||||
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
|
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
|
||||||
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
|
| 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}` | |
|
| 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}/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}/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`,
|
`GET /status` includes `certificates` for administrators: `total`,
|
||||||
`problems` (failing, expiring, expired), `next` and `next_after`,
|
`problems` (failing, expiring, expired), `next` and `next_after`,
|
||||||
@@ -616,7 +632,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/users` | admin |
|
| 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) |
|
| 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 | `/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 / 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 |
|
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
|
||||||
|
|||||||
+96
-1
@@ -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
|
be able to read both files (e.g. `chgrp jarl` and `chmod 640`, or have
|
||||||
your renewal hook copy them somewhere it can read).
|
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
|
## Security notes
|
||||||
|
|
||||||
- Certificates Jarl obtains, their keys and the ACME account key are kept
|
- 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
|
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
|
- Every publicly trusted certificate is published in Certificate
|
||||||
Transparency logs, with all its names. A name like `ns1.adbraun.lol`
|
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
|
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 |
|
| 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}/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 |
|
| 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
|
## Testing with Pebble
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -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
|
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
|
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
|
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
|
syntax of the common ones and checks them strictly, and the rest are
|
||||||
validated by parsing.
|
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 issue ns1 # renew now (--no-wait to return at once)
|
||||||
jarl cert update ns1 --challenge porkbun # auto, jarl or porkbun
|
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 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 delete ns1
|
||||||
jarl cert account # the ACME account (set in jarl.yaml)
|
jarl cert account # the ACME account (set in jarl.yaml)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.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). |
|
| `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.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)
|
### ACME (lego httpreq endpoint)
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ check` says so).
|
|||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
||||||
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
| `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
|
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
|
file: it isn't stored in the database or backups, and there is no API to
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ internal/
|
|||||||
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
|
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
|
||||||
(the downloads are in engine/blocklists.go)
|
(the downloads are in engine/blocklists.go)
|
||||||
config/ jarld's YAML configuration
|
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/ 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
|
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
|
||||||
contrib/acme.sh/ acme.sh DNS API hook
|
contrib/acme.sh/ acme.sh DNS API hook
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**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
|
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
|
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
|
when everyone can read it. Only administrators can use the registrar
|
||||||
features.
|
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`:
|
**Using it**: the zone's **Registrar** tab, or `jarl zone registrar`:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|
|||||||
@@ -317,6 +317,10 @@ systemctl status jarl
|
|||||||
journalctl -u jarl -f
|
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`.
|
Open `https://<management-address>:8053/` and log in as `admin`.
|
||||||
|
|
||||||
## 9. Set up the CLI
|
## 9. Set up the CLI
|
||||||
|
|||||||
+15
-3
@@ -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,
|
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,
|
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
|
||||||
whose key can move the domain), and manage TLS certificates (whose keys
|
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:
|
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) |
|
| `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) |
|
| `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 |
|
| `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.
|
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
|
||||||
Each takes comma separated patterns where `*` and `?` are wildcards, and a
|
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
|
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
|
||||||
* `views=internal`: view names (`_default` means zones outside any view)
|
* `views=internal`: view names (`_default` means zones outside any view)
|
||||||
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
|
* `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
|
* `types=TXT,CNAME`: record types
|
||||||
|
|
||||||
Examples:
|
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 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 names=_acme-challenge.* types=TXT ACME challenges anywhere
|
||||||
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
|
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
|
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
|
### Roles
|
||||||
|
|
||||||
|
|||||||
+22
-5
@@ -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.
|
3. the shortest name, then the first alphabetically.
|
||||||
|
|
||||||
Jarl says which name it chose, and which it didn't, so you can change it.
|
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
|
A reverse zone's page shows the addresses that still lack a PTR record and
|
||||||
button to add them. `jarl zone show` shows it as `Missing PTRs`.
|
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
|
## 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,
|
(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
|
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
|
same to its PTR record, in the reverse zone of each view that has the
|
||||||
views it is linked with ([views.md](views.md#linked-zones)). A whole set
|
record. A whole set replaced at once (`jarl record set`, a batch) works
|
||||||
replaced at once (`jarl record set`, a batch) works too.
|
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
|
* **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
|
host, it stays: a second A record for the same address doesn't add a
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ showing them.
|
|||||||
| `cname_multiple` | error | A name can have only one CNAME |
|
| `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_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
|
||||||
| `cname_loop` | error | CNAMEs that point back to themselves |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `apex_ns_missing` | error | A zone needs NS records at its apex |
|
||||||
|
|||||||
+15
-3
@@ -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
|
`jarl-view-` are reserved, and the view keys can be used in your own
|
||||||
options (`allow-transfer { key jarl-view-external; }`).
|
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
|
## Linked zones
|
||||||
|
|
||||||
Most split-horizon zones are the same in every view apart from a few
|
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
|
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
|
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
|
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
|
* A new record joins the set of the same name and type: local if that set
|
||||||
is local in its view, shared otherwise.
|
is local in its view, shared otherwise.
|
||||||
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
|
* **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
|
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
|
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
|
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
|
**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
|
the records like any others: shared, unless the name and type is local
|
||||||
@@ -235,7 +244,10 @@ before it).
|
|||||||
## Comparing views
|
## Comparing views
|
||||||
|
|
||||||
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
|
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
|
$ jarl zone diff adbraun.lol --view internal
|
||||||
|
|||||||
+40
-1
@@ -34,6 +34,9 @@ type Zone struct {
|
|||||||
LinkedWith []string `json:"linked_with,omitempty"`
|
LinkedWith []string `json:"linked_with,omitempty"`
|
||||||
// Links are the zones this one is linked with, with their views.
|
// Links are the zones this one is linked with, with their views.
|
||||||
Links []ZoneLink `json:"links,omitempty"`
|
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
|
// OtherViews are the other views with a primary zone of this name
|
||||||
// (linked or not): what GET /zones/{zone}/compare compares.
|
// (linked or not): what GET /zones/{zone}/compare compares.
|
||||||
OtherViews []string `json:"other_views,omitempty"`
|
OtherViews []string `json:"other_views,omitempty"`
|
||||||
@@ -42,6 +45,9 @@ type Zone struct {
|
|||||||
// (POST /zones/{zone}/fill-ptr adds them).
|
// (POST /zones/{zone}/fill-ptr adds them).
|
||||||
Prefix string `json:"prefix,omitempty"`
|
Prefix string `json:"prefix,omitempty"`
|
||||||
PTRMissing int `json:"ptr_missing,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 is set when listing with a changeset: add, change or delete.
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -67,6 +73,21 @@ type View struct {
|
|||||||
model.View
|
model.View
|
||||||
Zones int `json:"zone_count"`
|
Zones int `json:"zone_count"`
|
||||||
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
|
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.
|
// 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
|
Role *string `json:"role,omitempty"` // a role template name
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
Comment *string `json:"comment,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.
|
// TokenInput creates a token.
|
||||||
@@ -465,8 +489,20 @@ type DNSSECResponse struct {
|
|||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
*dnssec.Status
|
*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
|
// 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"`
|
ParentZone string `json:"parent_zone,omitempty"`
|
||||||
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
|
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
|
||||||
ParentView string `json:"parent_view,omitempty"`
|
ParentView string `json:"parent_view,omitempty"`
|
||||||
@@ -616,6 +652,9 @@ type BlockRule struct {
|
|||||||
type ZoneLink struct {
|
type ZoneLink struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
View string `json:"view"`
|
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.
|
// EncDNSSummary is what the dashboard says about encrypted DNS.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-4
@@ -21,11 +21,14 @@ const (
|
|||||||
ResAudit = "audit" // audit log and everyone's changesets
|
ResAudit = "audit" // audit log and everyone's changesets
|
||||||
ResStats = "stats" // query statistics
|
ResStats = "stats" // query statistics
|
||||||
ResBlocking = "blocking" // blocklists and block/allow rules
|
ResBlocking = "blocking" // blocklists and block/allow rules
|
||||||
ResAll = "*"
|
// 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.
|
// 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.
|
// Access levels.
|
||||||
const (
|
const (
|
||||||
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
|
|||||||
for i, t := range g.Types {
|
for i, t := range g.Types {
|
||||||
g.Types[i] = strings.ToUpper(t)
|
g.Types[i] = strings.ToUpper(t)
|
||||||
}
|
}
|
||||||
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
|
if len(g.Types) > 0 && g.Resource != ResRecords {
|
||||||
return fmt.Errorf("names and types filters only apply to the records resource")
|
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 {
|
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")
|
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 {
|
if g.Resource != ResAll && g.Resource != a.Resource {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if g.Resource == ResAll && a.Resource == ResCerts {
|
||||||
|
return false // only an explicit certificates grant
|
||||||
|
}
|
||||||
if a.Write && g.Access != Write {
|
if a.Write && g.Access != Write {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ type Config struct {
|
|||||||
PropagationTimeout time.Duration
|
PropagationTimeout time.Duration
|
||||||
// SkipPropagationCheck is !CheckPropagation (set by New).
|
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||||
SkipPropagationCheck bool
|
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.
|
// Manager keeps certificates current and serves jarld's own.
|
||||||
@@ -132,6 +135,8 @@ type Input struct {
|
|||||||
Challenge *string `json:"challenge"`
|
Challenge *string `json:"challenge"`
|
||||||
KeyType *string `json:"key_type"`
|
KeyType *string `json:"key_type"`
|
||||||
Comment *string `json:"comment"`
|
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}$`)
|
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 {
|
if in.Comment != nil {
|
||||||
c.Comment = *in.Comment
|
c.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.Owner != nil {
|
||||||
|
c.OwnerID = *in.Owner
|
||||||
|
}
|
||||||
switch c.Source {
|
switch c.Source {
|
||||||
case model.CertSourceACME:
|
case model.CertSourceACME:
|
||||||
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
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
|
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;
|
// Create adds a certificate. A file-source certificate is read at once;
|
||||||
// one Jarl obtains is left for Issue (or the background loop).
|
// 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) {
|
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ type Certificates struct {
|
|||||||
// HTTPS): one directory per certificate, readable by bind.file_group.
|
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||||
// "" is <bind.config_dir>/tls.
|
// "" is <bind.config_dir>/tls.
|
||||||
Dir string `yaml:"dir"`
|
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).
|
// 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)},
|
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,
|
TopN: 100,
|
||||||
},
|
},
|
||||||
|
Certificates: Certificates{MaxPerUser: 10},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
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},
|
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},
|
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Root is the root domain name.
|
// Root is the root domain name.
|
||||||
@@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string {
|
|||||||
return local + "@" + strings.Join(labels[1:], ".")
|
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.
|
// IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
|
||||||
func IsReverse(name string) bool {
|
func IsReverse(name string) bool {
|
||||||
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
|
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -330,6 +330,9 @@ func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if len(ops) == 0 {
|
||||||
|
return nil, errf(KindBadRequest, "nothing to change")
|
||||||
|
}
|
||||||
intro := validate.Introduced(before, e.validate(work))
|
intro := validate.Introduced(before, e.validate(work))
|
||||||
if validate.HasErrors(intro) {
|
if validate.HasErrors(intro) {
|
||||||
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
|
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
|
||||||
|
|||||||
@@ -462,3 +462,39 @@ func TestParseWarningsReachTheResult(t *testing.T) {
|
|||||||
t.Errorf("ops = %+v", res.Ops)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+285
-208
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,6 +32,12 @@ type expander struct {
|
|||||||
// exposures classifies the views as public or private (nil in tests:
|
// exposures classifies the views as public or private (nil in tests:
|
||||||
// every view private).
|
// every view private).
|
||||||
exposures func(*model.State) []validate.Exposure
|
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) {
|
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 == "" {
|
if zone == "" {
|
||||||
return nil, errf(KindBadRequest, "the zone is missing (zone_id or 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.
|
// FindZoneRef finds a zone by name and optional view name. Without a
|
||||||
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
// 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)
|
name, err := ParseZoneName(zone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errf(KindBadRequest, "%v", err)
|
return nil, errf(KindBadRequest, "%v", err)
|
||||||
@@ -90,6 +99,16 @@ func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
|||||||
return z, nil
|
return z, nil
|
||||||
}
|
}
|
||||||
zs := st.ZonesNamed(name)
|
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) {
|
switch len(zs) {
|
||||||
case 0:
|
case 0:
|
||||||
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
|
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) {
|
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||||
|
x.ptrForced, x.sharing = nil, nil
|
||||||
ops, err := x.expandOne(req)
|
ops, err := x.expandOne(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
switch req.Op {
|
switch req.Op {
|
||||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable, OpZoneFillPTR:
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
||||||
// Changes to linked zones reach the other zones of the link.
|
// 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 x.linkSync(ops)
|
||||||
}
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
@@ -193,6 +219,11 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
|||||||
if in.LinkWith != "" {
|
if in.LinkWith != "" {
|
||||||
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
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
|
return ops, nil
|
||||||
case OpRecordCreate:
|
case OpRecordCreate:
|
||||||
@@ -751,65 +782,227 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
|
|||||||
return netip.Addr{}, false
|
return netip.Addr{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ptrOps returns operations creating the PTR record for an address record.
|
// PTR records follow address records per view (docs/reverse.md): after a
|
||||||
//
|
// request's operations (and those keeping linked zones in step), ptrSync
|
||||||
// auto is set when the zone's auto_ptr asked rather than the request: a
|
// compares each touched A or AAAA set before and after, in every view it
|
||||||
// missing reverse zone isn't worth a warning then (most addresses have
|
// is in, and changes the PTR records of that view's reverse zone to match.
|
||||||
// none), and an address whose PTR record names another host keeps it.
|
// So a record local to one view of a linked zone changes PTR records in
|
||||||
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) {
|
// that view only, and an address that goes from one view keeps its PTR
|
||||||
addr, ok := addrOf(p)
|
// record in the views that still have it.
|
||||||
if !ok {
|
|
||||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
// 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
|
x.ptrForced[rrKey{z.ID, name, typ}] = true
|
||||||
found := false
|
for _, o := range x.st.LinkedWith(z) {
|
||||||
for _, vid := range ptrViews(x.st, z) {
|
x.ptrForced[rrKey{o.ID, name, typ}] = true
|
||||||
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
|
||||||
if rz == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
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
|
// Whether a set has an address: none, only in disabled records, enabled.
|
||||||
// z's address records: z's own, and, for a linked zone, the other views
|
const (
|
||||||
// of the link (the record is in all of them).
|
addrNone = iota
|
||||||
func ptrViews(st *model.State, z *model.Zone) []int64 {
|
addrDisabled
|
||||||
out := []int64{z.ViewID}
|
addrEnabled
|
||||||
for _, o := range st.LinkedWith(z) {
|
)
|
||||||
if !slices.Contains(out, o.ViewID) {
|
|
||||||
out = append(out, o.ViewID)
|
// 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
|
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
|
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
||||||
// in reverse zone rz (none when it is there; enabling it when disabled).
|
// 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
|
var other string
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
|
||||||
if r.Name == ptrName && r.Type == "PTR" {
|
if strings.EqualFold(r.Data, owner) {
|
||||||
if strings.EqualFold(r.Data, owner) {
|
if r.Disabled {
|
||||||
if r.Disabled {
|
in := RecordInput{Disabled: model.Ptr(false)}
|
||||||
in := RecordInput{Disabled: model.Ptr(false)}
|
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
|
||||||
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
|
|
||||||
}
|
|
||||||
return nil, nil // already there
|
|
||||||
}
|
|
||||||
if !r.Disabled {
|
|
||||||
other = r.Data
|
|
||||||
}
|
}
|
||||||
|
return nil, nil // already there
|
||||||
|
}
|
||||||
|
if !r.Disabled {
|
||||||
|
other = r.Data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if other != "" {
|
if other != "" {
|
||||||
@@ -824,38 +1017,12 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
|
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
|
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 {
|
func enableWord(disabled bool) string {
|
||||||
if disabled {
|
if disabled {
|
||||||
return "disable"
|
return "disable"
|
||||||
@@ -863,23 +1030,6 @@ func enableWord(disabled bool) string {
|
|||||||
return "enable"
|
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).
|
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||||
// The stored operation holds the canonical data, so applying it doesn't
|
// The stored operation holds the canonical data, so applying it doesn't
|
||||||
// notice again.
|
// notice again.
|
||||||
@@ -906,7 +1056,12 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
x.parseWarnings(owner, p)
|
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.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||||
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
||||||
ids, err := x.ids(1)
|
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.
|
// local on a zone that isn't linked: its records are local anyway.
|
||||||
in.Local = nil
|
in.Local = nil
|
||||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
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 {
|
if string(mustJSON(in)) != "{}" || !ptr {
|
||||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||||
}
|
}
|
||||||
disabled := r.Disabled
|
if ptr {
|
||||||
if in.Disabled != nil {
|
// The PTR records of the old and the new record, whichever is an
|
||||||
disabled = *in.Disabled
|
// address (ptrSync).
|
||||||
}
|
_, isAddr := addrOf(p)
|
||||||
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 old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
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 _, ok := addrOf(old); ok {
|
||||||
}
|
isAddr = true
|
||||||
if !disabled {
|
x.ptrForce(z, r.Name, r.Type)
|
||||||
more, err := x.ptrCreate(z, owner, p, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
ops = append(ops, more...)
|
|
||||||
}
|
}
|
||||||
}
|
if !isAddr {
|
||||||
if ptr && !moved && toggled {
|
|
||||||
if _, ok := addrOf(p); !ok {
|
|
||||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
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
|
return ops, nil
|
||||||
}
|
}
|
||||||
@@ -1146,9 +1248,8 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
|||||||
base = r.Version
|
base = r.Version
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
|
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 in.PTR {
|
||||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) {
|
x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
|
||||||
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
|
||||||
}
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
@@ -1184,25 +1285,12 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
|||||||
if in.MissingOK {
|
if in.MissingOK {
|
||||||
what += " (if present)"
|
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.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||||
in.Name, in.Type = &owner, &typ
|
in.Name, in.Type = &owner, &typ
|
||||||
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
|
return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordSetDisabled expands record.disable and record.enable: the record
|
// 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 {
|
if len(canon) == 0 {
|
||||||
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
||||||
}
|
}
|
||||||
ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary))
|
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
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 {
|
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))
|
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)
|
||||||
|
}
|
||||||
+55
-17
@@ -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
|
// - z overrides it (local records): z's shared copies go; the others
|
||||||
// keep theirs.
|
// keep theirs.
|
||||||
// - z's override was just removed: z gets the shared records back from
|
// - z's override was just removed: z goes without the name and type,
|
||||||
// a zone that has them.
|
// and the other zones' shared copies become local to each.
|
||||||
// - otherwise z's shared records are the truth: every zone that doesn't
|
// - otherwise z's shared records are the truth: every zone that doesn't
|
||||||
// override the name and type gets the same (created, updated or
|
// override the name and type gets the same (created, updated or
|
||||||
// deleted to match).
|
// deleted to match).
|
||||||
@@ -123,24 +123,40 @@ func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]
|
|||||||
}
|
}
|
||||||
truth := st.SharedRecords(z.ID, name, typ)
|
truth := st.SharedRecords(z.ID, name, typ)
|
||||||
members := st.LinkedWith(z)
|
members := st.LinkedWith(z)
|
||||||
|
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 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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, op)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
||||||
// The override was removed: back to the shared records.
|
// 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 {
|
for _, o := range members {
|
||||||
if st.HasLocal(o.ID, name, typ) {
|
if st.HasLocal(o.ID, name, typ) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
src := st.SharedRecords(o.ID, name, typ)
|
var more []model.Op
|
||||||
if len(src) == 0 {
|
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
||||||
continue
|
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))))
|
||||||
}
|
}
|
||||||
for _, r := range src {
|
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
||||||
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
|
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)",
|
||||||
if err != nil {
|
dnsname.Display(name), typ, where(z), where(o), where(o))
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ops = append(ops, op)
|
|
||||||
}
|
}
|
||||||
break
|
ops = append(ops, more...)
|
||||||
}
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
@@ -440,12 +456,30 @@ 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:
|
// 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.
|
// 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) {
|
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 {
|
||||||
if want == nil {
|
return *want, nil
|
||||||
return has, 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
|
// shareAllOps returns the operations that start a link with zone z: it
|
||||||
@@ -468,6 +502,10 @@ func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
|||||||
for _, o := range x.st.LinkedWith(z) {
|
for _, o := range x.st.LinkedWith(z) {
|
||||||
for _, r := range x.st.RRset(o.ID, name, typ) {
|
for _, r := range x.st.RRset(o.ID, name, typ) {
|
||||||
if r.SharedID == 0 {
|
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))))
|
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))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
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"))
|
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{}}))
|
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") {
|
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", env.view("internal"))
|
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.
|
// 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
"jarl/internal/rr"
|
|
||||||
"jarl/internal/validate"
|
"jarl/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,9 +226,11 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
var ops []model.Op
|
var ops []model.Op
|
||||||
for i, f := range fills {
|
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)}
|
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
|
||||||
if z.LinkGroup != 0 {
|
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)))
|
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")
|
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 {
|
func viewName(st *model.State, id int64) string {
|
||||||
if v := st.Views[id]; v != nil {
|
if v := st.Views[id]; v != nil {
|
||||||
return v.Name
|
return v.Name
|
||||||
|
|||||||
@@ -303,3 +303,102 @@ func TestReverseFromOtherView(t *testing.T) {
|
|||||||
t.Errorf("warnings %q", res.Warnings)
|
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")
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ type Certificate struct {
|
|||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
|
||||||
// The current certificate: acme source only (a file source's is read
|
// 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:"-"`
|
CertPEM string `json:"-"`
|
||||||
KeyPEM 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 says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||||
LastRoutes []string `json:"last_routes,omitempty"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CreatedBy string `json:"created_by"`
|
CreatedBy string `json:"created_by"`
|
||||||
UpdatedBy string `json:"updated_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.
|
// HasUse reports whether c is used for use.
|
||||||
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||||
@@ -76,6 +76,9 @@ type User struct {
|
|||||||
TOTPSecret string `json:"-"`
|
TOTPSecret string `json:"-"`
|
||||||
TOTPPending string `json:"-"`
|
TOTPPending string `json:"-"`
|
||||||
TOTPLastStep int64 `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.
|
// HasTOTP reports whether the user logs in with a TOTP code.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile st
|
|||||||
|
|
||||||
func withCerts(env *apiEnv) *certs.Manager {
|
func withCerts(env *apiEnv) *certs.Manager {
|
||||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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
|
env.srv.cfg.Certs = m
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -165,3 +165,39 @@ func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
|||||||
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
u.Comment = *in.Comment
|
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 != nil {
|
||||||
if *in.Password == "" {
|
if *in.Password == "" {
|
||||||
u.PasswordHash = "" // service account: tokens only
|
u.PasswordHash = "" // service account: tokens only
|
||||||
|
|||||||
+239
-5
@@ -2,8 +2,13 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
@@ -27,6 +32,10 @@ type certView struct {
|
|||||||
NamedUse []string `json:"named_use,omitempty"`
|
NamedUse []string `json:"named_use,omitempty"`
|
||||||
// IssuingSince is set while Jarl is obtaining it.
|
// IssuingSince is set while Jarl is obtaining it.
|
||||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
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 {
|
type certFiles struct {
|
||||||
@@ -61,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
|
|||||||
return nil
|
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 {
|
func certErr(err error) error {
|
||||||
var ie *certs.InputError
|
var ie *certs.InputError
|
||||||
switch {
|
switch {
|
||||||
@@ -70,13 +151,15 @@ func certErr(err error) error {
|
|||||||
return notFound("no such certificate")
|
return notFound("no such certificate")
|
||||||
case errors.Is(err, certs.ErrConflict):
|
case errors.Is(err, certs.ErrConflict):
|
||||||
return conflict("a certificate with that name exists")
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// certList: GET /certificates.
|
// certList: GET /certificates.
|
||||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
all, err := s.cfg.Certs.List(r.Context())
|
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{}
|
out := []certView{}
|
||||||
for _, c := range all {
|
for _, c := range all {
|
||||||
|
if !certAllowed(p, c, false) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
v := s.presentCert(c)
|
v := s.presentCert(c)
|
||||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||||
out = append(out, v)
|
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())})
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// certGet: GET /certificates/{name}.
|
// certGet: GET /certificates/{name}.
|
||||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
c, err := s.certFor(r, p, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return certErr(err)
|
return err
|
||||||
}
|
}
|
||||||
st, err := s.store.LoadState(r.Context())
|
st, err := s.store.LoadState(r.Context())
|
||||||
if err != nil {
|
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 := s.presentCert(c)
|
||||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
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)
|
writeJSON(w, http.StatusOK, v)
|
||||||
return nil
|
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
|
// certCreate: POST /certificates. With "issue": true (the default for
|
||||||
// certificates Jarl obtains) it is obtained before the answer.
|
// certificates Jarl obtains) it is obtained before the answer.
|
||||||
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
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}.
|
// certDelete: DELETE /certificates/{name}.
|
||||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
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
|
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()
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
|||||||
for _, v := range st.OrderedViews() {
|
for _, v := range st.OrderedViews() {
|
||||||
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
||||||
out = append(out, presentView(st, v, colors))
|
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)
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
|||||||
+49
-10
@@ -36,31 +36,70 @@ 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)
|
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)}
|
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)
|
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
|
||||||
for _, rec := range st.ServedRecords(pz.ID) {
|
for _, rec := range st.ServedRecords(pz.ID) {
|
||||||
if rec.Name == z.Name && rec.Type == "DS" {
|
if rec.Name == z.Name && rec.Type == "DS" {
|
||||||
resp.ParentDS = append(resp.ParentDS, rec.Data)
|
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)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parentInJarl returns the primary zone in z's view that z is delegated
|
// parentInJarl returns the primary zone in Jarl that z is delegated from:
|
||||||
// from, if Jarl has it.
|
// in z's own view, else in another view, a public one first (the one the
|
||||||
func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
|
// internet asks).
|
||||||
var best *model.Zone
|
func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone {
|
||||||
for _, o := range st.ZonesInView(z.ViewID) {
|
in := func(viewID int64) *model.Zone {
|
||||||
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
|
var best *model.Zone
|
||||||
|
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) {
|
||||||
|
best = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
if best == nil || len(o.Name) > len(best.Name) {
|
if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) {
|
||||||
best = o
|
other = pz
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best
|
return other
|
||||||
}
|
}
|
||||||
|
|
||||||
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
|
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
|
||||||
|
|||||||
@@ -35,11 +35,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// registrarFor names the registrar Jarl can manage z's delegation at:
|
// 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 {
|
func (s *Server) registrarFor(z *model.Zone) string {
|
||||||
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
|
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
|
||||||
return ""
|
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)) {
|
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -59,6 +64,16 @@ func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State
|
|||||||
return nil, nil, "", err
|
return nil, nil, "", err
|
||||||
}
|
}
|
||||||
if s.registrarFor(z) == "" {
|
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 {
|
if s.cfg.Porkbun == nil {
|
||||||
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
|
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal)
|
|||||||
name = "."
|
name = "."
|
||||||
}
|
}
|
||||||
var err error
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
|
||||||
out.Prefix = pf.String()
|
out.Prefix = pf.String()
|
||||||
if z.Type == model.ZonePrimary {
|
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) {
|
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]}
|
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 {
|
func presentACL(st *model.State, a *model.ACL) api.ACL {
|
||||||
used := engine.FindACLRefs(st, a.Name)
|
used := engine.FindACLRefs(st, a.Name)
|
||||||
if used == nil {
|
if used == nil {
|
||||||
|
|||||||
@@ -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)
|
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()
|
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)
|
||||||
|
}
|
||||||
@@ -174,6 +174,8 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
|
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
|
||||||
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
|
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}", 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("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
|
||||||
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||||
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
||||||
|
|||||||
@@ -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,
|
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,
|
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) {
|
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||||
c := &model.Certificate{}
|
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
|
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,
|
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,
|
&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
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, f := range []struct {
|
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 {
|
func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
c.CreatedAt, c.UpdatedAt = now, now
|
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) {
|
if isUnique(err) {
|
||||||
return ErrConflict
|
return ErrConflict
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) err
|
|||||||
c.UpdatedAt = time.Now().UTC()
|
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=?,
|
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=?,
|
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)...)
|
append(certArgs(c), c.ID)...)
|
||||||
if isUnique(err) {
|
if isUnique(err) {
|
||||||
return ErrConflict
|
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,
|
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),
|
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),
|
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)
|
||||||
}
|
}
|
||||||
@@ -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 reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
|
||||||
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
|
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
@@ -18,16 +18,21 @@ func isUnique(err error) bool {
|
|||||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
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) {
|
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||||
u := &model.User{}
|
u := &model.User{}
|
||||||
var grants, ca, ua string
|
var grants, ca, ua string
|
||||||
var admin, disabled int
|
var admin, disabled int
|
||||||
var ll, te sql.NullString
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if limit.Valid {
|
||||||
|
n := int(limit.Int64)
|
||||||
|
u.CertLimit = &n
|
||||||
|
}
|
||||||
u.Admin, u.Disabled = admin != 0, disabled != 0
|
u.Admin, u.Disabled = admin != 0, disabled != 0
|
||||||
u.HasPassword = u.PasswordHash != ""
|
u.HasPassword = u.PasswordHash != ""
|
||||||
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
|
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 {
|
if u.Grants == nil {
|
||||||
u.Grants = []model.Grant{}
|
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 (?,?,?,?,?,?,?,?)`,
|
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.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now), u.CertLimit)
|
||||||
if isUnique(err) {
|
if isUnique(err) {
|
||||||
return ErrConflict
|
return ErrConflict
|
||||||
}
|
}
|
||||||
@@ -64,8 +69,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
|||||||
// UpdateUser saves a user.
|
// UpdateUser saves a user.
|
||||||
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
||||||
u.UpdatedAt = time.Now()
|
u.UpdatedAt = time.Now()
|
||||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE 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.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) {
|
if isUnique(err) {
|
||||||
return ErrConflict
|
return ErrConflict
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,8 +385,16 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
|
|||||||
if !ix.inZoneTarget(target) {
|
if !ix.inZoneTarget(target) {
|
||||||
return
|
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}
|
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
|
cur := target
|
||||||
for hops := 0; hops < 20; hops++ {
|
for hops := 0; hops < 20; hops++ {
|
||||||
if seen[cur] {
|
if seen[cur] {
|
||||||
@@ -398,18 +406,18 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
|
|||||||
if len(next) == 0 {
|
if len(next) == 0 {
|
||||||
break
|
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]
|
p := ix.parsed[next[0].ID]
|
||||||
if p == nil {
|
if p == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
cur = p.RR.(*dns.CNAME).Target
|
cur = p.RR.(*dns.CNAME).Target
|
||||||
|
chain = append(chain, disp(cur))
|
||||||
if !ix.inZoneTarget(cur) {
|
if !ix.inZoneTarget(cur) {
|
||||||
|
note()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
note()
|
||||||
if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) {
|
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))
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,8 +161,9 @@ func TestRecordConflicts(t *testing.T) {
|
|||||||
}, "cname_loop", Error},
|
}, "cname_loop", Error},
|
||||||
{"cname chain", func(b *builder, z *model.Zone) {
|
{"cname chain", func(b *builder, z *model.Zone) {
|
||||||
b.rec(z, "a", "CNAME", "b")
|
b.rec(z, "a", "CNAME", "b")
|
||||||
b.rec(z, "b", "CNAME", "ns1")
|
b.rec(z, "b", "CNAME", "c")
|
||||||
}, "cname_chain", Warning},
|
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},
|
{"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) {
|
{"null mx not alone", func(b *builder, z *model.Zone) {
|
||||||
b.rec(z, "@", "MX", "0 .")
|
b.rec(z, "@", "MX", "0 .")
|
||||||
@@ -380,3 +381,16 @@ func TestProblemStringDisplaysZone(t *testing.T) {
|
|||||||
t.Errorf("String() = %q, want %q", got, want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,7 +283,7 @@ export function heatmap(hm, unit) {
|
|||||||
const n = hm.samples[d][hr];
|
const n = hm.samples[d][hr];
|
||||||
const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" :
|
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)");
|
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 = () => {
|
const showTip = () => {
|
||||||
tip.textContent = text;
|
tip.textContent = text;
|
||||||
tip.hidden = false;
|
tip.hidden = false;
|
||||||
@@ -313,7 +313,7 @@ export function heatmap(hm, unit) {
|
|||||||
h("span", { class: "muted small" }, "0"),
|
h("span", { class: "muted small" }, "0"),
|
||||||
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
|
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
|
||||||
h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"),
|
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);
|
return h("div", { class: "heatmap-wrap" }, grid, tip, scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export async function render(el) {
|
|||||||
const tokensEl = h("div");
|
const tokensEl = h("div");
|
||||||
const drawTokens = async () => {
|
const drawTokens = async () => {
|
||||||
const tokens = await get("/me/tokens");
|
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 cur = input({ type: "password", autocomplete: "current-password" });
|
||||||
const pw1 = input({ type: "password", autocomplete: "new-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: "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: "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) => [
|
{ label: "Used for", render: (c) => [
|
||||||
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
|
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.uses.includes("dns") ? namedUse(c) : null,
|
||||||
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
|
!c.uses.length ? h("span", { class: "muted", title: "Export it to use it in another program" }, "not used by Jarl") : null] },
|
||||||
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : 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: "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: "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) => {
|
{ label: "Status", render: (c) => {
|
||||||
@@ -45,6 +45,7 @@ export async function render(el) {
|
|||||||
{ label: "", render: (c) => h("span", null,
|
{ 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", 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"), " ",
|
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" },
|
h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" },
|
||||||
], r.certificates, { empty: "No certificates yet." })));
|
], 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) {
|
function details(c) {
|
||||||
const rows = [
|
const rows = [
|
||||||
["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file],
|
["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 });
|
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) {
|
async function issue(c) {
|
||||||
try {
|
try {
|
||||||
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
|
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
|
||||||
|
|||||||
@@ -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.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.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" },
|
!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)];
|
out)];
|
||||||
|
|
||||||
const live = s.keys.filter((k) => k.ds && k.goal === "omnipresent");
|
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) {
|
function dsPanel(zone, s, keys) {
|
||||||
const ph = s.phase;
|
const ph = s.phase;
|
||||||
const where = s.parent_zone
|
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."]
|
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)),
|
||||||
: "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.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" },
|
return h("div", { class: "panel" },
|
||||||
h("h2", null, "DS record for the parent zone"),
|
h("h2", null, "DS record for the parent zone"),
|
||||||
ph.can_publish_ds
|
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.",
|
"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.",
|
"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.",
|
"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,
|
split,
|
||||||
s.policies.length > 1 ? h("div", { class: "form-grid" }, h("label", null, "Policy"), pol) : null,
|
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."],
|
[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
|
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 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, "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."],
|
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ async function userDialog(u, grants) {
|
|||||||
tmpl.value = "";
|
tmpl.value = "";
|
||||||
});
|
});
|
||||||
const comment = input({ value: u ? u.comment : "" });
|
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" },
|
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Username", name),
|
field("Username", name),
|
||||||
field("Password", pw),
|
field("Password", pw),
|
||||||
@@ -62,6 +63,7 @@ async function userDialog(u, grants) {
|
|||||||
u ? h("span") : null, u ? disabled : null,
|
u ? h("span") : null, u ? disabled : null,
|
||||||
field("Permissions", gr, "One grant per line (ignored for administrators)."),
|
field("Permissions", gr, "One grant per line (ignored for administrators)."),
|
||||||
h("span"), tmpl,
|
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), [
|
field("Comment", comment)), err), [
|
||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
|
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
|
||||||
@@ -69,6 +71,8 @@ async function userDialog(u, grants) {
|
|||||||
if (service.input.checked) data.password = "";
|
if (service.input.checked) data.password = "";
|
||||||
else if (pw.value) data.password = pw.value;
|
else if (pw.value) data.password = pw.value;
|
||||||
if (u) data.disabled = disabled.input.checked;
|
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 {
|
try {
|
||||||
if (u) await api("PATCH", "/users/" + u.id, { body: data });
|
if (u) await api("PATCH", "/users/" + u.id, { body: data });
|
||||||
else await api("POST", "/users", { 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 base = own ? "/me/tokens" : "/users/" + u.id + "/tokens";
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const tokens = await get(base);
|
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 draw();
|
||||||
await modal("API tokens of " + u.username, box, [{ label: "Close", value: null }], { wide: true });
|
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 });
|
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 err = h("div");
|
||||||
const out = h("div");
|
const out = h("div");
|
||||||
const name = input({ placeholder: "e.g. certbot on web01" });
|
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 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 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", () => {
|
tmpl.addEventListener("change", () => {
|
||||||
if (!tmpl.value) return;
|
if (!tmpl.value) return;
|
||||||
gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n");
|
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("Name", name),
|
||||||
field("Expires", exp),
|
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."),
|
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,
|
err,
|
||||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
|
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
|
||||||
mount(err); mount(out);
|
mount(err); mount(out);
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ import { registrarTab } from "./registrar.js";
|
|||||||
|
|
||||||
let renderSeq = 0;
|
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) {
|
export async function render(el, params, query) {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]);
|
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"), " ",
|
badge(zone.type, "accent"), " ",
|
||||||
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
||||||
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : 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.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
||||||
zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : 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.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.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 ? [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)),
|
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
||||||
h("div", { class: "actions" },
|
h("div", { class: "actions" },
|
||||||
zone.disabled
|
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.")
|
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" },
|
: 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. ",
|
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)));
|
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
|
// 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
|
// 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.
|
// the same. ?name= (and &type=) narrows it to one name.
|
||||||
async function compareTab(el, zone, query) {
|
async function compareTab(el, zone, query) {
|
||||||
const others = zone.other_views || [];
|
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 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" } });
|
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
|
||||||
let showSame = false;
|
let showSame = false;
|
||||||
@@ -801,12 +825,21 @@ async function recordsTab(el, zone, query) {
|
|||||||
|
|
||||||
// ---- settings
|
// ---- 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) {
|
async function settingsTab(el, zone) {
|
||||||
const views = await get("/views");
|
const views = await get("/views");
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const rows = [];
|
const rows = [];
|
||||||
const s = zone.soa || {};
|
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 rname = input({ value: zone.rname_email || emailOf(s.rname) });
|
||||||
const refresh = input({ type: "number", value: s.refresh });
|
const refresh = input({ type: "number", value: s.refresh });
|
||||||
const retry = input({ type: "number", value: s.retry });
|
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);
|
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
|
||||||
if (zone.type === "primary") {
|
if (zone.type === "primary") {
|
||||||
rows.push(
|
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("Contact", rname, "SOA RNAME, as an email address."),
|
||||||
field("Refresh", refresh, "Seconds between secondaries checking for updates."),
|
field("Refresh", refresh, "Seconds between secondaries checking for updates."),
|
||||||
field("Retry", retry, "Seconds before a failed refresh is retried."),
|
field("Retry", retry, "Seconds before a failed refresh is retried."),
|
||||||
@@ -838,7 +871,8 @@ async function settingsTab(el, zone) {
|
|||||||
mount(err);
|
mount(err);
|
||||||
const body = { comment: comment.value };
|
const body = { comment: comment.value };
|
||||||
if (zone.type === "primary") {
|
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.default_ttl = +ttl.value;
|
||||||
body.serial_policy = policy.value;
|
body.serial_policy = policy.value;
|
||||||
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
|
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);
|
const r = await change("PATCH", "/zones/" + zone.id, body);
|
||||||
toast(describeResult(r));
|
toast(describeResult(r));
|
||||||
rerender();
|
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,
|
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"))));
|
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: save }, "Save settings"))));
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|||||||
.hm-row { display: flex; align-items: center; }
|
.hm-row { display: flex; align-items: center; }
|
||||||
.hm-cell { height: 22px; border-radius: 3px; }
|
.hm-cell { height: 22px; border-radius: 3px; }
|
||||||
.hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; }
|
.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-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-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); }
|
||||||
.heat-6 { background: var(--heat-6); }
|
.heat-6 { background: var(--heat-6); }
|
||||||
|
|||||||
@@ -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 "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
|
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
||||||
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
|
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
|
# Encrypted DNS set up by Jarl (docs/encrypted-dns.md), next to the
|
||||||
# hand-written listener above.
|
# hand-written listener above.
|
||||||
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}
|
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}
|
||||||
|
|||||||
Reference in new issue
Block a user