Compare commits
26
Commits
v0.6.0-rc.3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b367e0c830 | ||
|
|
254867c61d | ||
|
|
3ddd32f97f | ||
|
|
0976faba3f | ||
|
|
1d535c8063 | ||
|
|
7f3564c0a5 | ||
|
|
71d9a06e43 | ||
|
|
2fa9828bcf | ||
|
|
a93373687e | ||
|
|
6cd3f6ef4e | ||
|
|
d18fe18ccd | ||
|
|
fa8feb6bd9 | ||
|
|
12013b12d5 | ||
|
|
9db76a113c | ||
|
|
b4bb99b482 | ||
|
|
57012fece8 | ||
|
|
4cc2ab2287 | ||
|
|
a36c54ce60 | ||
|
|
82c282cd32 | ||
|
|
65086e314a | ||
|
|
e38390df7d | ||
|
|
0e663ab36c | ||
|
|
5cd22f52d4 | ||
|
|
aca0abc780 | ||
|
|
9175d75d61 | ||
|
|
48f0082d8b |
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.
|
||||
+177
@@ -6,6 +6,183 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.1] - 2026-10-20
|
||||
|
||||
### 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
|
||||
|
||||
- The Statistics page's range buttons follow `stats.retention`: ranges
|
||||
longer than any data kept are left out, and **1y** and **2y** appear
|
||||
when `retention.day` (or `retention.hour`) covers them (with the
|
||||
default 730d, both do). The busiest-times heatmap says how far back it
|
||||
really goes (at most `retention.hour`), and links to the Explore page
|
||||
keep within `retention.queries` instead of assuming 7 days.
|
||||
`GET /stats/status` and `GET /stats/explore` add `retention_seconds`.
|
||||
|
||||
- A reverse zone's missing PTR records count only its own view's A and
|
||||
AAAA records. An address only another view's records use (an internal
|
||||
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
|
||||
longer reported as missing; it shows apart, as `Other views` in `jarl
|
||||
zone show`, `ptr_other_views` in the API and a note on the zone's page.
|
||||
Filling (`--fill`, `jarl zone fill-ptr`, the page's button) names those
|
||||
from the other view only when asked, with `--other-views`
|
||||
(`fill_other_views`, `?other_views=1`, a checkbox in the New reverse
|
||||
zone dialog), so one view's host names don't leak into another. When
|
||||
there is nothing else to fill, Jarl says how many there are and how to
|
||||
add them. The page and `jarl zone show` list those addresses with the
|
||||
name each would get, and the view it comes from
|
||||
(`ptr_other_views_list`); `jarl zone show` lists the missing ones too.
|
||||
|
||||
- Records, zones and validation findings sort numeric labels by value, so
|
||||
`2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the
|
||||
web UI, the CLI and the rendered zone files. Names still group by label
|
||||
from the right (subdomains stay under their parent); other labels keep
|
||||
the old byte order (`host10` before `host2`). Zone files are rewritten
|
||||
in the new order the next time they change; named doesn't care about
|
||||
record order.
|
||||
|
||||
- `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
|
||||
|
||||
- `jarl cert get NAME` no longer takes over a name this host has saved
|
||||
for another Jarl server, which overwrote that certificate's settings
|
||||
(and, with the default `--dir`, its files), so it stopped being
|
||||
renewed. It is refused, as is a `--dir` holding another certificate's
|
||||
files; `--replace` takes the name or directory over.
|
||||
- The "query log is off" error named a setting that doesn't exist,
|
||||
`stats.query_retention`; it's `stats.retention.queries`.
|
||||
- `jarl zone show` for a reverse zone printed only "and 1 more" under
|
||||
"Other views:" when the server was older than the CLI (0.6.1-rc.2
|
||||
sends the count but not the addresses). It now prints just the count
|
||||
line then.
|
||||
- `jarl changeset show` printed its Created and Applied times in UTC,
|
||||
while `jarl history` and `jarl status` print local time. It uses local
|
||||
time now too.
|
||||
|
||||
- The statistics' "Busiest times" grid drew hours without data taller
|
||||
than the others (they picked up the page's padding for empty
|
||||
messages).
|
||||
|
||||
- A token for one view of a linked zone (such as an ACME token with
|
||||
`views=external`) couldn't add records: a new record is shared, and
|
||||
that reached the other view. A new name and type is now local to the
|
||||
view when the user may not change it in the other views.
|
||||
- A zone name in several views had to be given with `--view` even when
|
||||
the user could see it in only one (`jarl record list adbraun.lol` with
|
||||
a one-view token), and the error named views the user can't see. Now
|
||||
the one they can see is used.
|
||||
- `jarl view list` was empty for a user without `views:read`, though
|
||||
they can see zones in views. It now lists those views by name, colour
|
||||
and visible zone count (marked `limited` in the API), without their
|
||||
settings.
|
||||
|
||||
- The DNSSEC tab told you to give the DS record to your registrar for
|
||||
every zone whose parent isn't in Jarl in the same view. Now it (and
|
||||
`jarl zone dnssec status`, and the API's new `ds_at`, `ds_place` and
|
||||
`ds_note`) says where it really goes: the parent zone in Jarl (in any
|
||||
view now, the zone's own first), the registrar for a registered
|
||||
domain, the parent zone wherever it is hosted for a subdomain, your
|
||||
ISP or RIR for a reverse zone, and nowhere for a private name such as
|
||||
`home.lan` (signing it has no effect).
|
||||
|
||||
- The Registrar tab (and `jarl zone registrar`, and the DNSSEC page's
|
||||
registrar button) was offered for every zone when `porkbun.domains` is
|
||||
empty, and always failed for subdomain zones (`lab.adbraun.lol`),
|
||||
reverse zones and private names (`home.lan`): Porkbun only knows
|
||||
registered domains. Only zones that are a registered domain (by the
|
||||
public suffix list) get it now; the others say what delegates them
|
||||
instead.
|
||||
|
||||
- Auto-PTR in linked zones changed PTR records in every linked view, not
|
||||
just where the address record is. A record local to one view got PTR
|
||||
records in the other views too; deleting or changing the address in
|
||||
one view deleted or moved the PTR records of views that still had it.
|
||||
PTR records now follow each view's own address records, are local to
|
||||
their view in a linked reverse zone, and each zone's Reverse DNS
|
||||
setting counts for its own view. **Fill** also adds local PTR records
|
||||
now, not shared ones. The same applies to **Update PTR** (`--ptr`).
|
||||
|
||||
- Saving the settings of a zone whose primary name server is outside it
|
||||
(`ns1.adbraun.lol.` for `wg.adbraun.lol`) changed the name server to
|
||||
`ns1.adbraun.lol.wg.adbraun.lol.`. The form showed the name without its
|
||||
final dot, which the server reads as relative to the zone. Names outside
|
||||
the zone now keep the dot, and the name server is only sent when it was
|
||||
changed.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- Tested: a 0.6.0 install with views, linked and reverse zones and
|
||||
statistics, opened by 0.6.1-rc.1: every zone, record and view kept,
|
||||
named answered as before, and the new grant and limits worked.
|
||||
- The database gains a migration (certificate owners, per-user
|
||||
certificate limits, certificate downloads). Going back to an older
|
||||
version afterwards means restoring a backup made before the upgrade.
|
||||
|
||||
## [0.6.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- Reverse zones made easy (docs/reverse.md):
|
||||
|
||||
@@ -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:
|
||||
rm -f $(DESTDIR)$(BINDIR)/jarld $(DESTDIR)$(BINDIR)/jarl $(DESTDIR)$(UNITDIR)/jarl.service \
|
||||
$(DESTDIR)$(UNITDIR)/jarld.service \
|
||||
$(DESTDIR)$(UNITDIR)/jarl-backup.service $(DESTDIR)$(UNITDIR)/jarl-backup.timer \
|
||||
$(DESTDIR)$(BASHCOMPDIR)/jarl $(DESTDIR)$(BASHCOMPDIR)/jarld \
|
||||
$(DESTDIR)$(SYSUSERSDIR)/jarl.conf $(DESTDIR)$(TMPFILESDIR)/jarl.conf
|
||||
|
||||
@@ -202,8 +202,14 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.5.2 is the current release, and 0.6.0 is in testing
|
||||
(0.6.0-rc.3): it makes reverse zones easy (suggested from your records,
|
||||
Version 0.6.1 is the current release. 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.
|
||||
Reverse zones list addresses in numeric order and no longer report
|
||||
another view's hosts as missing PTR records.
|
||||
0.6.0 makes reverse zones easy (suggested from your records,
|
||||
created in the right view and filled, PTR records kept in step), rebuilds
|
||||
comparing a zone's views, lets you explore the statistics down to each
|
||||
client and name (a query log), shows the databases' size, and gives
|
||||
|
||||
@@ -378,6 +378,9 @@ func printUser(w io.Writer, u api.User) {
|
||||
if u.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", u.Comment)
|
||||
}
|
||||
if u.CertLimit != nil {
|
||||
fmt.Fprintf(w, "Certificates:\tat most %d (jarl cert get)\n", *u.CertLimit)
|
||||
}
|
||||
fmt.Fprintf(w, "Last login:\t%s\n", fmtTime(u.LastLoginAt))
|
||||
}
|
||||
|
||||
@@ -444,6 +447,7 @@ func userCmd() *cobra.Command {
|
||||
}
|
||||
var admin, noPassword, disabled bool
|
||||
var role, comment, rename string
|
||||
var certLimit int
|
||||
var grants []string
|
||||
userBody := func(cmd *cobra.Command) (api.UserInput, error) {
|
||||
var in api.UserInput
|
||||
@@ -466,6 +470,9 @@ func userCmd() *cobra.Command {
|
||||
if f.Changed("rename") {
|
||||
in.Username = &rename
|
||||
}
|
||||
if f.Changed("cert-limit") {
|
||||
in.CertLimit = &certLimit
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
addUserFlags := func(c *cobra.Command) {
|
||||
@@ -473,6 +480,7 @@ func userCmd() *cobra.Command {
|
||||
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
|
||||
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
|
||||
}
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// checkCertClash refuses a certificate that would take over what this
|
||||
// host keeps for another one: a name saved for another Jarl server
|
||||
// (each server has its own names, so the server can't refuse it), or a
|
||||
// directory with another certificate's files. saved[i] is sc's saved
|
||||
// settings (i < 0: none).
|
||||
func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error {
|
||||
if i >= 0 && !replace && !sameServer(saved[i].Conn.Server, sc.Conn.Server) {
|
||||
return fmt.Errorf("%s is saved here for the Jarl server %s, not %s: choose another name, or take it over with --replace", sc.Name, saved[i].Conn.Server, sc.Conn.Server)
|
||||
}
|
||||
for j, o := range saved {
|
||||
if j != i && o.Name != sc.Name && filepath.Clean(o.Dir) == filepath.Clean(sc.Dir) {
|
||||
return fmt.Errorf("%s holds the files of %s, saved for %s: give %s another --dir", sc.Dir, o.Name, o.Conn.Server, sc.Name)
|
||||
}
|
||||
}
|
||||
if m, err := readMeta(sc.Dir); err == nil && m.Name != "" && m.Name != sc.Name && !replace {
|
||||
return fmt.Errorf("%s holds the files of the certificate %s: give %s another --dir, or overwrite them with --replace", sc.Dir, m.Name, sc.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sameServer reports whether two server addresses name the same jarld.
|
||||
func sameServer(a, b string) bool {
|
||||
norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") }
|
||||
return norm(a) == norm(b)
|
||||
}
|
||||
|
||||
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, replace 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.
|
||||
|
||||
A name saved here for another Jarl server isn't taken over, nor a --dir
|
||||
holding another certificate's files, unless --replace is given; a --dir
|
||||
another saved certificate uses is always refused.
|
||||
|
||||
--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
|
||||
}
|
||||
if err := checkCertClash(saved, i, sc, replace); err != nil {
|
||||
return err
|
||||
}
|
||||
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, 5 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.BoolVar(&replace, "replace", false, "take over a name saved here for another Jarl server, or a --dir holding another certificate's files")
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckCertClash(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
a := connection{Server: "https://dns-a.example:8053"}
|
||||
b := connection{Server: "https://dns-b.example:8053"}
|
||||
saved := []savedCert{
|
||||
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Conn: a},
|
||||
{Name: "mail", Dir: filepath.Join(tmp, "mail"), Conn: a},
|
||||
}
|
||||
// mail's files are on disk; "other" has files but no saved settings.
|
||||
if err := (&localMeta{Name: "mail"}).write(mkdir(t, saved[1].Dir)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&localMeta{Name: "other"}).write(mkdir(t, filepath.Join(tmp, "other"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
what string
|
||||
i int
|
||||
sc savedCert
|
||||
replace bool
|
||||
want string // "" = allowed
|
||||
}{
|
||||
{"same server again", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: a}, false, ""},
|
||||
{"same server, other spelling", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: connection{Server: "HTTPS://dns-a.example:8053/"}}, false, ""},
|
||||
{"other server", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, false, "saved here for the Jarl server"},
|
||||
{"other server, replace", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, true, ""},
|
||||
{"new name, saved cert's dir", -1, savedCert{Name: "web", Dir: saved[1].Dir, Conn: b}, false, "holds the files of mail"},
|
||||
{"new name, saved cert's dir, replace", -1, savedCert{Name: "web", Dir: saved[1].Dir + "/", Conn: b}, true, "holds the files of mail"},
|
||||
{"new name, unsaved cert's files", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, false, "certificate other"},
|
||||
{"new name, unsaved cert's files, replace", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, true, ""},
|
||||
{"new name, empty dir", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "web"), Conn: b}, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := checkCertClash(saved, c.i, c.sc, c.replace)
|
||||
switch {
|
||||
case c.want == "" && err != nil:
|
||||
t.Errorf("%s: %v", c.what, err)
|
||||
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
|
||||
t.Errorf("%s: got %v, want %q", c.what, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mkdir(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
+109
-2
@@ -50,6 +50,13 @@ type certInfo struct {
|
||||
Chain string `json:"chain"`
|
||||
Key string `json:"key"`
|
||||
} `json:"files,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
PulledBy []struct {
|
||||
Who string `json:"who"`
|
||||
Serial string `json:"serial"`
|
||||
IP string `json:"ip"`
|
||||
At time.Time `json:"at"`
|
||||
} `json:"pulled_by,omitempty"`
|
||||
}
|
||||
|
||||
type certAccount struct {
|
||||
@@ -99,9 +106,10 @@ used too (--cert-file and --key-file): Jarl rereads it when it changes.`,
|
||||
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
|
||||
jarl cert list
|
||||
jarl cert issue ns1 # renew now
|
||||
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)`,
|
||||
jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)
|
||||
jarl cert export ns1 --dir /etc/ssl/ns1 # fullchain.pem and privkey.pem, for another program`,
|
||||
}
|
||||
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certDeleteCmd(), certAccountCmd())
|
||||
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certGetCmd(), certSavedCmd(), certForgetCmd(), certScheduleCmd(), certDeleteCmd(), certAccountCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -180,6 +188,20 @@ func printCert(w io.Writer, ci certInfo) {
|
||||
if ci.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
|
||||
}
|
||||
if ci.Owner != "" {
|
||||
fmt.Fprintf(w, "Owner:\t%s (jarl cert get)\n", ci.Owner)
|
||||
}
|
||||
for i, p := range ci.PulledBy {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "Pulled by:"
|
||||
}
|
||||
cur := "current"
|
||||
if p.Serial != ci.Serial {
|
||||
cur = "an older one: not fetched since the renewal"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s from %s, %s (%s)\n", label, p.Who, dash(p.IP), fmtTime(&p.At), cur)
|
||||
}
|
||||
state := ci.Status.State
|
||||
if ci.Status.Message != "" {
|
||||
state += ": " + ci.Status.Message
|
||||
@@ -493,6 +515,91 @@ is published and the CA isn't asked.`,
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certExportCmd() *cobra.Command {
|
||||
var dir, part string
|
||||
var noKey, force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "export NAME",
|
||||
Short: "Save a certificate (and its key) to use in another program",
|
||||
Long: `Saves a certificate's current files, to use it outside Jarl (a mail
|
||||
server, a reverse proxy): fullchain.pem and privkey.pem in --dir (the
|
||||
key readable only by you), or one part on standard output with --part:
|
||||
|
||||
fullchain the certificate and its chain
|
||||
cert the certificate alone
|
||||
key the private key
|
||||
bundle the key, then the chain (one file, as HAProxy wants)
|
||||
|
||||
Administrators only; every export of the key is audited. Jarl renews the
|
||||
certificate, but not your copy: export it again after a renewal (the
|
||||
'renews' date in 'jarl cert show'), or point the program at the files Jarl
|
||||
writes for named (use dns) instead.`,
|
||||
Example: ` jarl cert export ns1 --dir /etc/ssl/ns1
|
||||
jarl cert export ns1 --part bundle > /etc/haproxy/ns1.pem
|
||||
jarl cert export ns1 --no-key --dir .`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
get := func(p string) (string, error) {
|
||||
var out string
|
||||
err := c.Get(ctx(), certPath(args[0])+"/pem", url.Values{"part": {p}}, &out)
|
||||
return out, err
|
||||
}
|
||||
if part != "" {
|
||||
out, err := get(part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = os.Stdout.WriteString(out)
|
||||
return err
|
||||
}
|
||||
files := []struct{ part, name string }{{"fullchain", "fullchain.pem"}, {"key", "privkey.pem"}}
|
||||
if noKey {
|
||||
files = files[:1]
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
path := dir + string(os.PathSeparator) + f.name
|
||||
if _, err := os.Stat(path); err == nil && !force {
|
||||
return fmt.Errorf("%s exists (--force replaces it)", path)
|
||||
}
|
||||
}
|
||||
for _, f := range files {
|
||||
out, err := get(f.part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := dir + string(os.PathSeparator) + f.name
|
||||
mode := os.FileMode(0o644)
|
||||
if f.part == "key" {
|
||||
mode = 0o600
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(out), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("wrote", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&dir, "dir", ".", "directory for fullchain.pem and privkey.pem")
|
||||
cmd.Flags().StringVar(&part, "part", "", "write one part to standard output: fullchain, cert, key or bundle")
|
||||
cmd.Flags().BoolVar(&noKey, "no-key", false, "only fullchain.pem")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "replace existing files")
|
||||
_ = cmd.RegisterFlagCompletionFunc("part", cobra.FixedCompletions([]string{"fullchain", "cert", "key", "bundle"}, cobra.ShellCompDirectiveNoFileComp))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certDeleteCmd() *cobra.Command {
|
||||
var yes bool
|
||||
cmd := &cobra.Command{
|
||||
|
||||
+2
-2
@@ -272,9 +272,9 @@ func changesetCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
return output(cs, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
|
||||
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Local().Format("2006-01-02 15:04"), cs.Username, cs.Via)
|
||||
if cs.AppliedAt != nil {
|
||||
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
|
||||
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Local().Format("2006-01-02 15:04"), cs.AppliedBy)
|
||||
}
|
||||
fmt.Fprintln(w, "\n#\tOPERATION")
|
||||
for _, op := range cs.Ops {
|
||||
|
||||
@@ -30,7 +30,12 @@ func viewCmd() *cobra.Command {
|
||||
return output(views, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
||||
for i, v := range views {
|
||||
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, orAny(v.MatchClients), orAny(v.MatchDestinations), v.Zones, orDefault(v.Color))
|
||||
clients, dests := orAny(v.MatchClients), orAny(v.MatchDestinations)
|
||||
if v.Limited {
|
||||
// No views:read: only the zones they can see.
|
||||
clients, dests = "(not shown)", "(not shown)"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, clients, dests, v.Zones, orDefault(v.Color))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
+20
-9
@@ -23,8 +23,8 @@ keeps writing the unsigned zone, and named keeps a signed copy next to it
|
||||
and its keys in Jarl's key directory. See docs/dnssec.md.
|
||||
|
||||
Turning signing on is safe at any time; validation only starts once you add
|
||||
the zone's DS record at the parent zone (your registrar), and 'status' says
|
||||
when that is safe. Turning it off goes through BIND's insecure policy, which
|
||||
the zone's DS record at the parent zone (your registrar for a domain, the
|
||||
parent zone for a subdomain), and 'status' says when that is safe and where. Turning it off goes through BIND's insecure policy, which
|
||||
keeps the zone signed until the DS is gone from the parent.`,
|
||||
}
|
||||
var view string
|
||||
@@ -71,7 +71,7 @@ DS after that.`,
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
|
||||
fmt.Printf("\nnamed is signing %s now. Add its DS record to the parent zone only when\n'jarl zone dnssec status %s' says the zone is ready; it also says where it goes.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -87,8 +87,8 @@ DS after that.`,
|
||||
parent zone still has its DS record:
|
||||
|
||||
1. The first run switches the zone to BIND's insecure policy. named keeps it
|
||||
signed and publishes a CDS "delete" record. Remove the DS record at your
|
||||
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
|
||||
signed and publishes a CDS "delete" record. Remove the DS record at the
|
||||
parent ('status' says where), then run 'jarl zone dnssec check-ds' so named knows. named
|
||||
waits until resolvers have forgotten the DS (the parent's DS TTL, often a
|
||||
day) and then removes the keys and signatures.
|
||||
2. Run it again when 'status' says turning off has finished: DNSSEC is then
|
||||
@@ -137,7 +137,7 @@ A zone whose DS never reached the parent can be wiped at once instead
|
||||
if next == "insecure" && st.ParentZone != "" {
|
||||
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
||||
} else if next == "insecure" {
|
||||
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
||||
fmt.Printf("\nNext: remove the DS record for %s %s, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), dsPlace(st), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
||||
} else {
|
||||
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
|
||||
}
|
||||
@@ -153,7 +153,7 @@ A zone whose DS never reached the parent can be wiped at once instead
|
||||
Short: "Check the parent zone's DS records and tell named",
|
||||
Long: `Asks every server of the parent zone for the zone's DS records, and tells
|
||||
named (rndc dnssec -checkds) when its key's DS has appeared there (after you
|
||||
added it at the registrar) or is gone (while turning DNSSEC off). named
|
||||
added it there) or is gone (while turning DNSSEC off). named
|
||||
moves to the next step only once it knows.
|
||||
|
||||
The parent is found through jarld's resolver, or --resolver. Exits with 2
|
||||
@@ -345,9 +345,12 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
|
||||
}
|
||||
}
|
||||
if len(ds) > 0 && r.Policy != "insecure" {
|
||||
where := "at your registrar"
|
||||
if r.DSAt == "none" {
|
||||
fmt.Fprintf(w, "\n%s\n", r.DSNote)
|
||||
}
|
||||
where := dsPlace(r)
|
||||
if r.ParentZone != "" {
|
||||
where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there"
|
||||
where += ", which is in Jarl: add it as a DS record there"
|
||||
}
|
||||
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
|
||||
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
|
||||
@@ -368,6 +371,14 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
// dsPlace says where the zone's DS record goes ("at your registrar").
|
||||
func dsPlace(r *api.DNSSECResponse) string {
|
||||
if r.DSPlace != "" {
|
||||
return r.DSPlace
|
||||
}
|
||||
return "at the parent zone"
|
||||
}
|
||||
|
||||
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
|
||||
// record in its parent zone in Jarl.
|
||||
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
|
||||
|
||||
+36
-9
@@ -103,17 +103,30 @@ func saveCLIConfig(c *cliConfig) error {
|
||||
return os.WriteFile(flagConfig, b, 0o600)
|
||||
}
|
||||
|
||||
func newClient() (*client.Client, error) {
|
||||
// connection is the server a command talks to and how: Token may be
|
||||
// "@FILE" (read by open).
|
||||
type connection struct {
|
||||
Server string `json:"server"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
// Name is the named server's name ("" for one given by address).
|
||||
Name string `json:"-"`
|
||||
}
|
||||
|
||||
// resolveConnection works out the connection from the flags, the
|
||||
// environment and the CLI configuration.
|
||||
func resolveConnection() (connection, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
|
||||
return connection{}, fmt.Errorf("reading %s: %w", flagConfig, err)
|
||||
}
|
||||
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return connection{}, err
|
||||
}
|
||||
if len(ts) > 1 {
|
||||
return nil, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
|
||||
return connection{}, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
|
||||
}
|
||||
t := ts[0]
|
||||
// A named server's own token wins: a token from the environment
|
||||
@@ -125,15 +138,21 @@ func newClient() (*client.Client, error) {
|
||||
sock, isSocket := client.SocketPath(t.URL)
|
||||
if isSocket {
|
||||
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
||||
return nil, err
|
||||
return connection{}, err
|
||||
}
|
||||
}
|
||||
if token == "" && !isSocket {
|
||||
if t.Name != "" {
|
||||
return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
|
||||
return connection{}, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
|
||||
}
|
||||
return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
|
||||
return connection{}, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
|
||||
}
|
||||
return connection{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert), Name: t.Name}, nil
|
||||
}
|
||||
|
||||
// open returns a client for the connection.
|
||||
func (cn connection) open() (*client.Client, error) {
|
||||
token := cn.Token
|
||||
if strings.HasPrefix(token, "@") {
|
||||
b, err := os.ReadFile(token[1:])
|
||||
if err != nil {
|
||||
@@ -141,7 +160,15 @@ func newClient() (*client.Client, error) {
|
||||
}
|
||||
token = strings.TrimSpace(string(b))
|
||||
}
|
||||
c, err := client.New(client.Options{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert)})
|
||||
return client.New(client.Options{Server: cn.Server, Token: token, Insecure: cn.Insecure, CACert: cn.CACert})
|
||||
}
|
||||
|
||||
func newClient() (*client.Client, error) {
|
||||
cn, err := resolveConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := cn.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,7 +194,7 @@ func newClient() (*client.Client, error) {
|
||||
}
|
||||
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
||||
if !c.Preflight {
|
||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
||||
c.OnTokenExpiry = tokenExpiryWarning(cn.Name)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/api"
|
||||
)
|
||||
|
||||
func TestPrintPTRFills(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
printPTRFills(&b, []api.PTRFill{{Address: "10.0.10.20", Target: "nextcloud.adbraun.lol.", From: "internal"}}, 3)
|
||||
out := b.String()
|
||||
if !strings.Contains(out, "10.0.10.20 -> nextcloud.adbraun.lol (view internal)") || !strings.Contains(out, "and 2 more") {
|
||||
t.Errorf("listed fills:\n%s", out)
|
||||
}
|
||||
|
||||
// An older server sends the count without the list.
|
||||
b.Reset()
|
||||
printPTRFills(&b, nil, 1)
|
||||
if b.Len() != 0 {
|
||||
t.Errorf("no list from the server printed %q, want nothing", b.String())
|
||||
}
|
||||
}
|
||||
+73
-7
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -100,6 +101,16 @@ func zoneCmd() *cobra.Command {
|
||||
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
|
||||
if z.PTRMissing > 0 {
|
||||
fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0])
|
||||
printPTRFills(w, z.PTRMissingList, z.PTRMissing)
|
||||
}
|
||||
if z.PTROtherViews > 0 {
|
||||
what := "addresses"
|
||||
if z.PTROtherViews == 1 {
|
||||
what = "address"
|
||||
}
|
||||
fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n",
|
||||
z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0])
|
||||
printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews)
|
||||
}
|
||||
}
|
||||
if len(z.LinkedWith) > 0 {
|
||||
@@ -119,7 +130,7 @@ func zoneCmd() *cobra.Command {
|
||||
var ns, records, primaries, forwarders, opts []string
|
||||
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
||||
var ttl uint32
|
||||
var fill bool
|
||||
var fill, fillOther bool
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
Short: "Create a zone",
|
||||
@@ -128,7 +139,8 @@ create its reverse zone: a /32 (or /128) gives a zone for that one address,
|
||||
and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
(created together). Without --view, a reverse zone goes to the private
|
||||
view whose records use its addresses; --fill adds a PTR record for each
|
||||
of them (docs/reverse.md).
|
||||
of them, and --other-views also for the addresses only other views'
|
||||
records use, named from there (docs/reverse.md).
|
||||
|
||||
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
|
||||
jarl zone create 192.168.1.0/24
|
||||
@@ -169,9 +181,12 @@ of them (docs/reverse.md).
|
||||
if linkWith != "" {
|
||||
body["link_with"] = linkWith
|
||||
}
|
||||
if fill {
|
||||
if fill || fillOther {
|
||||
body["fill"] = true
|
||||
}
|
||||
if fillOther {
|
||||
body["fill_other_views"] = true
|
||||
}
|
||||
var recs []map[string]any
|
||||
for _, r := range records {
|
||||
f := strings.Fields(r)
|
||||
@@ -245,6 +260,7 @@ of them (docs/reverse.md).
|
||||
create.Flags().StringVar(&linkWith, "link-with", "", "link with the zone of the same name in this view: copy its shared records and keep them in step (docs/views.md#linked-zones)")
|
||||
create.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network")
|
||||
create.Flags().BoolVar(&fillOther, "other-views", false, "reverse zones: --fill, also naming the addresses only other views' records use")
|
||||
|
||||
var refresh, retry, expire, minimum, serial uint32
|
||||
var mname, policy, moveView string
|
||||
@@ -1166,17 +1182,61 @@ func terminalWidth() int {
|
||||
return width
|
||||
}
|
||||
|
||||
// printPTRFills lists PTR records a reverse zone lacks under zone show's
|
||||
// line about them: address -> name, and the view the name is from. A server
|
||||
// older than the CLI may send the count without the list; then nothing is
|
||||
// listed, rather than "and N more" under no addresses.
|
||||
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
|
||||
if len(fills) == 0 {
|
||||
return
|
||||
}
|
||||
for _, f := range fills {
|
||||
from := ""
|
||||
if f.From != "" {
|
||||
from = " (view " + f.From + ")"
|
||||
}
|
||||
fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from)
|
||||
}
|
||||
if total > len(fills) {
|
||||
fmt.Fprintf(w, "\t and %d more\n", total-len(fills))
|
||||
}
|
||||
}
|
||||
|
||||
// fromViews names the views PTR fills take their names from: "view
|
||||
// internal", "views internal and lab".
|
||||
func fromViews(fills []api.PTRFill) string {
|
||||
var vs []string
|
||||
for _, f := range fills {
|
||||
if f.From != "" && !slices.Contains(vs, f.From) {
|
||||
vs = append(vs, f.From)
|
||||
}
|
||||
}
|
||||
switch len(vs) {
|
||||
case 0:
|
||||
return "other views"
|
||||
case 1:
|
||||
return "view " + vs[0]
|
||||
}
|
||||
return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1]
|
||||
}
|
||||
|
||||
func zoneFillPTRCmd() *cobra.Command {
|
||||
var view string
|
||||
var otherViews bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "fill-ptr ZONE [--view VIEW]",
|
||||
Use: "fill-ptr ZONE [--view VIEW] [--other-views]",
|
||||
Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records",
|
||||
Long: `Adds a PTR record to a reverse zone for every address in its network that
|
||||
the view's A and AAAA records use and that has none yet. An address with
|
||||
several names gets the mail or name server's name, else a host's rather
|
||||
than the domain's, else the shortest (docs/reverse.md).`,
|
||||
than the domain's, else the shortest (docs/reverse.md).
|
||||
|
||||
--other-views also adds the addresses in the network that only other
|
||||
views' records use, naming them from there. That publishes those views'
|
||||
host names in this one, so it is never done unasked.`,
|
||||
Example: ` jarl zone fill-ptr 10.0.10.0/24
|
||||
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal`,
|
||||
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal
|
||||
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
@@ -1184,6 +1244,9 @@ than the domain's, else the shortest (docs/reverse.md).`,
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
if otherViews {
|
||||
qv.Set("other_views", "1")
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1192,6 +1255,7 @@ than the domain's, else the shortest (docs/reverse.md).`,
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
cmd.Flags().BoolVar(&otherViews, "other-views", false, "also name the addresses only other views' records use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1231,7 +1295,9 @@ again). A view can stop suggestions: jarl view update V
|
||||
--all lists every network the views' records use without a reverse zone,
|
||||
also where nothing is suggested unasked (a view with suggestions off, a
|
||||
dismissed network, a public view), to create one by hand; --view narrows
|
||||
the list to one view. Change the size as you need: a /48 suggested for
|
||||
the list to one view and adds the networks only other views' records use
|
||||
(hidden "records in VIEW"): create one with --fill --other-views to name
|
||||
them from there. Change the size as you need: a /48 suggested for
|
||||
IPv6 can be created as a /56 or /64 instead.`,
|
||||
Example: ` jarl zone suggest-reverse
|
||||
jarl zone suggest-reverse --create
|
||||
|
||||
@@ -318,6 +318,7 @@ func serveCmd() *cobra.Command {
|
||||
EABKeyID: a.cfg.ACME.Account.EABKeyID, EABHMAC: a.cfg.ACME.Account.EABHMAC,
|
||||
Resolvers: a.cfg.ACME.Resolvers, CABundle: a.cfg.ACME.CABundle, CheckPropagation: a.cfg.ACME.CheckPropagation,
|
||||
Dir: a.cfg.CertDir(), Version: version.String(), Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
|
||||
MaxPerUser: a.cfg.Certificates.MaxPerUser,
|
||||
}, a.store, a.eng, a.log)
|
||||
cm.Start(ctx)
|
||||
if a.cfg.ACME.Account.EABHMAC != "" {
|
||||
|
||||
@@ -38,6 +38,13 @@ if [ "$1" = configure ]; then
|
||||
if systemctl is-enabled --quiet jarl-backup.timer 2>/dev/null; then
|
||||
systemctl start jarl-backup.timer || true
|
||||
fi
|
||||
# The jarld.service alias is linked when jarl.service is enabled;
|
||||
# relink a unit enabled before the alias existed (this neither
|
||||
# starts nor stops jarld).
|
||||
if [ -n "$2" ] && systemctl is-enabled --quiet jarl.service 2>/dev/null \
|
||||
&& [ ! -e /etc/systemd/system/jarld.service ]; then
|
||||
systemctl reenable jarl.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
# An upgrade restarts a running jarld (its database is upgraded
|
||||
# when it starts). A first install starts nothing: Jarl needs
|
||||
# 'jarld init' and 'jarld adopt' first.
|
||||
|
||||
@@ -65,3 +65,5 @@ SystemCallErrorNumber=EPERM
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
# 'systemctl restart jarld' works too (the daemon's binary is jarld).
|
||||
Alias=jarld.service
|
||||
+3
-1
@@ -48,7 +48,9 @@ jarl acme unset DOMAIN [VALUE...] # remove these values, or all of them
|
||||
* The zone is found automatically: the longest matching primary zone.
|
||||
* If the zone exists in several **views** (split DNS), the record is written
|
||||
into every view, so the CA (on the internet, in your external view) and
|
||||
your internal resolvers see the same thing. `--view NAME` limits it to one.
|
||||
your internal resolvers see the same thing. `--view NAME` limits it to one,
|
||||
and so does a token for one view (`views=external`): in a linked zone its
|
||||
records are then local to that view.
|
||||
* All values and views go into one changeset. Setting a value that is
|
||||
already there succeeds, so a retried hook doesn't fail, and removing a
|
||||
value that isn't there succeeds as well.
|
||||
|
||||
+31
-10
@@ -191,6 +191,7 @@ ZoneInput:
|
||||
{
|
||||
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
|
||||
"fill": true, // reverse zones: PTR records for the view's A/AAAA records there
|
||||
"fill_other_views": true, // with fill: also for addresses only other views' records use
|
||||
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
|
||||
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
|
||||
"in_view": "internal", // type in-view only
|
||||
@@ -212,8 +213,12 @@ ZoneInput:
|
||||
|
||||
A reverse zone created without a view goes to the private view whose
|
||||
records use its addresses. Zones come back with `prefix` (reverse zones:
|
||||
the network) and `ptr_missing` (addresses in it that the view's A and AAAA
|
||||
records use, without a PTR record).
|
||||
the network), `ptr_missing` (addresses in it that the view's A and AAAA
|
||||
records use, without a PTR record) and `ptr_other_views` (addresses in it
|
||||
without a PTR record that only other views' records use; not missing),
|
||||
with the first 25 of each in `ptr_missing_list` and
|
||||
`ptr_other_views_list` (`address`, `name`, `target`, and `from`, the view
|
||||
the name comes from).
|
||||
|
||||
`/serials` and `/delegation` send DNS queries from the server, and only
|
||||
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
|
||||
@@ -294,11 +299,20 @@ view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)).
|
||||
},
|
||||
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
|
||||
"policies": ["default"],
|
||||
"parent_zone": "", // set when the parent zone is a primary zone in Jarl
|
||||
"ds_at": "registrar", // parent_zone, registrar, parent, reverse or none
|
||||
"ds_place": "at your registrar", // completes "add the DS record ..."
|
||||
"ds_note": "", // for ds_at none: why it can't have one
|
||||
"parent_zone": "", // set when the parent zone is a primary zone in Jarl (any view, its own first)
|
||||
"registrar": "porkbun" // when the DS can be published through Jarl
|
||||
}
|
||||
```
|
||||
|
||||
`ds_at` says where the DS record goes ([dnssec.md](dnssec.md#should-you-sign)):
|
||||
`parent_zone` (a zone in Jarl: `parent_zone`, `parent_zone_id`,
|
||||
`parent_view`), `registrar` (the zone is a registered domain), `parent` (a
|
||||
subdomain whose parent zone is hosted elsewhere), `reverse` (whoever
|
||||
delegates the addresses) or `none` (nothing on the internet delegates it).
|
||||
|
||||
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
|
||||
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
|
||||
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
|
||||
@@ -354,13 +368,16 @@ In a linked zone ([views.md](views.md#linked-zones)), records carry
|
||||
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
|
||||
true` (this view only, an override) or `false` (shared; on an update or a
|
||||
set it makes this view's version every view's). Zones carry `link_group`
|
||||
and `linked_with` (the other views).
|
||||
and `linked_with` (the other views). Every zone in a view carries
|
||||
`in_views`: the zones of the same name in the other views, linked or not
|
||||
and of any type, in view order (`[{"id": 289, "view": "external", "type":
|
||||
"primary", "linked": true}]`).
|
||||
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
|
||||
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
||||
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) |
|
||||
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records; `?other_views=1` also for the addresses only other views' records use, named from there ([reverse.md](reverse.md)) |
|
||||
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
|
||||
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
||||
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
|
||||
@@ -411,7 +428,9 @@ meantime stops it (and gives a `disabled_conflict` warning until then).
|
||||
`PUT /rrsets` makes the RRset exactly the given data: a disabled record
|
||||
with one of the values is enabled, other disabled records are left alone.
|
||||
Disabling a record that is already disabled is a 409; with `missing_ok=1`
|
||||
(by match) it's "nothing to do".
|
||||
(by match) it's "nothing to do". A zone update that leaves the zone as it
|
||||
is (the same SOA, TTL, comment...) changes nothing, so applying it is
|
||||
"nothing to do" and staging it is "nothing to change".
|
||||
|
||||
### Views
|
||||
|
||||
@@ -510,7 +529,7 @@ written in Jarl's fixed order (docs/concepts.md).
|
||||
| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` |
|
||||
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
|
||||
| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` |
|
||||
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations |
|
||||
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations; 400 "nothing to change" if they change nothing |
|
||||
| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation |
|
||||
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
|
||||
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
|
||||
@@ -560,12 +579,12 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
||||
|
||||
| Method | Path | Query | Returns |
|
||||
|--------|------|-------|---------|
|
||||
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
|
||||
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, retention_seconds, warnings}`: `retention` has `minute`, `hour`, `day`, `queries` as `"7d"`, `retention_seconds` the same in seconds (`queries` 0 without a query log) |
|
||||
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
|
||||
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
||||
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
||||
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
||||
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log |
|
||||
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention` (`"7d"`) and `retention_seconds`, `client_name`. 404 `query_log_off` without a query log |
|
||||
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}], database: {path, bytes, query_log_rows}}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
||||
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
||||
|
||||
@@ -590,6 +609,8 @@ Administrators only. See [certificates.md](certificates.md#api).
|
||||
| GET, PATCH, DELETE | `/certificates/{name}` | |
|
||||
| POST | `/certificates/{name}/issue` | 202: being obtained (poll until `issuing_since` is gone); a file certificate is reread (200) |
|
||||
| POST | `/certificates/{name}/check` | the routes of a certificate's names |
|
||||
| POST | `/certificates/{name}/request` | `{"domains": [...], "key_type": "ec256"}`: a certificate for another host, owned by the caller's user (needs `certificates:write` for every name). New: created and obtained, 202 (poll `GET /certificates/{name}` until `issuing_since` is gone). Existing: must be the caller's (else 409); obtained again only when its names change. Answers `certificate`, `created`, `changed` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
|
||||
| GET | `/certificates/{name}/pem?part=PART` | the certificate as PEM: `fullchain` (default), `cert`, `key`, `bundle`; key exports audited ([certificates.md](certificates.md#using-a-certificate-outside-jarl)) |
|
||||
|
||||
`GET /status` includes `certificates` for administrators: `total`,
|
||||
`problems` (failing, expiring, expired), `next` and `next_after`,
|
||||
@@ -616,7 +637,7 @@ Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
|
||||
|--------|------|------|
|
||||
| GET | `/users` | admin |
|
||||
| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) |
|
||||
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
|
||||
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled`, `username` to rename, and `cert_limit` (certificates the user may own through `jarl cert get`; negative: the `certificates.max_per_user` default). Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
|
||||
| GET / POST | `/users/{id}/tokens` | admin (or yourself) |
|
||||
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
|
||||
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
|
||||
|
||||
+103
-1
@@ -216,11 +216,112 @@ and the last good one stays in use. jarld runs as user `jarl`, which must
|
||||
be able to read both files (e.g. `chgrp jarl` and `chmod 640`, or have
|
||||
your renewal hook copy them somewhere it can read).
|
||||
|
||||
## Using a certificate outside Jarl
|
||||
|
||||
A certificate needn't be used by Jarl at all ("not used by Jarl" on the
|
||||
Certificates page): Jarl obtains and renews it, and you export it for
|
||||
another program, such as a mail server or a reverse proxy. **Export…** on
|
||||
the Certificates page downloads each file or copies it to the clipboard
|
||||
(copying needs the web UI over HTTPS, or from localhost). Or:
|
||||
|
||||
```sh
|
||||
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem, and privkey.pem (mode 600)
|
||||
jarl cert export mail --part bundle > /etc/haproxy/mail.pem
|
||||
```
|
||||
|
||||
The parts are `fullchain` (the certificate and its chain), `cert` (the
|
||||
certificate alone), `key` and `bundle` (the key, then the chain, in one
|
||||
file, as HAProxy wants). Administrators can export any certificate, and
|
||||
the owner of a certificate for another host (below) their own. Every
|
||||
export of the key is in the audit log (`certificate.export_key`), and a key
|
||||
only goes over HTTPS, the local socket, or to this server itself.
|
||||
|
||||
Jarl renews the certificate, not your copy: export it again after each
|
||||
renewal (`jarl cert show` says when that is). For another host, `jarl
|
||||
cert get` (below) does that for you. A program on this server can instead
|
||||
read the files Jarl writes for named (use `dns`), which Jarl keeps current.
|
||||
|
||||
## Certificates for other hosts
|
||||
|
||||
`jarl cert get` gets a certificate onto another host (a mail server, a
|
||||
reverse proxy) with one command: jarld obtains it from the CA and renews
|
||||
it, answering the challenges in its own zones as for its own certificates;
|
||||
the host asks for it, waits until it is there, downloads it and runs a
|
||||
hook. The host needs only a token, not an ACME client or access to DNS.
|
||||
|
||||
**On Jarl**, as an administrator, give the hosts a user and a token each:
|
||||
|
||||
```sh
|
||||
jarl user create hosts --no-password --grant "certificates:write names=*.adbraun.lol"
|
||||
jarl token create mx --user hosts # one token per host
|
||||
```
|
||||
|
||||
The `certificates` grant says which names the user may have certificates
|
||||
for. It is never part of `*`, so `operator` and `readonly` users can't
|
||||
get certificates (or download keys) unless you add one. A certificate
|
||||
belongs to the user that asked for it: its tokens can download it, other
|
||||
users can't take its name or see it, and the certificates Jarl uses
|
||||
itself are the administrators'. Hosts that should share certificates use
|
||||
the same user (each with its own token, so you can revoke one host
|
||||
alone); hosts that shouldn't, use different users. A user may own
|
||||
`certificates.max_per_user` certificates (10 by default), or what `jarl
|
||||
user update NAME --cert-limit N` (or the user's form) sets.
|
||||
|
||||
**On the host:**
|
||||
|
||||
```sh
|
||||
export JARL_SERVER=https://jarl.adbraun.lol:8053 JARL_TOKEN=@/root/jarl.token
|
||||
jarl cert get mail -d mail.adbraun.lol -d smtp.adbraun.lol --hook 'systemctl reload postfix dovecot'
|
||||
sudo jarl cert schedule # a daily systemd timer runs 'jarl cert get'
|
||||
jarl cert saved # what this host has, and when it expires
|
||||
```
|
||||
|
||||
* **Asking again** with the same names returns the certificate jarld has:
|
||||
nothing is obtained, nothing is downloaded until jarld renews it.
|
||||
**Adding or removing a name** gets a new certificate. A different
|
||||
`--key-type` alone doesn't; delete the certificate for that.
|
||||
* **Files**, in `--dir` (default `/etc/jarl/certs/NAME` as root, `./NAME`
|
||||
otherwise): `cert.pem`, `chain.pem` (the intermediates),
|
||||
`fullchain.pem`, `privkey.pem` (mode 600) and `jarl-cert.json` (serial,
|
||||
expiry, when it was fetched). Each file is replaced at once, never
|
||||
half written. The key is checked against the certificate before
|
||||
anything is written.
|
||||
* **Saved settings**: the names, `--dir`, `--hook`, the server and the
|
||||
token are saved in `/etc/jarl/cert-get/NAME.json` (as root;
|
||||
`~/.config/jarl/cert-get` otherwise), readable only by their owner.
|
||||
`jarl cert get` without a name does every saved certificate. A token
|
||||
given as `@FILE` is saved as that reference, not copied. `jarl cert
|
||||
forget NAME` stops getting one (`--delete` deletes it on jarld too).
|
||||
* **Several Jarl servers on one host**: each server has its own
|
||||
certificate names, so two servers may both have a `proxy`. The host
|
||||
keeps one `proxy` per settings directory, though: asking another server
|
||||
for a name saved here for the first one is refused, as is a `--dir`
|
||||
holding another certificate's files. Choose another name or `--dir`, or
|
||||
pass `--replace` to take the name (or directory) over. A `--dir` that
|
||||
another saved certificate uses is always refused.
|
||||
* **The hook** runs with `sh` after new files are written, with
|
||||
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
|
||||
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`,
|
||||
`JARL_CERT_SERIAL`, `JARL_CERT_NOT_AFTER` and `JARL_CERT_RENEWED` (1 when
|
||||
it replaced an older certificate). If it fails, `jarl cert get` exits
|
||||
non-zero and runs it again next time. It has 5 minutes.
|
||||
* **`jarl cert schedule`** (Linux with systemd, as root) writes
|
||||
`/etc/systemd/system/jarl-cert-get.service` and `.timer`, which run
|
||||
`jarl cert get` daily at a random time within the hour after midnight,
|
||||
and catch up after the host was off. `--remove` takes them away. On
|
||||
other systems, run `jarl cert get` daily with their scheduler.
|
||||
|
||||
**On the Certificates page**, the administrators see each certificate's
|
||||
owner and who downloaded it: the token, from where, when, and whether
|
||||
that host has the current certificate or hasn't fetched the renewed one
|
||||
yet. `jarl cert show NAME` says the same.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Certificates Jarl obtains, their keys and the ACME account key are kept
|
||||
in Jarl's database, so they are in its backups (like TSIG secrets). The
|
||||
API never returns a key.
|
||||
API returns a key only to an administrator who exports it, and records
|
||||
that in the audit log.
|
||||
- Every publicly trusted certificate is published in Certificate
|
||||
Transparency logs, with all its names. A name like `ns1.adbraun.lol`
|
||||
used for both the web UI and DNS reveals less than separate names. Avoid
|
||||
@@ -240,6 +341,7 @@ your renewal hook copy them somewhere it can read).
|
||||
| GET, PATCH, DELETE | `/certificates/{name}` | `domains` and `uses` replace the whole list |
|
||||
| POST | `/certificates/{name}/issue` | obtain now: 202, then poll `GET /certificates/{name}` until `issuing_since` is gone; a file certificate is reread (200) |
|
||||
| POST | `/certificates/{name}/check` | the routes for an existing certificate |
|
||||
| GET | `/certificates/{name}/pem?part=fullchain` | the certificate as PEM, to save: `fullchain` (default), `cert`, `key` or `bundle` (key and chain). Exports of the key are audited |
|
||||
|
||||
## Testing with Pebble
|
||||
|
||||
|
||||
+11
-1
@@ -179,6 +179,7 @@ jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.i
|
||||
jarl zone create 10.0.10.100/32 # a zone for just that address
|
||||
jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs
|
||||
jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
|
||||
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views # also name addresses only other views' records use
|
||||
jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours)
|
||||
jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
|
||||
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
|
||||
@@ -318,7 +319,8 @@ host name (a bare `nas` is refused, since it would mean
|
||||
keeps the PTR record in the reverse zone in step (when that zone is managed
|
||||
by Jarl); `jarl record update ID --ptr` with nothing else adds the PTR record
|
||||
if it is missing, e.g. after creating the reverse zone. In linked zones, the
|
||||
reverse zones of every linked view count. Every record type BIND supports can be used. Jarl knows the
|
||||
reverse zone of each linked view that has the record counts
|
||||
([reverse.md](reverse.md#keeping-ptr-records-in-step)). Every record type BIND supports can be used. Jarl knows the
|
||||
syntax of the common ones and checks them strictly, and the rest are
|
||||
validated by parsing.
|
||||
|
||||
@@ -445,6 +447,14 @@ jarl cert show ns1
|
||||
jarl cert issue ns1 # renew now (--no-wait to return at once)
|
||||
jarl cert update ns1 --challenge porkbun # auto, jarl or porkbun
|
||||
jarl cert update ns1 --use dns # --use and --domain replace the list (--use none)
|
||||
jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey.pem, for another program
|
||||
jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle
|
||||
jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook
|
||||
jarl cert get # every saved certificate (what the timer runs)
|
||||
jarl cert get proxy -s other --replace # take over a name saved here for another server
|
||||
jarl cert saved # this host's certificates and their expiry
|
||||
jarl cert forget mail [--delete] # stop getting it here
|
||||
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
|
||||
jarl cert delete ns1
|
||||
jarl cert account # the ACME account (set in jarl.yaml)
|
||||
```
|
||||
|
||||
@@ -101,6 +101,7 @@ jarld restarts.
|
||||
| `acme.ca_bundle` | `""` | PEM file of extra CA certificates to trust for the ACME server (a private CA). |
|
||||
| `acme.check_propagation` | `true` | Wait until every name server of the zone serves a challenge before the CA is told to look (they are asked directly, on port 53). |
|
||||
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the `dns` use are written for named: `<dir>/<name>/fullchain.pem` and `privkey.pem`. |
|
||||
| `certificates.max_per_user` | `10` | How many certificates a user may own through `jarl cert get` ([certificates.md](certificates.md#certificates-for-other-hosts)). A user's own limit (`jarl user update NAME --cert-limit N`) replaces it. |
|
||||
|
||||
### ACME (lego httpreq endpoint)
|
||||
|
||||
@@ -163,7 +164,7 @@ check` says so).
|
||||
|-----|---------|---------|
|
||||
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
||||
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
||||
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). |
|
||||
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). Subdomain and reverse zones never get the registrar features ([dnssec.md](dnssec.md#porkbun)). |
|
||||
|
||||
The key can move your domain anywhere, so it is only ever read from this
|
||||
file: it isn't stored in the database or backups, and there is no API to
|
||||
|
||||
@@ -32,6 +32,12 @@ internal/
|
||||
blocking/ blocklist parser, response policy zone writers, and matching a name the way named does
|
||||
(the downloads are in engine/blocklists.go)
|
||||
config/ jarld's YAML configuration
|
||||
certs/ certificates: Jarl as an ACME client (lego), renewal, files for named
|
||||
encdns/ encrypted DNS (DoT/DoH) setup, status and health checks
|
||||
mailcheck/ reverse DNS check of the addresses mail is sent from
|
||||
totp/ two-factor login codes and recovery codes
|
||||
version/ what a build reports (release tag, commit, commits since a tag)
|
||||
testutil/ test helpers, chiefly finding BIND's tools (JARL_BIND_ROOT)
|
||||
deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files
|
||||
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
|
||||
contrib/acme.sh/ acme.sh DNS API hook
|
||||
|
||||
@@ -30,6 +30,17 @@ Signing an internal-only zone (one no registrar delegates, or a split-DNS
|
||||
view only your own network reaches) adds nothing. There is no parent to
|
||||
publish a DS, and BIND doesn't validate the zones it serves itself.
|
||||
|
||||
**Where the DS record goes** depends on the zone. The DNSSEC tab and
|
||||
`jarl zone dnssec status` say which:
|
||||
|
||||
| Zone | The DS record goes |
|
||||
|---|---|
|
||||
| a registered domain (`adbraun.lol`) | at your registrar ([Porkbun](#porkbun) can do it for you) |
|
||||
| a subdomain whose parent zone is in Jarl (`lab.adbraun.lol`) | in the parent zone, as a DS record named `lab`: one button, or the `jarl record add` command shown. The parent in the zone's own view counts first, else one in another view (a public one first). |
|
||||
| a subdomain whose parent zone is hosted elsewhere | in that parent zone, wherever it is hosted (at Porkbun: the domain's DNS records) |
|
||||
| a reverse zone | with whoever delegates it to you: your ISP, or your RIR for an address block of your own. Many ISPs don't take DS records. |
|
||||
| a private name (`home.lan`) | nowhere: nothing on the internet delegates it, so signing it has no effect |
|
||||
|
||||
**Split DNS.** In Jarl a zone belongs to a view, so you sign the external
|
||||
view's `adbraun.lol` and leave the internal view's copy unsigned. That is
|
||||
the usual setup, with one catch. Once the DS is at the parent, the world
|
||||
@@ -204,6 +215,18 @@ root:jarl`, `chmod 640`, as the package installs it); jarld warns at start
|
||||
when everyone can read it. Only administrators can use the registrar
|
||||
features.
|
||||
|
||||
Only a zone that is a registered domain gets the registrar features
|
||||
(`adbraun.lol`, `example.co.uk`, judged by the public suffix list). Their
|
||||
parent zone delegates the others, not a registrar:
|
||||
|
||||
* a subdomain zone such as `lab.adbraun.lol`: the NS and DS records in
|
||||
`adbraun.lol`;
|
||||
* a reverse zone: whoever holds the addresses (your ISP, or the RIR);
|
||||
* a zone under a suffix that isn't public (`home.lan`, `home.arpa`):
|
||||
nobody.
|
||||
|
||||
The zone's **Name servers** tab (Check delegation) checks all of them.
|
||||
|
||||
**Using it**: the zone's **Registrar** tab, or `jarl zone registrar`:
|
||||
|
||||
| | |
|
||||
|
||||
@@ -317,6 +317,10 @@ systemctl status jarl
|
||||
journalctl -u jarl -f
|
||||
```
|
||||
|
||||
The unit also answers to `jarld` (`Alias=jarld.service`), so
|
||||
`systemctl restart jarld` and `journalctl -u jarl` work alike once it
|
||||
is enabled.
|
||||
|
||||
Open `https://<management-address>:8053/` and log in as `admin`.
|
||||
|
||||
## 9. Set up the CLI
|
||||
|
||||
+15
-3
@@ -118,7 +118,8 @@ A user's permissions are a list of grants. Administrators (`admin: true`)
|
||||
may do everything, and only they can manage users, see everyone's tokens,
|
||||
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
|
||||
whose key can move the domain), and manage TLS certificates (whose keys
|
||||
can impersonate the server; [certificates.md](certificates.md)).
|
||||
can impersonate the server; [certificates.md](certificates.md)). The one
|
||||
exception is certificates for other hosts, below.
|
||||
|
||||
A grant is written as:
|
||||
|
||||
@@ -137,7 +138,8 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
|
||||
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
|
||||
| `stats` | query statistics, including client addresses and the names they look up (read only) |
|
||||
| `blocking` | blocklists and block/allow rules ([blocking.md](blocking.md)); `write` also downloads lists now |
|
||||
| `*` | all of the above |
|
||||
| `certificates` | certificates for other hosts (`jarl cert get`), by the names in them: `write` gets one obtained, `read` only downloads it. Never part of `*` |
|
||||
| `*` | all of the above, except `certificates` |
|
||||
|
||||
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
|
||||
Each takes comma separated patterns where `*` and `?` are wildcards, and a
|
||||
@@ -146,6 +148,8 @@ missing filter matches everything:
|
||||
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
|
||||
* `views=internal`: view names (`_default` means zones outside any view)
|
||||
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
|
||||
(for `certificates`: the names in the certificate; `*.adbraun.lol` also
|
||||
allows the wildcard certificate `*.adbraun.lol`)
|
||||
* `types=TXT,CNAME`: record types
|
||||
|
||||
Examples:
|
||||
@@ -157,10 +161,18 @@ zones:write zones=*.lab.adbraun.lol create/delete zones be
|
||||
records:write zones=adbraun.lol views=internal only the internal copy of a split zone
|
||||
records:write names=_acme-challenge.* types=TXT ACME challenges anywhere
|
||||
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
|
||||
certificates:write names=mail.adbraun.lol,smtp.adbraun.lol certificates for a mail server (jarl cert get)
|
||||
```
|
||||
|
||||
A user who may see some records of a zone may also see that the zone exists
|
||||
(its name and type), but not its other records.
|
||||
(its name and type), but not its other records. They also see the zone's
|
||||
view in `jarl view list` (`GET /views`): its name, colour and how many
|
||||
zones they can see there, marked `limited`, but not its settings
|
||||
(`match-clients` and the rest need `views:read`).
|
||||
|
||||
A zone name that is in several views needs a view (`--view`), unless the
|
||||
user can see the zone in only one of them: a token for
|
||||
`views=external` finds the external copy without being told.
|
||||
|
||||
### Roles
|
||||
|
||||
|
||||
+50
-9
@@ -97,10 +97,19 @@ jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill
|
||||
the dialog's View), the list also has the addresses only other views'
|
||||
records use, marked "records in external": say a VPS's AAAA record that
|
||||
only the external view has. Fill names those from the other view's
|
||||
record, when the zone's own view has none for the address, and says so.
|
||||
Keeping PTR records in step (below) stays within a zone's own view and
|
||||
its linked views: a later change to that external record doesn't reach
|
||||
the internal reverse zone.
|
||||
record only when asked: `--other-views` (with `--fill` or `jarl zone
|
||||
fill-ptr`), or the dialog's **Also for addresses only other views'
|
||||
records use**, which is ticked for you when you pick a "records in"
|
||||
network. Otherwise one view's host names would turn up in another's
|
||||
reverse zone, which is what views are for keeping apart. Jarl says which
|
||||
names came from another view. Keeping PTR records in step (below) stays
|
||||
within a zone's own view and its linked views: a later change to that
|
||||
external record doesn't reach the internal reverse zone.
|
||||
|
||||
```sh
|
||||
jarl zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views
|
||||
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views
|
||||
```
|
||||
|
||||
## Creating and filling
|
||||
|
||||
@@ -132,8 +141,25 @@ view's A and AAAA records use. An address with several names gets one PTR:
|
||||
3. the shortest name, then the first alphabetically.
|
||||
|
||||
Jarl says which name it chose, and which it didn't, so you can change it.
|
||||
A reverse zone's page shows how many addresses still lack a PTR, with a
|
||||
button to add them. `jarl zone show` shows it as `Missing PTRs`.
|
||||
A reverse zone's page shows the addresses that still lack a PTR record and
|
||||
the name each would get (the first 25), with a button to add them. `jarl
|
||||
zone show` shows how many as `Missing PTRs`; the API lists them in
|
||||
`ptr_missing_list`. Only the zone's own view's records count: an address
|
||||
that only another view's records use isn't missing (say internal's
|
||||
`claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`).
|
||||
Those are listed apart, with the name each would get: `Other views` in
|
||||
`jarl zone show`, `ptr_other_views` and `ptr_other_views_list` in the
|
||||
API, and a quiet note on the page with an **Add PTR records for them in
|
||||
view vps** button. Filling never adds them unasked. `jarl zone show`
|
||||
lists the missing ones too:
|
||||
|
||||
```
|
||||
Missing PTRs: 1 (addresses the view's records use here; add them: jarl zone fill-ptr 10.0.10.0/24)
|
||||
10.0.10.7 -> printer.adbraun.lol
|
||||
Other views: 2 addresses with A or AAAA records only in view internal, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr 10.0.10.0/24 --other-views)
|
||||
10.0.10.26 -> gomft.adbraun.lol (view internal)
|
||||
10.0.10.101 -> claude.adbraun.lol (view internal)
|
||||
```
|
||||
|
||||
## Keeping PTR records in step
|
||||
|
||||
@@ -147,9 +173,24 @@ jarl zone update adbraun.lol --view internal --auto-ptr # off: --auto-p
|
||||
|
||||
(Zone settings → **Reverse DNS** in the web UI.) Then adding, changing,
|
||||
disabling, enabling or deleting an A or AAAA record in the zone does the
|
||||
same to its PTR record, in the reverse zones of the zone's view and of the
|
||||
views it is linked with ([views.md](views.md#linked-zones)). A whole set
|
||||
replaced at once (`jarl record set`, a batch) works too.
|
||||
same to its PTR record, in the reverse zone of each view that has the
|
||||
record. A whole set replaced at once (`jarl record set`, a batch) works
|
||||
too.
|
||||
|
||||
**Linked zones** ([views.md](views.md#linked-zones)). PTR records follow
|
||||
each view's own address records:
|
||||
|
||||
* A shared record gets a PTR record in every linked view that has it; a
|
||||
record local to one view gets one in that view only.
|
||||
* An address that goes from one view (deleted, changed, or overridden by
|
||||
a local record) loses its PTR record there only. Views that still have
|
||||
the address keep theirs.
|
||||
* Each zone's own **Reverse DNS** setting counts: a linked zone without
|
||||
it doesn't get PTR records from its partner's changes.
|
||||
* In a linked reverse zone, these PTR records (and those **Fill** adds)
|
||||
are local to their view. A PTR record that is shared becomes local in
|
||||
each view the first time one of them changes it, so that a change in
|
||||
one view doesn't reach the others.
|
||||
|
||||
* **One name per address.** When an address's PTR record names another
|
||||
host, it stays: a second A record for the same address doesn't add a
|
||||
|
||||
+5
-1
@@ -263,7 +263,11 @@ database, roughly in proportion to the number of views.
|
||||
lists for `retention.hour`, daily ones for `retention.day`. Older data is
|
||||
deleted every hour.
|
||||
- Charts use the finest resolution that covers the range: minutes up to 48
|
||||
hours, hours up to 90 days, days beyond.
|
||||
hours (and `retention.minute`), hours up to `retention.hour`, days beyond.
|
||||
- The Statistics page offers only the ranges the data is kept for: up to 90
|
||||
days with the defaults, plus a year and 2 years when `retention.day` (or
|
||||
`retention.hour`) is that long. The Explore page offers the ranges up to
|
||||
`retention.queries`.
|
||||
- Top lists are exact within an hour (up to 200,000 distinct names or
|
||||
clients per hour, beyond which the rarest are dropped). Daily lists are
|
||||
built from the hourly top lists, so for very long tails they are
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ showing them.
|
||||
| `cname_multiple` | error | A name can have only one CNAME |
|
||||
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
|
||||
| `cname_loop` | error | CNAMEs that point back to themselves |
|
||||
| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution |
|
||||
| `cname_chain` | info | a chain of three or more CNAMEs (one CNAME to another says nothing): works, but each is another lookup for some resolvers |
|
||||
| `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone |
|
||||
| `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) |
|
||||
| `apex_ns_missing` | error | A zone needs NS records at its apex |
|
||||
|
||||
+15
-3
@@ -168,6 +168,10 @@ shows one (it needs `tsig:write` and is audited). Key names starting with
|
||||
`jarl-view-` are reserved, and the view keys can be used in your own
|
||||
options (`allow-transfer { key jarl-view-external; }`).
|
||||
|
||||
**Moving between views.** A zone's page lists the other views that have a
|
||||
zone of the same name ("also in external", in each view's colour). Each
|
||||
one links straight to that zone, on the tab you are on.
|
||||
|
||||
## Linked zones
|
||||
|
||||
Most split-horizon zones are the same in every view apart from a few
|
||||
@@ -183,7 +187,9 @@ own and its own zone options.
|
||||
the web UI). A local record overrides the shared records of the same name
|
||||
and type in that view: `www A 10.0.10.5` local to internal, while the
|
||||
external view keeps the shared `www A 203.0.113.20`. Delete the local
|
||||
records and the view gets the shared ones back.
|
||||
records and the name and type are gone from that view only: the other
|
||||
views keep their records, each local to its view now. To give the view
|
||||
the shared version back instead, **Share** it from a view that has it.
|
||||
* A new record joins the set of the same name and type: local if that set
|
||||
is local in its view, shared otherwise.
|
||||
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
|
||||
@@ -216,7 +222,10 @@ anyway), so scripts don't need to know; `--shared` there is refused.
|
||||
a grant for one view only (`views=internal`) can change that view's local
|
||||
records and make overrides, but not a shared record, since that would
|
||||
change the other views too: Jarl says so and suggests a local record. A
|
||||
grant without a view (the usual `zones=adbraun.lol`) covers every view.
|
||||
new name and type it adds is local to its view without being asked (an
|
||||
ACME token for `views=external` adds its `_acme-challenge` TXT records to
|
||||
the external view only). A grant without a view (the usual
|
||||
`zones=adbraun.lol`) covers every view.
|
||||
|
||||
**Importing a zone file** into a linked zone (`jarl zone import`) adds
|
||||
the records like any others: shared, unless the name and type is local
|
||||
@@ -235,7 +244,10 @@ before it).
|
||||
## Comparing views
|
||||
|
||||
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
|
||||
same zone in another view (or all of them), from the view you're on:
|
||||
same zone in another view (or all of them), from the view you're on.
|
||||
The tab starts with a view the zone is linked to, if any, else the
|
||||
first other view; `jarl zone diff` compares with every other view unless
|
||||
`--with` names some:
|
||||
|
||||
```
|
||||
$ jarl zone diff adbraun.lol --view internal
|
||||
|
||||
+46
-1
@@ -34,6 +34,9 @@ type Zone struct {
|
||||
LinkedWith []string `json:"linked_with,omitempty"`
|
||||
// Links are the zones this one is linked with, with their views.
|
||||
Links []ZoneLink `json:"links,omitempty"`
|
||||
// InViews are the zones of this name in the other views (any type),
|
||||
// in view order: for going straight to one.
|
||||
InViews []ZoneLink `json:"in_views,omitempty"`
|
||||
// OtherViews are the other views with a primary zone of this name
|
||||
// (linked or not): what GET /zones/{zone}/compare compares.
|
||||
OtherViews []string `json:"other_views,omitempty"`
|
||||
@@ -42,6 +45,15 @@ type Zone struct {
|
||||
// (POST /zones/{zone}/fill-ptr adds them).
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
PTRMissing int `json:"ptr_missing,omitempty"`
|
||||
// PTRMissingList are the first 25 of them: the PTR records adding
|
||||
// them would create.
|
||||
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"`
|
||||
// PTROtherViews counts the addresses in it, without a PTR record,
|
||||
// that only other views' records use: not missing, but fill-ptr with
|
||||
// other_views=1 adds them, naming them from there. PTROtherViewsList
|
||||
// are the first 25 (From says which view).
|
||||
PTROtherViews int `json:"ptr_other_views,omitempty"`
|
||||
PTROtherViewsList []PTRFill `json:"ptr_other_views_list,omitempty"`
|
||||
// Pending is set when listing with a changeset: add, change or delete.
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
@@ -67,6 +79,21 @@ type View struct {
|
||||
model.View
|
||||
Zones int `json:"zone_count"`
|
||||
Color string `json:"color,omitempty"` // web UI colour, see model.ViewColors
|
||||
// Limited is set for a view listed only because the principal can see
|
||||
// zones in it (no views:read): only its name, colour and the zones
|
||||
// they can see are given.
|
||||
Limited bool `json:"limited,omitempty"`
|
||||
}
|
||||
|
||||
// PTRFill is a PTR record a reverse zone lacks: Name (the PTR owner) for
|
||||
// Address would give Target. Others are its other names; From the view
|
||||
// whose record names it, when the zone's own view has none.
|
||||
type PTRFill struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
Target string `json:"target"`
|
||||
Others []string `json:"others,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
// ViewColor sets the colour the web UI shows a view in.
|
||||
@@ -399,6 +426,9 @@ type UserInput struct {
|
||||
Role *string `json:"role,omitempty"` // a role template name
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// CertLimit is how many certificates the user may own (jarl cert
|
||||
// get); a negative number goes back to certificates.max_per_user.
|
||||
CertLimit *int `json:"cert_limit,omitempty"`
|
||||
}
|
||||
|
||||
// TokenInput creates a token.
|
||||
@@ -465,8 +495,20 @@ type DNSSECResponse struct {
|
||||
View string `json:"view,omitempty"`
|
||||
Type string `json:"type"`
|
||||
*dnssec.Status
|
||||
// DSAt says where the zone's DS record goes: "parent_zone" (a primary
|
||||
// zone in Jarl, below), "registrar" (the zone is a registered domain),
|
||||
// "parent" (a subdomain whose parent zone is hosted elsewhere),
|
||||
// "reverse" (whoever delegates the addresses) or "none" (nothing on
|
||||
// the internet delegates the zone, so it can't have one).
|
||||
DSAt string `json:"ds_at"`
|
||||
// DSPlace completes "add the DS record …" ("at your registrar"); ""
|
||||
// for "none".
|
||||
DSPlace string `json:"ds_place,omitempty"`
|
||||
// DSNote explains DSAt "none".
|
||||
DSNote string `json:"ds_note,omitempty"`
|
||||
// ParentZone is set when the parent zone is a primary zone in Jarl
|
||||
// (in the same view): the DS record then goes there, as a record.
|
||||
// (in the zone's own view, else preferably a public one): the DS
|
||||
// record then goes there, as a record.
|
||||
ParentZone string `json:"parent_zone,omitempty"`
|
||||
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
|
||||
ParentView string `json:"parent_view,omitempty"`
|
||||
@@ -616,6 +658,9 @@ type BlockRule struct {
|
||||
type ZoneLink struct {
|
||||
ID int64 `json:"id"`
|
||||
View string `json:"view"`
|
||||
// Type and Linked are set in Zone.InViews.
|
||||
Type string `json:"type,omitempty"`
|
||||
Linked bool `json:"linked,omitempty"`
|
||||
}
|
||||
|
||||
// EncDNSSummary is what the dashboard says about encrypted DNS.
|
||||
|
||||
@@ -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
|
||||
ResStats = "stats" // query statistics
|
||||
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.
|
||||
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking}
|
||||
var Resources = []string{ResZones, ResRecords, ResViews, ResACLs, ResTSIG, ResOptions, ResAudit, ResStats, ResBlocking, ResCerts}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
@@ -74,8 +77,11 @@ func ValidateGrant(g *model.Grant) error {
|
||||
for i, t := range g.Types {
|
||||
g.Types[i] = strings.ToUpper(t)
|
||||
}
|
||||
if (len(g.Names) > 0 || len(g.Types) > 0) && g.Resource != ResRecords {
|
||||
return fmt.Errorf("names and types filters only apply to the records resource")
|
||||
if len(g.Types) > 0 && g.Resource != ResRecords {
|
||||
return fmt.Errorf("the types filter only applies to the records resource")
|
||||
}
|
||||
if len(g.Names) > 0 && g.Resource != ResRecords && g.Resource != ResCerts {
|
||||
return fmt.Errorf("the names filter only applies to the records and certificates resources")
|
||||
}
|
||||
if (len(g.Zones) > 0 || len(g.Views) > 0) && g.Resource != ResRecords && g.Resource != ResZones && g.Resource != ResViews {
|
||||
return fmt.Errorf("zone and view filters only apply to the zones, records and views resources")
|
||||
@@ -228,6 +234,9 @@ func GrantAllows(g model.Grant, a Action) bool {
|
||||
if g.Resource != ResAll && g.Resource != a.Resource {
|
||||
return false
|
||||
}
|
||||
if g.Resource == ResAll && a.Resource == ResCerts {
|
||||
return false // only an explicit certificates grant
|
||||
}
|
||||
if a.Write && g.Access != Write {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ type Config struct {
|
||||
PropagationTimeout time.Duration
|
||||
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||
SkipPropagationCheck bool
|
||||
// MaxPerUser is how many certificates a user may own (Ensure), unless
|
||||
// the user has a limit of their own.
|
||||
MaxPerUser int
|
||||
}
|
||||
|
||||
// Manager keeps certificates current and serves jarld's own.
|
||||
@@ -132,6 +135,8 @@ type Input struct {
|
||||
Challenge *string `json:"challenge"`
|
||||
KeyType *string `json:"key_type"`
|
||||
Comment *string `json:"comment"`
|
||||
// Owner sets the owning user (Ensure only).
|
||||
Owner *int64 `json:"-"`
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
@@ -210,6 +215,9 @@ func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
|
||||
if in.Comment != nil {
|
||||
c.Comment = *in.Comment
|
||||
}
|
||||
if in.Owner != nil {
|
||||
c.OwnerID = *in.Owner
|
||||
}
|
||||
switch c.Source {
|
||||
case model.CertSourceACME:
|
||||
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
||||
@@ -295,6 +303,128 @@ func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, err
|
||||
return c, err
|
||||
}
|
||||
|
||||
// PEM returns a certificate's current chain and private key, for
|
||||
// exporting it: from Jarl's store, or from the files of one you renew
|
||||
// yourself.
|
||||
func (m *Manager) PEM(ctx context.Context, name string) (*model.Certificate, []byte, []byte, error) {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
cert, err := os.ReadFile(c.CertFile)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fileErr(err)
|
||||
}
|
||||
key, err := os.ReadFile(c.KeyFile)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fileErr(err)
|
||||
}
|
||||
return c, cert, key, nil
|
||||
}
|
||||
if c.CertPEM == "" {
|
||||
return nil, nil, nil, &InputError{Msg: "certificate " + name + " hasn't been obtained yet, so there is nothing to export"}
|
||||
}
|
||||
return c, []byte(c.CertPEM), []byte(c.KeyPEM), nil
|
||||
}
|
||||
|
||||
// ErrTaken is Ensure's answer for a name another user's certificate (or
|
||||
// the administrators') has.
|
||||
var ErrTaken = errors.New("certificate name taken")
|
||||
|
||||
// LimitFor is how many certificates u may own.
|
||||
func (m *Manager) LimitFor(u *model.User) int {
|
||||
if u != nil && u.CertLimit != nil {
|
||||
return *u.CertLimit
|
||||
}
|
||||
return m.cfg.MaxPerUser
|
||||
}
|
||||
|
||||
// EnsureResult is what Ensure did.
|
||||
type EnsureResult struct {
|
||||
Certificate *model.Certificate
|
||||
Created bool // a new certificate
|
||||
Changed bool // its names changed, so it is obtained again
|
||||
// Ignored says what of the request an existing certificate keeps as
|
||||
// it is (a different key type).
|
||||
Ignored string
|
||||
}
|
||||
|
||||
// Ensure is jarl cert get: the certificate name, owned by p's user, for
|
||||
// domains. A new one is created (within the user's limit) and obtained
|
||||
// at once. An existing one must be the user's: it is obtained again only
|
||||
// when its names change (a name added or removed); otherwise it is
|
||||
// returned as it is, renewed by the loop as usual. The caller checks that
|
||||
// p may have these names.
|
||||
func (m *Manager) Ensure(ctx context.Context, name string, domains []string, keyType string, p *auth.Principal) (*EnsureResult, error) {
|
||||
owner := p.User
|
||||
want, err := normDomains(domains)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := m.Get(ctx, name)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
all, err := m.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := 0
|
||||
for _, o := range all {
|
||||
if o.OwnerID == owner.ID {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if limit := m.LimitFor(owner); n >= limit {
|
||||
return nil, inputErr("user %s has %d certificates, its limit (an administrator can raise it: jarl user update %s --cert-limit N, or certificates.max_per_user in jarl.yaml)", owner.Username, n, owner.Username)
|
||||
}
|
||||
in := Input{Name: &name, Domains: &want, Owner: &owner.ID, Comment: model.Ptr("for " + owner.Username + " (jarl cert get)")}
|
||||
if keyType != "" {
|
||||
in.KeyType = &keyType
|
||||
}
|
||||
c, err = m.Create(ctx, in, p)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
return nil, ErrTaken // created meanwhile
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err = m.Get(ctx, name)
|
||||
return &EnsureResult{Certificate: c, Created: true}, err
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
if c.OwnerID != owner.ID && !(p.IsAdmin() && c.OwnerID == 0) {
|
||||
return nil, ErrTaken
|
||||
}
|
||||
if c.Source != model.CertSourceACME {
|
||||
return nil, inputErr("certificate %s is read from files on the server, so Jarl can't obtain it for you", name)
|
||||
}
|
||||
res := &EnsureResult{Certificate: c}
|
||||
if keyType != "" && keyType != c.KeyType {
|
||||
res.Ignored = "it keeps its key type " + c.KeyType + " (changing it means a new certificate: delete this one, or ask an administrator)"
|
||||
}
|
||||
have := slices.Clone(c.Domains)
|
||||
slices.Sort(have)
|
||||
sorted := slices.Clone(want)
|
||||
slices.Sort(sorted)
|
||||
if !slices.Equal(have, sorted) {
|
||||
if c, err = m.Update(ctx, name, Input{Domains: &want}, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Certificate, res.Changed = c, true
|
||||
}
|
||||
if res.Changed || c.CertPEM == "" && m.Issuing(name).IsZero() && c.Failures == 0 {
|
||||
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Create adds a certificate. A file-source certificate is read at once;
|
||||
// one Jarl obtains is left for Issue (or the background loop).
|
||||
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||
|
||||
@@ -51,6 +51,10 @@ type Certificates struct {
|
||||
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||
// "" is <bind.config_dir>/tls.
|
||||
Dir string `yaml:"dir"`
|
||||
// MaxPerUser is how many certificates a user may own through jarl
|
||||
// cert get (certificates for other hosts), unless the user has a
|
||||
// limit of their own (jarl user update --cert-limit). Default 10.
|
||||
MaxPerUser int `yaml:"max_per_user"`
|
||||
}
|
||||
|
||||
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||
@@ -467,6 +471,7 @@ func Default() *Config {
|
||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 24 * time.Hour)},
|
||||
TopN: 100,
|
||||
},
|
||||
Certificates: Certificates{MaxPerUser: 10},
|
||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
||||
Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true},
|
||||
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"golang.org/x/net/idna"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// Root is the root domain name.
|
||||
@@ -305,6 +306,28 @@ func EmailFromMailbox(rname string) string {
|
||||
return local + "@" + strings.Join(labels[1:], ".")
|
||||
}
|
||||
|
||||
// RegisteredDomain returns the domain registered at a registrar that name
|
||||
// is in, without a trailing dot: adbraun.lol for lab.adbraun.lol, from
|
||||
// the public suffix list. ok is false when no registrar is involved:
|
||||
// reverse zones and the rest of .arpa (delegated by whoever holds the
|
||||
// addresses), names under a suffix the list doesn't know (home.lan,
|
||||
// internal) and public suffixes themselves (co.uk).
|
||||
func RegisteredDomain(name string) (string, bool) {
|
||||
n := strings.ToLower(strings.TrimSuffix(name, "."))
|
||||
if n == "" || n == "arpa" || strings.HasSuffix(n, ".arpa") {
|
||||
return "", false
|
||||
}
|
||||
suffix, icann := publicsuffix.PublicSuffix(n)
|
||||
if !icann || suffix == n {
|
||||
return "", false
|
||||
}
|
||||
d, err := publicsuffix.EffectiveTLDPlusOne(n)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// IsReverse reports whether name is in in-addr.arpa or ip6.arpa.
|
||||
func IsReverse(name string) bool {
|
||||
return IsSubdomain(name, "in-addr.arpa.") || IsSubdomain(name, "ip6.arpa.") || IsSubdomain(name, "ip6.int.")
|
||||
|
||||
@@ -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 {
|
||||
return nil, err
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil, errf(KindBadRequest, "nothing to change")
|
||||
}
|
||||
intro := validate.Introduced(before, e.validate(work))
|
||||
if validate.HasErrors(intro) {
|
||||
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
|
||||
|
||||
@@ -462,3 +462,39 @@ func TestParseWarningsReachTheResult(t *testing.T) {
|
||||
t.Errorf("ops = %+v", res.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnchangedZoneUpdateStagesNothing(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("wg.example.com")}))
|
||||
z := env.state().FindZone("wg.example.com.", 0)
|
||||
if z.SOA.MName != "ns1.example.com." {
|
||||
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{MName: model.Ptr("ns1.example.com.")}}))
|
||||
z = env.state().FindZone("wg.example.com.", 0)
|
||||
}
|
||||
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The settings form saved as it was.
|
||||
same := ZoneInput{Comment: model.Ptr(z.Comment), DefaultTTL: model.Ptr(z.DefaultTTL), SerialPolicy: model.Ptr(z.SerialPolicy),
|
||||
SOA: &SOAInput{RName: model.Ptr("hostmaster@example.com"), Refresh: model.Ptr(z.SOA.Refresh), Retry: model.Ptr(z.SOA.Retry),
|
||||
Expire: model.Ptr(z.SOA.Expire), Minimum: model.Ptr(z.SOA.Minimum)}}
|
||||
if z.SOA.RName != "hostmaster.example.com." {
|
||||
same.SOA.RName = nil
|
||||
}
|
||||
_, err = env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, same)}, env.admin)
|
||||
if e, ok := AsError(err); !ok || e.Kind != KindBadRequest || e.Message != "nothing to change" {
|
||||
t.Fatalf("unchanged update: %v", err)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, same)); e.Message != "nothing to do" {
|
||||
t.Errorf("unchanged update applied: %+v", e)
|
||||
}
|
||||
// A real change still stages, and an absolute MNAME stays absolute.
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{Comment: model.Ptr("the VPN")})}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := env.state().FindZone("wg.example.com.", 0).SOA.MName; got != "ns1.example.com." {
|
||||
t.Errorf("mname = %q", got)
|
||||
}
|
||||
}
|
||||
+293
-211
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -31,6 +32,12 @@ type expander struct {
|
||||
// exposures classifies the views as public or private (nil in tests:
|
||||
// every view private).
|
||||
exposures func(*model.State) []validate.Exposure
|
||||
// ptrForced are the address sets whose PTR records the request being
|
||||
// expanded asked to update (ptrForce).
|
||||
ptrForced map[rrKey]bool
|
||||
// sharing are the local sets the request deletes so that the shared
|
||||
// version reaches them (dropOverrides).
|
||||
sharing map[rrKey]bool
|
||||
}
|
||||
|
||||
func (x *expander) ids(n int) ([]int64, error) {
|
||||
@@ -69,11 +76,13 @@ func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, er
|
||||
if zone == "" {
|
||||
return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)")
|
||||
}
|
||||
return FindZoneRef(x.st, zone, view)
|
||||
return FindZoneRef(x.st, zone, view, func(z *model.Zone) bool { return x.p.CanSeeZone(z.Name, x.st.ViewName(z)) })
|
||||
}
|
||||
|
||||
// FindZoneRef finds a zone by name and optional view name.
|
||||
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
||||
// FindZoneRef finds a zone by name and optional view name. Without a
|
||||
// view, a name in several views needs one, counting only the zones sees
|
||||
// allows (nil: all): a token for one view finds that view's zone.
|
||||
func FindZoneRef(st *model.State, zone, view string, sees func(*model.Zone) bool) (*model.Zone, error) {
|
||||
name, err := ParseZoneName(zone)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
@@ -90,6 +99,16 @@ func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
||||
return z, nil
|
||||
}
|
||||
zs := st.ZonesNamed(name)
|
||||
if sees != nil && len(zs) > 1 {
|
||||
var vis []*model.Zone
|
||||
for _, z := range zs {
|
||||
if sees(z) {
|
||||
vis = append(vis, z)
|
||||
}
|
||||
}
|
||||
zs = vis // none: not found, without naming the views
|
||||
}
|
||||
st.SortByView(zs)
|
||||
switch len(zs) {
|
||||
case 0:
|
||||
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
|
||||
@@ -135,13 +154,20 @@ func dateSerial(now time.Time) uint32 {
|
||||
}
|
||||
|
||||
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
x.ptrForced, x.sharing = nil, nil
|
||||
ops, err := x.expandOne(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch req.Op {
|
||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable, OpZoneFillPTR:
|
||||
// Changes to linked zones reach the other zones of the link.
|
||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
||||
// Changes to linked zones reach the other zones of the link, then
|
||||
// PTR records follow the addresses in each view.
|
||||
if ops, err = x.linkSync(ops); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x.ptrSync(ops)
|
||||
case OpZoneFillPTR:
|
||||
return x.linkSync(ops)
|
||||
}
|
||||
return ops, nil
|
||||
@@ -193,6 +219,11 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
||||
if in.LinkWith != "" {
|
||||
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
||||
}
|
||||
// An update that leaves the zone as it is stages nothing (a
|
||||
// settings form saved without changes).
|
||||
if zoneUnchanged(x.st, z, &in) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
case OpRecordCreate:
|
||||
@@ -440,8 +471,8 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
fill := in.Fill
|
||||
in.Fill = false
|
||||
fill, fillOther := in.Fill, in.FillOtherViews
|
||||
in.Fill, in.FillOtherViews = false, false
|
||||
if fill {
|
||||
if zt != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "fill only applies to primary zones")
|
||||
@@ -453,10 +484,15 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
if in.ViewID != nil {
|
||||
vid = *in.ViewID
|
||||
}
|
||||
fills := PTRFills(x.st, vid, name, moved)
|
||||
fills := PTRFills(x.st, vid, name, moved, fillOther)
|
||||
if len(fills) == 0 {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
|
||||
}
|
||||
if !fillOther {
|
||||
if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" {
|
||||
x.warnings = append(x.warnings, h)
|
||||
}
|
||||
}
|
||||
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
||||
for _, f := range fills {
|
||||
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
|
||||
@@ -751,65 +787,227 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// ptrOps returns operations creating the PTR record for an address record.
|
||||
//
|
||||
// auto is set when the zone's auto_ptr asked rather than the request: a
|
||||
// missing reverse zone isn't worth a warning then (most addresses have
|
||||
// none), and an address whose PTR record names another host keeps it.
|
||||
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) {
|
||||
addr, ok := addrOf(p)
|
||||
if !ok {
|
||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||
// PTR records follow address records per view (docs/reverse.md): after a
|
||||
// request's operations (and those keeping linked zones in step), ptrSync
|
||||
// compares each touched A or AAAA set before and after, in every view it
|
||||
// is in, and changes the PTR records of that view's reverse zone to match.
|
||||
// So a record local to one view of a linked zone changes PTR records in
|
||||
// that view only, and an address that goes from one view keeps its PTR
|
||||
// record in the views that still have it.
|
||||
|
||||
// ptrForce marks a name and type whose PTR records the request asked to
|
||||
// update (ptr: true), in its zone and the zones linked with it.
|
||||
func (x *expander) ptrForce(z *model.Zone, name, typ string) {
|
||||
if x.ptrForced == nil {
|
||||
x.ptrForced = map[rrKey]bool{}
|
||||
}
|
||||
var ops []model.Op
|
||||
found := false
|
||||
for _, vid := range ptrViews(x.st, z) {
|
||||
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||
if rz == nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
more, err := x.ptrCreateIn(rz, ptrName, owner, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
x.ptrForced[rrKey{z.ID, name, typ}] = true
|
||||
for _, o := range x.st.LinkedWith(z) {
|
||||
x.ptrForced[rrKey{o.ID, name, typ}] = true
|
||||
}
|
||||
if !found && !auto {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// ptrViews are the views whose reverse zones get the PTR records of zone
|
||||
// z's address records: z's own, and, for a linked zone, the other views
|
||||
// of the link (the record is in all of them).
|
||||
func ptrViews(st *model.State, z *model.Zone) []int64 {
|
||||
out := []int64{z.ViewID}
|
||||
for _, o := range st.LinkedWith(z) {
|
||||
if !slices.Contains(out, o.ViewID) {
|
||||
out = append(out, o.ViewID)
|
||||
// Whether a set has an address: none, only in disabled records, enabled.
|
||||
const (
|
||||
addrNone = iota
|
||||
addrDisabled
|
||||
addrEnabled
|
||||
)
|
||||
|
||||
// addrStates returns the addresses of a zone's A or AAAA set.
|
||||
func addrStates(st *model.State, k rrKey) map[netip.Addr]int {
|
||||
out := map[netip.Addr]int{}
|
||||
for _, r := range st.RRset(k.zone, k.name, k.typ) {
|
||||
a, err := netip.ParseAddr(r.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := addrDisabled
|
||||
if !r.Disabled {
|
||||
s = addrEnabled
|
||||
}
|
||||
out[a.Unmap()] = max(out[a.Unmap()], s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ptrSync returns ops followed by the PTR changes they call for: for the
|
||||
// zone's auto_ptr, or because the request asked (ptrForce).
|
||||
func (x *expander) ptrSync(ops []model.Op) ([]model.Op, error) {
|
||||
forced := x.ptrForced
|
||||
x.ptrForced = nil
|
||||
work := x.st.Clone()
|
||||
c := &applyCtx{p: x.p, now: x.now}
|
||||
for _, op := range ops {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
keys := affectedKeys(x.st, work, ops)
|
||||
for _, k := range sortedForced(forced) {
|
||||
if !slices.Contains(keys, k) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
var extra []model.Op
|
||||
for _, k := range keys {
|
||||
z := work.Zones[k.zone]
|
||||
if z == nil || (k.typ != "A" && k.typ != "AAAA") || strings.HasPrefix(k.name, "*.") {
|
||||
continue
|
||||
}
|
||||
force := forced[k]
|
||||
if !force && !z.AutoPTR {
|
||||
continue
|
||||
}
|
||||
before, after := addrStates(x.st, k), addrStates(work, k)
|
||||
var addrs []netip.Addr
|
||||
for _, m := range []map[netip.Addr]int{before, after} {
|
||||
for a := range m {
|
||||
if !slices.Contains(addrs, a) {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(addrs, func(a, b netip.Addr) int { return a.Compare(b) })
|
||||
for _, a := range addrs {
|
||||
was, is := before[a], after[a]
|
||||
if !force && (was == is || was == addrNone && is == addrDisabled) {
|
||||
continue
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(work, z.ViewID, a)
|
||||
if rz == nil {
|
||||
if force && is == addrEnabled {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in %s, so no PTR record was added there (create one: jarl zone create %s/%d%s)",
|
||||
a, viewOrServer(work, z.ViewID), a, a.BitLen(), viewFlag(work, z.ViewID)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
more, err := x.ptrChange(work, rz, ptrName, k.name, is, !force)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, op := range more {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
extra = append(extra, more...)
|
||||
}
|
||||
}
|
||||
if len(extra) == 0 {
|
||||
return ops, nil
|
||||
}
|
||||
// The PTR records are local (see ptrChange), so this only keeps a
|
||||
// linked reverse zone consistent.
|
||||
extra, err := x.linkSync(extra)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(ops, extra...), nil
|
||||
}
|
||||
|
||||
func sortedForced(m map[rrKey]bool) []rrKey {
|
||||
var out []rrKey
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b rrKey) int {
|
||||
if a.zone != b.zone {
|
||||
return int(a.zone - b.zone)
|
||||
}
|
||||
if a.name != b.name {
|
||||
return strings.Compare(a.name, b.name)
|
||||
}
|
||||
return strings.Compare(a.typ, b.typ)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func viewFlag(st *model.State, viewID int64) string {
|
||||
if v := st.Views[viewID]; v != nil {
|
||||
return " --view " + v.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ptrChange returns the operations that give reverse zone rz's PTR
|
||||
// records at ptrName for host owner the state of its address: none
|
||||
// (deleted), disabled, or enabled (added when missing).
|
||||
//
|
||||
// In a linked reverse zone the PTR records are local to each view, since
|
||||
// each view's follow its own address records: a shared set at ptrName
|
||||
// is made local in every view of the link first, as each view has it.
|
||||
func (x *expander) ptrChange(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
|
||||
nwarn := len(x.warnings)
|
||||
ops, err := x.ptrChangeIn(st, rz, ptrName, owner, state, auto)
|
||||
if err != nil || len(ops) == 0 || rz.LinkGroup == 0 {
|
||||
return ops, err
|
||||
}
|
||||
var local []model.Op
|
||||
for _, m := range st.LinkMembers(rz.LinkGroup) {
|
||||
for _, r := range st.RRset(m.ID, ptrName, "PTR") {
|
||||
if r.SharedID != 0 {
|
||||
local = append(local, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
|
||||
fmt.Sprintf("linked: %s PTR %s becomes local to %s (PTR records follow each view's own address records)", r.Name, r.Data, zoneDesc(st, m))))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(local) == 0 {
|
||||
return ops, nil
|
||||
}
|
||||
// Again on the local records (their versions changed).
|
||||
scratch := st.Clone()
|
||||
c := &applyCtx{p: x.p, now: x.now}
|
||||
for _, op := range local {
|
||||
if err := applyOp(scratch, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
x.warnings = x.warnings[:nwarn]
|
||||
ops, err = x.ptrChangeIn(scratch, rz, ptrName, owner, state, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(local, ops...), nil
|
||||
}
|
||||
|
||||
func (x *expander) ptrChangeIn(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) {
|
||||
var ops []model.Op
|
||||
switch state {
|
||||
case addrNone:
|
||||
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
|
||||
if strings.EqualFold(r.Data, owner) {
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("delete %s PTR %s", r.Name, r.Data)))
|
||||
}
|
||||
}
|
||||
case addrDisabled:
|
||||
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
|
||||
if strings.EqualFold(r.Data, owner) && !r.Disabled {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{Disabled: model.Ptr(true)}, fmt.Sprintf("disable %s PTR %s", r.Name, r.Data)))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return x.ptrCreateIn(st, rz, ptrName, owner, auto)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
||||
// in reverse zone rz (none when it is there; enabling it when disabled).
|
||||
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
|
||||
// auto is set when the zone's auto_ptr asked rather than the request: an
|
||||
// address whose PTR record names another host keeps it then.
|
||||
func (x *expander) ptrCreateIn(st *model.State, rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
|
||||
var other string
|
||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||
if r.Name == ptrName && r.Type == "PTR" {
|
||||
if strings.EqualFold(r.Data, owner) {
|
||||
if r.Disabled {
|
||||
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 nil, nil // already there
|
||||
}
|
||||
if !r.Disabled {
|
||||
other = r.Data
|
||||
for _, r := range st.RRset(rz.ID, ptrName, "PTR") {
|
||||
if strings.EqualFold(r.Data, owner) {
|
||||
if r.Disabled {
|
||||
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 nil, nil // already there
|
||||
}
|
||||
if !r.Disabled {
|
||||
other = r.Data
|
||||
}
|
||||
}
|
||||
if other != "" {
|
||||
@@ -824,38 +1022,12 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool)
|
||||
return nil, err
|
||||
}
|
||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
|
||||
if rz.LinkGroup != 0 {
|
||||
in.SharedID = model.Ptr(int64(0))
|
||||
}
|
||||
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
|
||||
}
|
||||
|
||||
// ptrSetDisabled returns operations disabling or enabling the PTR record
|
||||
// of an address record.
|
||||
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
|
||||
addr, ok := addrOf(p)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var ops []model.Op
|
||||
for _, vid := range ptrViews(x.st, z) {
|
||||
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||
if rz == nil {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
|
||||
var ops []model.Op
|
||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
||||
in := RecordInput{Disabled: model.Ptr(disabled)}
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data)))
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func enableWord(disabled bool) string {
|
||||
if disabled {
|
||||
return "disable"
|
||||
@@ -863,23 +1035,6 @@ func enableWord(disabled bool) string {
|
||||
return "enable"
|
||||
}
|
||||
|
||||
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
|
||||
addr, ok := addrOf(p)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var ops []model.Op
|
||||
for _, vid := range ptrViews(x.st, z) {
|
||||
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||
if rz == nil {
|
||||
continue
|
||||
}
|
||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
||||
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||
// The stored operation holds the canonical data, so applying it doesn't
|
||||
// notice again.
|
||||
@@ -906,7 +1061,12 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||
return nil, err
|
||||
}
|
||||
x.parseWarnings(owner, p)
|
||||
ptr, auto := in.PTR, !in.PTR && autoPTR(z, owner, p)
|
||||
if in.PTR {
|
||||
if _, ok := addrOf(p); !ok {
|
||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||
}
|
||||
x.ptrForce(z, owner, p.Type)
|
||||
}
|
||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
||||
ids, err := x.ids(1)
|
||||
@@ -934,15 +1094,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
// local on a zone that isn't linked: its records are local anyway.
|
||||
in.Local = nil
|
||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||
if ptr || auto && (in.Disabled == nil || !*in.Disabled) {
|
||||
more, err := x.ptrCreate(z, owner, p, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, nil
|
||||
}
|
||||
|
||||
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
@@ -1063,67 +1215,22 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
if string(mustJSON(in)) != "{}" || !ptr {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||
}
|
||||
disabled := r.Disabled
|
||||
if in.Disabled != nil {
|
||||
disabled = *in.Disabled
|
||||
}
|
||||
moved := p.Data != r.Data || owner != r.Name || p.Type != r.Type
|
||||
if !ptr && z.AutoPTR {
|
||||
// The zone keeps PTR records in step (docs/reverse.md): as if
|
||||
// asked, for whichever of the old and new record is an address.
|
||||
old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data)
|
||||
oldAuto := err == nil && autoPTR(z, r.Name, old)
|
||||
newAuto := autoPTR(z, owner, p)
|
||||
switch {
|
||||
case moved:
|
||||
if oldAuto {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
||||
}
|
||||
if newAuto && !disabled {
|
||||
more, err := x.ptrCreate(z, owner, p, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
case toggled && newAuto && disabled:
|
||||
ops = append(ops, x.ptrSetDisabled(z, owner, p, true)...)
|
||||
case toggled && newAuto:
|
||||
// Enables the PTR record, or adds it when it is missing.
|
||||
more, err := x.ptrCreate(z, owner, p, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
if ptr && moved {
|
||||
if ptr {
|
||||
// The PTR records of the old and the new record, whichever is an
|
||||
// address (ptrSync).
|
||||
_, isAddr := addrOf(p)
|
||||
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
||||
}
|
||||
if !disabled {
|
||||
more, err := x.ptrCreate(z, owner, p, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := addrOf(old); ok {
|
||||
isAddr = true
|
||||
x.ptrForce(z, r.Name, r.Type)
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
}
|
||||
if ptr && !moved && toggled {
|
||||
if _, ok := addrOf(p); !ok {
|
||||
if !isAddr {
|
||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||
}
|
||||
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
||||
}
|
||||
if 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
|
||||
if _, ok := addrOf(p); ok {
|
||||
x.ptrForce(z, owner, p.Type)
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
@@ -1146,9 +1253,8 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
||||
base = r.Version
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
|
||||
z := x.st.Zones[r.ZoneID]
|
||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
||||
if in.PTR {
|
||||
x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
@@ -1184,25 +1290,12 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
||||
if in.MissingOK {
|
||||
what += " (if present)"
|
||||
}
|
||||
ptr := in.PTR
|
||||
if in.PTR && parsed != nil {
|
||||
x.ptrForce(z, owner, typ)
|
||||
}
|
||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||
in.Name, in.Type = &owner, &typ
|
||||
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
|
||||
switch {
|
||||
case ptr && parsed != nil:
|
||||
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
|
||||
case z.AutoPTR:
|
||||
// Every matching record's PTR record goes too.
|
||||
for _, r := range x.st.RRset(z.ID, owner, typ) {
|
||||
if parsed != nil && r.Data != parsed.Data {
|
||||
continue
|
||||
}
|
||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && autoPTR(z, r.Name, p) {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, nil
|
||||
}
|
||||
|
||||
// recordSetDisabled expands record.disable and record.enable: the record
|
||||
@@ -1336,32 +1429,7 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
||||
if len(canon) == 0 {
|
||||
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
||||
}
|
||||
ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary))
|
||||
if z.AutoPTR && (typ == "A" || typ == "AAAA") && !strings.HasPrefix(owner, "*.") {
|
||||
// PTR records follow the addresses that go and come.
|
||||
old := map[string]bool{}
|
||||
for _, r := range x.st.RRset(z.ID, owner, typ) {
|
||||
old[r.Data] = true
|
||||
if !slices.Contains(canon, r.Data) {
|
||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range canon {
|
||||
if old[d] {
|
||||
continue
|
||||
}
|
||||
if _, p, err := parseRecord(z, owner, typ, d); err == nil {
|
||||
more, err := x.ptrCreate(z, owner, p, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
|
||||
}
|
||||
|
||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
||||
@@ -1555,3 +1623,17 @@ func firstNonEmptyStr(a, b string) string {
|
||||
func notLinked(st *model.State, z *model.Zone) error {
|
||||
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
|
||||
}
|
||||
|
||||
// zoneUnchanged reports whether an update would leave the zone as it is.
|
||||
// Errors are left for applying the update to report.
|
||||
func zoneUnchanged(st *model.State, z *model.Zone, in *ZoneInput) bool {
|
||||
if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 {
|
||||
return false
|
||||
}
|
||||
c := st.Clone()
|
||||
n := c.Zones[z.ID]
|
||||
if err := setZoneFields(c, n, in, false); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(n, z)
|
||||
}
|
||||
@@ -163,8 +163,11 @@ type ZoneInput struct {
|
||||
// step (docs/reverse.md).
|
||||
AutoPTR *bool `json:"auto_ptr,omitempty"`
|
||||
// Fill (create only, reverse zones) adds a PTR record for every
|
||||
// address of the view's A and AAAA records in the zone's range.
|
||||
Fill bool `json:"fill,omitempty"`
|
||||
// address of the view's A and AAAA records in the zone's range;
|
||||
// FillOtherViews also for the addresses only other views' records
|
||||
// use, named from there.
|
||||
Fill bool `json:"fill,omitempty"`
|
||||
FillOtherViews bool `json:"fill_other_views,omitempty"`
|
||||
|
||||
// Create only: name servers for the apex NS records (default: the
|
||||
// configured default name servers), additional records, and a zone to
|
||||
@@ -224,6 +227,13 @@ type LinkInput struct {
|
||||
With []string `json:"with,omitempty"`
|
||||
}
|
||||
|
||||
// FillPTRInput is zone.fill_ptr: OtherViews also fills the addresses only
|
||||
// other views' records use, naming them from there.
|
||||
type FillPTRInput struct {
|
||||
ZoneID int64 `json:"zone_id,omitempty"`
|
||||
OtherViews bool `json:"other_views,omitempty"`
|
||||
}
|
||||
|
||||
// RRSetInput replaces all records of one name and type.
|
||||
type RRSetInput struct {
|
||||
ZoneID int64 `json:"zone_id,omitempty"`
|
||||
|
||||
+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
|
||||
// keep theirs.
|
||||
// - z's override was just removed: z gets the shared records back from
|
||||
// a zone that has them.
|
||||
// - z's override was just removed: z goes without the name and type,
|
||||
// and the other zones' shared copies become local to each.
|
||||
// - otherwise z's shared records are the truth: every zone that doesn't
|
||||
// override the name and type gets the same (created, updated or
|
||||
// deleted to match).
|
||||
@@ -123,24 +123,40 @@ func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]
|
||||
}
|
||||
truth := st.SharedRecords(z.ID, name, typ)
|
||||
members := st.LinkedWith(z)
|
||||
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) && 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) {
|
||||
// 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 {
|
||||
if st.HasLocal(o.ID, name, typ) {
|
||||
continue
|
||||
}
|
||||
src := st.SharedRecords(o.ID, name, typ)
|
||||
if len(src) == 0 {
|
||||
continue
|
||||
var more []model.Op
|
||||
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
||||
more = append(more, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))},
|
||||
fmt.Sprintf("linked: %s %s %s stays in %s, local to it now (%s has none)", name, typ, r.Data, where(o), where(z))))
|
||||
}
|
||||
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)
|
||||
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
||||
return nil, errf(KindForbidden, "deleting %s %s from %s makes it local in %s, which you may not change; ask someone who may to delete it, or share %s's version (jarl record update ID --shared)",
|
||||
dnsname.Display(name), typ, where(z), where(o), where(o))
|
||||
}
|
||||
break
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
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:
|
||||
// as asked, or like the records of the same name and type already there.
|
||||
// A new name and type is shared, unless the principal may not change it
|
||||
// in every linked view (an ACME token for the external view only): then
|
||||
// it is local to z's view, which is all they may change.
|
||||
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
|
||||
has := x.st.HasLocal(z.ID, name, typ)
|
||||
if want == nil {
|
||||
return has, nil
|
||||
if want != nil {
|
||||
return *want, nil
|
||||
}
|
||||
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
|
||||
@@ -468,6 +502,10 @@ func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
||||
for _, o := range x.st.LinkedWith(z) {
|
||||
for _, r := range x.st.RRset(o.ID, name, typ) {
|
||||
if r.SharedID == 0 {
|
||||
if x.sharing == nil {
|
||||
x.sharing = map[rrKey]bool{}
|
||||
}
|
||||
x.sharing[rrKey{o.ID, name, typ}] = true
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,10 +130,15 @@ func TestLinkedZones(t *testing.T) {
|
||||
if !slices.Contains(env.view("external"), "www A 192.0.2.11 S") || slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
||||
t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
// Removing the override brings the shared records back.
|
||||
// Removing the override removes www A from internal only: external
|
||||
// keeps its record, local now. Sharing it brings it back to internal.
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
|
||||
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
||||
t.Errorf("override removed: internal %q", env.view("internal"))
|
||||
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "www A ") }) || !slices.Contains(env.view("external"), "www A 192.0.2.11 L") {
|
||||
t.Errorf("override removed: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") || !slices.Contains(env.view("external"), "www A 192.0.2.11 S") {
|
||||
t.Errorf("shared again: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
|
||||
// Make a shared set local, then share another view's version.
|
||||
@@ -386,3 +391,59 @@ func TestCompareReport(t *testing.T) {
|
||||
t.Errorf("mail A: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// A shared record made local in one view, then deleted there, is gone from
|
||||
// that view only; the other view keeps it, local now.
|
||||
func TestDeleteLocalOverride(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin, recIn("internal", "a", "A", "192.0.2.50"))
|
||||
if !slices.Contains(env.view("external"), "a A 192.0.2.50 S") {
|
||||
t.Fatalf("not shared: %q", env.view("external"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Local: model.Ptr(true)}))
|
||||
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "a", "A"), nil))
|
||||
if slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "a A ") }) {
|
||||
t.Errorf("internal still has a: %q", env.view("internal"))
|
||||
}
|
||||
if !slices.Contains(env.view("external"), "a A 192.0.2.50 L") {
|
||||
t.Errorf("external: %q", env.view("external"))
|
||||
}
|
||||
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||
t.Errorf("validation: %+v", probs)
|
||||
}
|
||||
}
|
||||
|
||||
// An ACME token for one view of a linked zone: its challenge records are
|
||||
// local to that view, the zone is found without naming the view, and they
|
||||
// go again without touching the other view.
|
||||
func TestLinkedACMEToken(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
u := &model.User{Username: "certbot", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Zones: []string{"example.com"}, Views: []string{"external"}, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}}}
|
||||
if err := env.st.CreateUser(env.ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bot := &auth.Principal{User: u, Method: "token"}
|
||||
name, typ, data := "_acme-challenge", "TXT", "token-value"
|
||||
res := env.submit(bot, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: &name, Type: &typ, Data: &data}))
|
||||
if !slices.Contains(env.view("external"), "_acme-challenge TXT \"token-value\" L") || slices.ContainsFunc(env.view("internal"), func(s string) bool { return strings.HasPrefix(s, "_acme-challenge") }) {
|
||||
t.Errorf("challenge: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "local to view external") }) {
|
||||
t.Errorf("warnings %q", res.Warnings)
|
||||
}
|
||||
env.submit(bot, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: &name, Type: &typ, Data: &data}))
|
||||
if slices.ContainsFunc(env.view("external"), func(s string) bool { return strings.HasPrefix(s, "_acme-challenge") }) {
|
||||
t.Errorf("not deleted: %q", env.view("external"))
|
||||
}
|
||||
// The admin, who may change both, still gets a shared record.
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("both"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.7")}))
|
||||
if !slices.Contains(env.view("external"), "both A 192.0.2.7 S") {
|
||||
t.Errorf("admin: %q", env.view("external"))
|
||||
}
|
||||
// Without a view, the admin is still asked which.
|
||||
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.8")})); !strings.Contains(e.Message, "several views (internal, external)") {
|
||||
t.Errorf("admin without a view: %v", e)
|
||||
}
|
||||
}
|
||||
+38
-22
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/rr"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
@@ -88,11 +87,12 @@ type PTRFill struct {
|
||||
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
||||
// lacks for the addresses of the view's A and AAAA records: one per
|
||||
// address, naming its mail or name server host when it is one, otherwise
|
||||
// a host rather than the domain itself, then the shortest name. An
|
||||
// address no record of the view uses is named from another view's
|
||||
// records (fillAddresses), marked From. existing are the zone's records
|
||||
// (none for a new zone).
|
||||
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
|
||||
// a host rather than the domain itself, then the shortest name. With
|
||||
// otherViews, an address no record of the view uses is named from another
|
||||
// view's records (fillAddresses), marked From; that is only on request,
|
||||
// since it publishes one view's host names in another. existing are the
|
||||
// zone's records (none for a new zone).
|
||||
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record, otherViews bool) []PTRFill {
|
||||
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -106,7 +106,11 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
|
||||
servers := serverNames(st, viewID)
|
||||
byAddr := map[netip.Addr][]addrUse{}
|
||||
var order []netip.Addr
|
||||
for _, u := range fillAddresses(st, viewID) {
|
||||
addrs := forwardAddresses(st, viewID)
|
||||
if otherViews {
|
||||
addrs = fillAddresses(st, viewID)
|
||||
}
|
||||
for _, u := range addrs {
|
||||
if !prefix.Contains(u.addr) {
|
||||
continue
|
||||
}
|
||||
@@ -196,9 +200,23 @@ func displayAll(names []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// otherViewsHint says how many addresses only other views' records use in
|
||||
// a reverse zone, and how to add them; "" when there are none.
|
||||
func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []*model.Record, own int, how string) string {
|
||||
n := len(PTRFills(st, viewID, zoneName, existing, true)) - own
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
what, has, they := "addresses", "have", "they are"
|
||||
if n == 1 {
|
||||
what, has, they = "address", "has", "it is"
|
||||
}
|
||||
return fmt.Sprintf("%d %s in %s %s A or AAAA records only in other views, so %s not missing here; %s", n, what, dnsname.Display(zoneName), has, they, how)
|
||||
}
|
||||
|
||||
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
|
||||
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
||||
var in LinkInput
|
||||
var in FillPTRInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -216,9 +234,15 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
||||
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
|
||||
}
|
||||
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID))
|
||||
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), in.OtherViews)
|
||||
if len(fills) == 0 {
|
||||
return nil, errf(KindBadRequest, "nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
|
||||
msg := fmt.Sprintf("nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
|
||||
if !in.OtherViews {
|
||||
if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to add PTR records for them in this view too: --other-views"); h != "" {
|
||||
msg += "; " + h
|
||||
}
|
||||
}
|
||||
return nil, errf(KindBadRequest, "%s", msg)
|
||||
}
|
||||
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
||||
ids, err := x.ids(len(fills))
|
||||
@@ -227,9 +251,11 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
||||
}
|
||||
var ops []model.Op
|
||||
for i, f := range fills {
|
||||
// Local to the view in a linked zone, like the PTR records that
|
||||
// follow address records (ptrChange).
|
||||
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
|
||||
if z.LinkGroup != 0 {
|
||||
ri.SharedID = model.Ptr(ids[i])
|
||||
ri.SharedID = model.Ptr(int64(0))
|
||||
}
|
||||
ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
|
||||
}
|
||||
@@ -414,7 +440,7 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
|
||||
continue
|
||||
}
|
||||
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
|
||||
fills := PTRFills(st, sc.id, name, nil)
|
||||
fills := PTRFills(st, sc.id, name, nil, opt.All && opt.View != "")
|
||||
s.Fill = len(fills)
|
||||
for i, f := range fills {
|
||||
if i == 3 {
|
||||
@@ -531,16 +557,6 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
|
||||
|
||||
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
|
||||
|
||||
// autoPTR reports whether a record's PTR is kept in step by its zone's
|
||||
// auto_ptr setting.
|
||||
func autoPTR(z *model.Zone, owner string, p *rr.Parsed) bool {
|
||||
if z == nil || !z.AutoPTR || strings.HasPrefix(owner, "*.") {
|
||||
return false
|
||||
}
|
||||
_, ok := addrOf(p)
|
||||
return ok
|
||||
}
|
||||
|
||||
func viewName(st *model.State, id int64) string {
|
||||
if v := st.Views[id]; v != nil {
|
||||
return v.Name
|
||||
|
||||
@@ -69,11 +69,11 @@ func TestReverseFillAndSuggest(t *testing.T) {
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
|
||||
st := env.state()
|
||||
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
|
||||
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{}))
|
||||
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{}))
|
||||
if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
|
||||
t.Errorf("ip6 fill: %q", p)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})); !strings.Contains(e.Message, "nothing to add") {
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") {
|
||||
t.Errorf("second fill: %v", e)
|
||||
}
|
||||
|
||||
@@ -288,18 +288,140 @@ func TestReverseFromOtherView(t *testing.T) {
|
||||
t.Errorf("suggested unasked: %+v", s)
|
||||
}
|
||||
}
|
||||
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
||||
st := env.state()
|
||||
var zone string
|
||||
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
|
||||
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
|
||||
zone = z.Name
|
||||
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
||||
if strings.HasPrefix(s.Prefix, "2600:") && (s.Fill != 1 || len(s.Samples) != 1) {
|
||||
t.Errorf("candidate counts the other view's address: %+v", s)
|
||||
}
|
||||
}
|
||||
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
||||
// Filling doesn't take names from other views unasked: it would
|
||||
// publish one view's host names in another. It says how to.
|
||||
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
||||
st := env.state()
|
||||
var z *model.Zone
|
||||
for _, o := range st.ZonesInView(st.ViewByName("internal").ID) {
|
||||
if strings.HasSuffix(o.Name, ".ip6.arpa.") {
|
||||
z = o
|
||||
}
|
||||
}
|
||||
if p := env.ptrs("internal", z.Name); len(p) != 0 {
|
||||
t.Errorf("filled from another view unasked: %q", p)
|
||||
}
|
||||
if !slices.ContainsFunc(res.Warnings, func(w string) bool {
|
||||
return strings.Contains(w, "1 address in") && strings.Contains(w, "--other-views")
|
||||
}) {
|
||||
t.Errorf("warnings %q", res.Warnings)
|
||||
}
|
||||
// Not missing: no record of the zone's own view uses the address.
|
||||
if f := PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false); len(f) != 0 {
|
||||
t.Errorf("missing %+v", f)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") || !strings.Contains(e.Message, "--other-views") {
|
||||
t.Errorf("fill-ptr: %v", e)
|
||||
}
|
||||
res = env.submit(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{OtherViews: true}))
|
||||
if p := env.ptrs("internal", z.Name); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
||||
t.Errorf("filled %q", p)
|
||||
}
|
||||
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
||||
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")
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func (s *State) SortedBlockRules() []*BlockRule {
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
if a.Name != b.Name {
|
||||
return CanonicalLess(a.Name+".", b.Name+".")
|
||||
return NameLess(a.Name+".", b.Name+".")
|
||||
}
|
||||
if a.Exact != b.Exact {
|
||||
return !a.Exact
|
||||
|
||||
@@ -39,7 +39,8 @@ type Certificate struct {
|
||||
Comment string `json:"comment"`
|
||||
|
||||
// The current certificate: acme source only (a file source's is read
|
||||
// from its files). The key never leaves the server through the API.
|
||||
// from its files). The key leaves the server only through the
|
||||
// administrators' export (GET /certificates/{name}/pem), audited.
|
||||
CertPEM string `json:"-"`
|
||||
KeyPEM string `json:"-"`
|
||||
|
||||
@@ -61,11 +62,27 @@ type Certificate struct {
|
||||
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||
LastRoutes []string `json:"last_routes,omitempty"`
|
||||
|
||||
// OwnerID is the user a certificate for another host belongs to
|
||||
// (jarl cert get); 0 for the administrators' own.
|
||||
OwnerID int64 `json:"owner_id,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
// CertificatePull records that someone downloaded a certificate: who
|
||||
// ("token NAME" or "user NAME"), which one (Serial), when and from where.
|
||||
type CertificatePull struct {
|
||||
CertID int64 `json:"-"`
|
||||
Who string `json:"who"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
TokenID int64 `json:"token_id,omitempty"`
|
||||
Serial string `json:"serial"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HasUse reports whether c is used for use.
|
||||
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||
@@ -76,6 +76,9 @@ type User struct {
|
||||
TOTPSecret string `json:"-"`
|
||||
TOTPPending string `json:"-"`
|
||||
TOTPLastStep int64 `json:"-"`
|
||||
// CertLimit is how many certificates the user may own (jarl cert get);
|
||||
// nil: certificates.max_per_user in jarl.yaml.
|
||||
CertLimit *int `json:"cert_limit,omitempty"`
|
||||
}
|
||||
|
||||
// HasTOTP reports whether the user logs in with a TOTP code.
|
||||
|
||||
+41
-10
@@ -524,14 +524,14 @@ func (s *State) SortedZones() []*Zone {
|
||||
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return CanonicalLess(out[i].Name, out[j].Name)
|
||||
return NameLess(out[i].Name, out[j].Name)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// SortZones sorts zones by name in DNS canonical order.
|
||||
// SortZones sorts zones by name (see NameLess).
|
||||
func SortZones(zs []*Zone) {
|
||||
sort.Slice(zs, func(i, j int) bool { return CanonicalLess(zs[i].Name, zs[j].Name) })
|
||||
sort.Slice(zs, func(i, j int) bool { return NameLess(zs[i].Name, zs[j].Name) })
|
||||
}
|
||||
|
||||
// FindZone finds a zone by name within a view (viewID 0 for no view).
|
||||
@@ -646,7 +646,7 @@ func typeOrder(t string) string {
|
||||
}
|
||||
|
||||
// SortRecords sorts records in zone-file order: apex first, then names in
|
||||
// DNS canonical order (so subdomains group together), then by type and
|
||||
// NameLess order (so subdomains group together), then by type and
|
||||
// data.
|
||||
func SortRecords(rs []*Record, z *Zone) {
|
||||
apex := ""
|
||||
@@ -662,7 +662,7 @@ func SortRecords(rs []*Record, z *Zone) {
|
||||
if b.Name == apex {
|
||||
return false
|
||||
}
|
||||
return CanonicalLess(a.Name, b.Name)
|
||||
return NameLess(a.Name, b.Name)
|
||||
}
|
||||
if a.Type != b.Type {
|
||||
return typeOrder(a.Type) < typeOrder(b.Type)
|
||||
@@ -674,19 +674,50 @@ func SortRecords(rs []*Record, z *Zone) {
|
||||
})
|
||||
}
|
||||
|
||||
// CanonicalLess compares names in DNS canonical order (RFC 4034 section
|
||||
// 6.1): by label from the right.
|
||||
func CanonicalLess(a, b string) bool {
|
||||
// NameLess compares names label by label from the right, like DNS
|
||||
// canonical order (RFC 4034 section 6.1), except that two all-digit
|
||||
// labels compare as numbers, so 2.10.0.10.in-addr.arpa comes before
|
||||
// 101.10.0.10.in-addr.arpa. It orders names for people and zone files;
|
||||
// BIND does its own canonical ordering where DNSSEC needs it.
|
||||
func NameLess(a, b string) bool {
|
||||
la := strings.Split(strings.TrimSuffix(a, "."), ".")
|
||||
lb := strings.Split(strings.TrimSuffix(b, "."), ".")
|
||||
for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
|
||||
if la[i] != lb[j] {
|
||||
return la[i] < lb[j]
|
||||
if c := compareLabel(la[i], lb[j]); c != 0 {
|
||||
return c < 0
|
||||
}
|
||||
}
|
||||
return len(la) < len(lb)
|
||||
}
|
||||
|
||||
func compareLabel(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
if allDigits(a) && allDigits(b) {
|
||||
na, nb := strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0")
|
||||
if len(na) != len(nb) {
|
||||
return len(na) - len(nb)
|
||||
}
|
||||
if c := strings.Compare(na, nb); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// RRSetKey identifies an RRset.
|
||||
type RRSetKey struct {
|
||||
Name string
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNameLess(t *testing.T) {
|
||||
want := []string{
|
||||
"10.0.10.in-addr.arpa.",
|
||||
"1.10.0.10.in-addr.arpa.",
|
||||
"02.10.0.10.in-addr.arpa.",
|
||||
"2.10.0.10.in-addr.arpa.",
|
||||
"10.10.0.10.in-addr.arpa.",
|
||||
"101.10.0.10.in-addr.arpa.",
|
||||
"x.101.10.0.10.in-addr.arpa.",
|
||||
"254.10.0.10.in-addr.arpa.",
|
||||
"a.10.0.10.in-addr.arpa.",
|
||||
}
|
||||
got := slices.Clone(want)
|
||||
slices.Reverse(got)
|
||||
slices.SortFunc(got, func(a, b string) int {
|
||||
if NameLess(a, b) {
|
||||
return -1
|
||||
}
|
||||
if NameLess(b, a) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("got %v\nwant %v", got, want)
|
||||
}
|
||||
|
||||
// Non-numeric labels keep canonical (byte) order.
|
||||
if !NameLess("host10.example.", "host2.example.") {
|
||||
t.Error("host10 should sort before host2")
|
||||
}
|
||||
if !NameLess("example.", "www.example.") {
|
||||
t.Error("parent should sort before child")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
|
||||
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir", MaxPerUser: 10}, env.st, env.srv.eng, quiet)
|
||||
env.srv.cfg.Certs = m
|
||||
return m
|
||||
}
|
||||
@@ -165,3 +165,39 @@ func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
||||
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A certificate's files can be exported for use elsewhere; the key only
|
||||
// by an administrator, audited.
|
||||
func TestCertificateExport(t *testing.T) {
|
||||
env := newAPIEnv(t)
|
||||
withCerts(env)
|
||||
certFile, keyFile := selfSigned(t, t.TempDir(), "mail.example.com")
|
||||
env.do("POST", "/certificates", env.token, map[string]any{"name": "mail", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
|
||||
chain, _ := os.ReadFile(certFile)
|
||||
key, _ := os.ReadFile(keyFile)
|
||||
for part, want := range map[string]string{"": string(chain), "fullchain": string(chain), "cert": string(chain), "key": string(key), "bundle": string(key) + "\n" + string(chain)} {
|
||||
b, status := env.req("GET", "/certificates/mail/pem?part="+part, env.token, nil)
|
||||
if status != 200 || string(b) != want {
|
||||
t.Errorf("part %q: %d %q", part, status, b)
|
||||
}
|
||||
}
|
||||
if _, status := env.req("GET", "/certificates/mail/pem?part=nope", env.token, nil); status != 400 {
|
||||
t.Errorf("bad part: %d", status)
|
||||
}
|
||||
if _, status := env.req("GET", "/certificates/none/pem", env.token, nil); status != 404 {
|
||||
t.Errorf("no such certificate: %d", status)
|
||||
}
|
||||
// Not for a user who isn't an administrator.
|
||||
hash, _ := auth.HashPassword("pw-pw-pw-pw")
|
||||
u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}}
|
||||
env.st.CreateUser(context.Background(), u)
|
||||
tok, pid, sh := auth.NewToken()
|
||||
env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||
if _, status := env.req("GET", "/certificates/mail/pem?part=key", tok, nil); status != 403 {
|
||||
t.Errorf("non-admin: %d", status)
|
||||
}
|
||||
audit, _ := env.req("GET", "/audit?action=certificate.export_key", env.token, nil)
|
||||
if n := strings.Count(string(audit), "certificate.export_key"); n != 2 {
|
||||
t.Errorf("audited key exports: %d (%s)", n, audit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
// An address only another view's records use isn't missing from a
|
||||
// reverse zone: it is counted apart, and filled only when asked.
|
||||
func TestPTROtherViews(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": "vps"}, 201)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."},
|
||||
"records": []map[string]any{{"name": "claude", "type": "A", "data": "10.0.10.101"}}}, 201)
|
||||
for _, v := range []string{"internal", "vps"} {
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "10.0.10.0/24", "view": v, "nameservers": []string{"ns1.example.net."}}, 201)
|
||||
}
|
||||
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=internal", e.token, nil, 200); z["ptr_missing"] != float64(1) || z["ptr_other_views"] != nil {
|
||||
t.Errorf("internal: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
|
||||
}
|
||||
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_missing"] != nil || z["ptr_other_views"] != float64(1) {
|
||||
t.Errorf("vps: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
|
||||
} else if l, _ := z["ptr_other_views_list"].([]any); len(l) != 1 || l[0].(map[string]any)["address"] != "10.0.10.101" || l[0].(map[string]any)["target"] != "claude.adbraun.lol." || l[0].(map[string]any)["from"] != "internal" {
|
||||
t.Errorf("vps: ptr_other_views_list %v", z["ptr_other_views_list"])
|
||||
}
|
||||
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps", e.token, nil, 400)
|
||||
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps&other_views=1", e.token, nil, 201)
|
||||
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_other_views"] != nil {
|
||||
t.Errorf("vps after filling: ptr_other_views %v", z["ptr_other_views"])
|
||||
}
|
||||
}
|
||||
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
|
||||
if in.Comment != nil {
|
||||
u.Comment = *in.Comment
|
||||
}
|
||||
if in.CertLimit != nil {
|
||||
u.CertLimit = in.CertLimit
|
||||
if *in.CertLimit < 0 {
|
||||
u.CertLimit = nil
|
||||
}
|
||||
}
|
||||
if in.Password != nil {
|
||||
if *in.Password == "" {
|
||||
u.PasswordHash = "" // service account: tokens only
|
||||
|
||||
+239
-5
@@ -2,8 +2,13 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
@@ -27,6 +32,10 @@ type certView struct {
|
||||
NamedUse []string `json:"named_use,omitempty"`
|
||||
// IssuingSince is set while Jarl is obtaining it.
|
||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||
// Owner is the user a certificate for another host belongs to; PulledBy
|
||||
// who downloaded it (list and get, administrators only).
|
||||
Owner string `json:"owner,omitempty"`
|
||||
PulledBy []model.CertificatePull `json:"pulled_by,omitempty"`
|
||||
}
|
||||
|
||||
type certFiles struct {
|
||||
@@ -61,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// certsAccess lets in administrators and users with a certificates grant
|
||||
// (certificates for other hosts: their own only).
|
||||
func (s *Server) certsAccess(p *auth.Principal) error {
|
||||
if s.cfg.Certs == nil {
|
||||
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||
}
|
||||
if p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResCerts}) {
|
||||
return nil
|
||||
}
|
||||
return forbidden("certificates need an administrator, or a certificates grant for their names (certificates:write names=*.example.com)")
|
||||
}
|
||||
|
||||
// certAllowed reports whether p may see (or, with write, change) c: an
|
||||
// administrator any; anyone else their own, while their grants cover all
|
||||
// its names.
|
||||
func certAllowed(p *auth.Principal, c *model.Certificate, write bool) bool {
|
||||
if p.IsAdmin() {
|
||||
return true
|
||||
}
|
||||
if p.User == nil || c.OwnerID == 0 || c.OwnerID != p.User.ID {
|
||||
return false
|
||||
}
|
||||
for _, d := range c.Domains {
|
||||
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: write, Name: d}) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// certFor returns the certificate in the path, if p may see it (else not
|
||||
// found, so names of others' certificates don't leak).
|
||||
func (s *Server) certFor(r *http.Request, p *auth.Principal, write bool) (*model.Certificate, error) {
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return nil, certErr(err)
|
||||
}
|
||||
if !certAllowed(p, c, write) {
|
||||
if write && certAllowed(p, c, false) {
|
||||
return nil, forbidden("you may only download certificate %s", c.Name)
|
||||
}
|
||||
return nil, notFound("no such certificate")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// withOwners adds owners and downloads to views (for administrators).
|
||||
func (s *Server) withOwners(ctx context.Context, views []certView) error {
|
||||
pulls, err := s.store.CertificatePulls(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := s.store.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := map[int64]string{}
|
||||
for _, u := range users {
|
||||
names[u.ID] = u.Username
|
||||
}
|
||||
for i := range views {
|
||||
if id := views[i].OwnerID; id != 0 {
|
||||
views[i].Owner = names[id]
|
||||
if views[i].Owner == "" {
|
||||
views[i].Owner = fmt.Sprintf("deleted user %d", id)
|
||||
}
|
||||
}
|
||||
views[i].PulledBy = pulls[views[i].ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certErr(err error) error {
|
||||
var ie *certs.InputError
|
||||
switch {
|
||||
@@ -70,13 +151,15 @@ func certErr(err error) error {
|
||||
return notFound("no such certificate")
|
||||
case errors.Is(err, certs.ErrConflict):
|
||||
return conflict("a certificate with that name exists")
|
||||
case errors.Is(err, certs.ErrTaken):
|
||||
return conflict("the certificate name is taken by another user's certificate (or one Jarl uses): choose another name")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// certList: GET /certificates.
|
||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := s.cfg.Certs.List(r.Context())
|
||||
@@ -89,22 +172,32 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
||||
}
|
||||
out := []certView{}
|
||||
for _, c := range all {
|
||||
if !certAllowed(p, c, false) {
|
||||
continue
|
||||
}
|
||||
v := s.presentCert(c)
|
||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||
out = append(out, v)
|
||||
}
|
||||
if !p.IsAdmin() {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
|
||||
return nil
|
||||
}
|
||||
if err := s.withOwners(r.Context(), out); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certGet: GET /certificates/{name}.
|
||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
c, err := s.certFor(r, p, false)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
return err
|
||||
}
|
||||
st, err := s.store.LoadState(r.Context())
|
||||
if err != nil {
|
||||
@@ -112,10 +205,123 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
}
|
||||
v := s.presentCert(c)
|
||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||
if p.IsAdmin() {
|
||||
vs := []certView{v}
|
||||
if err := s.withOwners(r.Context(), vs); err != nil {
|
||||
return err
|
||||
}
|
||||
v = vs[0]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certRequest: POST /certificates/{name}/request {"domains": [...],
|
||||
// "key_type": "ec256"}, for jarl cert get: the certificate for another
|
||||
// host, owned by the caller's user. A new one is created and obtained
|
||||
// (202 while that runs; poll GET /certificates/{name} until
|
||||
// issuing_since is gone). An existing one is the caller's, obtained again
|
||||
// only when its names change; otherwise it is returned as it is (200).
|
||||
func (s *Server) certRequest(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.User == nil || p.User.ID == 0 {
|
||||
return forbidden("certificates for other hosts belong to a user: use an API token, not the local socket as root")
|
||||
}
|
||||
var in struct {
|
||||
Domains []string `json:"domains"`
|
||||
KeyType string `json:"key_type"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range in.Domains {
|
||||
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
|
||||
if !p.Can(auth.Action{Resource: auth.ResCerts, Write: true, Name: d}) {
|
||||
return forbidden("you may not get a certificate for %s: that needs certificates:write with a names pattern that matches it", d)
|
||||
}
|
||||
}
|
||||
res, err := s.cfg.Certs.Ensure(r.Context(), r.PathValue("name"), in.Domains, in.KeyType, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
v := s.presentCert(res.Certificate)
|
||||
out := map[string]any{"certificate": v, "created": res.Created, "changed": res.Changed}
|
||||
if res.Ignored != "" {
|
||||
out["warnings"] = []string{res.Ignored}
|
||||
}
|
||||
status := http.StatusOK
|
||||
if v.IssuingSince != nil {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle,
|
||||
// the certificate as PEM for use elsewhere: the chain (default), the
|
||||
// certificate alone, the private key, or the key followed by the chain
|
||||
// (one file, as HAProxy wants). Anything with the key is audited.
|
||||
func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.certFor(r, p, false); err != nil {
|
||||
return err
|
||||
}
|
||||
part := r.URL.Query().Get("part")
|
||||
if part == "" {
|
||||
part = "fullchain"
|
||||
}
|
||||
if part == "key" || part == "bundle" {
|
||||
if ok, why := s.keyTransport(r); !ok {
|
||||
return &httpError{http.StatusForbidden, "https_required", "a private key is only handed out over HTTPS (or to this server itself), and this request came by " + why}
|
||||
}
|
||||
}
|
||||
c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
var out []byte
|
||||
file := c.Name + "-" + part + ".pem"
|
||||
switch part {
|
||||
case "fullchain":
|
||||
out = chain
|
||||
case "cert":
|
||||
if b, _ := pem.Decode(chain); b != nil {
|
||||
out = pem.EncodeToMemory(b)
|
||||
}
|
||||
case "key":
|
||||
out = key
|
||||
case "bundle":
|
||||
out = append(append(append([]byte{}, key...), '\n'), chain...)
|
||||
default:
|
||||
return badRequest("part must be fullchain, cert, key or bundle")
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return badRequest("certificate %s has no %s", c.Name, part)
|
||||
}
|
||||
if part == "key" || part == "bundle" {
|
||||
s.eng.Audit(r.Context(), p, "certificate.export_key", "certificate", c.ID, c.Name, "success", "exported the private key ("+part+")")
|
||||
}
|
||||
pull := &model.CertificatePull{CertID: c.ID, Who: pullWho(p), Serial: c.Serial, IP: s.clientIP(r), At: time.Now().UTC()}
|
||||
if p.User != nil {
|
||||
pull.UserID = p.User.ID
|
||||
}
|
||||
if p.Token != nil {
|
||||
pull.TokenID = p.Token.ID
|
||||
}
|
||||
if err := s.store.RecordCertificatePull(r.Context(), pull); err != nil {
|
||||
s.log.Warn("recording a certificate download failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, err = w.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
// certCreate: POST /certificates. With "issue": true (the default for
|
||||
// certificates Jarl obtains) it is obtained before the answer.
|
||||
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
@@ -162,7 +368,10 @@ func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
|
||||
// certDelete: DELETE /certificates/{name}.
|
||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
if err := s.certsAccess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.certFor(r, p, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||
@@ -257,3 +466,28 @@ func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keyTransport reports whether a private key may go back over r: HTTPS
|
||||
// (also through a trusted proxy), the local socket, or loopback.
|
||||
func (s *Server) keyTransport(r *http.Request) (bool, string) {
|
||||
https, why := s.transport(r)
|
||||
if https {
|
||||
return true, why
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && a.Unmap().IsLoopback() && !s.trusted(a.Unmap()) {
|
||||
return true, "plain HTTP from this server"
|
||||
}
|
||||
return false, why
|
||||
}
|
||||
|
||||
// pullWho names a downloader: its token (and user), or the user.
|
||||
func pullWho(p *auth.Principal) string {
|
||||
switch {
|
||||
case p.Token != nil:
|
||||
return "token " + p.Token.Name + " (" + p.Name() + ")"
|
||||
case p.User != nil && p.Method != "":
|
||||
return p.Method + " " + p.Name()
|
||||
}
|
||||
return p.Name()
|
||||
}
|
||||
@@ -57,6 +57,8 @@ func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
||||
for _, v := range st.OrderedViews() {
|
||||
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
||||
out = append(out, presentView(st, v, colors))
|
||||
} else if lv := limitedView(st, v, colors, p); lv != nil {
|
||||
out = append(out, *lv)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
@@ -635,7 +637,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
}
|
||||
sort.Slice(recs, func(i, j int) bool {
|
||||
if recs[i].Name != recs[j].Name {
|
||||
return model.CanonicalLess(recs[i].Name, recs[j].Name)
|
||||
return model.NameLess(recs[i].Name, recs[j].Name)
|
||||
}
|
||||
return recs[i].ID < recs[j].ID
|
||||
})
|
||||
|
||||
+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)
|
||||
}
|
||||
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
|
||||
if pz := parentInJarl(st, z); pz != nil {
|
||||
public := map[int64]bool{}
|
||||
for _, e := range s.eng.Exposures(st) {
|
||||
public[e.ViewID] = e.Public
|
||||
}
|
||||
name := dnsname.Display(z.Name)
|
||||
reg, regOK := dnsname.RegisteredDomain(z.Name)
|
||||
switch pz := parentInJarl(st, z, public); {
|
||||
case pz != nil:
|
||||
resp.DSAt = "parent_zone"
|
||||
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
|
||||
for _, rec := range st.ServedRecords(pz.ID) {
|
||||
if rec.Name == z.Name && rec.Type == "DS" {
|
||||
resp.ParentDS = append(resp.ParentDS, rec.Data)
|
||||
}
|
||||
}
|
||||
resp.DSPlace = "in the parent zone " + dnsname.Display(pz.Name)
|
||||
if pz.ViewID != z.ViewID {
|
||||
resp.DSPlace += " (view " + st.ViewName(pz) + ")"
|
||||
}
|
||||
case dnsname.IsReverse(z.Name):
|
||||
resp.DSAt = "reverse"
|
||||
resp.DSPlace = "with whoever delegates " + name + " to you (your ISP, or your RIR for an address block of your own; many ISPs don't take DS records)"
|
||||
case !regOK:
|
||||
resp.DSAt = "none"
|
||||
resp.DSNote = "No zone on the internet delegates " + name + ", so it can't get a DS record and resolvers never validate it. Signing only matters for a zone on the internet."
|
||||
case reg == name:
|
||||
resp.DSAt, resp.DSPlace = "registrar", "at your registrar"
|
||||
default:
|
||||
resp.DSAt = "parent"
|
||||
resp.DSPlace = "in its parent zone (most likely " + reg + "), wherever that zone's DNS is hosted (at Porkbun: " + reg + "'s DNS records, as a DS record named " + strings.TrimSuffix(name, "."+reg) + ")"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parentInJarl returns the primary zone in z's view that z is delegated
|
||||
// from, if Jarl has it.
|
||||
func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
|
||||
var best *model.Zone
|
||||
for _, o := range st.ZonesInView(z.ViewID) {
|
||||
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
|
||||
// parentInJarl returns the primary zone in Jarl that z is delegated from:
|
||||
// in z's own view, else in another view, a public one first (the one the
|
||||
// internet asks).
|
||||
func parentInJarl(st *model.State, z *model.Zone, public map[int64]bool) *model.Zone {
|
||||
in := func(viewID int64) *model.Zone {
|
||||
var best *model.Zone
|
||||
for _, o := range st.ZonesInView(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
|
||||
}
|
||||
if best == nil || len(o.Name) > len(best.Name) {
|
||||
best = o
|
||||
if pz := in(v.ID); pz != nil && (other == nil || public[v.ID] && !public[other.ViewID]) {
|
||||
other = pz
|
||||
}
|
||||
}
|
||||
return best
|
||||
return other
|
||||
}
|
||||
|
||||
// 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:
|
||||
// "porkbun" when a key is configured and allows the zone's name.
|
||||
// "porkbun" when a key is configured and allows the zone's name, and the
|
||||
// zone is a registered domain (not a subdomain or a reverse zone, which
|
||||
// their parent zone delegates).
|
||||
func (s *Server) registrarFor(z *model.Zone) string {
|
||||
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
|
||||
return ""
|
||||
}
|
||||
if d, ok := dnsname.RegisteredDomain(z.Name); !ok || d != dnsname.Display(z.Name) {
|
||||
return ""
|
||||
}
|
||||
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
|
||||
return ""
|
||||
}
|
||||
@@ -59,6 +64,16 @@ func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if s.registrarFor(z) == "" {
|
||||
d, ok := dnsname.RegisteredDomain(z.Name)
|
||||
switch {
|
||||
case s.cfg.Porkbun == nil:
|
||||
case dnsname.IsReverse(z.Name):
|
||||
return nil, nil, "", badRequest("%s is a reverse zone: whoever holds the addresses (your ISP, or the RIR) delegates it, not a registrar. Check its delegation on the zone's Name servers tab (jarl zone delegation ZONE)", dnsname.Display(z.Name))
|
||||
case !ok:
|
||||
return nil, nil, "", badRequest("%s isn't a domain registered at a registrar (its suffix isn't a public one), so there is nothing to manage at Porkbun", dnsname.Display(z.Name))
|
||||
case d != dnsname.Display(z.Name):
|
||||
return nil, nil, "", badRequest("%s is a subdomain of %s: the NS (and DS) records in the zone %s delegate it, not the registrar. Manage %s at Porkbun instead", dnsname.Display(z.Name), d, d, d)
|
||||
}
|
||||
if s.cfg.Porkbun == nil {
|
||||
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
)
|
||||
|
||||
// zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse
|
||||
// zone lacks for its view's A and AAAA records (docs/reverse.md).
|
||||
// zone lacks for its view's A and AAAA records; ?other_views=1 also for
|
||||
// the addresses only other views' records use (docs/reverse.md).
|
||||
func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
@@ -18,7 +19,7 @@ func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.LinkInput{ZoneID: z.ID})}, nil)
|
||||
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.FillPTRInput{ZoneID: z.ID, OtherViews: queryBool(r, "other_views")})}, nil)
|
||||
}
|
||||
|
||||
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
|
||||
|
||||
@@ -215,7 +215,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
|
||||
}
|
||||
slices.SortFunc(recs, func(a, b *model.Record) int {
|
||||
if a.Name != b.Name {
|
||||
if model.CanonicalLess(a.Name, b.Name) {
|
||||
if model.NameLess(a.Name, b.Name) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
|
||||
@@ -34,7 +34,7 @@ func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal)
|
||||
name = "."
|
||||
}
|
||||
var err error
|
||||
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"))
|
||||
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"), func(z *model.Zone) bool { return p.CanSeeZone(z.Name, st.ViewName(z)) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -29,10 +29,33 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
||||
}
|
||||
}
|
||||
}
|
||||
if z.ViewID != 0 {
|
||||
others := st.ZonesNamed(z.Name)
|
||||
st.SortByView(others)
|
||||
for _, o := range others {
|
||||
if o.ID != z.ID {
|
||||
out.InViews = append(out.InViews, api.ZoneLink{ID: o.ID, View: st.ViewName(o), Type: o.Type, Linked: z.LinkGroup != 0 && o.LinkGroup == z.LinkGroup})
|
||||
}
|
||||
}
|
||||
}
|
||||
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
|
||||
out.Prefix = pf.String()
|
||||
if z.Type == model.ZonePrimary {
|
||||
out.PTRMissing = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID)))
|
||||
fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false)
|
||||
out.PTRMissing = len(fills)
|
||||
for _, f := range fills[:min(len(fills), 25)] {
|
||||
out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f))
|
||||
}
|
||||
if z.ViewID != 0 {
|
||||
for _, f := range engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true) {
|
||||
if f.From == "" {
|
||||
continue
|
||||
}
|
||||
if out.PTROtherViews++; out.PTROtherViews <= 25 {
|
||||
out.PTROtherViewsList = append(out.PTROtherViewsList, api.PTRFill(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range st.LinkedWith(z) {
|
||||
@@ -73,6 +96,22 @@ func presentView(st *model.State, v *model.View, colors map[int64]string) api.Vi
|
||||
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID)), Color: colors[v.ID]}
|
||||
}
|
||||
|
||||
// limitedView presents a view to a principal without views:read who can
|
||||
// see zones in it (the zone list names the view anyway): its name and
|
||||
// colour, and how many of its zones they can see. nil when they see none.
|
||||
func limitedView(st *model.State, v *model.View, colors map[int64]string, p *auth.Principal) *api.View {
|
||||
n := 0
|
||||
for _, z := range st.ZonesInView(v.ID) {
|
||||
if p.CanSeeZone(z.Name, v.Name) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return &api.View{View: model.View{ID: v.ID, Name: v.Name, Position: v.Position}, Zones: n, Color: colors[v.ID], Limited: true}
|
||||
}
|
||||
|
||||
func presentACL(st *model.State, a *model.ACL) api.ACL {
|
||||
used := engine.FindACLRefs(st, a.Name)
|
||||
if used == nil {
|
||||
|
||||
@@ -170,3 +170,29 @@ func writeKey(t *testing.T, dir, zone string) uint16 {
|
||||
os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644)
|
||||
return k.KeyTag()
|
||||
}
|
||||
|
||||
// With no porkbun.domains, only zones that are registered domains get the
|
||||
// registrar: not subdomains or reverse zones, which their parent delegates.
|
||||
func TestRegistrarOnlyRegisteredDomains(t *testing.T) {
|
||||
fake := porkbun.NewFake("pk1_a", "sk1_b", "adbraun.lol")
|
||||
pts := httptest.NewServer(fake)
|
||||
t.Cleanup(pts.Close)
|
||||
e := newAPIEnvConfig(t, Config{Version: "test", Porkbun: &porkbun.Client{BaseURL: pts.URL + "/api/json/v3", APIKey: "pk1_a", Secret: "sk1_b"}})
|
||||
for _, z := range []string{"adbraun.lol", "lab.adbraun.lol", "10.0.10.0/24", "home.lan"} {
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": z, "nameservers": []string{"ns1.example.net."}}, 201)
|
||||
}
|
||||
for zone, want := range map[string]string{"adbraun.lol": "porkbun", "lab.adbraun.lol": "", "10.0.10.in-addr.arpa": "", "home.lan": ""} {
|
||||
got, _ := e.do("GET", "/zones/"+zone, e.token, nil, 200)["registrar"].(string)
|
||||
if got != want {
|
||||
t.Errorf("%s: registrar %q, want %q", zone, got, want)
|
||||
}
|
||||
}
|
||||
for zone, msg := range map[string]string{"lab.adbraun.lol": "subdomain of adbraun.lol", "10.0.10.in-addr.arpa": "reverse zone", "home.lan": "isn't a domain registered"} {
|
||||
out := e.do("GET", "/zones/"+zone+"/registrar", e.token, nil, 400)
|
||||
errObj, _ := out["error"].(map[string]any)
|
||||
if m, _ := errObj["message"].(string); !strings.Contains(m, msg) {
|
||||
t.Errorf("%s: %v", zone, out)
|
||||
}
|
||||
}
|
||||
e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
}
|
||||
@@ -174,6 +174,8 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate))
|
||||
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
|
||||
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
|
||||
m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/request", s.authed(s.certRequest))
|
||||
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
|
||||
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
|
||||
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
|
||||
|
||||
@@ -95,6 +95,13 @@ type Status struct {
|
||||
Day string `json:"day"`
|
||||
Queries string `json:"queries"` // the query log ("" when off)
|
||||
} `json:"retention"`
|
||||
// RetentionSeconds is Retention in seconds, for the web UI's ranges.
|
||||
RetentionSeconds struct {
|
||||
Minute int64 `json:"minute"`
|
||||
Hour int64 `json:"hour"`
|
||||
Day int64 `json:"day"`
|
||||
Queries int64 `json:"queries"` // 0 when the query log is off
|
||||
} `json:"retention_seconds"`
|
||||
}
|
||||
|
||||
// Collector gathers statistics into a Store.
|
||||
@@ -164,8 +171,12 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
|
||||
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
||||
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
||||
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
||||
c.status.RetentionSeconds.Minute = int64(opts.RetainMinute / time.Second)
|
||||
c.status.RetentionSeconds.Hour = int64(opts.RetainHour / time.Second)
|
||||
c.status.RetentionSeconds.Day = int64(opts.RetainDay / time.Second)
|
||||
if opts.QueryLog && opts.RetainQueries > 0 {
|
||||
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
|
||||
c.status.RetentionSeconds.Queries = int64(opts.RetainQueries / time.Second)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
||||
// last days (stats.query_retention), filtered by any mix of client, name,
|
||||
// last days (stats.retention.queries), filtered by any mix of client, name,
|
||||
// view, type, result and blocking, broken down by each of them.
|
||||
|
||||
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
||||
@@ -47,8 +47,9 @@ type Exploration struct {
|
||||
Recent []QueryRow `json:"recent"`
|
||||
// Oldest is the first answer the query log still has.
|
||||
Oldest *time.Time `json:"oldest,omitempty"`
|
||||
// Retention is how long it keeps them.
|
||||
Retention string `json:"retention"`
|
||||
// Retention is how long it keeps them, also in seconds.
|
||||
Retention string `json:"retention"`
|
||||
RetentionSeconds int64 `json:"retention_seconds"`
|
||||
}
|
||||
|
||||
// ExplorePoint is one step of the series.
|
||||
@@ -59,7 +60,7 @@ type ExplorePoint struct {
|
||||
}
|
||||
|
||||
// ErrNoQueryLog is returned when the query log is off.
|
||||
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.query_retention above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
||||
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.retention.queries above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
||||
|
||||
// values splits a filter field into the values it must be one of and the
|
||||
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
|
||||
@@ -178,7 +179,7 @@ func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int
|
||||
}
|
||||
from := now.Add(-since).Unix()
|
||||
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
|
||||
Retention: humanDuration(q.Opts.RetainQueries)}
|
||||
Retention: humanDuration(q.Opts.RetainQueries), RetentionSeconds: int64(q.Opts.RetainQueries / time.Second)}
|
||||
cond, args := q.where(f, from)
|
||||
db := q.Store.db
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ var schema = []string{
|
||||
PRIMARY KEY (res, ts, kind, key)
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
||||
// 2: the query log, every answer for a while (stats.query_retention),
|
||||
// 2: the query log, every answer for a while (stats.retention.queries),
|
||||
// for drilling down: what a client asked, who asked for a name
|
||||
// (docs/stats.md#exploring).
|
||||
`CREATE TABLE queries (
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const certCols = `id, name, source, domains, cert_file, key_file, uses, challenge, key_type, comment, cert_pem, key_pem, names,
|
||||
not_before, not_after, issuer, serial, renew_at, ari_checked_at, last_attempt, last_success, last_error, failures, last_routes,
|
||||
created_at, updated_at, created_by, updated_by`
|
||||
created_at, updated_at, created_by, updated_by, owner_id`
|
||||
|
||||
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||
c := &model.Certificate{}
|
||||
@@ -20,7 +20,7 @@ func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||
var nb, na, ra, ac, la, ls sql.NullString
|
||||
if err := sc.Scan(&c.ID, &c.Name, &c.Source, &domains, &c.CertFile, &c.KeyFile, &uses, &c.Challenge, &c.KeyType, &c.Comment,
|
||||
&c.CertPEM, &c.KeyPEM, &names, &nb, &na, &c.Issuer, &c.Serial, &ra, &ac, &la, &ls, &c.LastError, &c.Failures, &routes,
|
||||
&ca, &ua, &c.CreatedBy, &c.UpdatedBy); err != nil {
|
||||
&ca, &ua, &c.CreatedBy, &c.UpdatedBy, &c.OwnerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range []struct {
|
||||
@@ -71,7 +71,7 @@ func (s *Store) CertificateByName(ctx context.Context, name string) (*model.Cert
|
||||
func (s *Store) CreateCertificate(ctx context.Context, c *model.Certificate) error {
|
||||
now := time.Now().UTC()
|
||||
c.CreatedAt, c.UpdatedAt = now, now
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(27)+`)`, certArgs(c)...)
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO certificates (`+certCols[len("id, "):]+`) VALUES (`+placeholders(28)+`)`, certArgs(c)...)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) err
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE certificates SET name=?, source=?, domains=?, cert_file=?, key_file=?, uses=?, challenge=?, key_type=?,
|
||||
comment=?, cert_pem=?, key_pem=?, names=?, not_before=?, not_after=?, issuer=?, serial=?, renew_at=?, ari_checked_at=?, last_attempt=?,
|
||||
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=? WHERE id=?`,
|
||||
last_success=?, last_error=?, failures=?, last_routes=?, created_at=?, updated_at=?, created_by=?, updated_by=?, owner_id=? WHERE id=?`,
|
||||
append(certArgs(c), c.ID)...)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
@@ -123,5 +123,35 @@ func certArgs(c *model.Certificate) []any {
|
||||
return []any{c.Name, c.Source, list(c.Domains), c.CertFile, c.KeyFile, list(c.Uses), c.Challenge, c.KeyType, c.Comment,
|
||||
c.CertPEM, c.KeyPEM, list(c.Names), tsPtr(c.NotBefore), tsPtr(c.NotAfter), c.Issuer, c.Serial, tsPtr(c.RenewAt),
|
||||
tsPtr(c.ARICheckedAt), tsPtr(c.LastAttempt), tsPtr(c.LastSuccess), c.LastError, c.Failures, list(c.LastRoutes),
|
||||
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy}
|
||||
ts(c.CreatedAt), ts(c.UpdatedAt), c.CreatedBy, c.UpdatedBy, c.OwnerID}
|
||||
}
|
||||
|
||||
// RecordCertificatePull notes a download of a certificate, replacing the
|
||||
// same downloader's earlier one.
|
||||
func (s *Store) RecordCertificatePull(ctx context.Context, p *model.CertificatePull) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO certificate_pulls (cert_id, who, user_id, token_id, serial, ip, at) VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT (cert_id, who) DO UPDATE SET user_id = excluded.user_id, token_id = excluded.token_id, serial = excluded.serial, ip = excluded.ip, at = excluded.at`,
|
||||
p.CertID, p.Who, p.UserID, p.TokenID, p.Serial, p.IP, ts(p.At))
|
||||
return err
|
||||
}
|
||||
|
||||
// CertificatePulls returns who downloaded each certificate, newest first,
|
||||
// by certificate ID.
|
||||
func (s *Store) CertificatePulls(ctx context.Context) (map[int64][]model.CertificatePull, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT cert_id, who, user_id, token_id, serial, ip, at FROM certificate_pulls ORDER BY at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[int64][]model.CertificatePull{}
|
||||
for rows.Next() {
|
||||
var p model.CertificatePull
|
||||
var at string
|
||||
if err := rows.Scan(&p.CertID, &p.Who, &p.UserID, &p.TokenID, &p.Serial, &p.IP, &at); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
p.At = parseTS(at)
|
||||
out[p.CertID] = append(out[p.CertID], p)
|
||||
}
|
||||
return out, closeRows(rows)
|
||||
}
|
||||
@@ -384,6 +384,22 @@ ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
|
||||
`,
|
||||
// 14: certificates for other hosts (docs/certificates.md#certificates-for-other-hosts):
|
||||
// an owner, a per-user limit, and who downloaded them.
|
||||
`
|
||||
ALTER TABLE certificates ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN cert_limit INTEGER;
|
||||
CREATE TABLE certificate_pulls (
|
||||
cert_id INTEGER NOT NULL REFERENCES certificates(id) ON DELETE CASCADE,
|
||||
who TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
token_id INTEGER NOT NULL DEFAULT 0,
|
||||
serial TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (cert_id, who)
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -18,16 +18,21 @@ func isUnique(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step`
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step, cert_limit`
|
||||
|
||||
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||
u := &model.User{}
|
||||
var grants, ca, ua string
|
||||
var admin, disabled int
|
||||
var ll, te sql.NullString
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep); err != nil {
|
||||
var limit sql.NullInt64
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep, &limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit.Valid {
|
||||
n := int(limit.Int64)
|
||||
u.CertLimit = &n
|
||||
}
|
||||
u.Admin, u.Disabled = admin != 0, disabled != 0
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
|
||||
@@ -48,8 +53,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
||||
if u.Grants == nil {
|
||||
u.Grants = []model.Grant{}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at, cert_limit) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now), u.CertLimit)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
@@ -64,8 +69,8 @@ func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
||||
// UpdateUser saves a user.
|
||||
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
||||
u.UpdatedAt = time.Now()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ?, cert_limit = ? WHERE id = ?`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.CertLimit, u.ID)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func (c *checker) checkZoneRecords(z *model.Zone) {
|
||||
for n := range ix.types {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool { return model.CanonicalLess(names[i], names[j]) })
|
||||
sort.Slice(names, func(i, j int) bool { return model.NameLess(names[i], names[j]) })
|
||||
|
||||
for _, name := range names {
|
||||
types := ix.types[name]
|
||||
@@ -385,8 +385,16 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
|
||||
if !ix.inZoneTarget(target) {
|
||||
return
|
||||
}
|
||||
// Follow the chain inside the zone to detect loops.
|
||||
// Follow the chain inside the zone to detect loops. One CNAME to
|
||||
// another is common and costs nothing (named answers the chain in
|
||||
// one response); a longer chain gets a note.
|
||||
seen := map[string]bool{r.Name: true}
|
||||
chain := []string{disp(r.Name), disp(target)}
|
||||
note := func() {
|
||||
if len(chain) > 3 {
|
||||
c.recProblem(z, r, Info, "cname_chain", "%s reaches its address through %d CNAMEs (%s); fine, but each one is another lookup for resolvers that don't get the chain at once", disp(r.Name), len(chain)-1, strings.Join(chain, " → "))
|
||||
}
|
||||
}
|
||||
cur := target
|
||||
for hops := 0; hops < 20; hops++ {
|
||||
if seen[cur] {
|
||||
@@ -398,18 +406,18 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
|
||||
if len(next) == 0 {
|
||||
break
|
||||
}
|
||||
if hops == 0 {
|
||||
c.recProblem(z, r, Warning, "cname_chain", "%s is a CNAME to %s, which is itself a CNAME; chains slow down resolution", disp(r.Name), disp(target))
|
||||
}
|
||||
p := ix.parsed[next[0].ID]
|
||||
if p == nil {
|
||||
break
|
||||
}
|
||||
cur = p.RR.(*dns.CNAME).Target
|
||||
chain = append(chain, disp(cur))
|
||||
if !ix.inZoneTarget(cur) {
|
||||
note()
|
||||
return
|
||||
}
|
||||
}
|
||||
note()
|
||||
if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) {
|
||||
c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target))
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ func sortProblems(ps []Problem) {
|
||||
return a.Zone < b.Zone
|
||||
}
|
||||
if a.Name != b.Name {
|
||||
return model.CanonicalLess(a.Name, b.Name)
|
||||
return model.NameLess(a.Name, b.Name)
|
||||
}
|
||||
return a.Message < b.Message
|
||||
})
|
||||
|
||||
@@ -161,8 +161,9 @@ func TestRecordConflicts(t *testing.T) {
|
||||
}, "cname_loop", Error},
|
||||
{"cname chain", func(b *builder, z *model.Zone) {
|
||||
b.rec(z, "a", "CNAME", "b")
|
||||
b.rec(z, "b", "CNAME", "ns1")
|
||||
}, "cname_chain", Warning},
|
||||
b.rec(z, "b", "CNAME", "c")
|
||||
b.rec(z, "c", "CNAME", "ns1")
|
||||
}, "cname_chain", Info},
|
||||
{"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning},
|
||||
{"null mx not alone", func(b *builder, z *model.Zone) {
|
||||
b.rec(z, "@", "MX", "0 .")
|
||||
@@ -380,3 +381,16 @@ func TestProblemStringDisplaysZone(t *testing.T) {
|
||||
t.Errorf("String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// One CNAME to another is common and says nothing.
|
||||
func TestShortCNAMEChain(t *testing.T) {
|
||||
b := newBuilder()
|
||||
z := b.primary("example.com", nil)
|
||||
b.rec(z, "songshare", "CNAME", "navidrome")
|
||||
b.rec(z, "navidrome", "CNAME", "ns1")
|
||||
for _, p := range State(b.s, Options{}) {
|
||||
if p.Code == "cname_chain" {
|
||||
t.Errorf("one extra CNAME: %s %s", p.Severity, p.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ export function heatmap(hm, unit) {
|
||||
const n = hm.samples[d][hr];
|
||||
const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" :
|
||||
day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)");
|
||||
const cell = h("div", { class: "hm-cell " + (v < 0 ? "empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
|
||||
const cell = h("div", { class: "hm-cell " + (v < 0 ? "hm-empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
|
||||
const showTip = () => {
|
||||
tip.textContent = text;
|
||||
tip.hidden = false;
|
||||
@@ -313,7 +313,7 @@ export function heatmap(hm, unit) {
|
||||
h("span", { class: "muted small" }, "0"),
|
||||
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
|
||||
h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"),
|
||||
h("span", { class: "hm-swatch empty" }), h("span", { class: "muted small" }, "no data"));
|
||||
h("span", { class: "hm-swatch hm-empty" }), h("span", { class: "muted small" }, "no data"));
|
||||
return h("div", { class: "heatmap-wrap" }, grid, tip, scale);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function render(el) {
|
||||
const tokensEl = h("div");
|
||||
const drawTokens = async () => {
|
||||
const tokens = await get("/me/tokens");
|
||||
mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens));
|
||||
mount(tokensEl, tokenTable(tokens, drawTokens), newTokenForm("/me/tokens", grants, drawTokens, { admin: me.admin, grants: me.user.grants }));
|
||||
};
|
||||
const cur = input({ type: "password", autocomplete: "current-password" });
|
||||
const pw1 = input({ type: "password", autocomplete: "new-password" });
|
||||
|
||||
@@ -32,10 +32,10 @@ export async function render(el) {
|
||||
{ label: "Name", render: (c) => h("a", { href: "#", onclick: (e) => { e.preventDefault(); details(c); } }, h("strong", null, c.name)) },
|
||||
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
|
||||
{ label: "Used for", render: (c) => [
|
||||
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
|
||||
c.uses.includes("dns") ? [badge("named", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))),
|
||||
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
|
||||
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
||||
c.uses.includes("web") ? [badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : ""), " "] : null,
|
||||
c.uses.includes("dns") ? namedUse(c) : null,
|
||||
!c.uses.length ? h("span", { class: "muted", title: "Export it to use it in another program" }, "not used by Jarl") : null] },
|
||||
{ label: "Owner", render: (c) => c.owner ? h("span", null, c.owner, pulledBy(c)) : h("span", { class: "muted" }, "Jarl") },
|
||||
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
||||
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
||||
{ label: "Status", render: (c) => {
|
||||
@@ -45,6 +45,7 @@ export async function render(el) {
|
||||
{ label: "", render: (c) => h("span", null,
|
||||
h("button", { class: "small", disabled: !!c.issuing_since, title: c.source === "acme" ? "Obtain a new certificate now" : "Reread the files now", onclick: () => issue(c) }, c.source === "acme" ? "Renew" : "Reread"), " ",
|
||||
h("button", { class: "small", onclick: () => editDialog(c, a) }, "Edit"), " ",
|
||||
c.not_after ? [h("button", { class: "small", title: "Download it to use in another program", onclick: () => exportDialog(c) }, "Export…"), " "] : null,
|
||||
h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" },
|
||||
], r.certificates, { empty: "No certificates yet." })));
|
||||
|
||||
@@ -64,6 +65,24 @@ export async function render(el) {
|
||||
}
|
||||
}
|
||||
|
||||
// pulledBy says who downloaded a certificate for another host, and
|
||||
// whether they have the current one.
|
||||
function pulledBy(c) {
|
||||
const ps = c.pulled_by || [];
|
||||
if (!ps.length) return h("div", { class: "small muted" }, "not downloaded yet");
|
||||
return ps.map((p) => h("div", { class: "small " + (p.serial === c.serial ? "muted" : "warn-text"), title: p.who + " from " + (p.ip || "?") + ", " + fmtTime(p.at) },
|
||||
p.who.replace(/^token /, "") + ", " + fmtTime(p.at) + (p.serial === c.serial ? "" : " (old: not fetched since the renewal)")));
|
||||
}
|
||||
|
||||
// namedUse is what named uses a certificate for: DoT and DoH, with the
|
||||
// addresses in the tooltip (the details list them).
|
||||
function namedUse(c) {
|
||||
const uses = c.named_use || [];
|
||||
if (!uses.length) return h("span", { class: "muted", title: "Encrypted DNS sets up named's DNS over TLS and HTTPS" }, "named: not set up");
|
||||
const kinds = [["DNS over TLS", "DoT"], ["DNS over HTTPS", "DoH"]].filter(([long]) => uses.some((u) => u.startsWith(long)));
|
||||
return h("span", { title: uses.join("\n") }, kinds.map(([, short]) => [badge(short, ""), " "]));
|
||||
}
|
||||
|
||||
function details(c) {
|
||||
const rows = [
|
||||
["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file],
|
||||
@@ -85,6 +104,62 @@ function details(c) {
|
||||
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
|
||||
}
|
||||
|
||||
// exportDialog downloads a certificate's files to use elsewhere.
|
||||
function exportDialog(c) {
|
||||
const parts = [
|
||||
["fullchain", "fullchain.pem", "the certificate and its chain: what most programs want"],
|
||||
["cert", "cert.pem", "the certificate alone"],
|
||||
["key", "privkey.pem", "the private key"],
|
||||
["bundle", "bundle.pem", "the key, then the chain, in one file (HAProxy)"],
|
||||
];
|
||||
modal("Export certificate " + c.name, h("div", null,
|
||||
c.source === "acme"
|
||||
? h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). Jarl renews the certificate, but not your copy: export it again after each renewal",
|
||||
c.next_attempt ? " (next: " + fmtTime(c.next_attempt) + ")" : "", ", or have the program read the files Jarl writes for named (use it for named).")
|
||||
: h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). You renew this one yourself, so a program on this server can read your files directly: ", h("code", null, c.cert_file), "."),
|
||||
h("table", { class: "small" }, parts.map(([part, file, what]) => h("tr", null,
|
||||
h("td", null, h("button", { class: "small" + (part === "fullchain" ? " primary" : ""), title: "Download " + file, onclick: () => download(c, part, file) }, file), " ",
|
||||
h("button", { class: "small", title: "Copy " + file + " to the clipboard", onclick: () => copyPEM(c, part, file) }, "Copy")),
|
||||
h("td", { class: part === "key" || part === "bundle" ? "" : "muted" }, what, part === "key" || part === "bundle" ? h("span", { class: "muted" }, " (audited)") : null)))),
|
||||
h("p", { class: "small muted" }, "CLI: ", h("code", null, "jarl cert export " + c.name + " --dir DIR"))), [{ label: "Close", value: null }]);
|
||||
}
|
||||
|
||||
// fetchPEM gets one part of a certificate, after asking when it holds the
|
||||
// private key (verb: "Download" or "Copy"). null when not confirmed.
|
||||
async function fetchPEM(c, part, verb) {
|
||||
if ((part === "key" || part === "bundle") && !await confirmDialog(verb + " the private key",
|
||||
"Whoever has the key of " + c.name + " can pose as " + (c.names || c.domains || []).join(", ") + " until the certificate expires. Keep it as safe as the server keeps it" +
|
||||
(verb === "Copy" ? ", and clear it from the clipboard when you're done (clipboard managers keep a history)" : "") + ". This is recorded in the audit log.", verb)) return null;
|
||||
const resp = await fetch("/api/v1/certificates/" + encodeURIComponent(c.name) + "/pem?part=" + part, { credentials: "same-origin" });
|
||||
if (!resp.ok) {
|
||||
let msg = "HTTP " + resp.status;
|
||||
try { msg = (await resp.json()).error.message; } catch { /* not JSON */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
async function copyPEM(c, part, file) {
|
||||
try {
|
||||
const text = await fetchPEM(c, part, "Copy");
|
||||
if (text === null) return;
|
||||
if (!navigator.clipboard) throw new Error("the browser allows copying only over HTTPS (or from localhost); download it instead");
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("Copied " + file);
|
||||
} catch (e) { toast("Copy failed: " + e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function download(c, part, file) {
|
||||
try {
|
||||
const text = await fetchPEM(c, part, "Download");
|
||||
if (text === null) return;
|
||||
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "application/x-pem-file" })), download: c.name + "-" + file });
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function issue(c) {
|
||||
try {
|
||||
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
|
||||
|
||||
@@ -44,7 +44,8 @@ export async function dnssecTab(el, zone) {
|
||||
s.named_error ? h("div", { class: "msg warn small" }, "named: " + s.named_error) : null,
|
||||
s.key_error ? h("div", { class: "msg err small" }, "The keys can't be read: " + s.key_error) : null,
|
||||
!s.policy && !s.keys.length ? h("p", { class: "muted small" },
|
||||
"Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record in the parent zone (at your registrar), and resolvers on your own network don't validate zones this server answers itself.") : null,
|
||||
"Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record " + (s.ds_place || "in the parent zone") + ", and resolvers on your own network don't validate zones this server answers itself.") : null,
|
||||
s.ds_at === "none" ? h("div", { class: "msg warn small" }, s.ds_note) : null,
|
||||
out)];
|
||||
|
||||
const live = s.keys.filter((k) => k.ds && k.goal === "omnipresent");
|
||||
@@ -98,8 +99,12 @@ async function addToParent(zone, s, k) {
|
||||
function dsPanel(zone, s, keys) {
|
||||
const ph = s.phase;
|
||||
const where = s.parent_zone
|
||||
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
|
||||
: "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields.";
|
||||
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)),
|
||||
s.parent_view && s.parent_view !== zone.view ? " (view " + s.parent_view + ")" : "",
|
||||
" is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
|
||||
: s.ds_at === "registrar"
|
||||
? "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields."
|
||||
: s.ds_at === "none" ? s.ds_note : "Add this " + s.ds_place + ".";
|
||||
return h("div", { class: "panel" },
|
||||
h("h2", null, "DS record for the parent zone"),
|
||||
ph.can_publish_ds
|
||||
@@ -153,7 +158,7 @@ async function enableDialog(zone, s, others) {
|
||||
"The server's clock must be right (NTP): signatures are valid for a fixed time window.",
|
||||
"TTLs in the zone must be at most a day; Jarl refuses longer ones.",
|
||||
"Secondaries get the signed zone by zone transfer; they need nothing else.",
|
||||
"With the default policy the key is never replaced, so the DS at your registrar never has to change. Keep backups: the daily backup includes the keys.",
|
||||
"With the default policy the key is never replaced, so the DS record at the parent never has to change. Keep backups: the daily backup includes the keys.",
|
||||
]),
|
||||
split,
|
||||
s.policies.length > 1 ? h("div", { class: "form-grid" }, h("label", null, "Policy"), pol) : null,
|
||||
@@ -175,7 +180,9 @@ async function disableDialog(zone, s) {
|
||||
[h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."],
|
||||
s.parent_zone
|
||||
? [h("strong", null, "You: "), "remove the DS record from ", display(s.parent_zone), " (", h("code", null, jarlCmd(zone, s, "delete", "")), ", or on its Records page), then click Check DS at parent so named knows it's gone."]
|
||||
: [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."],
|
||||
: s.ds_at === "registrar"
|
||||
? [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."]
|
||||
: [h("strong", null, "You: "), "remove the DS record ", s.ds_place || "from the parent zone", " (if it was ever added), then click Check DS at parent so named knows it's gone."],
|
||||
[h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."],
|
||||
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
|
||||
]),
|
||||
|
||||
@@ -16,6 +16,13 @@ const filterWords = { client: "client", name: "name", domain: "domain", view: "v
|
||||
const keys = Object.keys(filterWords);
|
||||
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
|
||||
|
||||
// exploreRange is the longest range here of at most secs ("1h" at least).
|
||||
export function exploreRange(secs) {
|
||||
let out = "1h";
|
||||
for (const [k, s] of ranges) if (s <= secs) out = k;
|
||||
return out;
|
||||
}
|
||||
|
||||
// exploreHref is the explore page for these filters (and range).
|
||||
export function exploreHref(filters, since) {
|
||||
const q = new URLSearchParams();
|
||||
@@ -74,7 +81,7 @@ export async function render(el, params, query) {
|
||||
}
|
||||
|
||||
const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
|
||||
const maxSecs = retentionSecs(ex.retention);
|
||||
const maxSecs = ex.retention_seconds || 7 * 86400;
|
||||
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||
h("span", { class: "muted" }, "Range"),
|
||||
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
|
||||
@@ -234,9 +241,3 @@ function stepWord(s) {
|
||||
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
|
||||
return s === 60 ? "minute" : s / 60 + " minutes";
|
||||
}
|
||||
|
||||
function retentionSecs(r) {
|
||||
const m = /^(\d+)([dhm])$/.exec(r || "");
|
||||
if (!m) return 7 * 86400;
|
||||
return Number(m[1]) * { d: 86400, h: 3600, m: 60 }[m[2]];
|
||||
}
|
||||
@@ -2,10 +2,27 @@ import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
||||
import { exploreHref } from "./explore.js";
|
||||
import { exploreHref, exploreRange } from "./explore.js";
|
||||
|
||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
||||
// Ranges: the since value, its words and its button. Only the ones the
|
||||
// statistics are kept for are offered (keptSecs).
|
||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"],
|
||||
["365d", "year", "1y"], ["730d", "2 years", "2y"]];
|
||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400, "730d": 730 * 86400 };
|
||||
|
||||
// keptSecs is how far back the statistics go: the longest of the minute,
|
||||
// hour and day retentions (a server older than retention_seconds: 2 years).
|
||||
function keptSecs(status) {
|
||||
const r = status.retention_seconds;
|
||||
return r ? Math.max(r.minute, r.hour, r.day) : 730 * 86400;
|
||||
}
|
||||
|
||||
// longestWithin is the longest range of at most secs ("1h" at least).
|
||||
function longestWithin(secs) {
|
||||
let out = "1h";
|
||||
for (const [k] of ranges) if (rangeSecs[k] <= secs) out = k;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Failed answers keep the same color whatever the range: color follows
|
||||
// the result code, never its rank.
|
||||
@@ -36,8 +53,9 @@ const perViewGroups = ["qtype", "rcode", "view", "zone", "blocklist"];
|
||||
export async function render(el, params, query) {
|
||||
const gen = ++generation;
|
||||
const want = query && query.get ? query.get("since") : null;
|
||||
const since = rangeSecs[want] ? want : "24h";
|
||||
const status = await get("/stats/status");
|
||||
const kept = keptSecs(status);
|
||||
const since = rangeSecs[want] && rangeSecs[want] <= kept ? want : longestWithin(Math.min(86400, kept));
|
||||
const head = h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Statistics"),
|
||||
h("div", { class: "muted" }, status.enabled
|
||||
@@ -50,7 +68,10 @@ export async function render(el, params, query) {
|
||||
mount(el, head, disabledPanel());
|
||||
return;
|
||||
}
|
||||
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since;
|
||||
// The heatmap needs hourly counters: at least a week of them, at most
|
||||
// what is kept.
|
||||
const hourSecs = status.retention_seconds ? status.retention_seconds.hour : 90 * 86400;
|
||||
const heatSince = longestWithin(Math.min(Math.max(rangeSecs[since], 7 * 86400), hourSecs));
|
||||
// Which views: all of them unless some are switched off.
|
||||
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
|
||||
const off = viewsOff();
|
||||
@@ -58,14 +79,14 @@ export async function render(el, params, query) {
|
||||
const filtered = allViews.length > 0 && shown.length < allViews.length;
|
||||
const vf = filtered ? { views: shown.join(",") } : {};
|
||||
if (filtered && !shown.length) {
|
||||
mount(el, head, filterRow(since, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
|
||||
mount(el, head, filterRow(since, kept, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
|
||||
} catch (e) {
|
||||
mount(el, head, filterRow(since, allViews, off), errorBox(e));
|
||||
mount(el, head, filterRow(since, kept, allViews, off), errorBox(e));
|
||||
return;
|
||||
}
|
||||
if (gen !== generation) return; // the user moved on
|
||||
@@ -74,7 +95,7 @@ export async function render(el, params, query) {
|
||||
// sudden drop in traffic.
|
||||
const timeline = complete(data.timeline), errors = complete(data.errors);
|
||||
const step = stepSeconds[timeline.step];
|
||||
const parts = [head, filterRow(since, allViews, off)];
|
||||
const parts = [head, filterRow(since, kept, allViews, off)];
|
||||
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
|
||||
if (filtered) {
|
||||
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
|
||||
@@ -120,7 +141,7 @@ export async function render(el, params, query) {
|
||||
// Who asks for what. With the query log, every value leads to the
|
||||
// explore page: a client to what it asked, a name to who asked.
|
||||
const ql = !!(status.retention || {}).queries;
|
||||
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since)) : null);
|
||||
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since, status)) : null);
|
||||
if (status.query_log) {
|
||||
const t = data.tops;
|
||||
parts.push(h("div", { class: "chart-grid" },
|
||||
@@ -201,11 +222,11 @@ function complete(tl) {
|
||||
return tl;
|
||||
}
|
||||
|
||||
function filterRow(since, views, off) {
|
||||
function filterRow(since, kept, views, off) {
|
||||
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||
h("span", { class: "muted" }, "Range"),
|
||||
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
|
||||
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
||||
h("div", { class: "segmented" }, ranges.filter(([k]) => rangeSecs[k] <= kept).map(([k, label, button]) =>
|
||||
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, button || k))));
|
||||
if (!views || views.length < 2) return range;
|
||||
// A toggle per view (together: linked views, say), and all or none.
|
||||
const set = (next) => { saveViewsOff(next); rerender(); };
|
||||
@@ -312,10 +333,11 @@ function disabledPanel() {
|
||||
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
||||
}
|
||||
|
||||
// exploreSince is the Statistics range for the explore page: the query log
|
||||
// keeps days, not months.
|
||||
function exploreSince(since) {
|
||||
return rangeSecs[since] > 7 * 86400 ? "7d" : since;
|
||||
// exploreSince is the Statistics range for the explore page: at most what
|
||||
// the query log keeps (days, usually, not months).
|
||||
function exploreSince(since, status) {
|
||||
const q = status.retention_seconds ? status.retention_seconds.queries : 7 * 86400;
|
||||
return exploreRange(Math.min(rangeSecs[since], q));
|
||||
}
|
||||
|
||||
// dbTile is the statistics database's size: what the statistics cost on
|
||||
|
||||
@@ -54,6 +54,7 @@ async function userDialog(u, grants) {
|
||||
tmpl.value = "";
|
||||
});
|
||||
const comment = input({ value: u ? u.comment : "" });
|
||||
const certLimit = input({ type: "number", min: "0", value: u && u.cert_limit != null ? String(u.cert_limit) : "", placeholder: "certificates.max_per_user" });
|
||||
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Username", name),
|
||||
field("Password", pw),
|
||||
@@ -62,6 +63,7 @@ async function userDialog(u, grants) {
|
||||
u ? h("span") : null, u ? disabled : null,
|
||||
field("Permissions", gr, "One grant per line (ignored for administrators)."),
|
||||
h("span"), tmpl,
|
||||
field("Certificates", certLimit, "How many certificates this user may own through jarl cert get (needs a certificates grant). Empty: the default from jarl.yaml."),
|
||||
field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
|
||||
@@ -69,6 +71,8 @@ async function userDialog(u, grants) {
|
||||
if (service.input.checked) data.password = "";
|
||||
else if (pw.value) data.password = pw.value;
|
||||
if (u) data.disabled = disabled.input.checked;
|
||||
if (certLimit.value.trim() !== "") data.cert_limit = Number(certLimit.value);
|
||||
else if (u && u.cert_limit != null) data.cert_limit = -1;
|
||||
try {
|
||||
if (u) await api("PATCH", "/users/" + u.id, { body: data });
|
||||
else await api("POST", "/users", { body: data });
|
||||
@@ -104,7 +108,7 @@ export async function tokensDialog(u, grants, own) {
|
||||
const base = own ? "/me/tokens" : "/users/" + u.id + "/tokens";
|
||||
const draw = async () => {
|
||||
const tokens = await get(base);
|
||||
mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw));
|
||||
mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw, u));
|
||||
};
|
||||
await draw();
|
||||
await modal("API tokens of " + u.username, box, [{ label: "Close", value: null }], { wide: true });
|
||||
@@ -185,13 +189,26 @@ async function rotateDialog(t, redraw) {
|
||||
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
|
||||
}
|
||||
|
||||
export function newTokenForm(base, grants, redraw) {
|
||||
// coveredBy reports whether a user (admin, or grants) may do what a grant
|
||||
// string ("records:write names=..." ) allows, by resource and access. A
|
||||
// filter on either side only narrows the token further, so it is ignored.
|
||||
function coveredBy(user, grant) {
|
||||
if (!user || user.admin) return true;
|
||||
const [res, acc] = grant.split(/\s+/)[0].split(":");
|
||||
return (user.grants || []).some((g) => (g.resource === "*" || g.resource === res) && (g.access === "write" || acc === "read"));
|
||||
}
|
||||
|
||||
// newTokenForm makes a token for owner (the user it belongs to). Its
|
||||
// templates are only the roles within what owner may do: a token never
|
||||
// does more than its user, so offering more would only mislead.
|
||||
export function newTokenForm(base, grants, redraw, owner) {
|
||||
const err = h("div");
|
||||
const out = h("div");
|
||||
const name = input({ placeholder: "e.g. certbot on web01" });
|
||||
const exp = select([["", "never expires"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "1 year"]], "90d");
|
||||
const gr = textarea("", { class: "mono", rows: 3, placeholder: "optional restriction, e.g.\nrecords:write zones=example.com names=_acme-challenge.* types=TXT" });
|
||||
const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], "");
|
||||
const roles = Object.keys(grants.roles).filter((r) => grants.roles[r].every((g) => coveredBy(owner, g)));
|
||||
const tmpl = select([["", "Insert a template…"], ...roles.map((r) => [r, r])], "");
|
||||
tmpl.addEventListener("change", () => {
|
||||
if (!tmpl.value) return;
|
||||
gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n");
|
||||
@@ -203,7 +220,7 @@ export function newTokenForm(base, grants, redraw) {
|
||||
field("Name", name),
|
||||
field("Expires", exp),
|
||||
field("Restrict to", gr, "Leave empty for all of the user's permissions. A token can never do more than its user."),
|
||||
h("span"), tmpl),
|
||||
roles.length ? [h("span"), tmpl] : null),
|
||||
err,
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
|
||||
mount(err); mount(out);
|
||||
|
||||
@@ -9,6 +9,15 @@ import { registrarTab } from "./registrar.js";
|
||||
|
||||
let renderSeq = 0;
|
||||
|
||||
// tabIn is the "/tab" part of the address of zone o (an in_views entry)
|
||||
// that keeps the tab open now, when o has that tab.
|
||||
function tabIn(o, tab) {
|
||||
if (!tab || tab === "registrar") return "";
|
||||
if (["records", "file", "dnssec", "compare"].includes(tab) && o.type !== "primary") return "";
|
||||
if (tab === "servers" && !["primary", "secondary", "mirror"].includes(o.type)) return "";
|
||||
return "/" + tab;
|
||||
}
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const id = params.id;
|
||||
const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]);
|
||||
@@ -33,12 +42,19 @@ export async function render(el, params, query) {
|
||||
badge(zone.type, "accent"), " ",
|
||||
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
||||
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
|
||||
zone.in_views ? [h("span", { title: "The zone " + zone.display_name + " in another view" }, "also in ",
|
||||
zone.in_views.map((o, i) => {
|
||||
const v = views.find((x) => x.name === o.view);
|
||||
return [i ? ", " : "", h("a", { href: "#/zones/" + o.id + tabIn(o, params.tab), class: v && v.color ? "vc-" + v.color : null,
|
||||
title: "Go to " + zone.display_name + " in view " + o.view + (o.type !== zone.type ? " (" + o.type + ")" : "") + (o.linked ? ", linked with this one" : "") }, o.view),
|
||||
o.type !== zone.type ? h("span", { class: "muted" }, " (" + o.type + ")") : null];
|
||||
})), " · "] : null,
|
||||
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
||||
zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null,
|
||||
zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "ok")), " "] : null,
|
||||
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
||||
zone.links ? [h("span", { class: "badge accent" }, "linked with ",
|
||||
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null,
|
||||
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + tabIn({ type: zone.type }, params.tab), title: "The zone in view " + l.view }, l.view)])), " "] : null,
|
||||
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
||||
h("div", { class: "actions" },
|
||||
zone.disabled
|
||||
@@ -51,7 +67,26 @@ export async function render(el, params, query) {
|
||||
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.")
|
||||
: zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" },
|
||||
plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ",
|
||||
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them"))) : null;
|
||||
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them")),
|
||||
h("ul", { class: "small ptr-missing" }, (zone.ptr_missing_list || []).map((f) => h("li", null,
|
||||
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
|
||||
f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null,
|
||||
f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)),
|
||||
zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null))
|
||||
: null;
|
||||
// Addresses only other views have records for: not missing here, but
|
||||
// they can be added (publishing those views' names in this one).
|
||||
const others = zone.ptr_other_views_list || [];
|
||||
const fromViews = [...new Set(others.map((f) => f.from))];
|
||||
const otherNote = !zone.disabled && zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" },
|
||||
plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " " + (zone.ptr_other_views === 1 ? "has" : "have") + " A or AAAA records only in view " + fromViews.join(", ") +
|
||||
", not in view " + zone.view + ", so " + (zone.ptr_other_views === 1 ? "it isn't" : "they aren't") + " counted as missing here. ",
|
||||
h("button", { class: "small", title: "Clients of view " + zone.view + " will then get these names, which come from view " + fromViews.join(", "), onclick: () => fillPTR(zone, true) },
|
||||
"Add PTR records for " + (zone.ptr_other_views === 1 ? "it" : "them") + " in view " + zone.view),
|
||||
h("ul", { class: "small ptr-missing" }, others.map((f) => h("li", null,
|
||||
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
|
||||
fromViews.length > 1 ? h("span", { class: "muted" }, " (view " + f.from + ")") : null)),
|
||||
zone.ptr_other_views > others.length ? h("li", { class: "muted" }, "and " + (zone.ptr_other_views - others.length) + " more") : null)) : null;
|
||||
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
||||
// The header and tabs show at once. Below them, switching tabs keeps the
|
||||
// previous tab's content (faded) until the new one is loaded, rather
|
||||
@@ -62,7 +97,7 @@ export async function render(el, params, query) {
|
||||
holder.classList.add("stale");
|
||||
body.className = "zone-body";
|
||||
body.dataset.zone = id;
|
||||
mount(el, head, banner, tabBar, holder);
|
||||
mount(el, head, banner, otherNote, tabBar, holder);
|
||||
if (tab === "records") await recordsTab(body, zone, query);
|
||||
else if (tab === "settings") await settingsTab(body, zone);
|
||||
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
|
||||
@@ -88,7 +123,10 @@ export async function render(el, params, query) {
|
||||
// the same. ?name= (and &type=) narrows it to one name.
|
||||
async function compareTab(el, zone, query) {
|
||||
const others = zone.other_views || [];
|
||||
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || others[0] || "");
|
||||
// A linked zone starts with a view it is linked to: that is the
|
||||
// comparison that matters (its differences are its own records).
|
||||
const linked = (zone.linked_with || []).filter((v) => others.includes(v));
|
||||
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || linked[0] || others[0] || "");
|
||||
const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } });
|
||||
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
|
||||
let showSame = false;
|
||||
@@ -801,12 +839,21 @@ async function recordsTab(el, zone, query) {
|
||||
|
||||
// ---- settings
|
||||
|
||||
// inZone reports whether an absolute name is the zone or inside it.
|
||||
function inZone(name, zoneName) {
|
||||
if (!name) return true;
|
||||
return name === zoneName || name.endsWith("." + zoneName);
|
||||
}
|
||||
|
||||
async function settingsTab(el, zone) {
|
||||
const views = await get("/views");
|
||||
const err = h("div");
|
||||
const rows = [];
|
||||
const s = zone.soa || {};
|
||||
const mname = input({ value: display(s.mname), class: "mono" });
|
||||
// The server reads a name without a final dot as relative to the zone,
|
||||
// so a name outside it keeps its dot.
|
||||
const mnameText = inZone(s.mname, zone.name) ? display(s.mname) : (s.mname || "");
|
||||
const mname = input({ value: mnameText, class: "mono" });
|
||||
const rname = input({ value: zone.rname_email || emailOf(s.rname) });
|
||||
const refresh = input({ type: "number", value: s.refresh });
|
||||
const retry = input({ type: "number", value: s.retry });
|
||||
@@ -820,7 +867,7 @@ async function settingsTab(el, zone) {
|
||||
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
|
||||
if (zone.type === "primary") {
|
||||
rows.push(
|
||||
field("Primary name server", mname, "SOA MNAME."),
|
||||
field("Primary name server", mname, "SOA MNAME. A name outside " + zone.display_name + " ends with '.'; without it, the name is taken as relative to the zone."),
|
||||
field("Contact", rname, "SOA RNAME, as an email address."),
|
||||
field("Refresh", refresh, "Seconds between secondaries checking for updates."),
|
||||
field("Retry", retry, "Seconds before a failed refresh is retried."),
|
||||
@@ -838,7 +885,8 @@ async function settingsTab(el, zone) {
|
||||
mount(err);
|
||||
const body = { comment: comment.value };
|
||||
if (zone.type === "primary") {
|
||||
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
|
||||
body.soa = { rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
|
||||
if (mname.value.trim() !== mnameText) body.soa.mname = mname.value.trim();
|
||||
body.default_ttl = +ttl.value;
|
||||
body.serial_policy = policy.value;
|
||||
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
|
||||
@@ -849,7 +897,10 @@ async function settingsTab(el, zone) {
|
||||
const r = await change("PATCH", "/zones/" + zone.id, body);
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
} catch (e) {
|
||||
if (e.status === 400 && /^nothing to (change|do)/.test(e.message)) toast("Nothing changed");
|
||||
else mount(err, errorBox(e));
|
||||
}
|
||||
};
|
||||
mount(el, h("div", { class: "panel" }, h("div", { class: "form-grid" }, rows), err,
|
||||
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: save }, "Save settings"))));
|
||||
@@ -914,9 +965,9 @@ async function toggleZone(zone) {
|
||||
}
|
||||
|
||||
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
|
||||
async function fillPTR(zone) {
|
||||
async function fillPTR(zone, otherViews) {
|
||||
try {
|
||||
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr");
|
||||
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr", undefined, otherViews ? { query: { other_views: "1" } } : {});
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||
rerender();
|
||||
|
||||
@@ -103,7 +103,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
cloneSel.addEventListener("change", syncLink);
|
||||
const comment = input();
|
||||
const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true);
|
||||
const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
|
||||
// Off unless asked (or a network only other views use is picked): it
|
||||
// publishes one view's host names in another.
|
||||
const fillOther = checkbox("Also for addresses only other views' records use, named from there", false);
|
||||
const fillRow = field("PTR records", h("div", null, fill, views.length ? fillOther : null), "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
|
||||
const updateClones = () => {
|
||||
// Records can only be copied into a single zone.
|
||||
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
||||
@@ -206,6 +209,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
name.value = c.prefix;
|
||||
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
|
||||
if (views.length && c.view) view.value = c.view;
|
||||
if ((c.hidden || "").startsWith("records in ")) { fill.input.checked = true; fillOther.input.checked = true; }
|
||||
changed();
|
||||
} },
|
||||
h("span", { class: "mono" }, c.prefix),
|
||||
@@ -273,7 +277,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
if (view.value) data.view = view.value;
|
||||
const opts = [];
|
||||
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
||||
if (t === "primary" && kind === "reverse" && fill.input.checked) data.fill = true;
|
||||
if (t === "primary" && kind === "reverse" && fill.input.checked) {
|
||||
data.fill = true;
|
||||
if (fillOther.input.checked) data.fill_other_views = true;
|
||||
}
|
||||
if (t === "primary") {
|
||||
data.nameservers = lines(ns.value);
|
||||
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
||||
|
||||
@@ -398,7 +398,7 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.hm-row { display: flex; align-items: center; }
|
||||
.hm-cell { height: 22px; border-radius: 3px; }
|
||||
.hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; }
|
||||
.hm-cell.empty, .hm-swatch.empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
|
||||
.hm-empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
|
||||
.heat-0 { background: var(--heat-0); } .heat-1 { background: var(--heat-1); } .heat-2 { background: var(--heat-2); }
|
||||
.heat-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); }
|
||||
.heat-6 { background: var(--heat-6); }
|
||||
|
||||
+68
-1
@@ -703,6 +703,21 @@ run "fill-ptr" 0 $J zone fill-ptr fdab:99::/48
|
||||
expect_dig "filled later: IPv6 PTR" "nas.revtest.test." -x fdab:99:0:10::10
|
||||
run "fill-ptr again: nothing to add (exit 6)" 6 $J zone fill-ptr fdab:99::/48
|
||||
contains "nothing to add"
|
||||
# The same network in another view: internal's hosts aren't missing there,
|
||||
# and are named from internal only when asked.
|
||||
run "the network in external too, filled" 0 $J zone create 10.99.1.0/24 --view external --fill
|
||||
contains "A or AAAA records only in other views"
|
||||
run "nothing missing there" 0 $J zone show 1.99.10.in-addr.arpa --view external
|
||||
not_contains "Missing PTRs"
|
||||
contains "^Other views: *2 addresses with A or AAAA records only in view internal"
|
||||
contains "10.99.1.11 -> pi.revtest.test (view internal)"
|
||||
run "fill-ptr there: nothing of its own (exit 6)" 6 $J zone fill-ptr 10.99.1.0/24 --view external
|
||||
contains "--other-views"
|
||||
run "fill-ptr --other-views" 0 $J zone fill-ptr 10.99.1.0/24 --view external --other-views
|
||||
contains "only view internal has a record for it"
|
||||
run "now named there" 0 $J record list 1.99.10.in-addr.arpa --view external
|
||||
contains "pi.revtest.test"
|
||||
run "drop the external copy" 0 $J zone delete 1.99.10.in-addr.arpa --view external --yes
|
||||
run "keep PTRs in step" 0 $J zone update revtest.test --view internal --auto-ptr
|
||||
run "zone show says so" 0 $J zone show revtest.test --view internal
|
||||
contains "^PTR records: *kept in step"
|
||||
@@ -736,7 +751,7 @@ run "and back on" 0 $J view update internal --reverse-suggestions
|
||||
run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external
|
||||
run "offered for internal" 0 $J zone suggest-reverse --all --view internal
|
||||
contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external"
|
||||
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill
|
||||
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views
|
||||
expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1
|
||||
# A zone created inside another takes the parent's records under it.
|
||||
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal
|
||||
@@ -896,6 +911,58 @@ if [ -n "$PEBBLE" ]; then
|
||||
run "a second certificate for the web UI is refused (exit 6)" 6 $J cert create other --domain nas.adbraun.test --use web
|
||||
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
||||
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
|
||||
# Certificates for other hosts: jarl cert get (docs/certificates.md#certificates-for-other-hosts).
|
||||
run "user for other hosts' certificates" 0 $J user create hosts --no-password --grant "certificates:write names=*.adbraun.test"
|
||||
run "its token" 0 sh -c "$J token create mx --user hosts -q > $E/hosts.token"
|
||||
run "a second user with the same grant" 0 $J user create hosts2 --no-password --grant "certificates:write names=*.adbraun.test"
|
||||
run "and its token" 0 sh -c "$J token create web --user hosts2 -q > $E/hosts2.token"
|
||||
run "a user without a certificates grant" 0 $J user create nocert --no-password --role acme
|
||||
run "and its token too" 0 sh -c "$J token create nocert --user nocert -q > $E/nocert.token"
|
||||
CG="--token @$E/hosts.token"; CGD=$E/certget
|
||||
mkdir -p $CGD
|
||||
HOOK='echo "$JARL_CERT_NAME $JARL_CERT_RENEWED $(basename $JARL_CERT_KEY)" >> '$CGD/hook.log
|
||||
run "cert get: obtained by jarld, downloaded here" 0 $J $CG cert get mail -d mail.adbraun.test --dir $CGD/mail --hook "$HOOK" --config-dir $CGD/conf
|
||||
contains "mail .*new files"
|
||||
run "the files" 0 ls $CGD/mail
|
||||
contains "^chain.pem$"
|
||||
contains "^fullchain.pem$"
|
||||
contains "^jarl-cert.json$"
|
||||
run "the key is mode 600" 0 stat -c %a $CGD/mail/privkey.pem
|
||||
contains "^600$"
|
||||
run "the key matches" 0 sh -c "openssl x509 -in $CGD/mail/cert.pem -noout -pubkey | cmp - \$(openssl pkey -in $CGD/mail/privkey.pem -pubout > $CGD/pub.pem; echo $CGD/pub.pem)"
|
||||
run "the hook ran" 0 cat $CGD/hook.log
|
||||
contains "^mail 0 privkey.pem$"
|
||||
run "the settings are saved, readable only by their owner" 0 stat -c %a $CGD/conf/mail.json
|
||||
contains "^600$"
|
||||
run "cert get again: up to date, no hook" 0 $J cert get --config-dir $CGD/conf
|
||||
contains "mail .*up to date"
|
||||
if [ "$(wc -l < $CGD/hook.log)" = 1 ]; then ok "the hook didn't run again"; else bad "hook ran again: $(cat $CGD/hook.log)"; fi
|
||||
run "cert saved" 0 $J cert saved --config-dir $CGD/conf
|
||||
contains "mail *mail.adbraun.test .*$CGD/mail"
|
||||
run "another user can't take the name (exit 5)" 5 $J --token @$E/hosts2.token cert get mail -d mail.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
|
||||
contains "taken"
|
||||
run "an ACME token can't get certificates (exit 3)" 3 $J --token @$E/nocert.token cert get x -d x.adbraun.test --dir $CGD/x --no-save --config-dir $CGD/conf2
|
||||
run "a name outside the grant (exit 3)" 3 $J $CG cert get other -d mail.example.org --dir $CGD/other --no-save --config-dir $CGD/conf
|
||||
run "the admin sees the owner and who downloaded it" 0 $J cert show mail
|
||||
contains "Owner: *hosts"
|
||||
contains "Pulled by: *token mx (hosts)"
|
||||
run "jarld renews it" 0 $J cert issue mail
|
||||
run "cert get: the renewed one, hook told" 0 $J cert get --config-dir $CGD/conf
|
||||
contains "mail .*new files"
|
||||
run "the hook ran for the renewal" 0 tail -1 $CGD/hook.log
|
||||
contains "^mail 1 privkey.pem$"
|
||||
run "a name added: obtained again" 0 $J cert get mail -d mail.adbraun.test -d smtp.adbraun.test --config-dir $CGD/conf
|
||||
run "the new certificate has both names" 0 openssl x509 -in $CGD/mail/cert.pem -noout -ext subjectAltName
|
||||
contains "smtp.adbraun.test"
|
||||
run "cert schedule writes the units" 0 $J cert schedule --unit-dir $CGD --no-systemctl
|
||||
run "the service runs cert get" 0 cat $CGD/jarl-cert-get.service
|
||||
contains "^ExecStart=.* cert get --config-dir"
|
||||
run "the timer runs daily" 0 cat $CGD/jarl-cert-get.timer
|
||||
contains "^OnCalendar=daily$"
|
||||
run "cert forget" 0 $J cert forget mail --config-dir $CGD/conf
|
||||
run "nothing saved now" 0 $J cert saved --config-dir $CGD/conf
|
||||
contains "No saved certificates"
|
||||
run "the owner may delete it" 0 $J $CG cert delete mail --yes
|
||||
# Encrypted DNS set up by Jarl (docs/encrypted-dns.md), next to the
|
||||
# hand-written listener above.
|
||||
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}
|
||||
|
||||
Reference in new issue
Block a user