Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e5bbb628 | ||
|
|
740f3cc328 | ||
|
|
5fcf4082c9 | ||
|
|
1cec6cc393 | ||
|
|
921bdd6c95 | ||
|
|
12820b17bb | ||
|
|
ead749dd3d | ||
|
|
66dcf53684 | ||
|
|
ebd3a3dfbe | ||
|
|
fd0cbb47a9 | ||
|
|
057433c198 | ||
|
|
6313fa51f8 | ||
|
|
88f63ffb0a | ||
|
|
88965096bb | ||
|
|
bad360107e | ||
|
|
bdd7acfd3b |
No files matched your search
+114
@@ -8,12 +8,54 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Upgrading from 0.4
|
### Upgrading from 0.4
|
||||||
|
|
||||||
|
- The database gets three new versions (internet exposure, certificates,
|
||||||
|
linked zones), applied when jarld starts; a 0.4 jarld can't open it
|
||||||
|
afterwards, so take a backup first (`sudo jarld backup`). Backups made
|
||||||
|
by 0.4 restore into this version. Tested: 0.4.0 packages with views,
|
||||||
|
zones, records, blocking and statistics, upgraded in place and
|
||||||
|
restored from a 0.4.0 backup.
|
||||||
|
|
||||||
|
- Statistics per view start with this version: earlier figures are for
|
||||||
|
all views together.
|
||||||
|
|
||||||
|
- New exposure warnings (docs/exposure.md) appear for views reachable from
|
||||||
|
the internet, e.g. "no rate-limit" or "version visible" on an external
|
||||||
|
view: fix them from the Exposure page, or acknowledge them. A change
|
||||||
|
that newly opens a public view up (an open resolver, say) now needs
|
||||||
|
`--accept-risk`, or the checkbox on the Changes page.
|
||||||
|
|
||||||
- Once you use address ranges, the database can't go back to 0.4: its
|
- Once you use address ranges, the database can't go back to 0.4: its
|
||||||
parser doesn't know them. Remove the ranges first if you need to
|
parser doesn't know them. Remove the ranges first if you need to
|
||||||
downgrade.
|
downgrade.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The web UI's top bar had grown too long, and the user name, Users and
|
||||||
|
Log out ended up on a second line on the left. The server's
|
||||||
|
configuration and security pages (Server options, ACLs, TSIG keys,
|
||||||
|
Exposure, Certificates, Encrypted DNS, Audit log) are now in a
|
||||||
|
**Server** menu, and Account, Users and Log out in a menu under your
|
||||||
|
name. On narrow screens the search box and your name stay together on
|
||||||
|
the right.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- "update PTR" when editing a record (`jarl record update ID --ptr`) did
|
||||||
|
nothing unless the name or address changed too, and the web UI didn't
|
||||||
|
even send it alone: a reverse zone created after the record never got
|
||||||
|
its PTR. Alone, it now adds the PTR record if it is missing (or enables
|
||||||
|
it). For records of linked zones, the reverse zones of every linked view
|
||||||
|
count, not only the record's own view.
|
||||||
|
|
||||||
|
- Option values in the web UI looked different from the editor: a long
|
||||||
|
line wrapped back to the left edge, losing its indentation, and the
|
||||||
|
column changed width when Edit opened. Values now keep their layout
|
||||||
|
(a long line scrolls), with the same width and padding as the editor.
|
||||||
|
The "{ } around the list are optional" hint only shows for options that
|
||||||
|
end in a list (not rate-limit, which only has one inside).
|
||||||
|
- Query statistics skipped every query signed with a TSIG key (named
|
||||||
|
writes "/key NAME" after the client's port), such as queries with a
|
||||||
|
view's key: they are counted now.
|
||||||
- A changeset with a staged record delete could not be applied after that
|
- A changeset with a staged record delete could not be applied after that
|
||||||
record was deleted elsewhere (e.g. with the CLI), and it was hard to see
|
record was deleted elsewhere (e.g. with the CLI), and it was hard to see
|
||||||
which operation to remove: the error only said "record 832 does not
|
which operation to remove: the error only said "record 832 does not
|
||||||
@@ -25,6 +67,78 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Statistics per view ([docs/stats.md](docs/stats.md#views)): the
|
||||||
|
Statistics page has a toggle per view, with All and None, remembered in
|
||||||
|
the browser, to see some views only or several together (linked views,
|
||||||
|
say). Answers, result codes, record types, zones, blocking, rate
|
||||||
|
limiting and the top lists follow it; named's own counters stay for the
|
||||||
|
whole server, and the page says so. `jarl stats --view`, API
|
||||||
|
`?views=`. Per-view figures start with this version.
|
||||||
|
- DNS over TLS and HTTPS ([docs/encrypted-dns.md](docs/encrypted-dns.md)):
|
||||||
|
`jarl encrypted-dns setup --hostname NAME --address ADDR` (and the
|
||||||
|
Encrypted DNS page) obtains a certificate for the host name if needed,
|
||||||
|
checks that nothing else listens on port 853 and the DoH port (443 by
|
||||||
|
default) at those addresses, offering a free port, and stages named's
|
||||||
|
`tls` statement and listeners as one change, keeping port 53 where it
|
||||||
|
was, plus the host name's records in your private views (local to the
|
||||||
|
view in a linked zone). The page and `jarl encrypted-dns` say what to
|
||||||
|
give Android, Linux, Firefox, Chrome and Windows. A `tls` statement whose
|
||||||
|
files are missing is refused (`tls_file_missing`), and a certificate
|
||||||
|
named uses can't be deleted.
|
||||||
|
- Linked zones ([docs/views.md](docs/views.md#linked-zones)): zones of the
|
||||||
|
same name in different views can be linked, and Jarl keeps their shared
|
||||||
|
records in step: a change in one view reaches the others in the same
|
||||||
|
changeset (diff, permissions, history and undo cover every view). Each
|
||||||
|
view can still have records of its own (`--local`, "Only here"), which
|
||||||
|
override the shared records of that name and type there, and keeps its
|
||||||
|
own zone options. Linking existing zones changes no answer: what is the
|
||||||
|
same everywhere becomes shared, the rest stays local, and a view's
|
||||||
|
version can be shared later (`--shared`, "Share"). `jarl zone link` and
|
||||||
|
`unlink`, `zone create --link-with VIEW`, `record add|set|update
|
||||||
|
--local|--shared`, a LINK column in `record list`, "Keep them linked"
|
||||||
|
when copying a zone in the web UI, `POST /zones/{zone}/link` and
|
||||||
|
`/unlink`. ACME challenges are written once per link, and `--view all`
|
||||||
|
changes a link once.
|
||||||
|
- TLS certificates ([docs/certificates.md](docs/certificates.md)): Jarl
|
||||||
|
obtains certificates from Let's Encrypt or any ACME CA (account set up
|
||||||
|
in jarl.yaml under `acme.account`) and renews them itself, from the CA's
|
||||||
|
renewal information (ARI) or two thirds into their lifetime, with
|
||||||
|
back-off after failures. It answers the DNS-01 challenges in a zone it
|
||||||
|
serves, or through Porkbun's DNS API while Porkbun serves the domain;
|
||||||
|
`auto` decides from the domain's public name servers at every renewal,
|
||||||
|
so moving the domain to your own name servers needs no change. A CNAME
|
||||||
|
on `_acme-challenge` into a zone Jarl serves is followed, for domains
|
||||||
|
hosted elsewhere. A certificate is used for jarld's HTTPS ("web") and/or
|
||||||
|
written for named (`<bind.config_dir>/tls/NAME/`, "dns"; named rereads
|
||||||
|
it on renewal), and certificates you renew yourself can be used too
|
||||||
|
(reread when their files change). Certificates page and dashboard card
|
||||||
|
in the web UI, `jarl cert` (check, create, issue, update, delete,
|
||||||
|
account), `/api/v1/certificates`, Prometheus
|
||||||
|
`jarl_certificate_expiry_timestamp_seconds`, `jarl_certificate_failing`.
|
||||||
|
- jarld serves HTTPS and plain HTTP on the same ports once it has a
|
||||||
|
certificate (`tls.cert_file`, or one with the web use, which takes
|
||||||
|
effect without a restart): plain HTTP from the server itself and from
|
||||||
|
trusted proxies is served as before, browsers elsewhere are redirected
|
||||||
|
to HTTPS and API requests get `https_required`. Before, with
|
||||||
|
`tls.cert_file`, plain HTTP got an error even from localhost.
|
||||||
|
- Internet exposure ([docs/exposure.md](docs/exposure.md)): Jarl works out
|
||||||
|
which views are reachable from the internet (match-clients ending in a
|
||||||
|
catch-all; for a server without views, open allow-query and a
|
||||||
|
non-private listen address), with a per-view override, and checks them:
|
||||||
|
response rate limiting, open resolvers, ANY answers (minimal-any), cache
|
||||||
|
snooping, open NOTIFY and zone transfers, BIND version and host name,
|
||||||
|
management interfaces. Fixes are staged like any change, including
|
||||||
|
response rate limiting from ISC's example in log-only mode, with your
|
||||||
|
secondaries exempt, and a later "start enforcing". With statistics on,
|
||||||
|
Jarl reads named's rate limiting log lines and shows what was (or would
|
||||||
|
have been) limited: counts, client networks and names (Exposure page,
|
||||||
|
`jarl stats breakdown rrl`, top lists `ratelimited`, Prometheus
|
||||||
|
`jarl_ratelimit_logged_total`). A change that newly opens a public view
|
||||||
|
up must be confirmed (checkbox on the Changes page, `--accept-risk`, API
|
||||||
|
`accept_risk`; 409 `confirm_risk` otherwise). Findings can be
|
||||||
|
acknowledged with a reason: they stay visible as info, with who, when
|
||||||
|
and why. Exposure page and dashboard card in the web UI, `jarl
|
||||||
|
exposure` (fix, ack, unack), `GET /exposure`.
|
||||||
- `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the
|
- `jarl changeset show`, `diff`, `apply`, `discard` and `remove` take the
|
||||||
changeset from `-C` or `JARL_CHANGESET` when no ID is given
|
changeset from `-C` or `JARL_CHANGESET` when no ID is given
|
||||||
(`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now
|
(`jarl changeset apply`). A changeset from `JARL_CHANGESET` is now
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
|||||||
queries, record types, cache hit rate. Shown in the web UI and the CLI,
|
queries, record types, cache hit rate. Shown in the web UI and the CLI,
|
||||||
and exportable as JSON. Stored as aggregates in a separate database with
|
and exportable as JSON. Stored as aggregates in a separate database with
|
||||||
retention you set.
|
retention you set.
|
||||||
|
* **Internet exposure checks**: Jarl works out which views are reachable
|
||||||
|
from the internet (a GUA-addressed internal network still counts as
|
||||||
|
yours) and checks them: response rate limiting (set up in log-only mode
|
||||||
|
first, with what it would limit shown before you enforce it), open
|
||||||
|
resolvers, ANY amplification, cache snooping, version and host name
|
||||||
|
leaks, open management interfaces. Fixes are staged for review, changes
|
||||||
|
that open a public view up must be confirmed, and deliberate exceptions
|
||||||
|
can be acknowledged with a reason.
|
||||||
* **Prometheus metrics** (optional): health to alert on (named down,
|
* **Prometheus metrics** (optional): health to alert on (named down,
|
||||||
configuration problems, drift, failing blocklists, stale backups, DNSSEC
|
configuration problems, drift, failing blocklists, stale backups, DNSSEC
|
||||||
steps waiting at the parent, expiring tokens) and query counters per
|
steps waiting at the parent, expiring tokens) and query counters per
|
||||||
@@ -83,6 +91,18 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
|||||||
* **Let's Encrypt DNS-01** built in: `jarl acme` plus certbot, acme.sh and lego
|
* **Let's Encrypt DNS-01** built in: `jarl acme` plus certbot, acme.sh and lego
|
||||||
hooks, an HTTPS-only endpoint for lego's and Traefik's `httpreq` provider,
|
hooks, an HTTPS-only endpoint for lego's and Traefik's `httpreq` provider,
|
||||||
and CNAME delegation to a dedicated ACME zone.
|
and CNAME delegation to a dedicated ACME zone.
|
||||||
|
* **Certificates for the server itself**: Jarl obtains and renews TLS
|
||||||
|
certificates from Let's Encrypt (or any ACME CA), answering the DNS-01
|
||||||
|
challenge in its own zones or through Porkbun's API while Porkbun still
|
||||||
|
serves the domain, decided at every renewal. Use them for the web UI
|
||||||
|
(HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
|
||||||
|
rereads them on renewal), or bring your own.
|
||||||
|
* **DNS over TLS and HTTPS** for your own devices in one step: the
|
||||||
|
certificate, named's listeners on the addresses you choose (after
|
||||||
|
checking the ports are free) and the host name in your private views.
|
||||||
|
* **Linked zones for split DNS**: the same zone in several views, with the
|
||||||
|
shared records kept in step by Jarl and a few records of each view's own
|
||||||
|
(an internal address for a public name, say).
|
||||||
* **Adopts an existing server**: imports named.conf and zone files
|
* **Adopts an existing server**: imports named.conf and zone files
|
||||||
(comments included), then switches named over with an automatic
|
(comments included), then switches named over with an automatic
|
||||||
fallback.
|
fallback.
|
||||||
@@ -124,7 +144,10 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
|
|||||||
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
|
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
|
||||||
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
|
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
|
||||||
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
|
||||||
|
| [docs/encrypted-dns.md](docs/encrypted-dns.md) | DNS over TLS and HTTPS for your clients: setup, ports, client settings |
|
||||||
|
| [docs/certificates.md](docs/certificates.md) | TLS certificates Jarl obtains and renews for its web UI and named's DNS over TLS/HTTPS |
|
||||||
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
|
||||||
|
| [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
|
||||||
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
|
| [docs/metrics.md](docs/metrics.md) | Prometheus metrics: access, what is exported, example alerts |
|
||||||
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
| [docs/blocking.md](docs/blocking.md) | Blocking ads, trackers and malware: blocklists, your own rules, views, memory |
|
||||||
| [docs/validation.md](docs/validation.md) | Every check Jarl makes |
|
| [docs/validation.md](docs/validation.md) | Every check Jarl makes |
|
||||||
@@ -177,9 +200,12 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.4.0 is the current release, and 0.4.1 is in testing
|
Version 0.4.0 is the current release, and 0.5.0 is in testing
|
||||||
(0.4.1-rc.1): it adds Prometheus metrics, address ranges in ACLs and
|
(0.5.0-rc.1): it adds Prometheus metrics, address ranges in ACLs and
|
||||||
other address match lists, and safer changeset handling. 0.4.0 added blocking (blocklists and
|
other address match lists, safer changeset handling, internet exposure
|
||||||
|
checks with response rate limiting, TLS certificates Jarl obtains and
|
||||||
|
renews itself, linked zones for split DNS, DNS over TLS and HTTPS, and statistics
|
||||||
|
per view. 0.4.0 added blocking (blocklists and
|
||||||
your own rules, like Pi-hole, done by named, with different blocking for
|
your own rules, like Pi-hole, done by named, with different blocking for
|
||||||
different devices), disabling zones, server statistics and view keys, on
|
different devices), disabling zones, server statistics and view keys, on
|
||||||
top of 0.3.0's disabled records, script-friendly CLI output and client
|
top of 0.3.0's disabled records, script-friendly CLI output and client
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ func acmeZones(c *client.Client, name, view string) ([]acme.Zone, error) {
|
|||||||
}
|
}
|
||||||
refs := make([]acme.Zone, len(zones))
|
refs := make([]acme.Zone, len(zones))
|
||||||
for i, z := range zones {
|
for i, z := range zones {
|
||||||
refs[i] = acme.Zone{Name: z.Name, View: z.View}
|
refs[i] = acme.Zone{Name: z.Name, View: z.View, Link: z.LinkGroup}
|
||||||
}
|
}
|
||||||
out := acme.Zones(name, refs, view)
|
out := acme.Zones(name, refs, view)
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"jarl/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type certStatus struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type certInfo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
CertFile string `json:"cert_file,omitempty"`
|
||||||
|
KeyFile string `json:"key_file,omitempty"`
|
||||||
|
Uses []string `json:"uses"`
|
||||||
|
Challenge string `json:"challenge,omitempty"`
|
||||||
|
KeyType string `json:"key_type,omitempty"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Names []string `json:"names,omitempty"`
|
||||||
|
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||||
|
NotAfter *time.Time `json:"not_after,omitempty"`
|
||||||
|
Issuer string `json:"issuer,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
RenewAt *time.Time `json:"renew_at,omitempty"`
|
||||||
|
LastAttempt *time.Time `json:"last_attempt,omitempty"`
|
||||||
|
LastSuccess *time.Time `json:"last_success,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Failures int `json:"failures,omitempty"`
|
||||||
|
LastRoutes []string `json:"last_routes,omitempty"`
|
||||||
|
Status certStatus `json:"status"`
|
||||||
|
NextAttempt *time.Time `json:"next_attempt,omitempty"`
|
||||||
|
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||||
|
Serving bool `json:"serving,omitempty"`
|
||||||
|
Files *struct {
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
} `json:"files,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type certAccount struct {
|
||||||
|
Directory string `json:"directory"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AgreeTOS bool `json:"agree_tos"`
|
||||||
|
EAB bool `json:"eab"`
|
||||||
|
Registered bool `json:"registered"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
RegisteredAt *time.Time `json:"registered_at"`
|
||||||
|
Porkbun bool `json:"porkbun"`
|
||||||
|
Resolvers []string `json:"resolvers"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
ActiveWebCertificate string `json:"active_web_certificate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type certRoute struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Record string `json:"record"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Route string `json:"route"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
PublicNS []string `json:"public_ns"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func certPath(name string) string { return "/certificates/" + url.PathEscape(name) }
|
||||||
|
|
||||||
|
func certCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "cert",
|
||||||
|
Aliases: []string{"certificate", "certificates"},
|
||||||
|
Short: "TLS certificates Jarl obtains and renews, or reads from files (docs/certificates.md)",
|
||||||
|
Long: `Certificates for jarld's own HTTPS (use "web") and for named's DNS over
|
||||||
|
TLS and HTTPS (use "dns": written where named can read them).
|
||||||
|
|
||||||
|
Jarl obtains them from an ACME CA (Let's Encrypt by default; the account is
|
||||||
|
set up in jarl.yaml under acme.account) and renews them itself. It answers
|
||||||
|
the DNS-01 challenges in a zone it serves, or through Porkbun's API while
|
||||||
|
Porkbun serves the domain ("auto" decides at every renewal from the
|
||||||
|
domain's public name servers). A certificate you renew yourself can be
|
||||||
|
used too (--cert-file and --key-file): Jarl rereads it when it changes.`,
|
||||||
|
Example: ` jarl cert check --domain ns1.adbraun.lol # where would the challenge go?
|
||||||
|
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns
|
||||||
|
jarl cert create wild --domain adbraun.lol --domain '*.adbraun.lol' --use dns
|
||||||
|
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)`,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certDeleteCmd(), certAccountCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func certListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
|
Short: "List certificates",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Certificates []certInfo `json:"certificates"`
|
||||||
|
}
|
||||||
|
if err := c.Get(ctx(), "/certificates", nil, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(r.Certificates, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "NAME\tSOURCE\tUSES\tNAMES\tEXPIRES\tSTATUS")
|
||||||
|
for _, ci := range r.Certificates {
|
||||||
|
names := ci.Names
|
||||||
|
if len(names) == 0 {
|
||||||
|
names = ci.Domains
|
||||||
|
}
|
||||||
|
state := ci.Status.State
|
||||||
|
if ci.IssuingSince != nil {
|
||||||
|
state = "obtaining"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ci.Name, ci.Source, dash(strings.Join(ci.Uses, ",")), strings.Join(names, " "), fmtDate(ci.NotAfter), state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtDate(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return t.Local().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func printCert(w io.Writer, ci certInfo) {
|
||||||
|
fmt.Fprintf(w, "Name:\t%s\n", ci.Name)
|
||||||
|
switch ci.Source {
|
||||||
|
case "acme":
|
||||||
|
fmt.Fprintf(w, "Source:\tacme (Jarl obtains and renews it)\n")
|
||||||
|
fmt.Fprintf(w, "Domains:\t%s\n", strings.Join(ci.Domains, " "))
|
||||||
|
fmt.Fprintf(w, "Challenge:\t%s\n", ci.Challenge)
|
||||||
|
fmt.Fprintf(w, "Key type:\t%s\n", ci.KeyType)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(w, "Source:\tfile (you renew it)\n")
|
||||||
|
fmt.Fprintf(w, "Cert file:\t%s\n", ci.CertFile)
|
||||||
|
fmt.Fprintf(w, "Key file:\t%s\n", ci.KeyFile)
|
||||||
|
}
|
||||||
|
uses := []string{}
|
||||||
|
for _, u := range ci.Uses {
|
||||||
|
switch u {
|
||||||
|
case "web":
|
||||||
|
s := "web (jarld's HTTPS"
|
||||||
|
if ci.Serving {
|
||||||
|
s += ", serving now"
|
||||||
|
}
|
||||||
|
uses = append(uses, s+")")
|
||||||
|
case "dns":
|
||||||
|
uses = append(uses, "dns (for named)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Uses:\t%s\n", dash(strings.Join(uses, ", ")))
|
||||||
|
if ci.Comment != "" {
|
||||||
|
fmt.Fprintf(w, "Comment:\t%s\n", ci.Comment)
|
||||||
|
}
|
||||||
|
state := ci.Status.State
|
||||||
|
if ci.Status.Message != "" {
|
||||||
|
state += ": " + ci.Status.Message
|
||||||
|
}
|
||||||
|
if ci.IssuingSince != nil {
|
||||||
|
state = "obtaining since " + fmtTime(ci.IssuingSince)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Status:\t%s\n", state)
|
||||||
|
if ci.NotAfter != nil {
|
||||||
|
fmt.Fprintf(w, "Names:\t%s\n", strings.Join(ci.Names, " "))
|
||||||
|
fmt.Fprintf(w, "Valid:\t%s to %s\n", fmtTime(ci.NotBefore), fmtTime(ci.NotAfter))
|
||||||
|
fmt.Fprintf(w, "Issuer:\t%s\n", ci.Issuer)
|
||||||
|
fmt.Fprintf(w, "Serial:\t%s\n", ci.Serial)
|
||||||
|
}
|
||||||
|
if ci.Source == "acme" {
|
||||||
|
if ci.NextAttempt != nil && ci.NotAfter != nil {
|
||||||
|
fmt.Fprintf(w, "Renews:\t%s\n", fmtTime(ci.NextAttempt))
|
||||||
|
} else if ci.NextAttempt != nil {
|
||||||
|
fmt.Fprintf(w, "Next try:\t%s\n", fmtTime(ci.NextAttempt))
|
||||||
|
}
|
||||||
|
if len(ci.LastRoutes) > 0 {
|
||||||
|
fmt.Fprintf(w, "Challenges:\t%s\n", strings.Join(ci.LastRoutes, "; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ci.LastSuccess != nil {
|
||||||
|
fmt.Fprintf(w, "Last success:\t%s\n", fmtTime(ci.LastSuccess))
|
||||||
|
}
|
||||||
|
if ci.LastError != "" {
|
||||||
|
fmt.Fprintf(w, "Last error:\t%s\n", ci.LastError)
|
||||||
|
}
|
||||||
|
if ci.Files != nil {
|
||||||
|
fmt.Fprintf(w, "For named:\t%s\n\t%s\n", ci.Files.Chain, ci.Files.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func certShowCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "show NAME",
|
||||||
|
Short: "Show a certificate",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ci certInfo
|
||||||
|
if err := c.Get(ctx(), certPath(args[0]), nil, &ci); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(ci, func(w io.Writer) { printCert(w, ci) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type certFlags struct {
|
||||||
|
domains []string
|
||||||
|
uses []string
|
||||||
|
challenge, keyType string
|
||||||
|
comment string
|
||||||
|
certFile, keyFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *certFlags) add(cmd *cobra.Command) {
|
||||||
|
fl := cmd.Flags()
|
||||||
|
fl.StringArrayVarP(&f.domains, "domain", "d", nil, "a name on the certificate (repeat for several; *.example.com for a wildcard)")
|
||||||
|
fl.StringArrayVarP(&f.uses, "use", "u", nil, "what it is for: web (jarld's HTTPS) or dns (named's DNS over TLS/HTTPS); repeat for both")
|
||||||
|
fl.StringVar(&f.challenge, "challenge", "", "where DNS-01 challenges go: auto (from the domain's public name servers), jarl or porkbun")
|
||||||
|
fl.StringVar(&f.keyType, "key-type", "", "key type: ec256 (default), ec384, rsa2048, rsa3072 or rsa4096")
|
||||||
|
fl.StringVar(&f.comment, "comment", "", "a comment")
|
||||||
|
fl.StringVar(&f.certFile, "cert-file", "", "a certificate you renew yourself: the full chain (PEM)")
|
||||||
|
fl.StringVar(&f.keyFile, "key-file", "", "its private key (PEM)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *certFlags) body(cmd *cobra.Command) map[string]any {
|
||||||
|
b := map[string]any{}
|
||||||
|
ch := cmd.Flags().Changed
|
||||||
|
if ch("domain") {
|
||||||
|
b["domains"] = f.domains
|
||||||
|
}
|
||||||
|
if ch("use") {
|
||||||
|
uses := []string{}
|
||||||
|
for _, u := range f.uses {
|
||||||
|
for _, x := range strings.Split(u, ",") {
|
||||||
|
if x = strings.TrimSpace(x); x != "" && x != "none" {
|
||||||
|
uses = append(uses, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b["uses"] = uses
|
||||||
|
}
|
||||||
|
for flag, key := range map[string]string{"challenge": "challenge", "key-type": "key_type", "comment": "comment", "cert-file": "cert_file", "key-file": "key_file"} {
|
||||||
|
if ch(flag) {
|
||||||
|
v, _ := cmd.Flags().GetString(flag)
|
||||||
|
b[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func certCreateCmd() *cobra.Command {
|
||||||
|
var f certFlags
|
||||||
|
var noIssue, noWait bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create NAME",
|
||||||
|
Short: "Add a certificate: obtained by Jarl (--domain), or one you renew (--cert-file, --key-file)",
|
||||||
|
Long: `Adds a certificate. With --domain, Jarl obtains it from the CA at once
|
||||||
|
(and renews it from then on); the command waits for the outcome. With
|
||||||
|
--cert-file and --key-file it is a certificate you renew yourself, which
|
||||||
|
Jarl reads (and rereads when the files change).
|
||||||
|
|
||||||
|
The name is used for the files for named (<certificates.dir>/NAME/).`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b := f.body(cmd)
|
||||||
|
b["name"] = args[0]
|
||||||
|
b["source"] = "acme"
|
||||||
|
if f.certFile != "" || f.keyFile != "" {
|
||||||
|
b["source"] = "file"
|
||||||
|
}
|
||||||
|
b["issue"] = !noIssue
|
||||||
|
var r struct {
|
||||||
|
Certificate certInfo `json:"certificate"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/certificates", nil, b, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.Certificate.IssuingSince != nil && !noWait {
|
||||||
|
return waitIssue(c, args[0])
|
||||||
|
}
|
||||||
|
if r.Error != "" {
|
||||||
|
return errors.New(r.Error)
|
||||||
|
}
|
||||||
|
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f.add(cmd)
|
||||||
|
cmd.Flags().BoolVar(&noIssue, "no-issue", false, "don't obtain it now (jarld does within minutes)")
|
||||||
|
cmd.Flags().BoolVar(&noWait, "no-wait", false, "return at once instead of waiting for the CA")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func certUpdateCmd() *cobra.Command {
|
||||||
|
var f certFlags
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "update NAME",
|
||||||
|
Short: "Change a certificate (--domain and --use replace the whole list)",
|
||||||
|
Long: `Changes a certificate. --domain and --use replace the whole list
|
||||||
|
(--use none for no use). New names or a new key type make Jarl obtain it
|
||||||
|
again soon (run 'jarl cert issue NAME' to do it now).`,
|
||||||
|
Example: ` jarl cert update ns1 --use web --use dns
|
||||||
|
jarl cert update ns1 --challenge auto
|
||||||
|
jarl cert update ns1 --domain ns1.adbraun.lol --domain dns.adbraun.lol`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b := f.body(cmd)
|
||||||
|
if len(b) == 0 {
|
||||||
|
return errors.New("nothing to change")
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Certificate certInfo `json:"certificate"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "PATCH", certPath(args[0]), nil, b, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f.add(cmd)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func certIssueCmd() *cobra.Command {
|
||||||
|
var noWait bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "issue NAME",
|
||||||
|
Aliases: []string{"renew"},
|
||||||
|
Short: "Obtain the certificate now (reread the files of one you renew yourself)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Certificate certInfo `json:"certificate"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", certPath(args[0])+"/issue", nil, map[string]any{}, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.Error != "" {
|
||||||
|
return errors.New(r.Error)
|
||||||
|
}
|
||||||
|
if r.Certificate.IssuingSince != nil && !noWait {
|
||||||
|
return waitIssue(c, args[0])
|
||||||
|
}
|
||||||
|
return output(r.Certificate, func(w io.Writer) { printCert(w, r.Certificate) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&noWait, "no-wait", false, "return at once instead of waiting for the CA")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitIssue polls until jarld has finished obtaining the certificate and
|
||||||
|
// reports the outcome.
|
||||||
|
func waitIssue(c *client.Client, name string) error {
|
||||||
|
tty := !flagQuiet && flagOutput == "table"
|
||||||
|
if tty {
|
||||||
|
fmt.Fprintf(os.Stderr, "Obtaining %s from the CA (this can take a few minutes while the challenges reach every name server)", name)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(20 * time.Minute)
|
||||||
|
var ci certInfo
|
||||||
|
for {
|
||||||
|
ci = certInfo{} // a field the answer leaves out must not keep the last value
|
||||||
|
if err := c.Get(ctx(), certPath(name), nil, &ci); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ci.IssuingSince == nil || time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if tty {
|
||||||
|
fmt.Fprint(os.Stderr, ".")
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
if tty {
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
}
|
||||||
|
if ci.IssuingSince != nil {
|
||||||
|
return fmt.Errorf("jarld is still obtaining %s; check later with: jarl cert show %s", name, name)
|
||||||
|
}
|
||||||
|
if err := output(ci, func(w io.Writer) { printCert(w, ci) }); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ci.LastError != "" && (ci.LastSuccess == nil || ci.LastAttempt != nil && ci.LastAttempt.After(*ci.LastSuccess)) {
|
||||||
|
return fmt.Errorf("obtaining %s failed (jarld tries again at %s)", name, fmtTime(ci.NextAttempt))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func certCheckCmd() *cobra.Command {
|
||||||
|
var f certFlags
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "check [NAME]",
|
||||||
|
Short: "Show where each name's DNS-01 challenge would go, without asking the CA",
|
||||||
|
Long: `For an existing certificate, or for --domain names, shows where Jarl would
|
||||||
|
publish each DNS-01 challenge (in a zone it serves, or at Porkbun) and why,
|
||||||
|
seen from the resolvers in acme.resolvers, as the CA would see it. Nothing
|
||||||
|
is published and the CA isn't asked.`,
|
||||||
|
Example: ` jarl cert check ns1
|
||||||
|
jarl cert check --domain ns1.adbraun.lol --domain '*.adbraun.lol'`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Routes []certRoute `json:"routes"`
|
||||||
|
Account certAccount `json:"account"`
|
||||||
|
}
|
||||||
|
if len(args) == 1 {
|
||||||
|
err = c.Do(ctx(), "POST", certPath(args[0])+"/check", nil, map[string]any{}, &r)
|
||||||
|
} else {
|
||||||
|
b := f.body(cmd)
|
||||||
|
if b["domains"] == nil {
|
||||||
|
return errors.New("give a certificate name, or --domain")
|
||||||
|
}
|
||||||
|
err = c.Do(ctx(), "POST", "/certificates/check", nil, b, &r)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := output(r, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "NAME\tROUTE\tRECORD\tWHY")
|
||||||
|
for _, rt := range r.Routes {
|
||||||
|
rec := strings.TrimSuffix(rt.Target, ".")
|
||||||
|
if rt.Target != rt.Record && rt.Target != "" {
|
||||||
|
rec += " (CNAME from " + strings.TrimSuffix(rt.Record, ".") + ")"
|
||||||
|
}
|
||||||
|
if rt.Error != "" {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Domain, "none", dash(rec), rt.Error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rt.Domain, rt.Route, rec, rt.Why)
|
||||||
|
}
|
||||||
|
if !r.Account.AgreeTOS {
|
||||||
|
fmt.Fprintln(w, "\nNote: acme.account.agree_tos isn't set in jarl.yaml, so Jarl can't register with the CA yet.")
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !r.OK {
|
||||||
|
return errors.New("not every name can be validated")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringArrayVarP(&f.domains, "domain", "d", nil, "a name to check (repeat for several)")
|
||||||
|
cmd.Flags().StringVar(&f.challenge, "challenge", "", "the challenge setting to check with: auto, jarl or porkbun")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func certDeleteCmd() *cobra.Command {
|
||||||
|
var yes bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "delete NAME",
|
||||||
|
Short: "Delete a certificate (and its files for named)",
|
||||||
|
Long: `Deletes a certificate. Its files for named are removed, so take it out of
|
||||||
|
any tls block in named's configuration first. If jarld served it, jarld
|
||||||
|
goes back to tls.cert_file, or to plain HTTP.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if !confirm("Delete certificate "+args[0]+"?", yes) {
|
||||||
|
return errors.New("aborted")
|
||||||
|
}
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "DELETE", certPath(args[0]), nil, nil, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !flagQuiet {
|
||||||
|
fmt.Println("deleted certificate", args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func certAccountCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "account",
|
||||||
|
Short: "Show the ACME account and settings (set in jarl.yaml under acme)",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Account certAccount `json:"account"`
|
||||||
|
}
|
||||||
|
if err := c.Get(ctx(), "/certificates", nil, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a := r.Account
|
||||||
|
return output(a, func(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "Directory:\t%s\n", a.Directory)
|
||||||
|
fmt.Fprintf(w, "Email:\t%s\n", dash(a.Email))
|
||||||
|
fmt.Fprintf(w, "Terms agreed:\t%v\n", a.AgreeTOS)
|
||||||
|
if a.Registered {
|
||||||
|
fmt.Fprintf(w, "Account:\t%s (since %s)\n", a.URI, fmtTime(a.RegisteredAt))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "Account:\tnot registered yet (it is, with the first certificate)\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "EAB:\t%v\n", a.EAB)
|
||||||
|
fmt.Fprintf(w, "Porkbun:\t%v\n", a.Porkbun)
|
||||||
|
fmt.Fprintf(w, "Resolvers:\t%s\n", strings.Join(a.Resolvers, " "))
|
||||||
|
fmt.Fprintf(w, "Files for named:\t%s\n", a.Dir)
|
||||||
|
fmt.Fprintf(w, "Serving HTTPS with:\t%s\n", dash(a.ActiveWebCertificate))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -130,7 +130,7 @@ func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest)
|
|||||||
}
|
}
|
||||||
return report(&resp)
|
return report(&resp)
|
||||||
}
|
}
|
||||||
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil {
|
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force, AcceptRisk: c.AcceptRisk}, &resp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return report(&resp)
|
return report(&resp)
|
||||||
@@ -349,7 +349,7 @@ func changesetCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var resp api.MutationResponse
|
var resp api.MutationResponse
|
||||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
|
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := report(&resp); err != nil {
|
if err := report(&resp); err != nil {
|
||||||
|
|||||||
+10
-2
@@ -36,7 +36,7 @@ func viewCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
var clients, dests []string
|
var clients, dests []string
|
||||||
var recursiveOnly, comment string
|
var recursiveOnly, comment, exposure string
|
||||||
var adopt, cascade, yes bool
|
var adopt, cascade, yes bool
|
||||||
viewBody := func(cmd *cobra.Command) map[string]any {
|
viewBody := func(cmd *cobra.Command) map[string]any {
|
||||||
body := map[string]any{}
|
body := map[string]any{}
|
||||||
@@ -60,6 +60,9 @@ func viewCmd() *cobra.Command {
|
|||||||
if f.Changed("comment") {
|
if f.Changed("comment") {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
if f.Changed("exposure") {
|
||||||
|
body["exposure"] = exposure
|
||||||
|
}
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
addViewFlags := func(c *cobra.Command) {
|
addViewFlags := func(c *cobra.Command) {
|
||||||
@@ -67,6 +70,7 @@ func viewCmd() *cobra.Command {
|
|||||||
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
|
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
|
||||||
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
||||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
|
||||||
}
|
}
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
@@ -756,7 +760,7 @@ func settingsCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
var ns []string
|
var ns []string
|
||||||
var contact, policy string
|
var contact, policy, srvExposure string
|
||||||
var ttl, refresh, retry, expire, neg uint32
|
var ttl, refresh, retry, expire, neg uint32
|
||||||
set := &cobra.Command{
|
set := &cobra.Command{
|
||||||
Use: "set",
|
Use: "set",
|
||||||
@@ -777,6 +781,9 @@ func settingsCmd() *cobra.Command {
|
|||||||
if f.Changed("serial-policy") {
|
if f.Changed("serial-policy") {
|
||||||
body["serial_policy"] = policy
|
body["serial_policy"] = policy
|
||||||
}
|
}
|
||||||
|
if f.Changed("exposure") {
|
||||||
|
body["exposure"] = srvExposure
|
||||||
|
}
|
||||||
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
|
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
|
||||||
if f.Changed(name) {
|
if f.Changed(name) {
|
||||||
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
|
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
|
||||||
@@ -799,6 +806,7 @@ func settingsCmd() *cobra.Command {
|
|||||||
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
|
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
|
||||||
set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
|
set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
|
||||||
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
|
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
|
||||||
|
set.Flags().StringVar(&srvExposure, "exposure", "", "without views: is the server reachable from the internet? auto, public or private (docs/exposure.md)")
|
||||||
set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
|
set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
|
||||||
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
|
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
|
||||||
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
|
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type encEndpoint struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
DoTHost string `json:"dot_host"`
|
||||||
|
DoHURL string `json:"doh_url"`
|
||||||
|
DoT []encEndpoint `json:"dot"`
|
||||||
|
DoH []encEndpoint `json:"doh"`
|
||||||
|
Other bool `json:"other"`
|
||||||
|
Addresses []struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
} `json:"addresses"`
|
||||||
|
Views []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
HasZone bool `json:"has_zone"`
|
||||||
|
} `json:"views"`
|
||||||
|
Certificates []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
DNS bool `json:"dns"`
|
||||||
|
Obtained bool `json:"obtained"`
|
||||||
|
} `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encConflict struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
What string `json:"what"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpoints(es []encEndpoint) string {
|
||||||
|
var out []string
|
||||||
|
for _, e := range es {
|
||||||
|
out = append(out, fmt.Sprintf("%s port %d", e.Address, e.Port))
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptedDNSCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "encrypted-dns",
|
||||||
|
Aliases: []string{"encdns"},
|
||||||
|
Short: "DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md)",
|
||||||
|
Long: `Shows whether named serves DNS over TLS (DoT) and DNS over HTTPS (DoH),
|
||||||
|
where, and what to give clients. 'setup' sets it up: a certificate for the
|
||||||
|
host name (obtained if needed), named's tls statement and listeners on the
|
||||||
|
addresses you choose, and the host name's address records in your private
|
||||||
|
views.`,
|
||||||
|
Example: ` jarl encrypted-dns
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --no-doh
|
||||||
|
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
|
||||||
|
jarl encrypted-dns disable`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var s encStatus
|
||||||
|
if err := c.Get(ctx(), "/encrypted-dns", nil, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(s, func(w io.Writer) {
|
||||||
|
if !s.Enabled {
|
||||||
|
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
|
||||||
|
if len(s.DoT) > 0 {
|
||||||
|
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
|
||||||
|
}
|
||||||
|
if len(s.DoH) > 0 {
|
||||||
|
fmt.Fprintf(w, "DNS over HTTPS:\t%s\n", endpoints(s.DoH))
|
||||||
|
}
|
||||||
|
if s.DoTHost != "" {
|
||||||
|
fmt.Fprintf(w, "For DoT clients:\t%s (Android: Private DNS hostname; Linux: DNS=ADDRESS#%s, DNSOverTLS=yes)\n", s.DoTHost, s.Hostname)
|
||||||
|
}
|
||||||
|
if s.DoHURL != "" {
|
||||||
|
fmt.Fprintf(w, "For DoH clients:\t%s (Firefox, Chrome, Windows 11)\n", s.DoHURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Other {
|
||||||
|
fmt.Fprintln(w, "Note:\tnamed has DoT or DoH listeners written by hand too; setup leaves them alone")
|
||||||
|
}
|
||||||
|
var addrs []string
|
||||||
|
for _, a := range s.Addresses {
|
||||||
|
t := a.Address
|
||||||
|
if !a.Private {
|
||||||
|
t += " (public)"
|
||||||
|
}
|
||||||
|
addrs = append(addrs, t)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "This server's addresses:\t%s\n", strings.Join(addrs, ", "))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var host, certName string
|
||||||
|
var addrs, views []string
|
||||||
|
var noDoT, noDoH bool
|
||||||
|
var dohPort, dotPort int
|
||||||
|
setup := &cobra.Command{
|
||||||
|
Use: "setup --hostname NAME --address ADDR...",
|
||||||
|
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)",
|
||||||
|
Long: `Sets up DoT (port 853) and DoH (port 443, or --doh-port) on the addresses
|
||||||
|
given, which should be this server's addresses on your own networks:
|
||||||
|
|
||||||
|
- the certificate: --certificate, or one with the dns use whose names
|
||||||
|
cover the host name, or a new one Jarl obtains for it (and waits for);
|
||||||
|
- a check that nothing else listens on those ports there;
|
||||||
|
- named's tls statement and listen-on statements (and an explicit
|
||||||
|
listen-on { any; } when there was none, as the first listen-on would
|
||||||
|
otherwise stop named listening on port 53 everywhere);
|
||||||
|
- the host name's A and AAAA records, in the private views that have its
|
||||||
|
zone (or --view), local to the view when the zone is linked.
|
||||||
|
|
||||||
|
It is one change, staged or applied like any other. Running it again
|
||||||
|
replaces the earlier setup.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||||
|
if host == "" {
|
||||||
|
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
|
||||||
|
}
|
||||||
|
var s encStatus
|
||||||
|
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
var list []string
|
||||||
|
for _, a := range s.Addresses {
|
||||||
|
list = append(list, a.Address)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("give --address (this server's addresses on your own networks); it has %s", strings.Join(list, ", "))
|
||||||
|
}
|
||||||
|
// The certificate.
|
||||||
|
if certName == "" {
|
||||||
|
for _, ci := range s.Certificates {
|
||||||
|
if coversName(append(ci.Names, ci.Domains...), host) {
|
||||||
|
certName = ci.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if certName == "" {
|
||||||
|
certName = strings.SplitN(host, ".", 2)[0]
|
||||||
|
fmt.Printf("Creating certificate %s for %s\n", certName, host)
|
||||||
|
var r struct {
|
||||||
|
Certificate certInfo `json:"certificate"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": []string{host}, "uses": []string{"dns"}}, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := waitIssue(c, certName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var ci certInfo
|
||||||
|
if err := c.Get(ctx(), certPath(certName), nil, &ci); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !contains(ci.Uses, "dns") {
|
||||||
|
fmt.Printf("Adding the dns use to certificate %s\n", certName)
|
||||||
|
if err := c.Do(ctx(), "PATCH", certPath(certName), nil, map[string]any{"uses": append(ci.Uses, "dns")}, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ci.NotAfter == nil {
|
||||||
|
if ci.IssuingSince == nil {
|
||||||
|
if err := c.Do(ctx(), "POST", certPath(certName)+"/issue", nil, map[string]any{}, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := waitIssue(c, certName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := map[string]any{"hostname": host, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
|
||||||
|
if cmd.Flags().Changed("doh-port") {
|
||||||
|
body["doh_port"] = dohPort
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("dot-port") {
|
||||||
|
body["dot_port"] = dotPort
|
||||||
|
}
|
||||||
|
// Nothing else may listen there.
|
||||||
|
var chk struct {
|
||||||
|
Conflicts []encConflict `json:"conflicts"`
|
||||||
|
FreeDoHPort int `json:"free_doh_port"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, body, &chk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(chk.Conflicts) > 0 {
|
||||||
|
var msgs []string
|
||||||
|
for _, cf := range chk.Conflicts {
|
||||||
|
msgs = append(msgs, fmt.Sprintf("%s listens on %s port %d", cf.What, cf.Address, cf.Port))
|
||||||
|
}
|
||||||
|
hint := ""
|
||||||
|
if chk.FreeDoHPort != 0 && !noDoH {
|
||||||
|
hint = fmt.Sprintf("; for DoH, port %d is free there (--doh-port %d)", chk.FreeDoHPort, chk.FreeDoHPort)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s%s", strings.Join(msgs, "; "), hint)
|
||||||
|
}
|
||||||
|
if chk.Error != "" && !flagQuiet {
|
||||||
|
fmt.Println("Note:", chk.Error)
|
||||||
|
}
|
||||||
|
if len(views) == 0 {
|
||||||
|
for _, v := range s.Views {
|
||||||
|
if !v.Public && v.HasZone {
|
||||||
|
views = append(views, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["views"] = views
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := setup.Flags()
|
||||||
|
f.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)")
|
||||||
|
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
|
||||||
|
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on (repeat for several)")
|
||||||
|
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
|
||||||
|
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
|
||||||
|
f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS")
|
||||||
|
f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
|
||||||
|
f.StringArrayVar(&views, "view", nil, "views to add the host name's records to (default: the private views that have its zone)")
|
||||||
|
|
||||||
|
var cAddrs []string
|
||||||
|
var cDoH int
|
||||||
|
check := &cobra.Command{
|
||||||
|
Use: "check --address ADDR...",
|
||||||
|
Short: "See whether something already listens on the DoT and DoH ports",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
Conflicts []encConflict `json:"conflicts"`
|
||||||
|
FreeDoHPort int `json:"free_doh_port"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, map[string]any{"addresses": cAddrs, "dot": true, "doh": true, "doh_port": cDoH}, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(r, func(w io.Writer) {
|
||||||
|
if r.Error != "" {
|
||||||
|
fmt.Fprintln(w, r.Error)
|
||||||
|
}
|
||||||
|
if len(r.Conflicts) == 0 {
|
||||||
|
fmt.Fprintln(w, "The ports are free.")
|
||||||
|
}
|
||||||
|
for _, cf := range r.Conflicts {
|
||||||
|
fmt.Fprintf(w, "%s port %d:\t%s listens there\n", cf.Address, cf.Port, cf.What)
|
||||||
|
}
|
||||||
|
if r.FreeDoHPort != 0 {
|
||||||
|
fmt.Fprintf(w, "Free for DoH:\tport %d\n", r.FreeDoHPort)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
check.Flags().StringArrayVar(&cAddrs, "address", nil, "address to check (repeat for several)")
|
||||||
|
check.Flags().IntVar(&cDoH, "doh-port", 443, "port for DNS over HTTPS")
|
||||||
|
|
||||||
|
disable := &cobra.Command{
|
||||||
|
Use: "disable",
|
||||||
|
Short: "Turn DNS over TLS and HTTPS off (the certificate and records stay)",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/encrypted-dns", url.Values{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(setup, check, disable)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// coversName reports whether certificate names cover host (a wildcard
|
||||||
|
// covers one label).
|
||||||
|
func coversName(names []string, host string) bool {
|
||||||
|
for _, n := range names {
|
||||||
|
n = strings.ToLower(strings.TrimSuffix(n, "."))
|
||||||
|
if n == host {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if base, ok := strings.CutPrefix(n, "*."); ok {
|
||||||
|
if _, rest, ok := strings.Cut(host, "."); ok && rest == base {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exposureFinding struct {
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
View string `json:"view"`
|
||||||
|
Fix string `json:"fix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type exposureReport struct {
|
||||||
|
Scopes []struct {
|
||||||
|
ViewID int64 `json:"view_id"`
|
||||||
|
View string `json:"view"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
Override string `json:"override"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
RateLimit string `json:"rate_limit"`
|
||||||
|
Findings []exposureFinding `json:"findings"`
|
||||||
|
} `json:"scopes"`
|
||||||
|
Server []exposureFinding `json:"server"`
|
||||||
|
RateLimiting *struct {
|
||||||
|
Since string `json:"since"`
|
||||||
|
Counts []struct {
|
||||||
|
Key string
|
||||||
|
Count int64
|
||||||
|
} `json:"counts"`
|
||||||
|
Networks []struct {
|
||||||
|
Key string
|
||||||
|
Count int64
|
||||||
|
} `json:"networks"`
|
||||||
|
Names []struct {
|
||||||
|
Key string
|
||||||
|
Count int64
|
||||||
|
} `json:"names"`
|
||||||
|
} `json:"rate_limiting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposureScope turns "server" (or "") into the server, anything else
|
||||||
|
// into a view name.
|
||||||
|
func exposureScope(s string) map[string]any {
|
||||||
|
if s == "" || strings.EqualFold(s, "server") {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return map[string]any{"view": s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exposureCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "exposure",
|
||||||
|
Short: "What is reachable from the internet, and what to harden (docs/exposure.md)",
|
||||||
|
Long: `Shows which views (or the server, without views) are reachable from the
|
||||||
|
internet and why, their rate limiting, and the exposure findings with the
|
||||||
|
fix Jarl can stage for each.`,
|
||||||
|
Example: ` jarl exposure
|
||||||
|
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
|
||||||
|
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
|
||||||
|
jarl exposure fix server exposure_version
|
||||||
|
jarl exposure ack external security_open_resolver --reason "public resolver for the club"
|
||||||
|
jarl view update external --exposure public # override the classification`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var r exposureReport
|
||||||
|
if err := c.Get(ctx(), "/exposure", nil, &r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(r, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "VIEW\tEXPOSURE\tRATE LIMIT\tWHY")
|
||||||
|
for _, s := range r.Scopes {
|
||||||
|
name, exp := s.View, "private"
|
||||||
|
if name == "" {
|
||||||
|
name = "(server)"
|
||||||
|
}
|
||||||
|
if s.Public {
|
||||||
|
exp = "public"
|
||||||
|
}
|
||||||
|
if s.Override != "" {
|
||||||
|
exp += " (set)"
|
||||||
|
}
|
||||||
|
rl := s.RateLimit
|
||||||
|
if !s.Public && rl == "off" {
|
||||||
|
rl = "-"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, exp, rl, s.Reason)
|
||||||
|
}
|
||||||
|
var all []exposureFinding
|
||||||
|
for _, s := range r.Scopes {
|
||||||
|
all = append(all, s.Findings...)
|
||||||
|
}
|
||||||
|
all = append(all, r.Server...)
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
if len(all) == 0 {
|
||||||
|
fmt.Fprintln(w, "No findings.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(w, "SEVERITY\tWHERE\tCODE\tFINDING")
|
||||||
|
for _, f := range all {
|
||||||
|
where := f.View
|
||||||
|
if where == "" {
|
||||||
|
where = "server"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Severity, where, f.Code, f.Message)
|
||||||
|
if f.Fix != "" {
|
||||||
|
fmt.Fprintf(w, "\t\t\tfix: jarl exposure fix %s %s (%s)\n", where, f.Code, f.Fix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
|
||||||
|
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
|
||||||
|
for _, e := range a.Counts {
|
||||||
|
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count)
|
||||||
|
}
|
||||||
|
for _, e := range a.Networks {
|
||||||
|
fmt.Fprintf(w, "network %s\t%d\n", e.Key, e.Count)
|
||||||
|
}
|
||||||
|
for _, e := range a.Names {
|
||||||
|
fmt.Fprintf(w, "name %s\t%d\n", e.Key, e.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fix := &cobra.Command{
|
||||||
|
Use: "fix VIEW|server CODE",
|
||||||
|
Short: "Stage (or apply) the fix Jarl suggests for a finding",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := exposureScope(args[0])
|
||||||
|
body["code"] = args[1]
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/exposure/fix", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var reason string
|
||||||
|
ack := &cobra.Command{
|
||||||
|
Use: "ack VIEW|server CODE --reason TEXT",
|
||||||
|
Short: "Accept a finding on purpose: it stays, as info, with who and why",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := exposureScope(args[0])
|
||||||
|
body["code"], body["reason"] = args[1], reason
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/exposure/ack", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ack.Flags().StringVar(&reason, "reason", "", "why the finding is accepted (required)")
|
||||||
|
unack := &cobra.Command{
|
||||||
|
Use: "unack VIEW|server CODE",
|
||||||
|
Short: "Take an acknowledgement back: the finding warns again",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := exposureScope(args[0])
|
||||||
|
body["code"] = args[1]
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/exposure/unack", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(fix, ack, unack)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+4
-2
@@ -47,6 +47,7 @@ var (
|
|||||||
flagChangeset int64
|
flagChangeset int64
|
||||||
flagDryRun bool
|
flagDryRun bool
|
||||||
flagForce bool
|
flagForce bool
|
||||||
|
flagRisk bool
|
||||||
flagOutput string
|
flagOutput string
|
||||||
flagInsecure bool
|
flagInsecure bool
|
||||||
flagCACert string
|
flagCACert string
|
||||||
@@ -162,7 +163,7 @@ func newClient() (*client.Client, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
|
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
||||||
if !c.Preflight {
|
if !c.Preflight {
|
||||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
||||||
}
|
}
|
||||||
@@ -244,6 +245,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
|
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
|
||||||
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
|
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
|
||||||
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
|
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
|
||||||
|
pf.BoolVar(&flagRisk, "accept-risk", false, "confirm a change that opens a public view up, such as an open resolver (docs/exposure.md)")
|
||||||
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
|
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
|
||||||
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
||||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||||
@@ -257,7 +259,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
}
|
}
|
||||||
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
||||||
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
||||||
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
add("admin", statusCmd(), exposureCmd(), certCmd(), encryptedDNSCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||||
setupCompletion(root)
|
setupCompletion(root)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-12
@@ -12,34 +12,60 @@ import (
|
|||||||
// zoneViews expands a --view value for a record command: "" or one view
|
// zoneViews expands a --view value for a record command: "" or one view
|
||||||
// as it is, "a,b" to those views, and "all" to every view that has the
|
// as it is, "a,b" to those views, and "all" to every view that has the
|
||||||
// zone.
|
// zone.
|
||||||
|
// localFanOut is set by commands making local changes (--local), which
|
||||||
|
// go to every view named even when the zones are linked.
|
||||||
|
var localFanOut bool
|
||||||
|
|
||||||
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
|
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
|
||||||
if view != "all" && !strings.Contains(view, ",") {
|
if view != "all" && !strings.Contains(view, ",") {
|
||||||
return []string{view}, nil
|
return []string{view}, nil
|
||||||
}
|
}
|
||||||
if view != "all" {
|
|
||||||
var out []string
|
|
||||||
for _, v := range strings.Split(view, ",") {
|
|
||||||
if v = strings.TrimSpace(v); v != "" {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
var zones []api.Zone
|
var zones []api.Zone
|
||||||
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
||||||
|
link := map[string]int64{}
|
||||||
var out []string
|
var out []string
|
||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
||||||
out = append(out, z.View)
|
link[z.View] = z.LinkGroup
|
||||||
|
if view == "all" {
|
||||||
|
out = append(out, z.View)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if view != "all" {
|
||||||
|
for _, v := range strings.Split(view, ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
return nil, fmt.Errorf("zone %s is in no view", zone)
|
return nil, fmt.Errorf("zone %s is in no view", zone)
|
||||||
}
|
}
|
||||||
return out, nil
|
// Linked zones (docs/views.md#linked-zones) get a shared change once:
|
||||||
|
// Jarl copies it to the others. A local change goes to each.
|
||||||
|
if localFanOut {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var dedup []string
|
||||||
|
first := map[int64]string{}
|
||||||
|
for _, v := range out {
|
||||||
|
g := link[v]
|
||||||
|
if g != 0 {
|
||||||
|
if f, ok := first[g]; ok {
|
||||||
|
if !flagQuiet {
|
||||||
|
fmt.Fprintf(os.Stderr, "view %s: linked with %s, so it gets the change from there\n", v, f)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
first[g] = v
|
||||||
|
}
|
||||||
|
dedup = append(dedup, v)
|
||||||
|
}
|
||||||
|
return dedup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// perView runs a record command once per view. With several views, the
|
// perView runs a record command once per view. With several views, the
|
||||||
@@ -90,7 +116,7 @@ func perView(c *client.Client, views []string, description string, do func(c *cl
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var resp api.MutationResponse
|
var resp api.MutationResponse
|
||||||
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp)
|
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp)
|
||||||
if err != nil || c.DryRun {
|
if err != nil || c.DryRun {
|
||||||
discard()
|
discard()
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-11
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,8 +22,22 @@ func statsCmd() *cobra.Command {
|
|||||||
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
Long: `Query statistics collected from named (see docs/stats.md). They must be
|
||||||
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
|
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
|
||||||
|
|
||||||
Ranges are given with --since: 90m, 24h, 7d, 2w. Use -o json or -o yaml to
|
Ranges are given with --since: 90m, 24h, 7d, 2w. --view limits the figures
|
||||||
export the data.`,
|
to some views (repeat it, or separate with commas): answers from named's
|
||||||
|
response log, per view; named's own counters stay for the whole server.
|
||||||
|
Use -o json or -o yaml to export the data.`,
|
||||||
|
}
|
||||||
|
var statsViews []string
|
||||||
|
cmd.PersistentFlags().StringSliceVar(&statsViews, "view", nil, "only these views (repeat, or comma separated)")
|
||||||
|
// withViews adds the --view filter to a query.
|
||||||
|
withViews := func(v url.Values) url.Values {
|
||||||
|
if len(statsViews) > 0 {
|
||||||
|
if v == nil {
|
||||||
|
v = url.Values{}
|
||||||
|
}
|
||||||
|
v.Set("views", strings.Join(statsViews, ","))
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
// Each command has its own --since variable: their defaults differ, and
|
// Each command has its own --since variable: their defaults differ, and
|
||||||
// a shared variable would take the last default for all of them.
|
// a shared variable would take the last default for all of them.
|
||||||
@@ -77,14 +92,27 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var s stats.Summary
|
var s stats.Summary
|
||||||
if err := c.Get(ctx(), "/stats/summary", q("since", summarySince, "top", strconv.Itoa(topN)), &s); err != nil {
|
if err := c.Get(ctx(), "/stats/summary", withViews(q("since", summarySince, "top", strconv.Itoa(topN))), &s); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(s, func(w io.Writer) {
|
return output(s, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Range:\t%s to %s\n", s.From.Local().Format("2006-01-02 15:04"), s.To.Local().Format("2006-01-02 15:04"))
|
fmt.Fprintf(w, "Range:\t%s to %s\n", s.From.Local().Format("2006-01-02 15:04"), s.To.Local().Format("2006-01-02 15:04"))
|
||||||
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
if len(s.FilterViews) > 0 {
|
||||||
|
since := ""
|
||||||
|
if s.ViewsSince != nil {
|
||||||
|
since = ", per view since " + s.ViewsSince.Local().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Views:\t%s (from the response log%s; named's own counters are left out)\n", strings.Join(s.FilterViews, ", "), since)
|
||||||
|
fmt.Fprintf(w, "Answers:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||||
|
}
|
||||||
if s.Peak != nil {
|
if s.Peak != nil {
|
||||||
fmt.Fprintf(w, "Busiest %s:\t%s with %d queries\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries)
|
what := "queries"
|
||||||
|
if len(s.FilterViews) > 0 {
|
||||||
|
what = "answers"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Busiest %s:\t%s with %d %s\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries, what)
|
||||||
}
|
}
|
||||||
if s.CacheHitRatio != nil {
|
if s.CacheHitRatio != nil {
|
||||||
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
||||||
@@ -128,14 +156,15 @@ export the data.`,
|
|||||||
|
|
||||||
var limit int
|
var limit int
|
||||||
top := &cobra.Command{
|
top := &cobra.Command{
|
||||||
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]",
|
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients|ratelimited|ratelimited-names]",
|
||||||
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
|
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
|
||||||
Example: ` jarl stats top names --since 7d
|
Example: ` jarl stats top names --since 7d
|
||||||
jarl stats top clients --limit 50
|
jarl stats top clients --limit 50
|
||||||
jarl stats top nxdomain # misconfigured hosts and typos
|
jarl stats top nxdomain # misconfigured hosts and typos
|
||||||
jarl stats top refused # who is asking for what they may not have
|
jarl stats top refused # who is asking for what they may not have
|
||||||
jarl stats top blocked # what blocking stops most (docs/blocking.md)
|
jarl stats top blocked # what blocking stops most (docs/blocking.md)
|
||||||
jarl stats top blocked-clients # the devices blocking stops most`,
|
jarl stats top blocked-clients # the devices blocking stops most
|
||||||
|
jarl stats top ratelimited # client networks rate limiting limits, or would (docs/exposure.md)`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
kind := stats.KindName
|
kind := stats.KindName
|
||||||
@@ -147,13 +176,16 @@ export the data.`,
|
|||||||
if kind == "blocked-client" {
|
if kind == "blocked-client" {
|
||||||
kind = stats.KindBlockedClient
|
kind = stats.KindBlockedClient
|
||||||
}
|
}
|
||||||
|
if kind == "ratelimited-name" {
|
||||||
|
kind = stats.KindRateLimitedName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var es []stats.Entry
|
var es []stats.Entry
|
||||||
if err := c.Get(ctx(), "/stats/top", q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit)), &es); err != nil {
|
if err := c.Get(ctx(), "/stats/top", withViews(q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit))), &es); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(es, func(w io.Writer) {
|
return output(es, func(w io.Writer) {
|
||||||
@@ -203,7 +235,7 @@ export the data.`,
|
|||||||
qv.Add("key", k)
|
qv.Add("key", k)
|
||||||
}
|
}
|
||||||
var tl stats.Timeline
|
var tl stats.Timeline
|
||||||
if err := c.Get(ctx(), "/stats/timeline", qv, &tl); err != nil {
|
if err := c.Get(ctx(), "/stats/timeline", withViews(qv), &tl); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(tl, func(w io.Writer) {
|
return output(tl, func(w io.Writer) {
|
||||||
@@ -252,7 +284,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var hm stats.Heatmap
|
var hm stats.Heatmap
|
||||||
if err := c.Get(ctx(), "/stats/heatmap", q("since", heatmapSince, "key", hmKey), &hm); err != nil {
|
if err := c.Get(ctx(), "/stats/heatmap", withViews(q("since", heatmapSince, "key", hmKey)), &hm); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(hm, func(w io.Writer) {
|
return output(hm, func(w io.Writer) {
|
||||||
@@ -304,7 +336,7 @@ export the data.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var es []stats.Entry
|
var es []stats.Entry
|
||||||
if err := c.Get(ctx(), "/stats/breakdown", q("group", args[0], "since", breakdownSince), &es); err != nil {
|
if err := c.Get(ctx(), "/stats/breakdown", withViews(q("group", args[0], "since", breakdownSince)), &es); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(es, func(w io.Writer) {
|
return output(es, func(w io.Writer) {
|
||||||
|
|||||||
+113
-5
@@ -93,6 +93,9 @@ func zoneCmd() *cobra.Command {
|
|||||||
if z.InView != "" {
|
if z.InView != "" {
|
||||||
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
||||||
}
|
}
|
||||||
|
if len(z.LinkedWith) > 0 {
|
||||||
|
fmt.Fprintf(w, "Linked with:\t%s (shared records are kept in step; 'jarl record list' shows which are local)\n", strings.Join(z.LinkedWith, ", "))
|
||||||
|
}
|
||||||
if z.ZoneFile != "" {
|
if z.ZoneFile != "" {
|
||||||
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
|
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,7 @@ func zoneCmd() *cobra.Command {
|
|||||||
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
|
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
|
||||||
|
|
||||||
var ns, records, primaries, forwarders, opts []string
|
var ns, records, primaries, forwarders, opts []string
|
||||||
var contact, comment, cloneFrom, file, inView, forward string
|
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
||||||
var ttl uint32
|
var ttl uint32
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
@@ -120,7 +123,8 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
||||||
jarl zone create partner.example --type secondary --primary 192.0.2.53
|
jarl zone create partner.example --type secondary --primary 192.0.2.53
|
||||||
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
||||||
jarl zone create example.com --view external --clone-from example.com --clone-view internal`,
|
jarl zone create example.com --view external --clone-from example.com --clone-view internal
|
||||||
|
jarl zone create example.com --view vps --link-with external # linked: shared records kept in step`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newClient()
|
c, err := newClient()
|
||||||
@@ -149,6 +153,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
if inView != "" {
|
if inView != "" {
|
||||||
body["in_view"] = inView
|
body["in_view"] = inView
|
||||||
}
|
}
|
||||||
|
if linkWith != "" {
|
||||||
|
body["link_with"] = linkWith
|
||||||
|
}
|
||||||
var recs []map[string]any
|
var recs []map[string]any
|
||||||
for _, r := range records {
|
for _, r := range records {
|
||||||
f := strings.Fields(r)
|
f := strings.Fields(r)
|
||||||
@@ -219,6 +226,7 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
|||||||
create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from")
|
create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from")
|
||||||
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
|
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
|
||||||
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
|
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
|
||||||
|
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().StringVar(&comment, "comment", "", "comment")
|
||||||
|
|
||||||
var refresh, retry, expire, minimum, serial uint32
|
var refresh, retry, expire, minimum, serial uint32
|
||||||
@@ -416,7 +424,7 @@ equal to the file: records not in the file are deleted.`,
|
|||||||
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
||||||
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
||||||
|
|
||||||
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
|
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false))
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,8 +464,25 @@ const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\"
|
|||||||
func recordCmd() *cobra.Command {
|
func recordCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
||||||
var view, name, typ, search, comment, ttlArg string
|
var view, name, typ, search, comment, ttlArg string
|
||||||
var ptr, missingOK, yes, onlyDisabled, wide bool
|
var ptr, missingOK, yes, onlyDisabled, wide, local, shared bool
|
||||||
var id int64
|
var id int64
|
||||||
|
// linkFlags adds --local and --shared (linked zones) to a command.
|
||||||
|
linkFlags := func(c *cobra.Command, what string) {
|
||||||
|
c.Flags().BoolVar(&local, "local", false, "linked zones: "+what+" in this view only (overriding the shared records of this name and type here)")
|
||||||
|
c.Flags().BoolVar(&shared, "shared", false, "linked zones: "+what+" in every linked view (this version becomes every view's)")
|
||||||
|
}
|
||||||
|
linkBody := func(body map[string]any) error {
|
||||||
|
switch {
|
||||||
|
case local && shared:
|
||||||
|
return fmt.Errorf("give --local or --shared, not both")
|
||||||
|
case local:
|
||||||
|
body["local"] = true
|
||||||
|
localFanOut = true
|
||||||
|
case shared:
|
||||||
|
body["local"] = false
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
list := &cobra.Command{
|
list := &cobra.Command{
|
||||||
Use: "list ZONE",
|
Use: "list ZONE",
|
||||||
@@ -479,8 +504,15 @@ func recordCmd() *cobra.Command {
|
|||||||
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
linked := false
|
||||||
|
for _, r := range recs {
|
||||||
|
linked = linked || r.Linked != ""
|
||||||
|
}
|
||||||
return output(recs, func(w io.Writer) {
|
return output(recs, func(w io.Writer) {
|
||||||
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
||||||
|
if linked {
|
||||||
|
rows[0] = []string{"ID", "NAME", "TTL", "TYPE", "LINK", "DATA", "COMMENT"}
|
||||||
|
}
|
||||||
for _, r := range recs {
|
for _, r := range recs {
|
||||||
ttl := fmt.Sprint(r.EffectiveTTL)
|
ttl := fmt.Sprint(r.EffectiveTTL)
|
||||||
if r.TTL == nil {
|
if r.TTL == nil {
|
||||||
@@ -490,10 +522,18 @@ func recordCmd() *cobra.Command {
|
|||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
comment = strings.TrimSpace("[disabled] " + comment)
|
comment = strings.TrimSpace("[disabled] " + comment)
|
||||||
}
|
}
|
||||||
|
if linked {
|
||||||
|
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Linked, r.Data, comment})
|
||||||
|
continue
|
||||||
|
}
|
||||||
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
||||||
}
|
}
|
||||||
if !wide {
|
if !wide {
|
||||||
fitColumn(rows, 4)
|
col := 4
|
||||||
|
if linked {
|
||||||
|
col = 5
|
||||||
|
}
|
||||||
|
fitColumn(rows, col)
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||||
@@ -535,6 +575,9 @@ and splitting are automatic).
|
|||||||
if ptr {
|
if ptr {
|
||||||
body["ptr"] = true
|
body["ptr"] = true
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
views, err := zoneViews(c, args[0], view)
|
views, err := zoneViews(c, args[0], view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -549,6 +592,7 @@ and splitting are automatic).
|
|||||||
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
||||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
||||||
|
linkFlags(add, "add it")
|
||||||
|
|
||||||
var data string
|
var data string
|
||||||
update := &cobra.Command{
|
update := &cobra.Command{
|
||||||
@@ -582,6 +626,9 @@ and splitting are automatic).
|
|||||||
if ptr {
|
if ptr {
|
||||||
body["ptr"] = true
|
body["ptr"] = true
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return fmt.Errorf("nothing to change (see --help)")
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
}
|
}
|
||||||
@@ -598,6 +645,8 @@ and splitting are automatic).
|
|||||||
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
|
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
|
||||||
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||||
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
|
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
|
||||||
|
update.Flags().BoolVar(&local, "local", false, "linked zones: make this name and type local to the record's view (an override)")
|
||||||
|
update.Flags().BoolVar(&shared, "shared", false, "linked zones: share this name and type with the linked views; this view's version becomes every view's")
|
||||||
|
|
||||||
del := &cobra.Command{
|
del := &cobra.Command{
|
||||||
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
|
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
|
||||||
@@ -678,6 +727,9 @@ spaces.
|
|||||||
if comment != "" {
|
if comment != "" {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
if err := linkBody(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
views, err := zoneViews(c, args[0], view)
|
views, err := zoneViews(c, args[0], view)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -691,6 +743,7 @@ spaces.
|
|||||||
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
||||||
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
||||||
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
||||||
|
linkFlags(set, "set it")
|
||||||
|
|
||||||
toggle := func(disable bool) *cobra.Command {
|
toggle := func(disable bool) *cobra.Command {
|
||||||
verb := "enable"
|
verb := "enable"
|
||||||
@@ -829,3 +882,58 @@ func fitColumn(rows [][]string, col int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zoneLinkCmd is 'zone link' and 'zone unlink' (docs/views.md#linked-zones).
|
||||||
|
func zoneLinkCmd(link bool) *cobra.Command {
|
||||||
|
var view string
|
||||||
|
var with []string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "link ZONE --view VIEW [--with VIEW...]",
|
||||||
|
Short: "Link zones of the same name in several views: shared records are kept in step",
|
||||||
|
Long: `Links a zone with the zones of the same name in other views (--with, or
|
||||||
|
every view that has one). Jarl then keeps their shared records in step: a
|
||||||
|
change to one reaches the others in the same changeset. Each view can
|
||||||
|
still have records of its own (record add/set --local), which override
|
||||||
|
the shared records of that name and type there, and every zone keeps its
|
||||||
|
own options, so views can still differ in who they answer and how.
|
||||||
|
|
||||||
|
Linking never changes what named answers: a name and type with the same
|
||||||
|
records in every view becomes shared, and one that differs stays local to
|
||||||
|
each view. Share one later from the view whose version is right:
|
||||||
|
jarl record update ID --shared.`,
|
||||||
|
Example: ` jarl zone link adbraun.lol --view external --with vps
|
||||||
|
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
||||||
|
jarl zone unlink adbraun.lol --view vps`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, qv := zonePath(args[0], view)
|
||||||
|
verb := "unlink"
|
||||||
|
var body any
|
||||||
|
if link {
|
||||||
|
verb = "link"
|
||||||
|
body = map[string]any{"with": with}
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", path+"/"+verb, qv, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||||
|
if link {
|
||||||
|
cmd.Flags().StringSliceVar(&with, "with", nil, "views to link with (default: every view with a zone of that name)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
cmd.Use = "unlink ZONE --view VIEW"
|
||||||
|
cmd.Short = "Take a zone out of its link: its records stay, all local"
|
||||||
|
cmd.Long = `Takes a zone out of its link. Its records stay as they are, all local to
|
||||||
|
its view, and the other zones of the link keep theirs. A link left with one
|
||||||
|
zone is dissolved.`
|
||||||
|
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -24,9 +24,11 @@ import (
|
|||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
"jarl/internal/bind"
|
"jarl/internal/bind"
|
||||||
|
"jarl/internal/certs"
|
||||||
"jarl/internal/config"
|
"jarl/internal/config"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/nscheck"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
"jarl/internal/render"
|
"jarl/internal/render"
|
||||||
"jarl/internal/server"
|
"jarl/internal/server"
|
||||||
@@ -122,6 +124,7 @@ func openApp(allowMissingConfig bool) (*app, error) {
|
|||||||
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
|
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
|
||||||
NoReload: cfg.BIND.NoReload,
|
NoReload: cfg.BIND.NoReload,
|
||||||
Extras: renderExtras(cfg),
|
Extras: renderExtras(cfg),
|
||||||
|
LocalAddrs: nscheck.LocalAddrs,
|
||||||
}, log)
|
}, log)
|
||||||
eng.Limits = engine.BlocklistLimits{
|
eng.Limits = engine.BlocklistLimits{
|
||||||
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
|
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
|
||||||
@@ -308,6 +311,18 @@ func serveCmd() *cobra.Command {
|
|||||||
a.log.Warn("the configuration file holds metrics.token and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
a.log.Warn("the configuration file holds metrics.token and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cm := certs.New(certs.Config{
|
||||||
|
Directory: a.cfg.ACME.Account.DirectoryURL(), Email: a.cfg.ACME.Account.Email, AgreeTOS: a.cfg.ACME.Account.AgreeTOS,
|
||||||
|
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,
|
||||||
|
}, a.store, a.eng, a.log)
|
||||||
|
cm.Start(ctx)
|
||||||
|
if a.cfg.ACME.Account.EABHMAC != "" {
|
||||||
|
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
|
||||||
|
a.log.Warn("the configuration file holds acme.account.eab_hmac and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
srv := server.New(server.Config{
|
srv := server.New(server.Config{
|
||||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
|
||||||
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
|
||||||
@@ -318,6 +333,7 @@ func serveCmd() *cobra.Command {
|
|||||||
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
|
||||||
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
|
||||||
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
|
||||||
|
Certs: cm,
|
||||||
HTTPReq: server.HTTPReq{
|
HTTPReq: server.HTTPReq{
|
||||||
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
Enabled: a.cfg.ACME.HTTPReq.Enabled,
|
||||||
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
|
||||||
@@ -335,6 +351,7 @@ func serveCmd() *cobra.Command {
|
|||||||
}()
|
}()
|
||||||
go auditRetention(ctx, a)
|
go auditRetention(ctx, a)
|
||||||
go blocklistLoop(ctx, a)
|
go blocklistLoop(ctx, a)
|
||||||
|
go cm.Run(ctx)
|
||||||
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
a.log.Info("jarld starting", "version", version.String(), "database", a.cfg.Database, "bind_config", a.cfg.BIND.ConfigDir)
|
||||||
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
|
err = srv.Serve(ctx, func() { sdNotify("READY=1\nSTATUS=serving") })
|
||||||
sdNotify("STOPPING=1")
|
sdNotify("STOPPING=1")
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Let's Encrypt (ACME DNS-01) with Jarl
|
# Let's Encrypt (ACME DNS-01) with Jarl
|
||||||
|
|
||||||
|
This page is about **other** ACME clients (certbot, acme.sh, lego,
|
||||||
|
Traefik) publishing their DNS-01 challenges through Jarl. For
|
||||||
|
certificates for the DNS server itself (Jarl's web UI, named's DNS over
|
||||||
|
TLS and HTTPS), Jarl can be the ACME client: see
|
||||||
|
[certificates.md](certificates.md).
|
||||||
|
|
||||||
The DNS-01 challenge proves that you control a domain by publishing a TXT
|
The DNS-01 challenge proves that you control a domain by publishing a TXT
|
||||||
record at `_acme-challenge.<domain>`. Jarl does this through its API, so no
|
record at `_acme-challenge.<domain>`. Jarl does this through its API, so no
|
||||||
dynamic updates (`allow-update`, nsupdate) are needed. That matters because
|
dynamic updates (`allow-update`, nsupdate) are needed. That matters because
|
||||||
|
|||||||
+42
@@ -342,6 +342,21 @@ registrar_error`.
|
|||||||
|
|
||||||
### Records
|
### Records
|
||||||
|
|
||||||
|
In a linked zone ([views.md](views.md#linked-zones)), records carry
|
||||||
|
`shared_id` and `linked` (`shared` or `local`). Creating, updating
|
||||||
|
(`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).
|
||||||
|
|
||||||
|
| 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` takes `"link_with": "VIEW"` to create a zone linked with
|
||||||
|
that view's zone of the same name.
|
||||||
|
|
||||||
| Method | Path | Body / query |
|
| Method | Path | Body / query |
|
||||||
|--------|------|--------------|
|
|--------|------|--------------|
|
||||||
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
||||||
@@ -471,6 +486,7 @@ written in Jarl's fixed order (docs/concepts.md).
|
|||||||
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
|
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
|
||||||
| GET | `/catalog/rrtypes` | record types with field hints and examples |
|
| GET | `/catalog/rrtypes` | record types with field hints and examples |
|
||||||
| GET | `/catalog/grants` | resources, roles and the grant syntax |
|
| GET | `/catalog/grants` | resources, roles and the grant syntax |
|
||||||
|
| GET | `/exposure` | which views are reachable from the internet, the exposure findings and their fixes ([exposure.md](exposure.md#api)); `POST /exposure/fix`, `/exposure/ack`, `/exposure/unack` |
|
||||||
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
|
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
|
||||||
| GET | `/search?q=text` | zones, records and ACLs matching the text |
|
| GET | `/search?q=text` | zones, records and ACLs matching the text |
|
||||||
|
|
||||||
@@ -523,6 +539,10 @@ in it and its records.
|
|||||||
|
|
||||||
### Query statistics
|
### Query statistics
|
||||||
|
|
||||||
|
Every statistics endpoint below takes `views=internal,vps` to limit the
|
||||||
|
figures to those views ([stats.md](stats.md#views)); a breakdown that only
|
||||||
|
exists for the whole server then answers `400 not_per_view`.
|
||||||
|
|
||||||
Needs `stats:read`. When statistics are disabled, every endpoint but
|
Needs `stats:read`. When statistics are disabled, every endpoint but
|
||||||
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
||||||
|
|
||||||
@@ -540,6 +560,28 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Encrypted DNS
|
||||||
|
|
||||||
|
See [encrypted-dns.md](encrypted-dns.md#api): `GET`, `POST` and `DELETE
|
||||||
|
/encrypted-dns`, `POST /encrypted-dns/check`.
|
||||||
|
|
||||||
|
### Certificates
|
||||||
|
|
||||||
|
Administrators only. See [certificates.md](certificates.md#api).
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/certificates` | `certificates` (with `status`, `next_attempt`, `issuing_since`, `serving`, `files`) and `account` |
|
||||||
|
| POST | `/certificates` | `{"name", "domains", "uses", "challenge", "key_type", "comment"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`; `"issue": false` doesn't start obtaining it |
|
||||||
|
| POST | `/certificates/check` | `{"domains", "challenge"}`: the route of each challenge |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
`GET /status` includes `certificates` for administrators: `total`,
|
||||||
|
`problems` (failing, expiring, expired), `next` and `next_after`,
|
||||||
|
`serving`.
|
||||||
|
|
||||||
### ACME: lego's httpreq provider
|
### ACME: lego's httpreq provider
|
||||||
|
|
||||||
Off unless `acme.httpreq.enabled` is set, and by default HTTPS only. The
|
Off unless `acme.httpreq.enabled` is set, and by default HTTPS only. The
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# TLS certificates
|
||||||
|
|
||||||
|
Jarl can obtain TLS certificates from an ACME CA (Let's Encrypt by
|
||||||
|
default) and renew them itself, and put them where they are used:
|
||||||
|
|
||||||
|
- **web**: jarld's own HTTPS (the web UI, the API and the CLI's server).
|
||||||
|
- **dns**: files named can read, for DNS over TLS (DoT) and DNS over HTTPS
|
||||||
|
(DoH) in named's `tls` blocks.
|
||||||
|
|
||||||
|
One certificate can have both uses. Jarl answers the ACME DNS-01
|
||||||
|
challenge itself: in a zone it serves, or through Porkbun's API while
|
||||||
|
Porkbun still serves the domain. So no other ACME client, hook script or
|
||||||
|
DNS provider plugin is needed, and the server doesn't have to be
|
||||||
|
reachable on port 80 or 443 from the internet.
|
||||||
|
|
||||||
|
A certificate you renew some other way (certbot, acme.sh, your own CA)
|
||||||
|
can be used too: Jarl reads it from its files and rereads it when they
|
||||||
|
change.
|
||||||
|
|
||||||
|
In the web UI: **Certificates** (administrators). In the CLI: `jarl cert`.
|
||||||
|
In the API: `/api/v1/certificates`. All of it is for administrators only:
|
||||||
|
a certificate's key can impersonate your server.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Set up the ACME account in `/etc/jarl/jarl.yaml` and restart jarld
|
||||||
|
(`systemctl restart jarl`). Try Let's Encrypt's staging CA first: its
|
||||||
|
certificates aren't trusted by browsers, but its rate limits are
|
||||||
|
generous, so mistakes cost nothing.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
acme:
|
||||||
|
account:
|
||||||
|
directory: letsencrypt-staging # then: letsencrypt
|
||||||
|
email: you@adbraun.lol # optional
|
||||||
|
agree_tos: true # after reading https://letsencrypt.org/repository/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. See where the challenges would go, without asking the CA for anything:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl cert check --domain ns1.adbraun.lol
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
NAME ROUTE RECORD WHY
|
||||||
|
ns1.adbraun.lol porkbun _acme-challenge.ns1.adbraun.lol adbraun.lol is served by Porkbun (curitiba.ns.porkbun.com, ...), so the record goes in through Porkbun's API
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create the certificate. Jarl obtains it at once; the command waits for
|
||||||
|
the outcome (usually under a minute, a few minutes through Porkbun):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns
|
||||||
|
```
|
||||||
|
|
||||||
|
4. When it works, switch `directory` to `letsencrypt`, restart jarld and
|
||||||
|
renew: `jarl cert issue ns1`.
|
||||||
|
|
||||||
|
From then on jarld renews it by itself.
|
||||||
|
|
||||||
|
## The ACME account
|
||||||
|
|
||||||
|
| Setting | Default | |
|
||||||
|
|--|--|--|
|
||||||
|
| `acme.account.directory` | `letsencrypt` | `letsencrypt`, `letsencrypt-staging`, or the directory URL of another ACME CA (ZeroSSL, Google, step-ca, ...). |
|
||||||
|
| `acme.account.email` | `""` | Contact address for the CA (optional; Let's Encrypt no longer sends expiry mails). |
|
||||||
|
| `acme.account.agree_tos` | `false` | You accept the CA's terms of service. Jarl registers no account without it, and says so. |
|
||||||
|
| `acme.account.eab_kid`, `eab_hmac` | `""` | External Account Binding, for CAs that need it (ZeroSSL, Google): from the CA's dashboard. |
|
||||||
|
| `acme.resolvers` | `1.1.1.1:53`, `9.9.9.9:53`, `8.8.8.8:53` | Recursive resolvers Jarl uses to see a domain as the CA does (below). |
|
||||||
|
| `acme.ca_bundle` | `""` | A PEM file of extra CA certificates to trust when talking to the ACME server (a private CA). |
|
||||||
|
| `acme.check_propagation` | `true` | Before telling the CA to look, wait until every name server of the zone serves the challenge (asked directly on port 53). |
|
||||||
|
| `certificates.dir` | `<bind.config_dir>/tls` | Where certificates with the dns use are written for named. |
|
||||||
|
|
||||||
|
The account is registered with the first certificate, and its key is
|
||||||
|
kept in Jarl's database. Changing `directory` registers a new account with
|
||||||
|
that CA; changing `email` updates the account.
|
||||||
|
|
||||||
|
## Where the challenge goes
|
||||||
|
|
||||||
|
For each name on a certificate, the CA looks up a TXT record at
|
||||||
|
`_acme-challenge.<name>` (a wildcard `*.example.com` uses
|
||||||
|
`_acme-challenge.example.com`). Jarl first looks the name up the way the
|
||||||
|
CA will, through `acme.resolvers` (public resolvers, not this server,
|
||||||
|
which may answer from an internal view), following a CNAME on
|
||||||
|
`_acme-challenge` if there is one. Then it finds the zone that holds the
|
||||||
|
record on the internet and that zone's public name servers:
|
||||||
|
|
||||||
|
| The zone's public name servers | Route | Jarl writes the record |
|
||||||
|
|--|--|--|
|
||||||
|
| Porkbun's (`*.ns.porkbun.com`), with `porkbun.api_key` set | `porkbun` | through Porkbun's DNS API (TTL 600, Porkbun's minimum), and deletes it afterwards. The key is the one the registrar features use ([configuration.md](configuration.md#porkbun-registrar-and-dns-host)); it needs DNS record write access, "API Access" switched on for the domain, and the domain in `porkbun.domains` if you set that |
|
||||||
|
| the name servers in Jarl's own zone of that name | `jarl` | in that zone, in every view that has it, as a change by `system` (in the history) |
|
||||||
|
| anything else | none | nowhere: the certificate fails with what to do |
|
||||||
|
|
||||||
|
The certificate's `challenge` setting chooses:
|
||||||
|
|
||||||
|
- **`auto`** (the default) decides again at **every** issue and renewal.
|
||||||
|
While Porkbun serves `adbraun.lol`, the challenge goes to Porkbun. Once
|
||||||
|
you point the domain's name servers at your own servers, the next
|
||||||
|
renewal writes into Jarl's zone instead. Nothing needs changing.
|
||||||
|
- **`jarl`** or **`porkbun`** pins the route. Jarl still checks that the
|
||||||
|
CA will see the record there and refuses to try otherwise (no wasted
|
||||||
|
attempt against the CA's rate limits).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl cert update ns1 --challenge porkbun # or jarl, or auto
|
||||||
|
```
|
||||||
|
|
||||||
|
`jarl cert check NAME` (or **Check the names** in the web UI) shows the
|
||||||
|
route for each name, and why, at any time.
|
||||||
|
|
||||||
|
### Domains Jarl can't write to: a CNAME
|
||||||
|
|
||||||
|
For a domain whose DNS is elsewhere (Cloudflare, say), point the
|
||||||
|
challenge name at a zone this server serves on the internet, once, by
|
||||||
|
hand:
|
||||||
|
|
||||||
|
```
|
||||||
|
_acme-challenge.www.example.org. CNAME www-example-org.acme.adbraun.lol.
|
||||||
|
```
|
||||||
|
|
||||||
|
Jarl follows the CNAME and writes the record at its target, in its zone
|
||||||
|
`acme.adbraun.lol` (which must be delegated to this server). Renewals
|
||||||
|
never touch the other provider again. That is the same idea as
|
||||||
|
[acme.md section 7](acme.md#7-a-dedicated-acme-zone-cname-delegation).
|
||||||
|
|
||||||
|
## Renewal
|
||||||
|
|
||||||
|
jarld looks at every certificate every 10 minutes:
|
||||||
|
|
||||||
|
- A certificate Jarl obtains is renewed at the time the CA suggests
|
||||||
|
(ACME Renewal Information, RFC 9773; asked twice a day, so a CA that
|
||||||
|
needs to replace certificates early, e.g. after revoking them, gets its
|
||||||
|
way), or, without one, two thirds into its lifetime: day 60 of a 90-day
|
||||||
|
certificate. Each renewal makes a new key.
|
||||||
|
- When obtaining fails, jarld tries again after 1, 2, 4, 8, 16 hours, then
|
||||||
|
daily. The dashboard and the Certificates page show the error; so do
|
||||||
|
`jarl cert list` and Prometheus (`jarl_certificate_failing`).
|
||||||
|
- New names or a new key type on a certificate make it due at once.
|
||||||
|
- `jarl cert issue NAME` (**Renew** in the web UI) obtains it now. It runs
|
||||||
|
in the background: the command waits and reports, the web page updates
|
||||||
|
itself.
|
||||||
|
|
||||||
|
A certificate counts as **expiring** (a warning) when less than a sixth of
|
||||||
|
its lifetime is left, which for a renewing certificate means renewal has
|
||||||
|
been failing for a while; and **expired** after its end.
|
||||||
|
|
||||||
|
## Use: web (jarld's HTTPS)
|
||||||
|
|
||||||
|
Only one certificate can have the web use. As soon as it has been
|
||||||
|
obtained, jarld serves it on all its `listen` addresses, **without a
|
||||||
|
restart**: each port speaks HTTPS and plain HTTP side by side.
|
||||||
|
|
||||||
|
- HTTPS connections get the certificate (HTTP/2 included).
|
||||||
|
- Plain HTTP from this server itself (`127.0.0.1`, `::1`) is served as
|
||||||
|
before, so local scripts and `--server http://127.0.0.1:8053` keep
|
||||||
|
working. So is plain HTTP from `trusted_proxies`.
|
||||||
|
- Plain HTTP from anywhere else: browsers are redirected to
|
||||||
|
`https://<the same host and port>`; API requests get `400
|
||||||
|
https_required` (and, if they carried a token, a note that it crossed
|
||||||
|
the network unencrypted).
|
||||||
|
|
||||||
|
Point browsers and CLIs at the certificate's name:
|
||||||
|
`jarl config set server https://ns1.adbraun.lol:8053`. A CLI that uses the
|
||||||
|
server's IP address fails the name check.
|
||||||
|
|
||||||
|
A managed certificate takes over from `tls.cert_file`. Taking the web use
|
||||||
|
off (or deleting the certificate) goes back to `tls.cert_file`, or to
|
||||||
|
plain HTTP.
|
||||||
|
|
||||||
|
## Use: dns (for named)
|
||||||
|
|
||||||
|
Jarl writes the certificate to `<certificates.dir>/<name>/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/etc/bind/jarl/tls/ns1/fullchain.pem (0644)
|
||||||
|
/etc/bind/jarl/tls/ns1/privkey.pem (0640, group bind)
|
||||||
|
```
|
||||||
|
|
||||||
|
Debian's AppArmor profile lets named read `/etc/bind`, and group `bind`
|
||||||
|
lets it read the key. When a renewal changes the files and named's
|
||||||
|
configuration names them, Jarl runs `rndc reconfig`, and named serves the
|
||||||
|
new certificate at once (checked with BIND 9.20: DoT keeps working, with
|
||||||
|
the new certificate).
|
||||||
|
|
||||||
|
`jarl encrypted-dns setup` sets up DNS over TLS and HTTPS with it
|
||||||
|
([encrypted-dns.md](encrypted-dns.md)). By hand, a `tls` statement names
|
||||||
|
the files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key-file "/etc/bind/jarl/tls/ns1/privkey.pem"; }' --toplevel
|
||||||
|
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
|
||||||
|
```
|
||||||
|
|
||||||
|
A certificate named uses can't be deleted or lose its `dns` use until
|
||||||
|
named no longer names its files.
|
||||||
|
|
||||||
|
## A certificate you renew yourself
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web
|
||||||
|
```
|
||||||
|
|
||||||
|
Jarl checks that the key belongs to the certificate, warns when the file
|
||||||
|
holds only the certificate without its intermediates (use the full
|
||||||
|
chain), and rereads both files when they change (within 10 minutes, or at
|
||||||
|
once with `jarl cert issue own`). A pair that doesn't match is reported
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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
|
||||||
|
wildcards where one name will do: a wildcard's key is good for every
|
||||||
|
name in the domain.
|
||||||
|
- Using one certificate for jarld and named puts its key in two places.
|
||||||
|
On one server, where jarld already writes named's configuration, that
|
||||||
|
changes little.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | |
|
||||||
|
|--|--|--|
|
||||||
|
| GET | `/certificates` | `certificates` (each with `status` {`state`: ok, pending, failing, expiring, expired; `message`}, `next_attempt`, `issuing_since` while being obtained, `serving`, `files`) and `account` |
|
||||||
|
| POST | `/certificates` | `{"name": "ns1", "domains": [...], "uses": ["web", "dns"], "challenge": "auto", "key_type": "ec256"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`. One Jarl obtains starts being obtained at once (`"issue": false` leaves it to the loop) |
|
||||||
|
| POST | `/certificates/check` | `{"domains": [...], "challenge": "auto"}`: where each challenge would go (`routes`: `route`, `target`, `zone`, `public_ns`, `why` or `error`) |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Testing with Pebble
|
||||||
|
|
||||||
|
[Pebble](https://github.com/letsencrypt/pebble) is Let's Encrypt's test
|
||||||
|
CA. Point it at a test named, and Jarl at it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d --name pebble --network host -e PEBBLE_VA_NOSLEEP=1 \
|
||||||
|
ghcr.io/letsencrypt/pebble -config test/config/pebble-config.json -dnsserver 127.0.0.1:53
|
||||||
|
docker cp pebble:/test/certs/pebble.minica.pem /etc/jarl/pebble.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
acme:
|
||||||
|
account: {directory: "https://localhost:14000/dir", agree_tos: true}
|
||||||
|
resolvers: ["127.0.0.1:53"]
|
||||||
|
ca_bundle: /etc/jarl/pebble.pem
|
||||||
|
```
|
||||||
+62
-1
@@ -143,6 +143,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
|
|||||||
| `-n, --dry-run` | Validate and show the diff without changing anything. |
|
| `-n, --dry-run` | Validate and show the diff without changing anything. |
|
||||||
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
|
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
|
||||||
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
|
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
|
||||||
|
| `--accept-risk` | Confirm a change that opens a public view up, such as an open resolver ([exposure.md](exposure.md)). Without it such a change stops with exit 5. |
|
||||||
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
|
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
@@ -210,6 +211,18 @@ router, answers for the rest of the /24. IPv4 networks smaller than a /24
|
|||||||
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
|
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
|
||||||
`zone create` sets any zone option in the same step.
|
`zone create` sets any zone option in the same step.
|
||||||
|
|
||||||
|
Linked zones ([views.md](views.md#linked-zones)) keep their shared
|
||||||
|
records in step across views:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone link adbraun.lol --view internal --with external
|
||||||
|
jarl zone create adbraun.lol --view vps --link-with external
|
||||||
|
jarl zone unlink adbraun.lol --view vps
|
||||||
|
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
|
||||||
|
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
|
||||||
|
jarl record update 1234 --local | --shared
|
||||||
|
```
|
||||||
|
|
||||||
When a zone exists in several views, add `--view NAME` to commands that
|
When a zone exists in several views, add `--view NAME` to commands that
|
||||||
name it.
|
name it.
|
||||||
|
|
||||||
@@ -294,7 +307,9 @@ adds the quotes and splits long values into 255-byte strings. In reverse zones t
|
|||||||
host name (a bare `nas` is refused, since it would mean
|
host name (a bare `nas` is refused, since it would mean
|
||||||
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
||||||
keeps the PTR record in the reverse zone in step (when that zone is managed
|
keeps the PTR record in the reverse zone in step (when that zone is managed
|
||||||
by Jarl). Every record type BIND supports can be used. Jarl knows the
|
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
|
||||||
syntax of the common ones and checks them strictly, and the rest are
|
syntax of the common ones and checks them strictly, and the rest are
|
||||||
validated by parsing.
|
validated by parsing.
|
||||||
|
|
||||||
@@ -382,6 +397,49 @@ an administrator).
|
|||||||
|
|
||||||
Changesets staged in the web UI show up here too, and the other way round.
|
Changesets staged in the web UI show up here too, and the other way round.
|
||||||
|
|
||||||
|
## Internet exposure
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl exposure # public views, findings, fixes, rate limiting
|
||||||
|
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
|
||||||
|
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
|
||||||
|
jarl exposure fix server exposure_version
|
||||||
|
jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
|
||||||
|
jarl exposure unack external security_open_resolver
|
||||||
|
jarl view update external --exposure public|private|auto
|
||||||
|
```
|
||||||
|
|
||||||
|
See [exposure.md](exposure.md).
|
||||||
|
|
||||||
|
## Encrypted DNS
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl encrypted-dns # what is set up, what to give clients
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 [--address IPv6] [--doh-port 8443] [--no-doh]
|
||||||
|
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
|
||||||
|
jarl encrypted-dns disable
|
||||||
|
```
|
||||||
|
|
||||||
|
See [encrypted-dns.md](encrypted-dns.md).
|
||||||
|
|
||||||
|
## Certificates
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl cert check --domain ns1.adbraun.lol # where each challenge would go, and why
|
||||||
|
jarl cert create ns1 --domain ns1.adbraun.lol --use web --use dns # obtained at once; waits
|
||||||
|
jarl cert create wild -d adbraun.lol -d '*.adbraun.lol' --use dns --key-type ec384
|
||||||
|
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 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 delete ns1
|
||||||
|
jarl cert account # the ACME account (set in jarl.yaml)
|
||||||
|
```
|
||||||
|
|
||||||
|
Administrators only. See [certificates.md](certificates.md).
|
||||||
|
|
||||||
## Views, ACLs, TSIG keys, options, settings
|
## Views, ACLs, TSIG keys, options, settings
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -472,6 +530,9 @@ working.
|
|||||||
|
|
||||||
## Query statistics
|
## Query statistics
|
||||||
|
|
||||||
|
`--view internal,vps` (or repeated) limits any `jarl stats` command to those
|
||||||
|
views ([stats.md](stats.md#views)).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl stats status
|
jarl stats status
|
||||||
jarl stats summary --since 7d
|
jarl stats summary --since 7d
|
||||||
|
|||||||
+33
-7
@@ -20,7 +20,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
|
|||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. Ports below 1024 (`"192.168.10.2:443"`) work because `jarl.service` gives jarld the `CAP_NET_BIND_SERVICE` capability, and only that ([HTTPS on port 443](#example-https-on-port-443)). |
|
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. Ports below 1024 (`"192.168.10.2:443"`) work because `jarl.service` gives jarld the `CAP_NET_BIND_SERVICE` capability, and only that ([HTTPS on port 443](#example-https-on-port-443)). |
|
||||||
| `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. |
|
| `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. |
|
||||||
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. |
|
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. Easier: let Jarl obtain and renew one ([certificates.md](certificates.md), use `web`), which takes over from these. With a certificate, the `listen` ports serve HTTPS and plain HTTP side by side: plain HTTP is still served to this server itself and to `trusted_proxies`; anyone else is sent to HTTPS. |
|
||||||
| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. |
|
| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. |
|
||||||
| `session.lifetime` | `12h` | A web session ends this long after login... |
|
| `session.lifetime` | `12h` | A web session ends this long after login... |
|
||||||
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
|
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
|
||||||
@@ -85,6 +85,23 @@ configuration. Changing it takes effect when jarld restarts.
|
|||||||
Query counters need `stats.enabled` as well. Changes take effect when jarld
|
Query counters need `stats.enabled` as well. Changes take effect when jarld
|
||||||
restarts.
|
restarts.
|
||||||
|
|
||||||
|
### Certificates (ACME account)
|
||||||
|
|
||||||
|
The account Jarl obtains certificates with, and where it writes them for
|
||||||
|
named. See [certificates.md](certificates.md). Changes take effect when
|
||||||
|
jarld restarts.
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `acme.account.directory` | `letsencrypt` | `letsencrypt`, `letsencrypt-staging` or an ACME directory URL (`https://...`). |
|
||||||
|
| `acme.account.email` | `""` | Contact address for the CA (optional). |
|
||||||
|
| `acme.account.agree_tos` | `false` | You accept the CA's terms of service; no account is registered without it. |
|
||||||
|
| `acme.account.eab_kid`, `acme.account.eab_hmac` | `""` | External Account Binding, for CAs that need it. Both or neither. Keep the file readable only by root and jarld. |
|
||||||
|
| `acme.resolvers` | `["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]` | Recursive resolvers (`address:port`) used to see domains as the CA does: their zone, public name servers, and a CNAME on `_acme-challenge`. Not this server: it may answer from an internal view. |
|
||||||
|
| `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`. |
|
||||||
|
|
||||||
### ACME (lego httpreq endpoint)
|
### ACME (lego httpreq endpoint)
|
||||||
|
|
||||||
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
|
An endpoint for lego's `httpreq` DNS provider, so lego and Traefik can
|
||||||
@@ -126,18 +143,27 @@ there (`ReadWritePaths`). If you change them, update both
|
|||||||
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
|
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
|
||||||
```
|
```
|
||||||
|
|
||||||
### Porkbun (registrar)
|
### Porkbun (registrar and DNS host)
|
||||||
|
|
||||||
With an API key, Jarl can show and set a domain's name servers, glue
|
With an API key, Jarl can:
|
||||||
records and DNSSEC DS records at Porkbun, after checking that the change
|
|
||||||
won't break the domain ([dnssec.md](dnssec.md#porkbun)). Without a key
|
- show and set a domain's name servers, glue records and DNSSEC DS records
|
||||||
(the default) nothing about Porkbun shows up.
|
at Porkbun, after checking that the change won't break the domain
|
||||||
|
([dnssec.md](dnssec.md#porkbun));
|
||||||
|
- publish the DNS-01 challenges of certificates it obtains while Porkbun
|
||||||
|
still serves the domain's DNS ([certificates.md](certificates.md#where-the-challenge-goes)):
|
||||||
|
it only adds TXT records at `_acme-challenge` names and removes them
|
||||||
|
again.
|
||||||
|
|
||||||
|
Without a key (the default) nothing about Porkbun shows up, and
|
||||||
|
certificates for domains Porkbun serves can't be obtained (`jarl cert
|
||||||
|
check` says so).
|
||||||
|
|
||||||
| Key | Default | Meaning |
|
| Key | Default | Meaning |
|
||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
|
||||||
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
|
||||||
| `porkbun.domains` | `[]` | Only these domains (default: every primary zone the key can reach). |
|
| `porkbun.domains` | `[]` | Only these domains, for both uses (default: any domain the key can reach). |
|
||||||
|
|
||||||
The key can move your domain anywhere, so it is only ever read from this
|
The key can move your domain anywhere, so it is only ever read from this
|
||||||
file: it isn't stored in the database or backups, and there is no API to
|
file: it isn't stored in the database or backups, and there is no API to
|
||||||
|
|||||||
+7
-1
@@ -118,7 +118,13 @@ checker tests work with the installed `bind9-utils` too.
|
|||||||
ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks
|
ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks
|
||||||
the answers with `dig`. The Porkbun code is tested against
|
the answers with `dig`. The Porkbun code is tested against
|
||||||
`porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real
|
`porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real
|
||||||
one. `KEEP=1` leaves named
|
one. With Docker, certificates are tested end to end against
|
||||||
|
[Pebble](https://github.com/letsencrypt/pebble), Let's Encrypt's test CA
|
||||||
|
(`ghcr.io/letsencrypt/pebble`, host network, ports 14000 and 15000),
|
||||||
|
which checks the challenges against the test named: obtaining, HTTPS
|
||||||
|
from jarld, DNS over TLS from named, renewal. `PEBBLE=0` skips that; the
|
||||||
|
certs package's own tests use a fake DNS server and `porkbun.Fake`, and
|
||||||
|
never a real CA. `KEEP=1` leaves named
|
||||||
and jarld running afterwards. The output says where; set `JARL_CLI_CONFIG`
|
and jarld running afterwards. The output says where; set `JARL_CLI_CONFIG`
|
||||||
to that directory's `cli.yaml` to keep using the CLI against it.
|
to that directory's `cli.yaml` to keep using the CLI against it.
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -175,13 +175,17 @@ Before you go public:
|
|||||||
## Porkbun
|
## Porkbun
|
||||||
|
|
||||||
With a Porkbun API key, Jarl manages a domain's name servers, glue records
|
With a Porkbun API key, Jarl manages a domain's name servers, glue records
|
||||||
and DS records at Porkbun, and checks each change before making it.
|
and DS records at Porkbun, and checks each change before making it. The
|
||||||
|
same key also lets Jarl publish the DNS-01 challenges of its own
|
||||||
|
certificates while Porkbun serves the domain's DNS
|
||||||
|
([certificates.md](certificates.md#where-the-challenge-goes)).
|
||||||
|
|
||||||
**Setting it up**
|
**Setting it up**
|
||||||
|
|
||||||
1. At porkbun.com, *Account → API Access*: create a key. If you can,
|
1. At porkbun.com, *Account → API Access*: create a key. If you can,
|
||||||
limit it to the domain and the permissions it needs (domains, read),
|
limit it to the domain and the permissions it needs, and to your
|
||||||
and to your server's address.
|
server's address. The registrar features need the domain permissions;
|
||||||
|
certificate challenges need DNS record write access too.
|
||||||
2. On the domain's page, switch **API Access** on (Porkbun refuses API
|
2. On the domain's page, switch **API Access** on (Porkbun refuses API
|
||||||
calls for a domain until you do).
|
calls for a domain until you do).
|
||||||
3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld:
|
3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld:
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# DNS over TLS and HTTPS
|
||||||
|
|
||||||
|
named (BIND 9.20) can answer over encrypted connections itself: DNS over
|
||||||
|
TLS (DoT, port 853) and DNS over HTTPS (DoH, at `/dns-query`). Your
|
||||||
|
phones, laptops and browsers then ask this server without anyone on the
|
||||||
|
network seeing or changing their queries, and they get exactly what plain
|
||||||
|
DNS gives them: the same views, the same records and the same blocking.
|
||||||
|
|
||||||
|
Jarl sets it up in one step: a certificate for the host name clients
|
||||||
|
connect to (obtained and renewed by Jarl, [certificates.md](certificates.md)),
|
||||||
|
named's `tls` statement and listeners on the addresses you choose, and the
|
||||||
|
host name's address records in your private views.
|
||||||
|
|
||||||
|
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
|
||||||
|
encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
|
||||||
|
|
||||||
|
## Setting it up
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
|
||||||
|
```
|
||||||
|
|
||||||
|
This:
|
||||||
|
|
||||||
|
1. **finds or obtains the certificate**: one Jarl manages whose names cover
|
||||||
|
the host name (`--certificate NAME` to pick one), or a new one named
|
||||||
|
after the host name's first label, which Jarl obtains from the CA (the
|
||||||
|
command waits). The certificate gets the `dns` use, so it is written
|
||||||
|
where named can read it, and named rereads it on every renewal.
|
||||||
|
2. **checks the ports**: nothing else may listen on the chosen addresses
|
||||||
|
on port 853 or the DoH port. Jarl looks at the host's listening sockets
|
||||||
|
(it binds nothing) and says what is in the way, e.g. `jarld (Jarl's web
|
||||||
|
UI and API) listens on 10.0.10.5 port 443`, and which port is free
|
||||||
|
instead (`--doh-port 8443`).
|
||||||
|
3. **stages named's configuration**, as one change you can review:
|
||||||
|
|
||||||
|
```
|
||||||
|
tls jarl-dns { cert-file ".../tls/dns/fullchain.pem"; key-file ".../tls/dns/privkey.pem"; protocols { TLSv1.2; TLSv1.3; }; };
|
||||||
|
options {
|
||||||
|
listen-on port 853 tls jarl-dns { 10.0.10.5; };
|
||||||
|
listen-on port 443 tls jarl-dns http default { 10.0.10.5; };
|
||||||
|
listen-on-v6 port 853 tls jarl-dns { 2a02:8013:7f3d:10::5; };
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
named listens on port 53 on every address only while there is no
|
||||||
|
`listen-on` statement at all. The first one, for DoT, would end that, so
|
||||||
|
when you have none the setup adds `listen-on { any; };` (and the same
|
||||||
|
for IPv6) to keep plain DNS where it was.
|
||||||
|
4. **adds the host name's A and AAAA records** with the chosen addresses,
|
||||||
|
in the private views that have a zone for it (`--view` to choose). In a
|
||||||
|
linked zone they are local to the view, so the name doesn't reach your
|
||||||
|
external view.
|
||||||
|
|
||||||
|
Running it again replaces the earlier setup (other addresses, ports, DoT
|
||||||
|
or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable`
|
||||||
|
takes the listeners and the `tls` statement out; the certificate and the
|
||||||
|
records stay.
|
||||||
|
|
||||||
|
| Option | Default | |
|
||||||
|
|--|--|--|
|
||||||
|
| `--hostname` | | The name clients connect to; the certificate is for it |
|
||||||
|
| `--address` | | This server's addresses to listen on (repeat). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there |
|
||||||
|
| `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
|
||||||
|
| `--no-dot`, `--no-doh` | both on | |
|
||||||
|
| `--dot-port` | 853 | Clients expect 853 |
|
||||||
|
| `--doh-port` | 443 | Any free port; clients take it in the URL |
|
||||||
|
| `--view` | private views with the zone | Where the host name's records go |
|
||||||
|
|
||||||
|
In the web UI the same form checks the ports as you choose, offers the
|
||||||
|
free port, and obtains the certificate before setting up.
|
||||||
|
|
||||||
|
## Which view clients get
|
||||||
|
|
||||||
|
A DoT or DoH client gets the view its address matches, as over plain DNS:
|
||||||
|
your phone at home gets the internal view, with its records and blocking.
|
||||||
|
Choosing a view by key (`match-clients { key ...; }`) doesn't work over
|
||||||
|
DoT and DoH, as clients don't sign their queries.
|
||||||
|
|
||||||
|
## Clients
|
||||||
|
|
||||||
|
`jarl encrypted-dns` (and the page) shows what to give them:
|
||||||
|
|
||||||
|
```
|
||||||
|
For DoT clients: dns.adbraun.lol
|
||||||
|
For DoH clients: https://dns.adbraun.lol/dns-query
|
||||||
|
```
|
||||||
|
|
||||||
|
| Client | Where |
|
||||||
|
|--|--|
|
||||||
|
| Android 9+ | Settings → Network → Private DNS → Private DNS provider hostname: `dns.adbraun.lol` (DoT) |
|
||||||
|
| Linux (systemd-resolved) | `/etc/systemd/resolved.conf`: `DNS=10.0.10.5#dns.adbraun.lol` and `DNSOverTLS=yes` (or `opportunistic`, which falls back to plain DNS) |
|
||||||
|
| Firefox | Settings → Privacy & Security → DNS over HTTPS → Increased protection → Custom: the DoH URL |
|
||||||
|
| Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → custom: the DoH URL |
|
||||||
|
| Windows 11 | Settings → Network → your connection → DNS server assignment → Edit: the server's address, DNS over HTTPS on (manual template), the DoH URL |
|
||||||
|
|
||||||
|
Test from a machine on your network:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com
|
||||||
|
dig +https @10.0.10.5 example.com
|
||||||
|
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Android away from home.** Private DNS in hostname mode applies on every
|
||||||
|
network. Away from home, your server can't be reached, and Android then
|
||||||
|
resolves nothing at all ("Private DNS server cannot be accessed"). Use a
|
||||||
|
VPN home (WireGuard) when you're out, or switch Private DNS to
|
||||||
|
*Automatic* when you leave. Firefox's *Increased protection* and
|
||||||
|
systemd-resolved's `opportunistic` fall back to normal DNS by themselves.
|
||||||
|
|
||||||
|
Clients check the certificate against the host name, so the name must
|
||||||
|
resolve to this server for them: the setup adds it to your private views.
|
||||||
|
Open the ports in the server's firewall for your own networks only.
|
||||||
|
|
||||||
|
## What Jarl checks
|
||||||
|
|
||||||
|
* A `tls` statement whose files don't exist is an error
|
||||||
|
(`tls_file_missing`): named won't load its configuration without them.
|
||||||
|
* A certificate named uses can't be deleted, nor lose its `dns` use: turn
|
||||||
|
encrypted DNS off first.
|
||||||
|
* Listeners you wrote yourself are left alone; the page notes them.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostname`, `dot` and `doh` (address and port), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone` for `?hostname=`), `certificates` |
|
||||||
|
| POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
|
||||||
|
| POST | `/encrypted-dns` | `{"hostname", "certificate", "addresses", "dot", "doh", "dot_port", "doh_port", "views"}`: staged or applied like any change; 409 while the certificate hasn't been obtained |
|
||||||
|
| DELETE | `/encrypted-dns` | Turns it off |
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Internet exposure
|
||||||
|
|
||||||
|
When a view (or the whole server) can be reached from the internet, a few
|
||||||
|
things matter that don't on your own network: it shouldn't help attackers
|
||||||
|
flood others with DNS answers (amplification), recurse for strangers, or
|
||||||
|
tell scanners its exact version. Jarl works out which views are public,
|
||||||
|
checks them, and stages the fixes for you to review.
|
||||||
|
|
||||||
|
In the web UI: **Exposure** (and its card on the dashboard). In the CLI:
|
||||||
|
`jarl exposure`. In the API: `GET /api/v1/exposure`.
|
||||||
|
|
||||||
|
## Which views are public
|
||||||
|
|
||||||
|
A view is **public** when its `match-clients` let in anyone: after ACLs
|
||||||
|
and ranges are filled in, they end in a catch-all (`any`, `0.0.0.0/0`,
|
||||||
|
`::/0`, or a prefix wider than /8 for IPv4 or /16 for IPv6), or they are
|
||||||
|
empty, which named reads as "any". A typical external view
|
||||||
|
(`match-clients { any; }` or `{ !internal; any; }`) is public.
|
||||||
|
|
||||||
|
A view that lists the networks it serves is **private**, whatever kind of
|
||||||
|
addresses those are. An internal view for your global IPv6 prefix
|
||||||
|
(`2a02:8013:7f3d:10::/64`) is as private as one for `192.168.1.0/24`:
|
||||||
|
listing a network says "these are mine". Jarl doesn't look at whether
|
||||||
|
addresses are private (RFC 1918, ULA) for this, because on IPv6 your own
|
||||||
|
devices have global addresses.
|
||||||
|
|
||||||
|
A view whose `match-destinations` list only private addresses (it answers
|
||||||
|
only on a private interface) is private too.
|
||||||
|
|
||||||
|
**A server without views** has no match list, so Jarl looks at
|
||||||
|
`allow-query` and where named listens: it is public when anyone may query
|
||||||
|
it (`allow-query` unset, or with a catch-all) **and** named listens on an
|
||||||
|
address that isn't private (a public IPv4 address or a global IPv6
|
||||||
|
address of this host). This can't see your firewall, so it is a guess.
|
||||||
|
|
||||||
|
**When Jarl gets it wrong**, override it per view: `public`, `private` or
|
||||||
|
`auto`. For example, behind a NAT port forward named only ever sees
|
||||||
|
private addresses although it is reachable from the internet.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl view update external --exposure public
|
||||||
|
jarl settings set --exposure private # a server without views
|
||||||
|
```
|
||||||
|
|
||||||
|
The Exposure page shows each view's classification and the reason
|
||||||
|
("match-clients ends in any", "match-clients lists only homelab, no
|
||||||
|
catch-all"), with a selector for the override.
|
||||||
|
|
||||||
|
## The checks
|
||||||
|
|
||||||
|
They run on public views only, and are warnings (they don't stop you
|
||||||
|
applying) unless acknowledged.
|
||||||
|
|
||||||
|
| Code | What | Fix Jarl can stage |
|
||||||
|
|------|------|-------------------|
|
||||||
|
| `exposure_rate_limit` | No response rate limiting: the view can be used to flood others with its answers (DNS amplification) | `rate-limit { responses-per-second 10; log-only yes; exempt-clients { your secondaries }; }` |
|
||||||
|
| `exposure_rate_limit_log_only` (info) | Rate limiting only logs | The same block without `log-only`: start enforcing |
|
||||||
|
| `security_open_resolver` | Recursion for anyone: an open resolver | `recursion no` in that view |
|
||||||
|
| `exposure_minimal_any` | ANY queries get full answers, a favourite for amplification | `minimal-any yes` |
|
||||||
|
| `exposure_cache_snooping` | `allow-query-cache` lets anyone see what your clients look up | `allow-query-cache { none; }` |
|
||||||
|
| `security_open_transfer` | Zone transfers for anyone | none: allow your secondaries or a TSIG key |
|
||||||
|
| `exposure_open_notify` | `allow-notify` accepts NOTIFY from anyone | none: allow your primaries |
|
||||||
|
| `exposure_version` | `dig CH TXT version.bind` shows the exact BIND version | `version none` |
|
||||||
|
| `exposure_hostname` | `dig CH TXT hostname.bind` shows the host name | `hostname none` |
|
||||||
|
| `exposure_management` | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone | none: use 127.0.0.1 or an allow list |
|
||||||
|
|
||||||
|
The last three are server-wide: they are checked when any view is public.
|
||||||
|
|
||||||
|
A **fix** goes into your pending changes like any edit (or is applied at
|
||||||
|
once in live mode), so you see the named.conf diff before it reaches named:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl exposure # classification, findings, fixes
|
||||||
|
jarl exposure fix external exposure_rate_limit
|
||||||
|
jarl exposure fix server exposure_version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response rate limiting
|
||||||
|
|
||||||
|
Response rate limiting (RRL, BIND's `rate-limit`) counts identical
|
||||||
|
responses sent to the same client network. Past the limit, named drops
|
||||||
|
some and "slips" others: it sends a short reply asking the client to retry
|
||||||
|
over TCP (or with a DNS cookie). A real client does; the spoofed victim of
|
||||||
|
an amplification attack never sees the answers it didn't ask for. ISC
|
||||||
|
recommends it for authoritative servers only, so Jarl only suggests it for
|
||||||
|
public views, never for your internal resolver.
|
||||||
|
|
||||||
|
Jarl starts from ISC's example, 10 identical responses per second per
|
||||||
|
client network, and exempts the servers your zones notify and allow
|
||||||
|
transfers to (`also-notify` and `allow-transfer` addresses, through ACLs
|
||||||
|
too): limiting your own secondaries is the classic RRL mistake.
|
||||||
|
|
||||||
|
**Rollout in two steps:**
|
||||||
|
|
||||||
|
1. The fix turns rate limiting on with `log-only yes`. named answers
|
||||||
|
everyone as before, but logs every response it *would* have limited.
|
||||||
|
With [query statistics](stats.md) on, Jarl reads those lines: the
|
||||||
|
Exposure page shows how many responses would have been limited, and
|
||||||
|
which client networks and names. Check that your secondaries,
|
||||||
|
monitoring, or a big shared resolver aren't among them (add them to
|
||||||
|
`exempt-clients` if they are).
|
||||||
|
2. When you're happy, **Start enforcing** (the fix of
|
||||||
|
`exposure_rate_limit_log_only`) removes `log-only`.
|
||||||
|
|
||||||
|
In log-only mode named's own counters don't move: what would be limited
|
||||||
|
is only in its log (category `query-errors`). That is why the activity
|
||||||
|
needs query statistics; without them, look at named's log for
|
||||||
|
`would rate limit`. When rate limiting is configured and statistics are
|
||||||
|
on, Jarl adds `query-errors` to its log channel (keeping named's syslog
|
||||||
|
destination for it).
|
||||||
|
|
||||||
|
The statistics also count it: the `rrl` breakdown (`drop`, `slip`,
|
||||||
|
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
|
||||||
|
lists, and in Prometheus `jarl_ratelimit_logged_total{action}` next to
|
||||||
|
named's own `jarl_ratelimit_responses_total` ([metrics.md](metrics.md)).
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
You can tune the block like any option; `option set` replaces the whole
|
||||||
|
block, so copy the current value from `jarl option list --view external`
|
||||||
|
and edit it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl option set rate-limit '{ responses-per-second 20; window 15; slip 2; exempt-clients { 192.0.2.53; }; log-only yes; }' --view external
|
||||||
|
```
|
||||||
|
|
||||||
|
named counts **identical responses** to the same **client network** (a
|
||||||
|
/24 for IPv4, a /56 for IPv6) over a **window**, and limits each kind of
|
||||||
|
response separately. These are the settings (BIND 9.20; the ARM has the
|
||||||
|
full text):
|
||||||
|
|
||||||
|
| Setting | Default | What it does | When to change it |
|
||||||
|
|---------|---------|--------------|-------------------|
|
||||||
|
| `responses-per-second` | 0 (off) | Answers with data for the same name and type, per client network. Wildcard answers count as the zone's apex. This is the main limit; Jarl's fix sets 10 | Raise it if a legitimate network shows up in log-only mode, e.g. a large shared resolver (mobile carriers, public resolvers) asking for your popular names |
|
||||||
|
| `nxdomains-per-second` | same as `responses-per-second` | NXDOMAIN answers (names that don't exist), counted together for a domain whatever the name asked | `0` turns NXDOMAIN limiting off, for zones that legitimately get many lookups of missing names |
|
||||||
|
| `nodata-per-second` | same as `responses-per-second` | Empty answers (the name exists, the type doesn't), counted together whatever the type | Rarely |
|
||||||
|
| `referrals-per-second` | same as `responses-per-second` | Referrals to child zones you delegate | Only if you delegate subdomains to other servers |
|
||||||
|
| `errors-per-second` | same as `responses-per-second` | Other errors (REFUSED, SERVFAIL, FORMERR), counted together whatever the name | Rarely. REFUSED and SERVFAIL can't be truncated, so slipped ones still go out at the slip rate |
|
||||||
|
| `all-per-second` | 0 (off) | All UDP answers to a client network, of any kind, like a firewall rate limit; over it, answers are only **dropped** (`slip` doesn't apply) | Usually leave off; if you use it, ISC says at least 4 times the other limits |
|
||||||
|
| `window` | 15 seconds (1–3600) | How far back named counts. A client over the limit stays limited until its rate over the window falls again | Longer windows remember attackers longer and also punish short legitimate bursts longer |
|
||||||
|
| `slip` | 2 (0–10) | Of the limited answers, every n-th "slips": instead of being dropped it gets a short reply asking the client to retry: truncated (TC) so it retries over TCP, or, when the query carried a DNS cookie, BADCOOKIE with a server cookie that lets it through on its retry. `0` drops all, `1` slips all | `1` if you'd rather every real client gets a retry hint; `0` drops all (spoofed victims see nothing, but real clients just time out) |
|
||||||
|
| `ipv4-prefix-length` | 24 | The size of the client network answers are counted for (IPv4) | `32` counts each address on its own: fairer to neighbours, more memory |
|
||||||
|
| `ipv6-prefix-length` | 56 | The same for IPv6 | `64` if one /56 holds many independent clients; `48` to treat a whole site as one |
|
||||||
|
| `exempt-clients` | none | Address match list (addresses, prefixes, [ranges](concepts.md#model-views-zones-records-options), ACLs) that is never limited. Jarl's fix puts your secondaries here | Add monitoring, other servers of yours, and anything legitimate that log-only mode shows |
|
||||||
|
| `log-only` | no | Count and log what would be limited, answer everyone normally | Jarl starts with `yes`; the "start enforcing" fix removes it |
|
||||||
|
| `qps-scale` | off | When the total query rate is above this, all limits shrink in proportion (at a rate of 1000 with `qps-scale 250`, a limit of 20 becomes 5) | Only on busy servers that see attacks spread over many networks |
|
||||||
|
| `max-table-size`, `min-table-size` | min 500 entries | The table of client networks named tracks (40–80 bytes per entry) | Only on busy servers |
|
||||||
|
|
||||||
|
What Jarl checked against real named: TCP answers are never limited;
|
||||||
|
`nxdomains-per-second` falls back to `responses-per-second` and `0`
|
||||||
|
turns it off; NXDOMAIN answers for different missing names in a zone
|
||||||
|
share one limit; `all-per-second` only drops; with `slip 1`, clients
|
||||||
|
without DNS cookies get truncated answers, and clients with cookies (dig
|
||||||
|
sends them by default) get through on their retry with the server's
|
||||||
|
cookie; `exempt-clients` is never limited; in log-only mode nothing is
|
||||||
|
limited and only the log shows what would be.
|
||||||
|
|
||||||
|
Rate limiting is per view: put it in the external view (`--view
|
||||||
|
external`). Set in the server options, it applies to every view that has
|
||||||
|
none of its own, your internal resolver included, which RRL isn't meant
|
||||||
|
for.
|
||||||
|
|
||||||
|
## Confirming a change that opens a view up
|
||||||
|
|
||||||
|
A change that *introduces* an open resolver, open zone transfers, cache
|
||||||
|
snooping, open NOTIFY or an open management interface on a public view
|
||||||
|
must be confirmed:
|
||||||
|
|
||||||
|
- **Web UI:** the Changes page shows the finding with a checkbox (*I
|
||||||
|
understand: …*); **Apply** stays disabled until it is ticked.
|
||||||
|
- **CLI:** the command stops (exit 5) and says why; run it again with
|
||||||
|
`--accept-risk`.
|
||||||
|
- **API:** `accept_risk: true` in the apply body, or `?accept_risk=1` on a
|
||||||
|
change applied at once; without it the answer is 409 `confirm_risk`.
|
||||||
|
Previews (`GET /changesets/{id}/preview`) list them as `risks`.
|
||||||
|
|
||||||
|
Only changes that newly open something up ask: an unrelated change to a
|
||||||
|
view that is already open, or taking back an acknowledgement, doesn't.
|
||||||
|
|
||||||
|
## Acknowledging a finding
|
||||||
|
|
||||||
|
Some findings are deliberate: a public resolver for a club's network, a
|
||||||
|
visible version string. Acknowledge them with a reason:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl exposure ack external security_open_resolver --reason "public resolver for the club, monitored"
|
||||||
|
jarl exposure ack server exposure_version --reason "version shown on purpose"
|
||||||
|
jarl exposure unack external security_open_resolver
|
||||||
|
```
|
||||||
|
|
||||||
|
The finding doesn't disappear: it stays visible as **info**, with who
|
||||||
|
accepted it, when and why (*open resolver … (acknowledged by adam on 12
|
||||||
|
Oct 2026: public resolver for the club, monitored)*), on the Exposure page,
|
||||||
|
the dashboard and in `jarl check`. An acknowledgement covers that one
|
||||||
|
finding on that one view (or the server); the same finding elsewhere
|
||||||
|
warns as usual. Acknowledging is a configuration change like any other,
|
||||||
|
so it is in the history and audit log, and can be reverted.
|
||||||
|
|
||||||
|
Jarl isn't designed to run an open resolver, and an acknowledgement makes
|
||||||
|
one deliberate and visible, not safe: a public resolver also needs abuse
|
||||||
|
monitoring, response size limits and usually a machine of its own.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Body |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`) |
|
||||||
|
| POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change |
|
||||||
|
| POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` |
|
||||||
|
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
|
||||||
|
|
||||||
|
The override is a view's `exposure` field (`PATCH /views/{id}`), or the
|
||||||
|
settings' for a server without views (`PATCH /settings`).
|
||||||
+14
-1
@@ -129,6 +129,9 @@ same values.
|
|||||||
| `jarl_blocklist_last_check_timestamp_seconds{list}`, `jarl_blocklist_last_change_timestamp_seconds{list}` | When the list was last checked, and last changed. |
|
| `jarl_blocklist_last_check_timestamp_seconds{list}`, `jarl_blocklist_last_change_timestamp_seconds{list}` | When the list was last checked, and last changed. |
|
||||||
| `jarl_dnssec_phase{zone,view,phase}` | Always 1, one per signed zone; `phase` is one of the [DNSSEC phases](dnssec.md). `ready` means: add the DS at the parent. `removing_ds`: remove it. |
|
| `jarl_dnssec_phase{zone,view,phase}` | Always 1, one per signed zone; `phase` is one of the [DNSSEC phases](dnssec.md). `ready` means: add the DS at the parent. `removing_ds`: remove it. |
|
||||||
| `jarl_token_expiry_timestamp_seconds{id,user,token}` | When each API token with an expiry date expires. |
|
| `jarl_token_expiry_timestamp_seconds{id,user,token}` | When each API token with an expiry date expires. |
|
||||||
|
| `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). |
|
||||||
|
| `jarl_certificate_failing{certificate,source}` | 1 when obtaining (or reading) the certificate failed the last time, or it has expired. |
|
||||||
|
| `jarl_certificate_renewal_failures{certificate}` | Failed attempts to obtain a certificate since its last success. |
|
||||||
|
|
||||||
### Queries (with `stats.enabled`)
|
### Queries (with `stats.enabled`)
|
||||||
|
|
||||||
@@ -148,7 +151,8 @@ for them.
|
|||||||
| `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. |
|
| `jarl_queries_by_transport_total{transport}` | `udp` and `tcp`. |
|
||||||
| `jarl_answers_total{source}` | `authoritative` or `recursive`. |
|
| `jarl_answers_total{source}` | `authoritative` or `recursive`. |
|
||||||
| `jarl_recursion_queries_total`, `jarl_dropped_queries_total`, `jarl_duplicate_queries_total` | Queries that caused recursion, that named dropped, and duplicates. |
|
| `jarl_recursion_queries_total`, `jarl_dropped_queries_total`, `jarl_duplicate_queries_total` | Queries that caused recursion, that named dropped, and duplicates. |
|
||||||
| `jarl_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped`. |
|
| `jarl_ratelimit_responses_total{action}` | Responses rate limiting `dropped` or `slipped` (named's counters; they don't move in log-only mode). |
|
||||||
|
| `jarl_ratelimit_logged_total{action}` | Rate limited responses from named's log: `drop`, `slip`, and in log-only mode `would-drop`, `would-slip` ([exposure.md](exposure.md#response-rate-limiting)). |
|
||||||
| `jarl_upstream_responses_by_rtt_total{rtt}` | Upstream answers during recursion by round-trip time (`<10ms` … `>1600ms`). |
|
| `jarl_upstream_responses_by_rtt_total{rtt}` | Upstream answers during recursion by round-trip time (`<10ms` … `>1600ms`). |
|
||||||
| `jarl_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. |
|
| `jarl_dnssec_validations_total{result}` | `valid`, `valid-negative` and `failed`. |
|
||||||
| `jarl_cache_lookups_total{result}` | `hits` and `misses`. |
|
| `jarl_cache_lookups_total{result}` | `hits` and `misses`. |
|
||||||
@@ -219,6 +223,15 @@ groups:
|
|||||||
expr: jarl_token_expiry_timestamp_seconds - time() < 7 * 86400
|
expr: jarl_token_expiry_timestamp_seconds - time() < 7 * 86400
|
||||||
annotations:
|
annotations:
|
||||||
summary: "API token {{ $labels.token }} of {{ $labels.user }} expires within a week"
|
summary: "API token {{ $labels.token }} of {{ $labels.user }} expires within a week"
|
||||||
|
- alert: CertificateFailing
|
||||||
|
expr: jarl_certificate_failing == 1
|
||||||
|
for: 12h
|
||||||
|
annotations:
|
||||||
|
summary: "Certificate {{ $labels.certificate }} can't be renewed (see the Certificates page)"
|
||||||
|
- alert: CertificateExpiresSoon
|
||||||
|
expr: jarl_certificate_expiry_timestamp_seconds - time() < 10 * 86400
|
||||||
|
annotations:
|
||||||
|
summary: "Certificate {{ $labels.certificate }} expires within 10 days"
|
||||||
- alert: BlockingPausedForLong
|
- alert: BlockingPausedForLong
|
||||||
expr: jarl_blocking_paused == 1
|
expr: jarl_blocking_paused == 1
|
||||||
for: 24h
|
for: 24h
|
||||||
|
|||||||
+3
-2
@@ -116,8 +116,9 @@ demote or delete the last enabled administrator.
|
|||||||
|
|
||||||
A user's permissions are a list of grants. Administrators (`admin: true`)
|
A user's permissions are a list of grants. Administrators (`admin: true`)
|
||||||
may do everything, and only they can manage users, see everyone's tokens,
|
may do everything, and only they can manage users, see everyone's tokens,
|
||||||
run `resync`, wipe a zone's DNSSEC keys, and use the registrar (Porkbun,
|
run `resync`, wipe a zone's DNSSEC keys, use the registrar (Porkbun,
|
||||||
whose key can move the domain).
|
whose key can move the domain), and manage TLS certificates (whose keys
|
||||||
|
can impersonate the server; [certificates.md](certificates.md)).
|
||||||
|
|
||||||
A grant is written as:
|
A grant is written as:
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -103,6 +103,7 @@ lists, views, zones). named's own counters can't be filtered.
|
|||||||
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
|
| `zone:NAME` | response log | answers per local zone; `zone:-` for names in none of your zones |
|
||||||
| `responses` | response log | lines read (a health check) |
|
| `responses` | response log | lines read (a health check) |
|
||||||
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
|
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
|
||||||
|
| `rrl:drop`, `rrl:slip`, `rrl:would-drop`, `rrl:would-slip` | response log | responses [rate limiting](exposure.md#response-rate-limiting) limited, or would have in log-only mode (named's own `ratelimit:*` counters don't move then) |
|
||||||
|
|
||||||
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
|
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
|
||||||
the samples and `gn:KEY` their number, so a chart shows the average):
|
the samples and `gn:KEY` their number, so a chart shows the average):
|
||||||
@@ -167,7 +168,8 @@ jarl stats top refused # who asks for what they may not have
|
|||||||
jarl stats timeline --since 6h # sparkline; --table for every bucket
|
jarl stats timeline --since 6h # sparkline; --table for every bucket
|
||||||
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
|
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
|
||||||
jarl stats heatmap --since 28d
|
jarl stats heatmap --since 28d
|
||||||
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit
|
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit | rrl
|
||||||
|
jarl stats top ratelimited # client networks rate limiting limits (or would)
|
||||||
jarl stats server # memory, CPU, cache, clients, host, disks now
|
jarl stats server # memory, CPU, cache, clients, host, disks now
|
||||||
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
|
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
|
||||||
jarl stats summary --since 30d -o json # export (also -o yaml)
|
jarl stats summary --since 30d -o json # export (also -o yaml)
|
||||||
@@ -182,6 +184,26 @@ jarl stats summary --since 30d -o json # export (also -o yaml)
|
|||||||
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
||||||
addresses and what they look up, so grant it with care.
|
addresses and what they look up, so grant it with care.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
With views, the Statistics page has a toggle per view (and **All** and
|
||||||
|
**None**): switch some off to see only the others, for example your
|
||||||
|
internal and VPS views without the internet's noise on the external one,
|
||||||
|
or several linked views together. The choice is remembered in your
|
||||||
|
browser. The CLI takes `--view` on every `jarl stats` command (repeat it,
|
||||||
|
or separate views with commas), and the API `?views=internal,vps`.
|
||||||
|
|
||||||
|
Only what named's response log says can be split by view: answers (shown
|
||||||
|
as *answers* instead of queries), result codes, record types, zones,
|
||||||
|
blocking, rate limiting and the top lists. named's own counters (answer
|
||||||
|
sources, transport, upstream response times, DNSSEC, the cache and the
|
||||||
|
server) are for the whole server, and the page says so; asking for them
|
||||||
|
per view (`jarl stats breakdown answer --view internal`) is refused.
|
||||||
|
|
||||||
|
Per-view figures are kept from the version that added them (Jarl 0.4.1);
|
||||||
|
the page says since when. They take some more room in the statistics
|
||||||
|
database, roughly in proportion to the number of views.
|
||||||
|
|
||||||
## How data is kept
|
## How data is kept
|
||||||
|
|
||||||
- Minute counters are kept for `retention.minute`, hourly counters and top
|
- Minute counters are kept for `retention.minute`, hourly counters and top
|
||||||
|
|||||||
@@ -104,6 +104,26 @@ applied.
|
|||||||
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
||||||
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
| Code | Severity | Check |
|
||||||
|
|------|----------|-------|
|
||||||
|
| `tls_file_missing` | error | The files a top-level `tls` statement names (certificate, key, CA) exist: named doesn't load its configuration without them |
|
||||||
|
|
||||||
|
## Linked zones
|
||||||
|
|
||||||
|
See [views.md](views.md#linked-zones). Jarl keeps linked zones in step
|
||||||
|
itself, so these only show when something got past it (a change staged
|
||||||
|
before the zones were linked, or the database edited by hand).
|
||||||
|
|
||||||
|
| Code | Severity | Check |
|
||||||
|
|------|----------|-------|
|
||||||
|
| `linked_zone_type` | error | Only primary zones can be linked |
|
||||||
|
| `linked_zone_name` | error | Linked zones have the same name |
|
||||||
|
| `linked_mixed` | error | A name and type in a zone is either shared or local, not both |
|
||||||
|
| `linked_out_of_sync` | error | Every view that doesn't override a shared name and type has the same records for it. Link the zones again (`jarl zone link`) to work out what is shared |
|
||||||
|
| `linked_zone_alone` | info | A zone linked with no other zone |
|
||||||
|
|
||||||
## Blocking
|
## Blocking
|
||||||
|
|
||||||
From [blocking.md](blocking.md).
|
From [blocking.md](blocking.md).
|
||||||
@@ -124,11 +144,26 @@ From [blocking.md](blocking.md).
|
|||||||
|
|
||||||
## Security lints
|
## Security lints
|
||||||
|
|
||||||
|
These and the exposure checks below only look at views that are
|
||||||
|
reachable from the internet ([exposure.md](exposure.md)); without views,
|
||||||
|
the open resolver and open transfer checks look at the server. Any of them
|
||||||
|
can be acknowledged (it then shows as info, with who accepted it and why),
|
||||||
|
and a change that newly introduces an open resolver, open transfers, cache
|
||||||
|
snooping, open NOTIFY or an open management interface on a public view
|
||||||
|
must be confirmed (`--accept-risk`).
|
||||||
|
|
||||||
| Code | Severity | What it catches |
|
| Code | Severity | What it catches |
|
||||||
|------|----------|-----------------|
|
|------|----------|-----------------|
|
||||||
| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks |
|
| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks |
|
||||||
| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key |
|
| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key |
|
||||||
| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead |
|
| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead |
|
||||||
|
| `exposure_rate_limit` | warning | A public view without response rate limiting (`rate-limit`) |
|
||||||
|
| `exposure_rate_limit_log_only` | info | Rate limiting in log-only mode: check what it would limit, then enforce |
|
||||||
|
| `exposure_minimal_any` | warning | A public view answering ANY queries in full (`minimal-any` off) |
|
||||||
|
| `exposure_cache_snooping` | warning | A public view without recursion whose cache anyone may query (`allow-query-cache`) |
|
||||||
|
| `exposure_open_notify` | warning | A public view accepting NOTIFY from anyone (`allow-notify`) |
|
||||||
|
| `exposure_version`, `exposure_hostname` | warning | The server is public and shows its BIND version or host name to anyone (`version none`, `hostname none`) |
|
||||||
|
| `exposure_management` | warning | `controls` or `statistics-channels` on an address that isn't loopback, open to anyone, while the server is public |
|
||||||
|
|
||||||
## What only BIND checks
|
## What only BIND checks
|
||||||
|
|
||||||
|
|||||||
+86
-9
@@ -81,21 +81,34 @@ avoids named-checkconf's warning that `allow-recursion` is still active in a
|
|||||||
view with recursion off. Jarl warns (`security_open_resolver`) if a view
|
view with recursion off. Jarl warns (`security_open_resolver`) if a view
|
||||||
ever offers recursion to everyone.
|
ever offers recursion to everyone.
|
||||||
|
|
||||||
|
Jarl counts the external view as reachable from the internet (its
|
||||||
|
match-clients end in `any`) and checks it: rate limiting, ANY queries,
|
||||||
|
the version string and more. See the **Exposure** page or `jarl exposure`,
|
||||||
|
and [exposure.md](exposure.md) to set up response rate limiting.
|
||||||
|
|
||||||
### 4. Give the external view its zones
|
### 4. Give the external view its zones
|
||||||
|
|
||||||
**Different data per view (split DNS).** Copy the zone, then change what
|
**Mostly the same, a few differences (split DNS).** Create the external
|
||||||
should differ:
|
copy *linked* ([below](#linked-zones)): Jarl keeps the shared records the
|
||||||
|
same in both views, and each view can have records of its own:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone create adbraun.lol --view external --link-with internal
|
||||||
|
jarl record set adbraun.lol www A 203.0.113.20 --view external --local # public address, external only
|
||||||
|
jarl record delete adbraun.lol nas-internal A --view external # gone from both: it was shared
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete records you don't want outside before you link, or make them local
|
||||||
|
to the internal view first (`jarl record update ID --local`): a shared
|
||||||
|
record is in both views.
|
||||||
|
|
||||||
|
**Two separate copies.** `--clone-from` copies the zone once; from then on
|
||||||
|
the two are separate zones, and a record added to one isn't in the other:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
||||||
jarl record list adbraun.lol --view external
|
|
||||||
jarl record update <id> --data 203.0.113.20 # public address instead of 10.10.0.20
|
|
||||||
jarl record delete adbraun.lol nas-internal A --view external
|
|
||||||
```
|
```
|
||||||
|
|
||||||
From then on the two copies are separate zones. A record added to one isn't
|
|
||||||
in the other, and the serials are separate.
|
|
||||||
|
|
||||||
**The same data in both views.** Use an `in-view` zone. The external view
|
**The same data in both views.** Use an `in-view` zone. The external view
|
||||||
then serves the internal view's zone, so there is one copy to maintain:
|
then serves the internal view's zone, so there is one copy to maintain:
|
||||||
|
|
||||||
@@ -155,6 +168,67 @@ shows one (it needs `tsig:write` and is audited). Key names starting with
|
|||||||
`jarl-view-` are reserved, and the view keys can be used in your own
|
`jarl-view-` are reserved, and the view keys can be used in your own
|
||||||
options (`allow-transfer { key jarl-view-external; }`).
|
options (`allow-transfer { key jarl-view-external; }`).
|
||||||
|
|
||||||
|
## Linked zones
|
||||||
|
|
||||||
|
Most split-horizon zones are the same in every view apart from a few
|
||||||
|
names. Linking zones of the same name in different views lets Jarl keep
|
||||||
|
the shared records in step, while each view can still have records of its
|
||||||
|
own and its own zone options.
|
||||||
|
|
||||||
|
* **Shared records** are in every linked view. Adding, changing,
|
||||||
|
disabling or deleting one in any view does the same in the others, in
|
||||||
|
the same changeset: the diff shows every view, and history, undo and
|
||||||
|
`jarl check` treat them together.
|
||||||
|
* **Local records** are in one view only (`--local`, or **Only here** in
|
||||||
|
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.
|
||||||
|
* 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
|
||||||
|
web UI) makes that view's records of that name and type the shared
|
||||||
|
version: the other views' records of that name and type are replaced.
|
||||||
|
* Zones stay separate in named, so each keeps its own serial, options
|
||||||
|
(allow-transfer, DNSSEC signing, ...) and file, and the views keep their
|
||||||
|
own `match-clients`, recursion and order.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone link adbraun.lol --view internal --with external # link two existing zones
|
||||||
|
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
||||||
|
jarl record list adbraun.lol --view internal # LINK column: shared or local
|
||||||
|
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local
|
||||||
|
jarl record update 1234 --local # this name and type: only this view
|
||||||
|
jarl record update 1234 --shared # this view's version, everywhere
|
||||||
|
jarl zone unlink adbraun.lol --view vps # records stay, all local
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linking never changes what named answers.** A name and type with the
|
||||||
|
same records in every view becomes shared; one that differs stays local to
|
||||||
|
each view (Jarl lists them). Share the right version afterwards. Unlinking
|
||||||
|
keeps every record, all local; a link left with one zone is dissolved, as
|
||||||
|
when the other zone is deleted.
|
||||||
|
|
||||||
|
**Permissions** are checked in every view a change reaches. A token with
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
there. With `--replace`, shared records missing from the file are deleted
|
||||||
|
in every linked view.
|
||||||
|
|
||||||
|
**ACME challenges** are written once per link, and so reach every linked
|
||||||
|
view; `jarl acme`, lego's httpreq and certificates Jarl obtains all do
|
||||||
|
this.
|
||||||
|
|
||||||
|
**Compared with BIND's `in-view`**, where one view serves another's zone
|
||||||
|
as it is: linked zones can differ per view, keep their own options, and
|
||||||
|
don't depend on the view order (`in-view` can only use a view defined
|
||||||
|
before it).
|
||||||
|
|
||||||
## Living with views
|
## Living with views
|
||||||
|
|
||||||
* Commands that name a zone that exists in several views need `--view`
|
* Commands that name a zone that exists in several views need `--view`
|
||||||
@@ -162,7 +236,10 @@ options (`allow-transfer { key jarl-view-external; }`).
|
|||||||
guess.
|
guess.
|
||||||
* `jarl acme` writes challenge records into **every** view that has the
|
* `jarl acme` writes challenge records into **every** view that has the
|
||||||
zone, so Let's Encrypt (external) and internal clients see the same
|
zone, so Let's Encrypt (external) and internal clients see the same
|
||||||
thing. Use `--view` to limit it.
|
thing (once per link, for linked zones). Use `--view` to limit it.
|
||||||
|
* `--view all` (or a list) on record commands changes each linked zone
|
||||||
|
once: the change reaches the others through the link. With `--local` it
|
||||||
|
goes to each view named.
|
||||||
* `jarl view order internal external` changes the match order, and
|
* `jarl view order internal external` changes the match order, and
|
||||||
`jarl view list` shows it.
|
`jarl view list` shows it.
|
||||||
* Deleting a view is refused while it still has zones, unless you pass
|
* Deleting a view is refused while it still has zones, unless you pass
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.27.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1
|
github.com/aymanbagabas/go-udiff v0.4.1
|
||||||
|
github.com/go-acme/lego/v4 v4.35.2
|
||||||
github.com/miekg/dns v1.1.73
|
github.com/miekg/dns v1.1.73
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
go.yaml.in/yaml/v3 v3.0.5
|
go.yaml.in/yaml/v3 v3.0.5
|
||||||
@@ -15,7 +16,9 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||||
|
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -24,6 +32,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
|||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
|||||||
+17
-1
@@ -21,6 +21,10 @@ const Comment = "ACME DNS-01 challenge"
|
|||||||
type Zone struct {
|
type Zone struct {
|
||||||
Name string // as Jarl stores it, e.g. "adbraun.lol."
|
Name string // as Jarl stores it, e.g. "adbraun.lol."
|
||||||
View string // "" when views are not used
|
View string // "" when views are not used
|
||||||
|
// Link is the zone's link group (0: not linked). A record written
|
||||||
|
// into one zone of a link reaches the others (docs/views.md#linked-zones),
|
||||||
|
// so only one of them is written to.
|
||||||
|
Link int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zones returns the zones that hold name: the longest matching zone, in
|
// Zones returns the zones that hold name: the longest matching zone, in
|
||||||
@@ -46,7 +50,19 @@ func Zones(name string, zones []Zone, view string) []Zone {
|
|||||||
out = append(out, z)
|
out = append(out, z)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
// One zone per link: Jarl writes the shared record into the others.
|
||||||
|
var dedup []Zone
|
||||||
|
links := map[int64]bool{}
|
||||||
|
for _, z := range out {
|
||||||
|
if z.Link != 0 {
|
||||||
|
if links[z.Link] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links[z.Link] = true
|
||||||
|
}
|
||||||
|
dedup = append(dedup, z)
|
||||||
|
}
|
||||||
|
return dedup
|
||||||
}
|
}
|
||||||
|
|
||||||
// Requests returns the changes that set (or, with set false, remove) the
|
// Requests returns the changes that set (or, with set false, remove) the
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ type Zone struct {
|
|||||||
// Registrar names the registrar Jarl can manage the domain at
|
// Registrar names the registrar Jarl can manage the domain at
|
||||||
// ("porkbun"), when one is configured for it.
|
// ("porkbun"), when one is configured for it.
|
||||||
Registrar string `json:"registrar,omitempty"`
|
Registrar string `json:"registrar,omitempty"`
|
||||||
|
// LinkedWith are the views of the zones this one is linked with
|
||||||
|
// (docs/views.md#linked-zones).
|
||||||
|
LinkedWith []string `json:"linked_with,omitempty"`
|
||||||
|
// Links are the zones this one is linked with, with their views.
|
||||||
|
Links []ZoneLink `json:"links,omitempty"`
|
||||||
// Pending is set when listing with a changeset: add, change or delete.
|
// Pending is set when listing with a changeset: add, change or delete.
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -40,6 +45,8 @@ type Record struct {
|
|||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
RelativeName string `json:"relative_name"`
|
RelativeName string `json:"relative_name"`
|
||||||
EffectiveTTL uint32 `json:"effective_ttl"`
|
EffectiveTTL uint32 `json:"effective_ttl"`
|
||||||
|
// Linked is "shared" or "local" for a record of a linked zone ("" otherwise).
|
||||||
|
Linked string `json:"linked,omitempty"`
|
||||||
// Text is the decoded value of TXT and SPF records.
|
// Text is the decoded value of TXT and SPF records.
|
||||||
Text *string `json:"text,omitempty"`
|
Text *string `json:"text,omitempty"`
|
||||||
// Pending is set when listing with a changeset: add, change, delete,
|
// Pending is set when listing with a changeset: add, change, delete,
|
||||||
@@ -208,6 +215,9 @@ type BatchRequest struct {
|
|||||||
Ops []ChangeRequest `json:"ops"`
|
Ops []ChangeRequest `json:"ops"`
|
||||||
DryRun bool `json:"dry_run,omitempty"`
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
Force bool `json:"force,omitempty"`
|
Force bool `json:"force,omitempty"`
|
||||||
|
// AcceptRisk confirms a change that opens the server up
|
||||||
|
// (docs/exposure.md).
|
||||||
|
AcceptRisk bool `json:"accept_risk,omitempty"`
|
||||||
// Apply (for POST /changesets) applies the new changeset at once.
|
// Apply (for POST /changesets) applies the new changeset at once.
|
||||||
Apply bool `json:"apply,omitempty"`
|
Apply bool `json:"apply,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -253,6 +263,29 @@ type StatusResponse struct {
|
|||||||
// BlockingPausedIndefinitely while it is paused until resumed.
|
// BlockingPausedIndefinitely while it is paused until resumed.
|
||||||
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
||||||
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
||||||
|
// Certificates sums up the TLS certificates Jarl manages
|
||||||
|
// (administrators only, when there are any).
|
||||||
|
Certificates *CertSummary `json:"certificates,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertSummary is what the dashboard reports about certificates.
|
||||||
|
type CertSummary struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
// Problems are the certificates that aren't fine: failing, expiring,
|
||||||
|
// expired (not ones still being obtained for the first time).
|
||||||
|
Problems []CertProblem `json:"problems"`
|
||||||
|
// Next is the certificate that expires first, and when.
|
||||||
|
Next string `json:"next,omitempty"`
|
||||||
|
NextAfter *time.Time `json:"next_after,omitempty"`
|
||||||
|
// Serving is the certificate jarld serves HTTPS with ("" for none or tls.cert_file).
|
||||||
|
Serving string `json:"serving,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertProblem is a certificate that needs attention.
|
||||||
|
type CertProblem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
|
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
|
||||||
@@ -536,3 +569,9 @@ type BlockRule struct {
|
|||||||
AutoViews bool `json:"auto_views,omitempty"`
|
AutoViews bool `json:"auto_views,omitempty"`
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZoneLink is a zone a zone is linked with.
|
||||||
|
type ZoneLink struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
View string `json:"view"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-acme/lego/v4/certcrypto"
|
||||||
|
"github.com/go-acme/lego/v4/certificate"
|
||||||
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||||
|
"github.com/go-acme/lego/v4/lego"
|
||||||
|
"github.com/go-acme/lego/v4/registration"
|
||||||
|
|
||||||
|
"jarl/internal/acme"
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account is the ACME account certificates are obtained with. Its key is
|
||||||
|
// kept in Jarl's database (and so in backups), like TSIG secrets.
|
||||||
|
type Account struct {
|
||||||
|
Directory string `json:"directory"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
URI string `json:"uri,omitempty"` // the account at the CA
|
||||||
|
RegisteredAt time.Time `json:"registered_at"`
|
||||||
|
KeyPEM string `json:"key_pem,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaAccount = "acme.account"
|
||||||
|
|
||||||
|
// account returns the stored account (nil when there is none).
|
||||||
|
func (m *Manager) account(ctx context.Context) *Account {
|
||||||
|
raw, err := m.store.GetMeta(ctx, metaAccount)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var a Account
|
||||||
|
if json.Unmarshal([]byte(raw), &a) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
type legoUser struct {
|
||||||
|
email string
|
||||||
|
reg *registration.Resource
|
||||||
|
key crypto.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *legoUser) GetEmail() string { return u.email }
|
||||||
|
func (u *legoUser) GetRegistration() *registration.Resource { return u.reg }
|
||||||
|
func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
||||||
|
|
||||||
|
// ErrTermsNotAgreed means acme.account.agree_tos isn't set.
|
||||||
|
var ErrTermsNotAgreed = errors.New("acme.account.agree_tos isn't set")
|
||||||
|
|
||||||
|
func (m *Manager) httpClient() (*http.Client, error) {
|
||||||
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||||
|
if m.cfg.CABundle != "" {
|
||||||
|
pem, err := os.ReadFile(m.cfg.CABundle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("acme.ca_bundle: %w", err)
|
||||||
|
}
|
||||||
|
pool, err := x509.SystemCertPool()
|
||||||
|
if err != nil || pool == nil {
|
||||||
|
pool = x509.NewCertPool()
|
||||||
|
}
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
return nil, fmt.Errorf("acme.ca_bundle: no certificates in %s", m.cfg.CABundle)
|
||||||
|
}
|
||||||
|
tr.TLSClientConfig.RootCAs = pool
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// client returns a lego client for the account (registering it when
|
||||||
|
// needed) that makes keys of keyType.
|
||||||
|
func (m *Manager) client(ctx context.Context, keyType string) (*lego.Client, error) {
|
||||||
|
dir := m.cfg.Directory
|
||||||
|
hc, err := m.httpClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := m.account(ctx)
|
||||||
|
user := &legoUser{email: m.cfg.Email}
|
||||||
|
if a != nil && a.Directory == dir && a.KeyPEM != "" {
|
||||||
|
if user.key, err = parseKey(a.KeyPEM); err != nil {
|
||||||
|
return nil, fmt.Errorf("the stored ACME account key: %w", err)
|
||||||
|
}
|
||||||
|
if a.URI != "" {
|
||||||
|
user.reg = ®istration.Resource{URI: a.URI}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user.key = k
|
||||||
|
}
|
||||||
|
cfg := lego.NewConfig(user)
|
||||||
|
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "jarl/"+m.cfg.Version
|
||||||
|
cfg.Certificate.KeyType = legoKeyType(keyType)
|
||||||
|
cfg.Certificate.Timeout = 90 * time.Second
|
||||||
|
c, err := lego.NewClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ACME server %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
if user.reg != nil && (a.Email == m.cfg.Email) {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if user.reg != nil {
|
||||||
|
// Only the email changed: update the account.
|
||||||
|
if reg, err := c.Registration.UpdateRegistration(registration.RegisterOptions{TermsOfServiceAgreed: true}); err == nil {
|
||||||
|
user.reg = reg
|
||||||
|
return c, m.saveAccount(ctx, user, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !m.cfg.AgreeTOS {
|
||||||
|
tos := c.GetToSURL()
|
||||||
|
if tos == "" {
|
||||||
|
tos = "on the CA's website"
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: Jarl registers an account with %s only after you accept its terms of service (%s): set acme.account.agree_tos: true in jarl.yaml and restart jarld", ErrTermsNotAgreed, dir, tos)
|
||||||
|
}
|
||||||
|
var reg *registration.Resource
|
||||||
|
if m.cfg.EABKeyID != "" {
|
||||||
|
reg, err = c.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{TermsOfServiceAgreed: true, Kid: m.cfg.EABKeyID, HmacEncoded: m.cfg.EABHMAC})
|
||||||
|
} else {
|
||||||
|
if c.GetExternalAccountRequired() {
|
||||||
|
return nil, fmt.Errorf("%s needs External Account Binding: set acme.account.eab_kid and eab_hmac in jarl.yaml (from the CA's dashboard)", dir)
|
||||||
|
}
|
||||||
|
reg, err = c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("registering the ACME account: %w", err)
|
||||||
|
}
|
||||||
|
user.reg = reg
|
||||||
|
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
|
||||||
|
return c, m.saveAccount(ctx, user, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) saveAccount(ctx context.Context, u *legoUser, dir string) error {
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(u.key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a := Account{Directory: dir, Email: u.email, URI: u.reg.URI, RegisteredAt: time.Now().UTC(),
|
||||||
|
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))}
|
||||||
|
b, _ := json.Marshal(a)
|
||||||
|
return m.store.SetMeta(ctx, metaAccount, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKey(s string) (crypto.PrivateKey, error) {
|
||||||
|
b, _ := pem.Decode([]byte(s))
|
||||||
|
if b == nil {
|
||||||
|
return nil, errors.New("not PEM")
|
||||||
|
}
|
||||||
|
return x509.ParsePKCS8PrivateKey(b.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func legoKeyType(t string) certcrypto.KeyType {
|
||||||
|
switch t {
|
||||||
|
case "ec384":
|
||||||
|
return certcrypto.EC384
|
||||||
|
case "rsa2048":
|
||||||
|
return certcrypto.RSA2048
|
||||||
|
case "rsa3072":
|
||||||
|
return certcrypto.RSA3072
|
||||||
|
case "rsa4096":
|
||||||
|
return certcrypto.RSA4096
|
||||||
|
}
|
||||||
|
return certcrypto.EC256
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyTypes are the key types a certificate Jarl obtains can have.
|
||||||
|
var KeyTypes = []string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}
|
||||||
|
|
||||||
|
// provider answers DNS-01 challenges for one issue: in a zone Jarl serves,
|
||||||
|
// or at Porkbun, as route decides for each name.
|
||||||
|
type provider struct {
|
||||||
|
m *Manager
|
||||||
|
ctx context.Context
|
||||||
|
cert *model.Certificate
|
||||||
|
mu sync.Mutex
|
||||||
|
cleanups map[string]func() error
|
||||||
|
routes []Route
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *provider) Present(domain, token, keyAuth string) error {
|
||||||
|
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||||
|
st, err := p.m.store.LoadState(p.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := p.m.route(p.ctx, st, p.cert.Challenge, domain, info.EffectiveFQDN)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.routes = append(p.routes, r)
|
||||||
|
p.mu.Unlock()
|
||||||
|
if r.Error != "" {
|
||||||
|
return errors.New(r.Error)
|
||||||
|
}
|
||||||
|
key := info.EffectiveFQDN + " " + info.Value
|
||||||
|
var cleanup func() error
|
||||||
|
switch r.Route {
|
||||||
|
case model.ChallengeJarl:
|
||||||
|
if err := p.m.publish(p.ctx, p.cert, info.EffectiveFQDN, info.Value, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cleanup = func() error {
|
||||||
|
return p.m.publish(context.WithoutCancel(p.ctx), p.cert, info.EffectiveFQDN, info.Value, false)
|
||||||
|
}
|
||||||
|
case model.ChallengePorkbun:
|
||||||
|
zone := strings.TrimSuffix(r.Zone, ".")
|
||||||
|
id, err := p.m.cfg.Porkbun.CreateTXT(p.ctx, zone, strings.TrimSuffix(info.EffectiveFQDN, "."), info.Value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("publishing the challenge for %s at Porkbun: %w", domain, err)
|
||||||
|
}
|
||||||
|
cleanup = func() error { return p.m.cfg.Porkbun.DeleteRecord(context.WithoutCancel(p.ctx), zone, id) }
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.cleanups[key] = cleanup
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *provider) CleanUp(domain, token, keyAuth string) error {
|
||||||
|
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||||
|
key := info.EffectiveFQDN + " " + info.Value
|
||||||
|
p.mu.Lock()
|
||||||
|
f := p.cleanups[key]
|
||||||
|
delete(p.cleanups, key)
|
||||||
|
p.mu.Unlock()
|
||||||
|
if f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := f(); err != nil {
|
||||||
|
p.m.log.Warn("removing an ACME challenge record failed", "certificate", p.cert.Name, "record", info.EffectiveFQDN, "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout is how long lego waits for the record to reach every name
|
||||||
|
// server of the zone: Porkbun's take a few minutes.
|
||||||
|
func (p *provider) Timeout() (timeout, interval time.Duration) {
|
||||||
|
if p.m.cfg.PropagationTimeout > 0 {
|
||||||
|
return p.m.cfg.PropagationTimeout, 2 * time.Second
|
||||||
|
}
|
||||||
|
return 10 * time.Minute, 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish writes (or removes) a challenge TXT record in the Jarl zone that
|
||||||
|
// holds name, in every view that has it, and returns once named serves it.
|
||||||
|
func (m *Manager) publish(ctx context.Context, c *model.Certificate, name, value string, set bool) error {
|
||||||
|
st, err := m.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
zones := acme.Zones(name, primaryZones(st), "")
|
||||||
|
if len(zones) == 0 {
|
||||||
|
return fmt.Errorf("no primary zone in Jarl contains %s", strings.TrimSuffix(name, "."))
|
||||||
|
}
|
||||||
|
var reqs []engine.Request
|
||||||
|
for _, r := range acme.Requests(name, zones, []string{value}, set, acme.DefaultTTL) {
|
||||||
|
reqs = append(reqs, engine.Request{Op: r.Op, ID: r.ID, BaseVersion: r.BaseVersion, Data: r.Data})
|
||||||
|
}
|
||||||
|
desc := acme.Description(name, set) + " (certificate " + c.Name + ")"
|
||||||
|
if _, err := m.eng.Submit(ctx, reqs, auth.System, "acme", desc, engine.ApplyOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("writing the challenge record %s: %w", strings.TrimSuffix(name, "."), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtain gets a new certificate for c from the CA. old is the certificate
|
||||||
|
// it replaces (nil for the first), so the CA can link them (ARI).
|
||||||
|
func (m *Manager) obtain(ctx context.Context, c *model.Certificate, old *x509.Certificate) (*certificate.Resource, []Route, error) {
|
||||||
|
m.legoMu.Lock()
|
||||||
|
defer m.legoMu.Unlock()
|
||||||
|
client, err := m.client(ctx, c.KeyType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
p := &provider{m: m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
|
||||||
|
opts := []dns01.ChallengeOption{dns01.AddRecursiveNameservers(m.cfg.Resolvers), dns01.AddDNSTimeout(10 * time.Second)}
|
||||||
|
if m.cfg.SkipPropagationCheck {
|
||||||
|
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
|
||||||
|
}
|
||||||
|
if err := client.Challenge.SetDNS01Provider(p, opts...); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
req := certificate.ObtainRequest{Domains: c.Domains, Bundle: true}
|
||||||
|
if old != nil {
|
||||||
|
if id, err := certificate.MakeARICertID(old); err == nil {
|
||||||
|
req.ReplacesCertID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := client.Certificate.Obtain(req)
|
||||||
|
if err != nil && req.ReplacesCertID != "" && len(p.routes) == 0 && ctx.Err() == nil {
|
||||||
|
// The CA refused the order itself, before any challenge: it may
|
||||||
|
// not link the new certificate to the old one (another CA, one
|
||||||
|
// already replaced, or its own bug). Ask for a plain new one.
|
||||||
|
m.log.Info("the CA refused to link the renewal to the old certificate; asking for a new one", "certificate", c.Name, "err", cleanLegoError(err))
|
||||||
|
req.ReplacesCertID = ""
|
||||||
|
res, err = client.Certificate.Obtain(req)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.routes, cleanLegoError(err)
|
||||||
|
}
|
||||||
|
return res, p.routes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewalInfo asks the CA when to renew (ARI, RFC 9773). nil, nil when
|
||||||
|
// it doesn't say.
|
||||||
|
func (m *Manager) renewalInfo(ctx context.Context, c *model.Certificate, leaf *x509.Certificate) (*time.Time, error) {
|
||||||
|
m.legoMu.Lock()
|
||||||
|
defer m.legoMu.Unlock()
|
||||||
|
client, err := m.client(ctx, c.KeyType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ri, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{Cert: leaf})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "does not advertise a renewal info endpoint") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A time within the CA's window (now if it has started); never later
|
||||||
|
// than two thirds into the lifetime, in case the window is odd.
|
||||||
|
at := ri.ShouldRenewAt(time.Now(), 10*365*24*time.Hour)
|
||||||
|
if at == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
t := at.UTC().Truncate(time.Second)
|
||||||
|
if limit := twoThirds(leaf.NotBefore, leaf.NotAfter); t.After(limit) {
|
||||||
|
t = limit
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func twoThirds(nb, na time.Time) time.Time {
|
||||||
|
return nb.Add(na.Sub(nb) * 2 / 3).UTC().Truncate(time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanLegoError shortens lego's errors, which wrap each name's problem in
|
||||||
|
// several prefixes and the whole ACME problem document:
|
||||||
|
//
|
||||||
|
// error: one or more domains had a problem:
|
||||||
|
// [a.example.com] [a.example.com] acme: error presenting token: MSG
|
||||||
|
// [b.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG
|
||||||
|
//
|
||||||
|
// becomes "a.example.com: MSG; b.example.com: the CA refused (unauthorized): MSG".
|
||||||
|
func cleanLegoError(err error) error {
|
||||||
|
msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1))
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(msg, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domain := ""
|
||||||
|
for strings.HasPrefix(line, "[") {
|
||||||
|
end := strings.Index(line, "] ")
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
domain = line[1:end]
|
||||||
|
}
|
||||||
|
line = line[end+2:]
|
||||||
|
}
|
||||||
|
for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} {
|
||||||
|
line = strings.TrimPrefix(line, p)
|
||||||
|
}
|
||||||
|
if m := acmeProblemRe.FindStringSubmatch(line); m != nil {
|
||||||
|
line = "the CA refused (" + m[2] + "): " + m[3]
|
||||||
|
}
|
||||||
|
if domain != "" {
|
||||||
|
line = domain + ": " + line
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.New(strings.Join(out, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// acmeProblemRe matches "403 :: urn:ietf:params:acme:error:unauthorized :: detail".
|
||||||
|
var acmeProblemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/porkbun"
|
||||||
|
"jarl/internal/render"
|
||||||
|
"jarl/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeDNS answers like the internet would: zones with their public name
|
||||||
|
// servers, CNAME records, and TXT records (Porkbun's, from the fake API).
|
||||||
|
type fakeDNS struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
zones map[string][]string // zone -> NS
|
||||||
|
cnames map[string]string
|
||||||
|
txt func(name string) []string
|
||||||
|
addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeDNS(t *testing.T) *fakeDNS {
|
||||||
|
f := &fakeDNS{zones: map[string][]string{}, cnames: map[string]string{}, txt: func(string) []string { return nil }}
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &dns.Server{PacketConn: pc, Handler: f}
|
||||||
|
go srv.ActivateAndServe()
|
||||||
|
t.Cleanup(func() { srv.Shutdown() })
|
||||||
|
f.addr = pc.LocalAddr().String()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDNS) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(r)
|
||||||
|
q := r.Question[0]
|
||||||
|
name := strings.ToLower(q.Name)
|
||||||
|
zone := ""
|
||||||
|
for z := range f.zones {
|
||||||
|
if dns.IsSubDomain(z, name) && len(z) > len(zone) {
|
||||||
|
zone = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zone == "" {
|
||||||
|
m.Rcode = dns.RcodeNameError
|
||||||
|
w.WriteMsg(m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
soa := &dns.SOA{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 60}, Ns: f.zones[zone][0], Mbox: "h." + zone, Serial: 1, Refresh: 1, Retry: 1, Expire: 1, Minttl: 60}
|
||||||
|
switch {
|
||||||
|
case q.Qtype == dns.TypeSOA && name == zone:
|
||||||
|
m.Answer = append(m.Answer, soa)
|
||||||
|
case q.Qtype == dns.TypeNS && name == zone:
|
||||||
|
for _, ns := range f.zones[zone] {
|
||||||
|
m.Answer = append(m.Answer, &dns.NS{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 60}, Ns: ns})
|
||||||
|
}
|
||||||
|
case f.cnames[name] != "":
|
||||||
|
m.Answer = append(m.Answer, &dns.CNAME{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: f.cnames[name]})
|
||||||
|
case q.Qtype == dns.TypeTXT && len(f.txt(name)) > 0:
|
||||||
|
for _, v := range f.txt(name) {
|
||||||
|
m.Answer = append(m.Answer, &dns.TXT{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, Txt: []string{v}})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
m.Ns = append(m.Ns, soa)
|
||||||
|
}
|
||||||
|
w.WriteMsg(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
t *testing.T
|
||||||
|
m *Manager
|
||||||
|
st *store.Store
|
||||||
|
eng *engine.Engine
|
||||||
|
dns *fakeDNS
|
||||||
|
pb *porkbun.Fake
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEnv sets up Jarl with zone example.com (name server
|
||||||
|
// ns1.example.com, which the internet also uses), a fake internet and a
|
||||||
|
// fake Porkbun account with pb.test.
|
||||||
|
func newEnv(t *testing.T, withPorkbun bool) *env {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
eng := engine.New(st, nil, engine.Config{
|
||||||
|
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||||
|
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
||||||
|
}, quiet)
|
||||||
|
e := &env{t: t, st: st, eng: eng, dns: newFakeDNS(t), dir: dir}
|
||||||
|
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
|
||||||
|
engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com")}))
|
||||||
|
e.dns.mu.Lock()
|
||||||
|
e.dns.zones["example.com."] = []string{"ns1.example.net."}
|
||||||
|
e.dns.zones["pb.test."] = []string{"curitiba.ns.porkbun.com.", "fortaleza.ns.porkbun.com."}
|
||||||
|
e.dns.zones["other.test."] = []string{"a.ns.cloudflare.com."}
|
||||||
|
e.dns.mu.Unlock()
|
||||||
|
cfg := Config{Resolvers: []string{e.dns.addr}, Dir: filepath.Join(dir, "tls"), Directory: "https://acme.invalid/dir"}
|
||||||
|
if withPorkbun {
|
||||||
|
e.pb = porkbun.NewFake("pk1_test", "sk1_test", "pb.test")
|
||||||
|
ts := httptest.NewServer(e.pb)
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
cfg.Porkbun = &porkbun.Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_test", Secret: "sk1_test"}
|
||||||
|
e.dns.mu.Lock()
|
||||||
|
e.dns.txt = func(name string) []string { return e.pb.TXT(name) }
|
||||||
|
e.dns.mu.Unlock()
|
||||||
|
}
|
||||||
|
e.m = New(cfg, st, eng, quiet)
|
||||||
|
// lego's own lookups (CNAME following) use the fake internet too.
|
||||||
|
dns01.AddRecursiveNameservers([]string{e.dns.addr})(nil)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) submit(reqs ...engine.Request) {
|
||||||
|
e.t.Helper()
|
||||||
|
if _, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{}); err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) state() *model.State {
|
||||||
|
st, err := e.st.LoadState(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoute(t *testing.T) {
|
||||||
|
e := newEnv(t, true)
|
||||||
|
ctx := context.Background()
|
||||||
|
e.dns.mu.Lock()
|
||||||
|
e.dns.cnames["_acme-challenge.www.other.test."] = "www-other.acme.example.com."
|
||||||
|
e.dns.mu.Unlock()
|
||||||
|
cases := []struct {
|
||||||
|
challenge, domain string
|
||||||
|
route string // "" for an error
|
||||||
|
target string
|
||||||
|
errHas string
|
||||||
|
}{
|
||||||
|
{"auto", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
|
||||||
|
{"auto", "*.example.com", "jarl", "_acme-challenge.example.com.", ""},
|
||||||
|
{"auto", "ns1.pb.test", "porkbun", "_acme-challenge.ns1.pb.test.", ""},
|
||||||
|
{"auto", "www.other.test", "jarl", "www-other.acme.example.com.", ""},
|
||||||
|
{"auto", "mail.other.test", "", "", "Point _acme-challenge.mail.other.test with a CNAME"},
|
||||||
|
{"porkbun", "www.example.com", "", "", "not Porkbun"},
|
||||||
|
{"jarl", "ns1.pb.test", "", "", "no primary zone in Jarl is pb.test"},
|
||||||
|
{"jarl", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
r := e.m.route(ctx, e.state(), c.challenge, c.domain, "")
|
||||||
|
if r.Route != c.route || c.target != "" && r.Target != c.target || c.errHas != "" && !strings.Contains(r.Error, c.errHas) {
|
||||||
|
t.Errorf("%s %s: route %q target %q error %q", c.challenge, c.domain, r.Route, r.Target, r.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The move from Porkbun to Jarl: while the internet still asks
|
||||||
|
// Porkbun, auto goes there even though Jarl has the zone; once the
|
||||||
|
// name servers change, it comes to Jarl.
|
||||||
|
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("pb.test")}))
|
||||||
|
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "porkbun" {
|
||||||
|
t.Errorf("before the move: %+v", r)
|
||||||
|
}
|
||||||
|
if r := e.m.route(ctx, e.state(), "jarl", "ns1.pb.test", ""); !strings.Contains(r.Error, "not by the name servers in Jarl's zone (ns1.example.net)") {
|
||||||
|
t.Errorf("jarl before the move: %+v", r)
|
||||||
|
}
|
||||||
|
e.dns.mu.Lock()
|
||||||
|
e.dns.zones["pb.test."] = []string{"ns1.example.net."}
|
||||||
|
e.dns.mu.Unlock()
|
||||||
|
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "jarl" {
|
||||||
|
t.Errorf("after the move: %+v", r)
|
||||||
|
}
|
||||||
|
if r := e.m.route(ctx, e.state(), "porkbun", "ns1.pb.test", ""); !strings.Contains(r.Error, "not Porkbun") {
|
||||||
|
t.Errorf("porkbun after the move: %+v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a Porkbun key, a Porkbun domain says what is missing.
|
||||||
|
e2 := newEnv(t, false)
|
||||||
|
if r := e2.m.route(ctx, e2.state(), "auto", "ns1.pb.test", ""); !strings.Contains(r.Error, "no porkbun.api_key") {
|
||||||
|
t.Errorf("without a key: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider(t *testing.T) {
|
||||||
|
e := newEnv(t, true)
|
||||||
|
ctx := context.Background()
|
||||||
|
c := &model.Certificate{Name: "t", Challenge: "auto"}
|
||||||
|
p := &provider{m: e.m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
|
||||||
|
|
||||||
|
// Porkbun: the record goes in through the API and is removed again.
|
||||||
|
info := dns01.GetChallengeInfo("ns1.pb.test", "key-auth-1")
|
||||||
|
if err := p.Present("ns1.pb.test", "tok", "key-auth-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 1 || got[0] != info.Value {
|
||||||
|
t.Fatalf("Porkbun TXT = %v, want %s", got, info.Value)
|
||||||
|
}
|
||||||
|
if err := p.CleanUp("ns1.pb.test", "tok", "key-auth-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 0 {
|
||||||
|
t.Fatalf("Porkbun TXT after cleanup = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jarl: the record goes into the zone, in a changeset by the system.
|
||||||
|
txt := func() []string {
|
||||||
|
var out []string
|
||||||
|
st := e.state()
|
||||||
|
for _, r := range st.Records {
|
||||||
|
if r.Type == "TXT" && r.Name == "_acme-challenge.www.example.com." {
|
||||||
|
out = append(out, r.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
info = dns01.GetChallengeInfo("www.example.com", "key-auth-2")
|
||||||
|
if err := p.Present("www.example.com", "tok", "key-auth-2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := txt(); len(got) != 1 || !strings.Contains(got[0], info.Value) {
|
||||||
|
t.Fatalf("Jarl TXT = %v, want %s", got, info.Value)
|
||||||
|
}
|
||||||
|
if err := p.CleanUp("www.example.com", "tok", "key-auth-2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := txt(); len(got) != 0 {
|
||||||
|
t.Fatalf("Jarl TXT after cleanup = %v", got)
|
||||||
|
}
|
||||||
|
if len(p.routes) != 2 || p.routes[0].Route != "porkbun" || p.routes[1].Route != "jarl" {
|
||||||
|
t.Errorf("routes = %+v", p.routes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A name nobody can answer for fails before anything is published.
|
||||||
|
if err := p.Present("mail.other.test", "tok", "k"); err == nil || !strings.Contains(err.Error(), "CNAME") {
|
||||||
|
t.Errorf("other.test: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeCert returns a certificate for names (signed by a CA unless
|
||||||
|
// selfSigned) and its key, as PEM; withCA appends the CA certificate.
|
||||||
|
func makeCert(t *testing.T, names []string, notBefore, notAfter time.Time, withCA bool) (certPEM, keyPEM []byte) {
|
||||||
|
t.Helper()
|
||||||
|
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA", Organization: []string{"Jarl"}},
|
||||||
|
NotBefore: notBefore, NotAfter: notAfter.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
|
||||||
|
caDER, _ := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey)
|
||||||
|
caCert, _ := x509.ParseCertificate(caDER)
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
leaf := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||||
|
NotBefore: notBefore, NotAfter: notAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, leaf, caCert, &key.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
if withCA {
|
||||||
|
certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...)
|
||||||
|
}
|
||||||
|
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
return certPEM, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspect(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
cert, key := makeCert(t, []string{"ns1.example.com", "*.example.com"}, now, now.Add(90*24*time.Hour), true)
|
||||||
|
in, pair, err := Inspect(cert, key)
|
||||||
|
if err != nil || pair == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(in.Names, " ") != "ns1.example.com *.example.com" || in.Issuer != "Jarl Test CA" || in.KeyType != "ec256" || len(in.Warnings) != 0 {
|
||||||
|
t.Errorf("info = %+v", in)
|
||||||
|
}
|
||||||
|
leafOnly, _ := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
|
||||||
|
_, otherKey := makeCert(t, []string{"b.example.com"}, now, now.Add(time.Hour), false)
|
||||||
|
if _, _, err := Inspect(cert, otherKey); err == nil || !strings.Contains(err.Error(), "doesn't belong") {
|
||||||
|
t.Errorf("mismatched key: %v", err)
|
||||||
|
}
|
||||||
|
leafCert, leafKey := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
|
||||||
|
if in, _, err := Inspect(leafCert, leafKey); err != nil || len(in.Warnings) != 1 || !strings.Contains(in.Warnings[0], "full chain") {
|
||||||
|
t.Errorf("leaf only: %+v %v", in, err)
|
||||||
|
}
|
||||||
|
_ = leafOnly
|
||||||
|
if !Covers([]string{"*.example.com"}, []string{"www.example.com"}) || Covers([]string{"*.example.com"}, []string{"a.b.example.com"}) || Covers([]string{"example.com"}, []string{"www.example.com"}) {
|
||||||
|
t.Error("Covers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDueAndStatus(t *testing.T) {
|
||||||
|
m := &Manager{now: time.Now}
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
nb, na := now.Add(-80*24*time.Hour), now.Add(10*24*time.Hour)
|
||||||
|
c := &model.Certificate{Source: model.CertSourceACME, CreatedAt: now.Add(-time.Hour)}
|
||||||
|
if got := m.Due(c); !got.Equal(c.CreatedAt) {
|
||||||
|
t.Errorf("never obtained: due %v", got)
|
||||||
|
}
|
||||||
|
if s := m.StatusOf(c); s.State != "pending" {
|
||||||
|
t.Errorf("pending: %+v", s)
|
||||||
|
}
|
||||||
|
c.CertPEM, c.NotBefore, c.NotAfter = "x", &nb, &na
|
||||||
|
if got, want := m.Due(c), twoThirds(nb, na); !got.Equal(want) {
|
||||||
|
t.Errorf("due %v, want two thirds %v", got, want)
|
||||||
|
}
|
||||||
|
ari := now.Add(time.Hour)
|
||||||
|
c.RenewAt = &ari
|
||||||
|
if got := m.Due(c); !got.Equal(ari) {
|
||||||
|
t.Errorf("due %v, want the CA's %v", got, ari)
|
||||||
|
}
|
||||||
|
// 10 days left of 90 is less than a sixth: expiring.
|
||||||
|
if s := m.StatusOf(c); s.State != "expiring" {
|
||||||
|
t.Errorf("status %+v", s)
|
||||||
|
}
|
||||||
|
// After three failures it waits four hours from the last attempt.
|
||||||
|
last := now.Add(-time.Hour)
|
||||||
|
c.Failures, c.LastAttempt, c.LastError = 3, &last, "boom"
|
||||||
|
if got := m.Due(c); !got.Equal(last.Add(4 * time.Hour)) {
|
||||||
|
t.Errorf("backoff: due %v", got)
|
||||||
|
}
|
||||||
|
if s := m.StatusOf(c); s.State != "failing" || !strings.Contains(s.Message, "boom") {
|
||||||
|
t.Errorf("failing: %+v", s)
|
||||||
|
}
|
||||||
|
c.Failures = 30
|
||||||
|
if got := m.Due(c); !got.Equal(last.Add(24 * time.Hour)) {
|
||||||
|
t.Errorf("backoff is at most a day: due %v", got)
|
||||||
|
}
|
||||||
|
old := now.Add(-time.Hour)
|
||||||
|
c.NotAfter = &old
|
||||||
|
if s := m.StatusOf(c); s.State != "expired" {
|
||||||
|
t.Errorf("expired: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInput(t *testing.T) {
|
||||||
|
e := newEnv(t, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||||
|
bad := []Input{
|
||||||
|
{Name: model.Ptr("Bad Name"), Domains: &[]string{"a.example.com"}},
|
||||||
|
{Name: model.Ptr("x"), Domains: &[]string{"localhost"}},
|
||||||
|
{Name: model.Ptr("x"), Domains: &[]string{"a.*.example.com"}},
|
||||||
|
{Name: model.Ptr("x"), Domains: &[]string{}},
|
||||||
|
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"mail"}},
|
||||||
|
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Challenge: model.Ptr("http")},
|
||||||
|
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, KeyType: model.Ptr("rsa1024")},
|
||||||
|
{Name: model.Ptr("x"), Source: model.Ptr("file"), CertFile: model.Ptr("rel.pem"), KeyFile: model.Ptr("/k.pem")},
|
||||||
|
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, CertFile: model.Ptr("/c.pem")},
|
||||||
|
}
|
||||||
|
for _, in := range bad {
|
||||||
|
if _, err := e.m.Create(ctx, in, admin); err == nil {
|
||||||
|
t.Errorf("accepted %+v", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"WWW.Example.com.", "www.example.com", "*.example.com"}, Uses: &[]string{"web", "dns", "web"}}, admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(c.Domains, " ") != "www.example.com *.example.com" || strings.Join(c.Uses, ",") != "dns,web" || c.Challenge != "auto" || c.KeyType != "ec256" {
|
||||||
|
t.Errorf("normalized: %+v", c)
|
||||||
|
}
|
||||||
|
// Only one certificate serves jarld's HTTPS.
|
||||||
|
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web2"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"web"}}, admin); err == nil || !strings.Contains(err.Error(), "already used for jarld's HTTPS") {
|
||||||
|
t.Errorf("second web certificate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"a.example.com"}}, admin); err != ErrConflict {
|
||||||
|
t.Errorf("duplicate name: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := e.m.Update(ctx, "web", Input{Name: model.Ptr("renamed")}, admin); err == nil {
|
||||||
|
t.Error("rename accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileSource(t *testing.T) {
|
||||||
|
e := newEnv(t, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||||
|
now := time.Now()
|
||||||
|
certFile, keyFile := filepath.Join(e.dir, "fullchain.pem"), filepath.Join(e.dir, "privkey.pem")
|
||||||
|
cert, key := makeCert(t, []string{"ns1.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
|
||||||
|
os.WriteFile(certFile, cert, 0o600)
|
||||||
|
os.WriteFile(keyFile, key, 0o600)
|
||||||
|
|
||||||
|
c, err := e.m.Create(ctx, Input{Name: model.Ptr("own"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"web", "dns"}}, admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, _ = e.m.Get(ctx, "own")
|
||||||
|
if strings.Join(c.Domains, " ") != "ns1.example.com" || c.NotAfter == nil || c.CertPEM != "" {
|
||||||
|
t.Errorf("read: %+v", c)
|
||||||
|
}
|
||||||
|
if e.m.WebCertificate() == nil || e.m.WebName() != "own" {
|
||||||
|
t.Error("not served for HTTPS")
|
||||||
|
}
|
||||||
|
chain := filepath.Join(e.m.FilesDir("own"), ChainFile)
|
||||||
|
if b, err := os.ReadFile(chain); err != nil || string(b) != string(cert) {
|
||||||
|
t.Fatalf("files for named: %v", err)
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(filepath.Join(e.m.FilesDir("own"), KeyFile)); err != nil || fi.Mode().Perm() != 0o640 {
|
||||||
|
t.Errorf("key file: %v %v", fi, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renewed by its owner: the loop notices and puts the new one in place.
|
||||||
|
cert2, key2 := makeCert(t, []string{"ns1.example.com", "dns.example.com"}, now, now.Add(90*24*time.Hour), true)
|
||||||
|
os.WriteFile(certFile, cert2, 0o600)
|
||||||
|
os.WriteFile(keyFile, key2, 0o600)
|
||||||
|
future := now.Add(time.Minute)
|
||||||
|
os.Chtimes(certFile, future, future)
|
||||||
|
e.m.Tick(ctx)
|
||||||
|
c, _ = e.m.Get(ctx, "own")
|
||||||
|
if strings.Join(c.Domains, " ") != "ns1.example.com dns.example.com" {
|
||||||
|
t.Errorf("not reread: %v", c.Domains)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
|
||||||
|
t.Error("files for named not updated")
|
||||||
|
}
|
||||||
|
if got := e.m.WebCertificate(); got == nil || len(got.Leaf.DNSNames) != 2 {
|
||||||
|
t.Error("HTTPS still serves the old certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broken renewal keeps the last good certificate in place.
|
||||||
|
os.WriteFile(keyFile, key, 0o600)
|
||||||
|
os.Chtimes(keyFile, future.Add(time.Minute), future.Add(time.Minute))
|
||||||
|
e.m.Tick(ctx)
|
||||||
|
c, _ = e.m.Get(ctx, "own")
|
||||||
|
if !strings.Contains(c.LastError, "doesn't belong") || e.m.StatusOf(c).State != "failing" {
|
||||||
|
t.Errorf("mismatch not reported: %+v", c)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
|
||||||
|
t.Error("files for named replaced by a broken pair")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the dns use the files go; deleting stops HTTPS.
|
||||||
|
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
|
||||||
|
// The pair is broken now; put the good key back first.
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
os.WriteFile(keyFile, key2, 0o600)
|
||||||
|
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(e.m.FilesDir("own")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("files for named left behind: %v", err)
|
||||||
|
}
|
||||||
|
if err := e.m.Delete(ctx, "own", admin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if e.m.WebCertificate() != nil {
|
||||||
|
t.Error("still serving a deleted certificate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanLegoError(t *testing.T) {
|
||||||
|
in := errors.New("error: one or more domains had a problem:\n[www.example.org] [www.example.org] acme: error presenting token: example.org is served by x, which Jarl can't write to\n[a.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: Incorrect TXT record \"abc\" found at _acme-challenge.a.example.com\n")
|
||||||
|
want := `www.example.org: example.org is served by x, which Jarl can't write to; a.example.com: the CA refused (unauthorized): Incorrect TXT record "abc" found at _acme-challenge.a.example.com`
|
||||||
|
if got := cleanLegoError(in).Error(); got != want {
|
||||||
|
t.Errorf("got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
if got := cleanLegoError(errors.New("acme: error: 429 :: urn:ietf:params:acme:error:rateLimited :: too many")).Error(); got != "the CA refused (rateLimited): too many" {
|
||||||
|
t.Errorf("rate limit: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Package certs manages TLS certificates (docs/certificates.md): ones Jarl
|
||||||
|
// obtains and renews itself over ACME, answering the DNS-01 challenges in
|
||||||
|
// its own zones or at Porkbun, and ones you renew yourself that Jarl only
|
||||||
|
// reads. A certificate is used for jarld's HTTPS ("web") and/or written
|
||||||
|
// where named can read it ("dns", for DNS over TLS and HTTPS).
|
||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info is what a certificate says about itself.
|
||||||
|
type Info struct {
|
||||||
|
Names []string
|
||||||
|
NotBefore time.Time
|
||||||
|
NotAfter time.Time
|
||||||
|
Issuer string
|
||||||
|
Serial string
|
||||||
|
KeyType string // ec256, ec384, rsa2048, ...
|
||||||
|
Leaf *x509.Certificate
|
||||||
|
// Warnings are problems that don't stop it working everywhere (a
|
||||||
|
// missing intermediate certificate).
|
||||||
|
Warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspect checks that certPEM (the certificate, then its intermediates)
|
||||||
|
// and keyPEM belong together and returns what the certificate says, and
|
||||||
|
// the pair ready to serve.
|
||||||
|
func Inspect(certPEM, keyPEM []byte) (*Info, *tls.Certificate, error) {
|
||||||
|
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
if err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "private key does not match public key"):
|
||||||
|
return nil, nil, errors.New("the key doesn't belong to the certificate")
|
||||||
|
case strings.Contains(msg, "failed to find any PEM data in certificate"):
|
||||||
|
return nil, nil, errors.New("no certificate found (expected PEM: -----BEGIN CERTIFICATE-----)")
|
||||||
|
case strings.Contains(msg, "failed to find any PEM data in key"), strings.Contains(msg, "failed to find PEM block with type ending in \"PRIVATE KEY\""):
|
||||||
|
return nil, nil, errors.New("no private key found (expected PEM: -----BEGIN ... PRIVATE KEY-----)")
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
leaf := pair.Leaf
|
||||||
|
if leaf == nil {
|
||||||
|
if leaf, err = x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
pair.Leaf = leaf
|
||||||
|
}
|
||||||
|
in := &Info{NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, Serial: fmt.Sprintf("%x", leaf.SerialNumber), Leaf: leaf, KeyType: keyType(leaf)}
|
||||||
|
in.Names = append(in.Names, leaf.DNSNames...)
|
||||||
|
for _, ip := range leaf.IPAddresses {
|
||||||
|
in.Names = append(in.Names, ip.String())
|
||||||
|
}
|
||||||
|
if len(in.Names) == 0 && leaf.Subject.CommonName != "" {
|
||||||
|
in.Names = []string{leaf.Subject.CommonName}
|
||||||
|
}
|
||||||
|
in.Issuer = leaf.Issuer.CommonName
|
||||||
|
if len(leaf.Issuer.Organization) > 0 {
|
||||||
|
if in.Issuer == "" {
|
||||||
|
in.Issuer = leaf.Issuer.Organization[0]
|
||||||
|
} else {
|
||||||
|
in.Issuer = leaf.Issuer.Organization[0] + " " + in.Issuer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selfSigned := leaf.Issuer.String() == leaf.Subject.String()
|
||||||
|
if len(pair.Certificate) == 1 && !selfSigned {
|
||||||
|
in.Warnings = append(in.Warnings, "the file holds only the certificate, without its intermediate certificate: many clients will reject it; use the full chain (fullchain.pem)")
|
||||||
|
}
|
||||||
|
return in, &pair, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyType(c *x509.Certificate) string {
|
||||||
|
switch k := c.PublicKey.(type) {
|
||||||
|
case *ecdsa.PublicKey:
|
||||||
|
return fmt.Sprintf("ec%d", k.Curve.Params().BitSize)
|
||||||
|
case *rsa.PublicKey:
|
||||||
|
return fmt.Sprintf("rsa%d", k.N.BitLen())
|
||||||
|
case ed25519.PublicKey:
|
||||||
|
return "ed25519"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Covers reports whether the certificate names cover every name in want
|
||||||
|
// (a wildcard covers one label).
|
||||||
|
func Covers(names, want []string) bool {
|
||||||
|
for _, w := range want {
|
||||||
|
if !covered(names, w) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func covered(names []string, want string) bool {
|
||||||
|
want = strings.ToLower(strings.TrimSuffix(want, "."))
|
||||||
|
for _, n := range names {
|
||||||
|
n = strings.ToLower(n)
|
||||||
|
if n == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if base, ok := strings.CutPrefix(n, "*."); ok && !strings.HasPrefix(want, "*.") {
|
||||||
|
if _, rest, ok := strings.Cut(want, "."); ok && rest == base {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolver asks recursive resolvers (acme.resolvers: public ones by
|
||||||
|
// default), to see a domain as the CA does rather than as this server's
|
||||||
|
// own views answer it.
|
||||||
|
type resolver struct {
|
||||||
|
servers []string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) {
|
||||||
|
if len(r.servers) == 0 {
|
||||||
|
return nil, errors.New("no resolvers configured (acme.resolvers)")
|
||||||
|
}
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion(dns.Fqdn(name), qtype)
|
||||||
|
m.SetEdns0(1232, false)
|
||||||
|
m.RecursionDesired = true
|
||||||
|
timeout := r.timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
for _, s := range r.servers {
|
||||||
|
c := &dns.Client{Timeout: timeout}
|
||||||
|
in, _, err := c.ExchangeContext(ctx, m, s)
|
||||||
|
if err == nil && in.Truncated {
|
||||||
|
c.Net = "tcp"
|
||||||
|
in, _, err = c.ExchangeContext(ctx, m, s)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", s, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if in.Rcode == dns.RcodeServerFailure || in.Rcode == dns.RcodeRefused {
|
||||||
|
errs = append(errs, fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], errors.Join(errs...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneOf returns the zone that name is in on the internet (the owner of
|
||||||
|
// the SOA record that answers for it).
|
||||||
|
func (r resolver) zoneOf(ctx context.Context, name string) (string, error) {
|
||||||
|
name = dns.Fqdn(strings.ToLower(name))
|
||||||
|
for cand := name; ; {
|
||||||
|
in, err := r.query(ctx, cand, dns.TypeSOA)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, sec := range [][]dns.RR{in.Answer, in.Ns} {
|
||||||
|
for _, rr := range sec {
|
||||||
|
if soa, ok := rr.(*dns.SOA); ok && dns.IsSubDomain(soa.Hdr.Name, name) {
|
||||||
|
return strings.ToLower(soa.Hdr.Name), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i, end := dns.NextLabel(cand, 0)
|
||||||
|
if end {
|
||||||
|
return "", fmt.Errorf("no zone found for %s", strings.TrimSuffix(name, "."))
|
||||||
|
}
|
||||||
|
cand = cand[i:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameServers returns the zone's name servers on the internet (lowercase,
|
||||||
|
// without trailing dots, sorted).
|
||||||
|
func (r resolver) nameServers(ctx context.Context, zone string) ([]string, error) {
|
||||||
|
in, err := r.query(ctx, zone, dns.TypeNS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, rr := range in.Answer {
|
||||||
|
if ns, ok := rr.(*dns.NS); ok && strings.EqualFold(ns.Hdr.Name, dns.Fqdn(zone)) {
|
||||||
|
out = append(out, normName(ns.Ns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s has no name servers on the internet (%s)", strings.TrimSuffix(zone, "."), dns.RcodeToString[in.Rcode])
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return slices.Compact(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// follow returns where name leads after its CNAME records (name itself
|
||||||
|
// when it has none).
|
||||||
|
func (r resolver) follow(ctx context.Context, name string) (string, error) {
|
||||||
|
name = dns.Fqdn(strings.ToLower(name))
|
||||||
|
for range 10 {
|
||||||
|
in, err := r.query(ctx, name, dns.TypeCNAME)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
for _, rr := range in.Answer {
|
||||||
|
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(c.Hdr.Name, name) {
|
||||||
|
next = strings.ToLower(c.Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next == "" {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
name = next
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s: more than 10 CNAME records in a row", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
|
||||||
|
|
||||||
|
// challengeName is the DNS-01 record name for a certificate name (a
|
||||||
|
// wildcard shares its base name's).
|
||||||
|
func challengeName(domain string) string {
|
||||||
|
return "_acme-challenge." + dns.Fqdn(strings.TrimPrefix(strings.ToLower(domain), "*."))
|
||||||
|
}
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
legolog "github.com/go-acme/lego/v4/log"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/porkbun"
|
||||||
|
"jarl/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures the manager (acme and certificates in jarl.yaml).
|
||||||
|
type Config struct {
|
||||||
|
Directory string // ACME directory URL
|
||||||
|
Email string
|
||||||
|
AgreeTOS bool
|
||||||
|
EABKeyID string
|
||||||
|
EABHMAC string
|
||||||
|
Resolvers []string
|
||||||
|
CABundle string
|
||||||
|
// Dir is where certificates with the dns use are written for named.
|
||||||
|
Dir string
|
||||||
|
Version string
|
||||||
|
// Porkbun publishes challenges for domains Porkbun serves (nil
|
||||||
|
// without an API key); PorkbunAllows limits it to porkbun.domains.
|
||||||
|
Porkbun *porkbun.Client
|
||||||
|
PorkbunAllows func(domain string) bool
|
||||||
|
// CheckPropagation makes lego wait until every name server of the
|
||||||
|
// zone serves the challenge before telling the CA (acme.check_propagation).
|
||||||
|
CheckPropagation bool
|
||||||
|
// PropagationTimeout is how long to wait for that (default 10 minutes).
|
||||||
|
PropagationTimeout time.Duration
|
||||||
|
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||||
|
SkipPropagationCheck bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager keeps certificates current and serves jarld's own.
|
||||||
|
type Manager struct {
|
||||||
|
cfg Config
|
||||||
|
store *store.Store
|
||||||
|
eng *engine.Engine
|
||||||
|
log *slog.Logger
|
||||||
|
res resolver
|
||||||
|
|
||||||
|
// legoMu: lego keeps its DNS settings in package variables, so one
|
||||||
|
// client works at a time. issueMu: one issue at a time.
|
||||||
|
legoMu sync.Mutex
|
||||||
|
issueMu sync.Mutex
|
||||||
|
|
||||||
|
web atomic.Pointer[webCert]
|
||||||
|
mu sync.Mutex
|
||||||
|
files map[int64]fileStamp // file-source certificates: what was read last
|
||||||
|
kick chan struct{}
|
||||||
|
now func() time.Time
|
||||||
|
// issuing holds the names being obtained now, with when it started.
|
||||||
|
issuing sync.Map
|
||||||
|
// base is the context background issues run in (jarld's lifetime).
|
||||||
|
base context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
type webCert struct {
|
||||||
|
name string
|
||||||
|
pair *tls.Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileStamp struct {
|
||||||
|
certMod, keyMod time.Time
|
||||||
|
certSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a manager. Call Start before serving, then Run.
|
||||||
|
func New(cfg Config, st *store.Store, eng *engine.Engine, logger *slog.Logger) *Manager {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
cfg.SkipPropagationCheck = !cfg.CheckPropagation
|
||||||
|
m := &Manager{cfg: cfg, store: st, eng: eng, log: logger, res: resolver{servers: cfg.Resolvers},
|
||||||
|
files: map[int64]fileStamp{}, kick: make(chan struct{}, 1), now: time.Now, base: context.Background()}
|
||||||
|
// lego logs every step; keep it at debug level.
|
||||||
|
legolog.Logger = log.New(slogWriter{logger}, "", 0)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
type slogWriter struct{ l *slog.Logger }
|
||||||
|
|
||||||
|
func (w slogWriter) Write(p []byte) (int, error) {
|
||||||
|
w.l.Debug("lego: " + strings.TrimSpace(string(p)))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors for the API.
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("no such certificate")
|
||||||
|
ErrConflict = errors.New("a certificate with that name exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputError is a request Jarl refuses.
|
||||||
|
type InputError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *InputError) Error() string { return e.Msg }
|
||||||
|
|
||||||
|
func inputErr(format string, a ...any) error { return &InputError{Msg: fmt.Sprintf(format, a...)} }
|
||||||
|
|
||||||
|
// Input creates or changes a certificate; nil fields are left alone.
|
||||||
|
type Input struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Source *string `json:"source"`
|
||||||
|
Domains *[]string `json:"domains"`
|
||||||
|
CertFile *string `json:"cert_file"`
|
||||||
|
KeyFile *string `json:"key_file"`
|
||||||
|
Uses *[]string `json:"uses"`
|
||||||
|
Challenge *string `json:"challenge"`
|
||||||
|
KeyType *string `json:"key_type"`
|
||||||
|
Comment *string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||||
|
var labelRe = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$`)
|
||||||
|
|
||||||
|
func normDomains(in []string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, d := range in {
|
||||||
|
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
|
||||||
|
if d == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
labels := strings.Split(d, ".")
|
||||||
|
if len(labels) < 2 {
|
||||||
|
return nil, inputErr("%q isn't a domain name a CA will sign (it needs at least two labels)", d)
|
||||||
|
}
|
||||||
|
for i, l := range labels {
|
||||||
|
if l == "*" && i == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !labelRe.MatchString(l) {
|
||||||
|
return nil, inputErr("%q isn't a valid domain name (a wildcard is only allowed as the first label: *.example.com)", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(d) > 253 {
|
||||||
|
return nil, inputErr("%q is too long", d)
|
||||||
|
}
|
||||||
|
if !slices.Contains(out, d) {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, inputErr("give at least one domain name")
|
||||||
|
}
|
||||||
|
if len(out) > 100 {
|
||||||
|
return nil, inputErr("at most 100 names per certificate")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
|
||||||
|
if in.Name != nil {
|
||||||
|
if !creating && *in.Name != c.Name {
|
||||||
|
return inputErr("a certificate can't be renamed (its files for named are named after it): create a new one and delete this one")
|
||||||
|
}
|
||||||
|
c.Name = *in.Name
|
||||||
|
}
|
||||||
|
if !nameRe.MatchString(c.Name) {
|
||||||
|
return inputErr("name %q: use lowercase letters, digits, - and _ (at most 63)", c.Name)
|
||||||
|
}
|
||||||
|
if in.Source != nil {
|
||||||
|
if !creating && *in.Source != c.Source {
|
||||||
|
return inputErr("a certificate's source can't change: create a new one")
|
||||||
|
}
|
||||||
|
c.Source = *in.Source
|
||||||
|
}
|
||||||
|
if c.Source == "" {
|
||||||
|
c.Source = model.CertSourceACME
|
||||||
|
}
|
||||||
|
if in.Uses != nil {
|
||||||
|
c.Uses = nil
|
||||||
|
for _, u := range *in.Uses {
|
||||||
|
u = strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if u != model.CertUseWeb && u != model.CertUseDNS {
|
||||||
|
return inputErr("unknown use %q: web (jarld's HTTPS) or dns (for named: DNS over TLS and HTTPS)", u)
|
||||||
|
}
|
||||||
|
if !slices.Contains(c.Uses, u) {
|
||||||
|
c.Uses = append(c.Uses, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(c.Uses)
|
||||||
|
}
|
||||||
|
if c.Uses == nil {
|
||||||
|
c.Uses = []string{}
|
||||||
|
}
|
||||||
|
if in.Comment != nil {
|
||||||
|
c.Comment = *in.Comment
|
||||||
|
}
|
||||||
|
switch c.Source {
|
||||||
|
case model.CertSourceACME:
|
||||||
|
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
||||||
|
return inputErr("cert_file and key_file are for --source file; Jarl keeps the files of certificates it obtains itself")
|
||||||
|
}
|
||||||
|
if in.Domains != nil {
|
||||||
|
d, err := normDomains(*in.Domains)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Domains = d
|
||||||
|
}
|
||||||
|
if len(c.Domains) == 0 {
|
||||||
|
return inputErr("give the domain names the certificate is for")
|
||||||
|
}
|
||||||
|
if in.Challenge != nil {
|
||||||
|
c.Challenge = *in.Challenge
|
||||||
|
}
|
||||||
|
switch c.Challenge {
|
||||||
|
case "":
|
||||||
|
c.Challenge = model.ChallengeAuto
|
||||||
|
case model.ChallengeAuto, model.ChallengeJarl, model.ChallengePorkbun:
|
||||||
|
default:
|
||||||
|
return inputErr("challenge must be auto, jarl or porkbun")
|
||||||
|
}
|
||||||
|
if in.KeyType != nil {
|
||||||
|
c.KeyType = *in.KeyType
|
||||||
|
}
|
||||||
|
if c.KeyType == "" {
|
||||||
|
c.KeyType = "ec256"
|
||||||
|
}
|
||||||
|
if !slices.Contains(KeyTypes, c.KeyType) {
|
||||||
|
return inputErr("key type must be one of %s", strings.Join(KeyTypes, ", "))
|
||||||
|
}
|
||||||
|
case model.CertSourceFile:
|
||||||
|
if in.Challenge != nil && *in.Challenge != "" || in.KeyType != nil && *in.KeyType != "" {
|
||||||
|
return inputErr("challenge and key type are for certificates Jarl obtains (--source acme)")
|
||||||
|
}
|
||||||
|
if in.CertFile != nil {
|
||||||
|
c.CertFile = *in.CertFile
|
||||||
|
}
|
||||||
|
if in.KeyFile != nil {
|
||||||
|
c.KeyFile = *in.KeyFile
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(c.CertFile) || !filepath.IsAbs(c.KeyFile) {
|
||||||
|
return inputErr("cert_file and key_file must be absolute paths (the full chain, and its key)")
|
||||||
|
}
|
||||||
|
c.Challenge, c.KeyType = "", ""
|
||||||
|
default:
|
||||||
|
return inputErr("source must be acme (Jarl obtains and renews it) or file (you renew it)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUses refuses a second certificate for jarld's HTTPS.
|
||||||
|
func (m *Manager) checkUses(ctx context.Context, c *model.Certificate) error {
|
||||||
|
if !c.HasUse(model.CertUseWeb) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
all, err := m.store.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, o := range all {
|
||||||
|
if o.ID != c.ID && o.HasUse(model.CertUseWeb) {
|
||||||
|
return inputErr("certificate %s is already used for jarld's HTTPS; take the web use off it first (jarl cert update %s --use ...)", o.Name, o.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns every certificate.
|
||||||
|
func (m *Manager) List(ctx context.Context) ([]*model.Certificate, error) {
|
||||||
|
return m.store.Certificates(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one certificate.
|
||||||
|
func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, error) {
|
||||||
|
c, err := m.store.CertificateByName(ctx, name)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
c := &model.Certificate{CreatedBy: p.Name(), UpdatedBy: p.Name()}
|
||||||
|
if err := m.apply(c, in, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.checkUses(ctx, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
if _, err := m.readFiles(c); err != nil {
|
||||||
|
return nil, inputErr("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.store.CreateCertificate(ctx, c); err != nil {
|
||||||
|
if errors.Is(err, store.ErrConflict) {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.eng.Audit(ctx, p, "certificate.create", "certificate", c.ID, c.Name, "ok", describe(c))
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
m.refreshFile(ctx, c, true)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update changes a certificate. New names or a new key type make one Jarl
|
||||||
|
// obtains due at once.
|
||||||
|
func (m *Manager) Update(ctx context.Context, name string, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
before := *c
|
||||||
|
if err := m.apply(c, in, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.checkUses(ctx, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if before.HasUse(model.CertUseDNS) && !c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
||||||
|
return nil, inputErr("named's configuration uses certificate %s (a tls statement names its files), so it keeps the dns use: take it out of named first (jarl encrypted-dns disable)", c.Name)
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
|
||||||
|
if _, err := m.readFiles(c); err != nil {
|
||||||
|
return nil, inputErr("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceACME && c.CertPEM != "" && (!slices.Equal(c.Domains, before.Domains) || c.KeyType != before.KeyType) {
|
||||||
|
now := m.now().UTC()
|
||||||
|
c.RenewAt, c.Failures = &now, 0
|
||||||
|
}
|
||||||
|
c.UpdatedBy = p.Name()
|
||||||
|
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.eng.Audit(ctx, p, "certificate.update", "certificate", c.ID, c.Name, "ok", describe(c))
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.files, c.ID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.refreshFile(ctx, c, true)
|
||||||
|
} else if c.CertPEM != "" {
|
||||||
|
if err := m.deploy(ctx, c, []byte(c.CertPEM), []byte(c.KeyPEM)); err != nil {
|
||||||
|
m.log.Warn("writing certificate files failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if before.HasUse(model.CertUseWeb) && !c.HasUse(model.CertUseWeb) {
|
||||||
|
m.dropWeb(c.Name)
|
||||||
|
}
|
||||||
|
m.Kick()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a certificate and its files for named.
|
||||||
|
func (m *Manager) Delete(ctx context.Context, name string, p *auth.Principal) error {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
||||||
|
return inputErr("named's configuration uses certificate %s (a tls statement names its files): take it out first (jarl encrypted-dns disable, or remove the tls statement)", c.Name)
|
||||||
|
}
|
||||||
|
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.dropWeb(c.Name)
|
||||||
|
if err := m.removeFiles(c.Name); err != nil {
|
||||||
|
m.log.Warn("removing certificate files failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.files, c.ID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.eng.Audit(ctx, p, "certificate.delete", "certificate", c.ID, c.Name, "ok", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func describe(c *model.Certificate) string {
|
||||||
|
s := c.Source
|
||||||
|
if c.Source == model.CertSourceACME {
|
||||||
|
s += " " + strings.Join(c.Domains, " ") + ", challenge " + c.Challenge
|
||||||
|
} else {
|
||||||
|
s += " " + c.CertFile
|
||||||
|
}
|
||||||
|
if len(c.Uses) > 0 {
|
||||||
|
s += ", uses " + strings.Join(c.Uses, ",")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview checks a new certificate's fields without saving it.
|
||||||
|
func (m *Manager) Preview(in Input) (*model.Certificate, error) {
|
||||||
|
c := &model.Certificate{}
|
||||||
|
if err := m.apply(c, in, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check works out where each name's challenge would go, without asking
|
||||||
|
// the CA for anything.
|
||||||
|
func (m *Manager) Check(ctx context.Context, c *model.Certificate) ([]Route, error) {
|
||||||
|
if c.Source != model.CertSourceACME {
|
||||||
|
return nil, inputErr("certificate %s is renewed by you (source file); there is nothing to check", c.Name)
|
||||||
|
}
|
||||||
|
st, err := m.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Route
|
||||||
|
for _, d := range c.Domains {
|
||||||
|
out = append(out, m.route(ctx, st, c.Challenge, d, ""))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueResult is the outcome of an issue.
|
||||||
|
type IssueResult struct {
|
||||||
|
Certificate *model.Certificate `json:"certificate"`
|
||||||
|
Routes []Route `json:"routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuing reports since when the certificate is being obtained (zero when
|
||||||
|
// it isn't).
|
||||||
|
func (m *Manager) Issuing(name string) time.Time {
|
||||||
|
if v, ok := m.issuing.Load(name); ok {
|
||||||
|
return v.(time.Time)
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueAsync starts obtaining a certificate in the background and returns
|
||||||
|
// at once: talking to the CA and waiting for every name server to serve
|
||||||
|
// the challenge can take minutes. Issuing tells when it is done; the
|
||||||
|
// outcome is in the certificate (last_success, last_error).
|
||||||
|
func (m *Manager) IssueAsync(ctx context.Context, name string, p *auth.Principal) (*model.Certificate, error) {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
res, err := m.Issue(ctx, name, p)
|
||||||
|
if res != nil {
|
||||||
|
return res.Certificate, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, busy := m.issuing.LoadOrStore(name, m.now()); busy {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer m.issuing.Delete(name)
|
||||||
|
m.issue(m.base, c, p)
|
||||||
|
}()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue obtains a new certificate for c now (a file-source certificate is
|
||||||
|
// reread instead).
|
||||||
|
func (m *Manager) Issue(ctx context.Context, name string, p *auth.Principal) (*IssueResult, error) {
|
||||||
|
c, err := m.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.files, c.ID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err := m.refreshFile(ctx, c, true); err != nil {
|
||||||
|
return &IssueResult{Certificate: c}, err
|
||||||
|
}
|
||||||
|
return &IssueResult{Certificate: c}, nil
|
||||||
|
}
|
||||||
|
return m.issue(ctx, c, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) issue(ctx context.Context, c *model.Certificate, p *auth.Principal) (*IssueResult, error) {
|
||||||
|
m.issueMu.Lock()
|
||||||
|
defer m.issueMu.Unlock()
|
||||||
|
// Reload: another issue may have finished while this one waited.
|
||||||
|
if fresh, err := m.Get(ctx, c.Name); err == nil {
|
||||||
|
c = fresh
|
||||||
|
}
|
||||||
|
var old *x509.Certificate
|
||||||
|
if c.CertPEM != "" {
|
||||||
|
if in, _, err := Inspect([]byte(c.CertPEM), []byte(c.KeyPEM)); err == nil {
|
||||||
|
old = in.Leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.log.Info("obtaining certificate", "certificate", c.Name, "domains", strings.Join(c.Domains, " "), "by", p.Name())
|
||||||
|
now := m.now().UTC()
|
||||||
|
c.LastAttempt = &now
|
||||||
|
res, routes, err := m.obtain(ctx, c, old)
|
||||||
|
c.LastRoutes = nil
|
||||||
|
for _, r := range routes {
|
||||||
|
if r.Error == "" {
|
||||||
|
c.LastRoutes = append(c.LastRoutes, r.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var info *Info
|
||||||
|
if err == nil {
|
||||||
|
info, _, err = Inspect(res.Certificate, res.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("the CA's certificate is unusable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.Failures++
|
||||||
|
c.LastError = err.Error()
|
||||||
|
if serr := m.store.UpdateCertificate(ctx, c); serr != nil {
|
||||||
|
m.log.Error("saving certificate state failed", "certificate", c.Name, "err", serr)
|
||||||
|
}
|
||||||
|
m.log.Warn("obtaining certificate failed", "certificate", c.Name, "failures", c.Failures, "err", err)
|
||||||
|
m.eng.Audit(ctx, p, "certificate.issue", "certificate", c.ID, c.Name, "error", err.Error())
|
||||||
|
return &IssueResult{Certificate: c, Routes: routes}, err
|
||||||
|
}
|
||||||
|
c.CertPEM, c.KeyPEM = string(res.Certificate), string(res.PrivateKey)
|
||||||
|
setInfo(c, info)
|
||||||
|
done := m.now().UTC()
|
||||||
|
c.LastSuccess, c.Failures, c.LastError = &done, 0, ""
|
||||||
|
at := twoThirds(info.NotBefore, info.NotAfter)
|
||||||
|
c.RenewAt, c.ARICheckedAt = &at, nil
|
||||||
|
if t, err := m.renewalInfo(ctx, c, info.Leaf); err == nil && t != nil {
|
||||||
|
c.RenewAt, c.ARICheckedAt = t, &done
|
||||||
|
}
|
||||||
|
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.log.Info("certificate obtained", "certificate", c.Name, "expires", info.NotAfter.Format(time.DateOnly), "renew_at", c.RenewAt.Format(time.RFC3339), "routes", strings.Join(c.LastRoutes, "; "))
|
||||||
|
m.eng.Audit(ctx, p, "certificate.issue", "certificate", c.ID, c.Name, "ok", fmt.Sprintf("%s, valid until %s, from %s", strings.Join(info.Names, " "), info.NotAfter.Format(time.DateOnly), info.Issuer))
|
||||||
|
if err := m.deploy(ctx, c, res.Certificate, res.PrivateKey); err != nil {
|
||||||
|
m.log.Error("writing certificate files failed", "certificate", c.Name, "err", err)
|
||||||
|
return &IssueResult{Certificate: c, Routes: routes}, fmt.Errorf("the certificate was obtained, but writing it for named failed: %w", err)
|
||||||
|
}
|
||||||
|
return &IssueResult{Certificate: c, Routes: routes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInfo(c *model.Certificate, in *Info) {
|
||||||
|
nb, na := in.NotBefore.UTC(), in.NotAfter.UTC()
|
||||||
|
c.Names, c.NotBefore, c.NotAfter, c.Issuer, c.Serial = in.Names, &nb, &na, in.Issuer, in.Serial
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFiles reads a file-source certificate and checks it.
|
||||||
|
func (m *Manager) readFiles(c *model.Certificate) (*Info, error) {
|
||||||
|
cert, err := os.ReadFile(c.CertFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fileErr(err)
|
||||||
|
}
|
||||||
|
key, err := os.ReadFile(c.KeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fileErr(err)
|
||||||
|
}
|
||||||
|
in, _, err := Inspect(cert, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s and %s: %w", c.CertFile, c.KeyFile, err)
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileErr(err error) error {
|
||||||
|
if errors.Is(err, fs.ErrPermission) {
|
||||||
|
return fmt.Errorf("%w: jarld runs as its own user (jarl) and must be able to read the certificate and its key; e.g. give the files group jarl (chgrp jarl FILE; chmod 640 FILE), or copy them somewhere it can read in your renewal hook", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshFile rereads a file-source certificate when its files changed
|
||||||
|
// (always with force) and deploys it.
|
||||||
|
func (m *Manager) refreshFile(ctx context.Context, c *model.Certificate, force bool) error {
|
||||||
|
cs, err1 := os.Stat(c.CertFile)
|
||||||
|
ks, err2 := os.Stat(c.KeyFile)
|
||||||
|
var stamp fileStamp
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
stamp = fileStamp{certMod: cs.ModTime(), keyMod: ks.ModTime(), certSize: cs.Size()}
|
||||||
|
m.mu.Lock()
|
||||||
|
prev, ok := m.files[c.ID]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if ok && prev == stamp && !force {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in, err := m.readFiles(c)
|
||||||
|
now := m.now().UTC()
|
||||||
|
c.LastAttempt = &now
|
||||||
|
if err != nil {
|
||||||
|
if c.LastError != err.Error() {
|
||||||
|
m.log.Warn("reading certificate failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
c.LastError = err.Error()
|
||||||
|
c.Failures = max(c.Failures, 1)
|
||||||
|
m.store.UpdateCertificate(ctx, c)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.Serial != in.Serial {
|
||||||
|
m.log.Info("certificate read", "certificate", c.Name, "file", c.CertFile, "expires", in.NotAfter.Format(time.DateOnly))
|
||||||
|
}
|
||||||
|
setInfo(c, in)
|
||||||
|
c.Domains = in.Names
|
||||||
|
c.LastSuccess, c.LastError, c.Failures = &now, "", 0
|
||||||
|
if len(in.Warnings) > 0 {
|
||||||
|
c.LastError = in.Warnings[0]
|
||||||
|
}
|
||||||
|
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cert, _ := os.ReadFile(c.CertFile)
|
||||||
|
key, _ := os.ReadFile(c.KeyFile)
|
||||||
|
if err := m.deploy(ctx, c, cert, key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.files[c.ID] = stamp
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesDir is where a certificate with the dns use is written for named.
|
||||||
|
func (m *Manager) FilesDir(name string) string { return filepath.Join(m.cfg.Dir, name) }
|
||||||
|
|
||||||
|
// Files names a certificate's files for named.
|
||||||
|
const (
|
||||||
|
ChainFile = "fullchain.pem"
|
||||||
|
KeyFile = "privkey.pem"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deploy puts a certificate where it is used: jarld's HTTPS and/or the
|
||||||
|
// files named reads. When those files change and named's configuration
|
||||||
|
// names them, named rereads it.
|
||||||
|
func (m *Manager) deploy(ctx context.Context, c *model.Certificate, cert, key []byte) error {
|
||||||
|
_, pair, err := Inspect(cert, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.HasUse(model.CertUseWeb) {
|
||||||
|
m.web.Store(&webCert{name: c.Name, pair: pair})
|
||||||
|
} else {
|
||||||
|
m.dropWeb(c.Name)
|
||||||
|
}
|
||||||
|
if !c.HasUse(model.CertUseDNS) {
|
||||||
|
return m.removeFiles(c.Name)
|
||||||
|
}
|
||||||
|
dir := m.FilesDir(c.Name)
|
||||||
|
changed := false
|
||||||
|
for _, f := range []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
mode fs.FileMode
|
||||||
|
}{{ChainFile, cert, 0o644}, {KeyFile, key, 0o640}} {
|
||||||
|
path := filepath.Join(dir, f.name)
|
||||||
|
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, f.content) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.eng.WriteFile(path, f.content, f.mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed && m.namedUses(ctx, dir) {
|
||||||
|
if err := m.eng.Reconfig(ctx); err != nil {
|
||||||
|
return fmt.Errorf("named didn't reread its configuration for the new certificate: %w", err)
|
||||||
|
}
|
||||||
|
m.log.Info("named rereads the new certificate", "certificate", c.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// namedUses reports whether named's configuration names files in dir.
|
||||||
|
func (m *Manager) namedUses(ctx context.Context, dir string) bool {
|
||||||
|
st, err := m.store.LoadState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, o := range st.Options {
|
||||||
|
if strings.Contains(o.Value, dir+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) removeFiles(name string) error {
|
||||||
|
if name == "" || strings.ContainsAny(name, "/.") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir := m.FilesDir(name)
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) dropWeb(name string) {
|
||||||
|
if w := m.web.Load(); w != nil && w.name == name {
|
||||||
|
m.web.CompareAndSwap(w, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebCertificate is the certificate for jarld's HTTPS (nil when no
|
||||||
|
// certificate has the web use, or it has none yet).
|
||||||
|
func (m *Manager) WebCertificate() *tls.Certificate {
|
||||||
|
if w := m.web.Load(); w != nil {
|
||||||
|
return w.pair
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebName is the name of the certificate jarld serves ("" for none).
|
||||||
|
func (m *Manager) WebName() string {
|
||||||
|
if w := m.web.Load(); w != nil {
|
||||||
|
return w.name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasWeb reports whether a certificate has the web use (whether or not
|
||||||
|
// it has been obtained yet).
|
||||||
|
func (m *Manager) HasWeb(ctx context.Context) bool {
|
||||||
|
all, err := m.store.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(all, func(c *model.Certificate) bool { return c.HasUse(model.CertUseWeb) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick makes the loop look at the certificates now.
|
||||||
|
func (m *Manager) Kick() {
|
||||||
|
select {
|
||||||
|
case m.kick <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start loads the certificates and puts them in place (jarld's HTTPS, the
|
||||||
|
// files for named). Call it before serving.
|
||||||
|
func (m *Manager) Start(ctx context.Context) {
|
||||||
|
all, err := m.store.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("loading certificates failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
switch {
|
||||||
|
case c.Source == model.CertSourceFile:
|
||||||
|
m.refreshFile(ctx, c, true)
|
||||||
|
case c.CertPEM != "":
|
||||||
|
if err := m.deploy(ctx, c, []byte(c.CertPEM), []byte(c.KeyPEM)); err != nil {
|
||||||
|
m.log.Warn("putting certificate in place failed", "certificate", c.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run renews certificates when they are due and rereads file-source ones
|
||||||
|
// when their files change, until ctx ends.
|
||||||
|
func (m *Manager) Run(ctx context.Context) {
|
||||||
|
m.base = ctx
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(20 * time.Second): // let jarld finish starting
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
m.Tick(ctx)
|
||||||
|
t := time.NewTimer(10 * time.Minute)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Stop()
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
case <-m.kick:
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick does one round of the loop.
|
||||||
|
func (m *Manager) Tick(ctx context.Context) {
|
||||||
|
all, err := m.store.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("loading certificates failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := m.now()
|
||||||
|
for _, c := range all {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceFile {
|
||||||
|
m.refreshFile(ctx, c, false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if at := m.Due(c); !at.After(now) {
|
||||||
|
if _, busy := m.issuing.LoadOrStore(c.Name, now); !busy {
|
||||||
|
m.issue(ctx, c, auth.System)
|
||||||
|
m.issuing.Delete(c.Name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Ask the CA about the renewal window twice a day (it moves it
|
||||||
|
// earlier when a certificate must be replaced, e.g. revoked).
|
||||||
|
if c.CertPEM != "" && (c.ARICheckedAt == nil || now.Sub(*c.ARICheckedAt) > 12*time.Hour) {
|
||||||
|
in, _, err := Inspect([]byte(c.CertPEM), []byte(c.KeyPEM))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := m.renewalInfo(ctx, c, in.Leaf)
|
||||||
|
checked := now.UTC()
|
||||||
|
c.ARICheckedAt = &checked
|
||||||
|
if err == nil && t != nil && (c.RenewAt == nil || !t.Equal(*c.RenewAt)) {
|
||||||
|
m.log.Info("renewal time from the CA", "certificate", c.Name, "renew_at", t.Format(time.RFC3339))
|
||||||
|
c.RenewAt = t
|
||||||
|
}
|
||||||
|
m.store.UpdateCertificate(ctx, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Due is when c should be obtained (again): at once when it has no
|
||||||
|
// certificate yet or its names changed; else at its renewal time. After
|
||||||
|
// failures it waits 1, 2, 4 ... hours (at most a day) between attempts.
|
||||||
|
func (m *Manager) Due(c *model.Certificate) time.Time {
|
||||||
|
var at time.Time
|
||||||
|
switch {
|
||||||
|
case c.CertPEM == "":
|
||||||
|
at = c.CreatedAt
|
||||||
|
case c.RenewAt != nil:
|
||||||
|
at = *c.RenewAt
|
||||||
|
case c.NotBefore != nil && c.NotAfter != nil:
|
||||||
|
at = twoThirds(*c.NotBefore, *c.NotAfter)
|
||||||
|
}
|
||||||
|
if c.Failures > 0 && c.LastAttempt != nil {
|
||||||
|
wait := time.Hour << min(c.Failures-1, 5)
|
||||||
|
at = maxTime(at, c.LastAttempt.Add(min(wait, 24*time.Hour)))
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxTime(a, b time.Time) time.Time {
|
||||||
|
if a.After(b) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is a certificate's health.
|
||||||
|
type Status struct {
|
||||||
|
State string `json:"state"` // ok, pending, failing, expiring, expired
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusOf says how c is doing.
|
||||||
|
func (m *Manager) StatusOf(c *model.Certificate) Status {
|
||||||
|
now := m.now()
|
||||||
|
if c.NotAfter == nil {
|
||||||
|
if c.Failures > 0 {
|
||||||
|
return Status{"failing", "not obtained yet: " + c.LastError}
|
||||||
|
}
|
||||||
|
return Status{"pending", "not obtained yet"}
|
||||||
|
}
|
||||||
|
left := c.NotAfter.Sub(now)
|
||||||
|
life := c.NotAfter.Sub(*c.NotBefore)
|
||||||
|
switch {
|
||||||
|
case left <= 0:
|
||||||
|
return Status{"expired", "expired on " + c.NotAfter.Format(time.DateOnly)}
|
||||||
|
case c.Failures > 0 && c.Source == model.CertSourceACME:
|
||||||
|
return Status{"failing", fmt.Sprintf("renewing failed %d times (next try %s): %s", c.Failures, m.Due(c).Local().Format("2 Jan 15:04"), c.LastError)}
|
||||||
|
case c.Failures > 0:
|
||||||
|
return Status{"failing", c.LastError}
|
||||||
|
case left < life/6 || (c.Source == model.CertSourceFile && left < 14*24*time.Hour):
|
||||||
|
return Status{"expiring", fmt.Sprintf("expires in %s (%s)", days(left), c.NotAfter.Format(time.DateOnly))}
|
||||||
|
}
|
||||||
|
return Status{"ok", ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func days(d time.Duration) string {
|
||||||
|
n := int(d.Hours() / 24)
|
||||||
|
if n == 1 {
|
||||||
|
return "1 day"
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountInfo is the ACME account as the API shows it.
|
||||||
|
type AccountInfo struct {
|
||||||
|
Directory string `json:"directory"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
AgreeTOS bool `json:"agree_tos"`
|
||||||
|
EAB bool `json:"eab"`
|
||||||
|
Registered bool `json:"registered"`
|
||||||
|
URI string `json:"uri,omitempty"`
|
||||||
|
RegisteredAt *time.Time `json:"registered_at,omitempty"`
|
||||||
|
Porkbun bool `json:"porkbun"`
|
||||||
|
Resolvers []string `json:"resolvers"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
ActiveWebCert string `json:"active_web_certificate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountInfo describes the ACME account and settings.
|
||||||
|
func (m *Manager) AccountInfo(ctx context.Context) AccountInfo {
|
||||||
|
ai := AccountInfo{Directory: m.cfg.Directory, Email: m.cfg.Email, AgreeTOS: m.cfg.AgreeTOS, EAB: m.cfg.EABKeyID != "",
|
||||||
|
Porkbun: m.cfg.Porkbun != nil, Resolvers: m.cfg.Resolvers, Dir: m.cfg.Dir, ActiveWebCert: m.WebName()}
|
||||||
|
if a := m.account(ctx); a != nil && a.Directory == m.cfg.Directory && a.URI != "" {
|
||||||
|
ai.Registered, ai.URI = true, a.URI
|
||||||
|
t := a.RegisteredAt
|
||||||
|
ai.RegisteredAt = &t
|
||||||
|
}
|
||||||
|
return ai
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package certs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/acme"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Route says where the DNS-01 challenge for one certificate name goes,
|
||||||
|
// and why.
|
||||||
|
type Route struct {
|
||||||
|
Domain string `json:"domain"` // the name on the certificate
|
||||||
|
Record string `json:"record"` // _acme-challenge.<domain>
|
||||||
|
// Target is where the TXT record is written: Record, or where its
|
||||||
|
// CNAME points.
|
||||||
|
Target string `json:"target"`
|
||||||
|
Route string `json:"route,omitempty"` // jarl or porkbun; "" when there is none
|
||||||
|
Zone string `json:"zone,omitempty"` // the zone on the internet that holds Target
|
||||||
|
PublicNS []string `json:"public_ns,omitempty"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Route) String() string {
|
||||||
|
s := r.Domain + ": " + r.Route
|
||||||
|
if r.Target != r.Record {
|
||||||
|
s += " via CNAME " + strings.TrimSuffix(r.Target, ".")
|
||||||
|
}
|
||||||
|
return s + " (zone " + strings.TrimSuffix(r.Zone, ".") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPorkbunNS(ns []string) bool {
|
||||||
|
for _, n := range ns {
|
||||||
|
if strings.HasSuffix(n, ".porkbun.com") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// route decides where the challenge for domain goes. challenge is the
|
||||||
|
// certificate's setting: auto picks from the zone's public name servers.
|
||||||
|
// target is the record name after CNAMEs ("" to look it up).
|
||||||
|
func (m *Manager) route(ctx context.Context, st *model.State, challenge, domain, target string) Route {
|
||||||
|
r := Route{Domain: domain, Record: challengeName(domain)}
|
||||||
|
fail := func(format string, a ...any) Route {
|
||||||
|
r.Error = fmt.Sprintf(format, a...)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
t, err := m.res.follow(ctx, r.Record)
|
||||||
|
if err != nil {
|
||||||
|
return fail("%v", err)
|
||||||
|
}
|
||||||
|
target = t
|
||||||
|
}
|
||||||
|
r.Target = target
|
||||||
|
zone, err := m.res.zoneOf(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
return fail("%v", err)
|
||||||
|
}
|
||||||
|
r.Zone = zone
|
||||||
|
ns, err := m.res.nameServers(ctx, zone)
|
||||||
|
if err != nil {
|
||||||
|
return fail("%v", err)
|
||||||
|
}
|
||||||
|
r.PublicNS = ns
|
||||||
|
zoneName, nsList := strings.TrimSuffix(zone, "."), strings.Join(ns, ", ")
|
||||||
|
|
||||||
|
// Jarl can answer when it has the zone and the internet asks the name
|
||||||
|
// servers listed in it.
|
||||||
|
var jarlNS []string
|
||||||
|
jarlHas := false
|
||||||
|
for _, z := range st.SortedZones() {
|
||||||
|
if z.Type != model.ZonePrimary || z.Disabled || normName(z.Name) != normName(zone) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jarlHas = true
|
||||||
|
for _, rec := range st.ZoneRecords(z.ID) {
|
||||||
|
if rec.Type == "NS" && !rec.Disabled && normName(rec.Name) == normName(zone) {
|
||||||
|
jarlNS = append(jarlNS, normName(rec.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(jarlNS)
|
||||||
|
jarlNS = slices.Compact(jarlNS)
|
||||||
|
servedByJarl := jarlHas && slices.ContainsFunc(ns, func(n string) bool { return slices.Contains(jarlNS, n) })
|
||||||
|
porkbunOK := m.cfg.Porkbun != nil && (m.cfg.PorkbunAllows == nil || m.cfg.PorkbunAllows(zoneName))
|
||||||
|
|
||||||
|
porkbun := func() Route {
|
||||||
|
r.Route, r.Why = model.ChallengePorkbun, fmt.Sprintf("%s is served by Porkbun (%s), so the record goes in through Porkbun's API", zoneName, nsList)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
jarl := func() Route {
|
||||||
|
r.Route, r.Why = model.ChallengeJarl, fmt.Sprintf("%s is served by this server's name servers (%s), so Jarl writes the record itself", zoneName, nsList)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
switch challenge {
|
||||||
|
case model.ChallengePorkbun:
|
||||||
|
switch {
|
||||||
|
case m.cfg.Porkbun == nil:
|
||||||
|
return fail("the challenge is set to porkbun, but jarl.yaml has no porkbun.api_key")
|
||||||
|
case !porkbunOK:
|
||||||
|
return fail("the challenge is set to porkbun, but %s isn't in porkbun.domains in jarl.yaml", zoneName)
|
||||||
|
case !isPorkbunNS(ns):
|
||||||
|
return fail("the challenge is set to porkbun, but on the internet %s is served by %s, not Porkbun: the CA would never see the record (set the challenge to auto)", zoneName, nsList)
|
||||||
|
}
|
||||||
|
return porkbun()
|
||||||
|
case model.ChallengeJarl:
|
||||||
|
switch {
|
||||||
|
case !jarlHas:
|
||||||
|
return fail("the challenge is set to jarl, but no primary zone in Jarl is %s (where %s is on the internet)", zoneName, strings.TrimSuffix(target, "."))
|
||||||
|
case !servedByJarl:
|
||||||
|
return fail("the challenge is set to jarl, but on the internet %s is served by %s, not by the name servers in Jarl's zone (%s): the CA would never see the record (set the challenge to auto)", zoneName, nsList, strings.Join(jarlNS, ", "))
|
||||||
|
}
|
||||||
|
return jarl()
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case isPorkbunNS(ns) && porkbunOK:
|
||||||
|
return porkbun()
|
||||||
|
case servedByJarl:
|
||||||
|
return jarl()
|
||||||
|
case isPorkbunNS(ns) && m.cfg.Porkbun == nil:
|
||||||
|
return fail("%s is served by Porkbun (%s) and jarl.yaml has no porkbun.api_key. Add the key, or point %s with a CNAME at a name in a zone this server serves on the internet", zoneName, nsList, strings.TrimSuffix(r.Record, "."))
|
||||||
|
case isPorkbunNS(ns):
|
||||||
|
return fail("%s is served by Porkbun (%s) but isn't in porkbun.domains in jarl.yaml", zoneName, nsList)
|
||||||
|
case jarlHas:
|
||||||
|
return fail("on the internet %s is served by %s, not by the name servers in Jarl's zone (%s), so the CA would never see a record Jarl writes. Delegate the zone to this server, or point %s with a CNAME at a name in a zone it serves", zoneName, nsList, strings.Join(jarlNS, ", "), strings.TrimSuffix(r.Record, "."))
|
||||||
|
}
|
||||||
|
return fail("%s is served by %s, which Jarl can't write to. Point %s with a CNAME at a name in a zone this server serves on the internet (for example %s.acme.<your domain>, in a zone acme.<your domain> delegated here): Jarl follows the CNAME and answers there", zoneName, nsList, strings.TrimSuffix(r.Record, "."), strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(r.Record, "_acme-challenge."), "."), ".", "-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// primaryZones lists the zones Jarl can write challenge records into.
|
||||||
|
func primaryZones(st *model.State) []acme.Zone {
|
||||||
|
var out []acme.Zone
|
||||||
|
for _, z := range st.SortedZones() {
|
||||||
|
if z.Type == model.ZonePrimary && !z.Disabled {
|
||||||
|
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z), Link: z.LinkGroup})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ type Client struct {
|
|||||||
csErr error
|
csErr error
|
||||||
DryRun bool
|
DryRun bool
|
||||||
Force bool
|
Force bool
|
||||||
|
// AcceptRisk confirms changes that open the server up
|
||||||
|
// (docs/exposure.md).
|
||||||
|
AcceptRisk bool
|
||||||
// Preflight lets only requests through that can't change anything:
|
// Preflight lets only requests through that can't change anything:
|
||||||
// reads and dry runs. Running a command on several servers uses it to
|
// reads and dry runs. Running a command on several servers uses it to
|
||||||
// check the command everywhere before running it anywhere.
|
// check the command everywhere before running it anywhere.
|
||||||
@@ -282,6 +285,9 @@ func (c *Client) Mutate(ctx context.Context, method, path string, query url.Valu
|
|||||||
if c.Force {
|
if c.Force {
|
||||||
q.Set("force", "1")
|
q.Set("force", "1")
|
||||||
}
|
}
|
||||||
|
if c.AcceptRisk {
|
||||||
|
q.Set("accept_risk", "1")
|
||||||
|
}
|
||||||
var out api.MutationResponse
|
var out api.MutationResponse
|
||||||
err = c.Do(ctx, method, path, q, body, &out)
|
err = c.Do(ctx, method, path, q, body, &out)
|
||||||
return &out, err
|
return &out, err
|
||||||
|
|||||||
+138
-13
@@ -40,6 +40,17 @@ type Config struct {
|
|||||||
Porkbun Porkbun `yaml:"porkbun"`
|
Porkbun Porkbun `yaml:"porkbun"`
|
||||||
Blocking Blocking `yaml:"blocking"`
|
Blocking Blocking `yaml:"blocking"`
|
||||||
Metrics Metrics `yaml:"metrics"`
|
Metrics Metrics `yaml:"metrics"`
|
||||||
|
// Certificates configures TLS certificates Jarl manages
|
||||||
|
// (docs/certificates.md); the ACME account is under acme.
|
||||||
|
Certificates Certificates `yaml:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificates configures the certificates Jarl manages.
|
||||||
|
type Certificates struct {
|
||||||
|
// Dir is where certificates are written for named (DNS over TLS and
|
||||||
|
// HTTPS): one directory per certificate, readable by bind.file_group.
|
||||||
|
// "" is <bind.config_dir>/tls.
|
||||||
|
Dir string `yaml:"dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
// Metrics configures the Prometheus endpoint /metrics (docs/metrics.md).
|
||||||
@@ -183,9 +194,85 @@ type Backup struct {
|
|||||||
WarnAfter Duration `yaml:"warn_after"`
|
WarnAfter Duration `yaml:"warn_after"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACME configures the ACME helpers.
|
// ACME configures the ACME helpers and the account Jarl uses to obtain
|
||||||
|
// certificates itself.
|
||||||
type ACME struct {
|
type ACME struct {
|
||||||
HTTPReq HTTPReq `yaml:"httpreq"`
|
HTTPReq HTTPReq `yaml:"httpreq"`
|
||||||
|
Account ACMEAccount `yaml:"account"`
|
||||||
|
// Resolvers are the recursive resolvers used to see a domain as the
|
||||||
|
// CA does: its zone, its public name servers and any CNAME on
|
||||||
|
// _acme-challenge. Public resolvers by default, because this server's
|
||||||
|
// own answers may come from an internal view.
|
||||||
|
Resolvers []string `yaml:"resolvers"`
|
||||||
|
// CABundle is a PEM file of extra CA certificates to trust when
|
||||||
|
// talking to the ACME server (a private CA such as step-ca).
|
||||||
|
CABundle string `yaml:"ca_bundle"`
|
||||||
|
// CheckPropagation waits until every name server of the zone serves
|
||||||
|
// a challenge before telling the CA to look.
|
||||||
|
CheckPropagation bool `yaml:"check_propagation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACMEAccount is the ACME account certificates are obtained with.
|
||||||
|
type ACMEAccount struct {
|
||||||
|
// Directory is letsencrypt, letsencrypt-staging or an ACME directory URL.
|
||||||
|
Directory string `yaml:"directory"`
|
||||||
|
Email string `yaml:"email"`
|
||||||
|
// AgreeTOS says you accept the CA's terms of service. Jarl doesn't
|
||||||
|
// register an account without it.
|
||||||
|
AgreeTOS bool `yaml:"agree_tos"`
|
||||||
|
// EABKeyID and EABHMAC are External Account Binding credentials, for
|
||||||
|
// CAs that need them (ZeroSSL, Google).
|
||||||
|
EABKeyID string `yaml:"eab_kid"`
|
||||||
|
EABHMAC string `yaml:"eab_hmac"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory URLs for the names acme.account.directory accepts.
|
||||||
|
const (
|
||||||
|
LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DirectoryURL is the account's ACME directory URL.
|
||||||
|
func (a ACMEAccount) DirectoryURL() string {
|
||||||
|
switch a.Directory {
|
||||||
|
case "", "letsencrypt":
|
||||||
|
return LetsEncrypt
|
||||||
|
case "letsencrypt-staging":
|
||||||
|
return LetsEncryptStaging
|
||||||
|
}
|
||||||
|
return a.Directory
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ACME) check() error {
|
||||||
|
if d := a.Account.Directory; d != "" && d != "letsencrypt" && d != "letsencrypt-staging" {
|
||||||
|
u, err := url.Parse(d)
|
||||||
|
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||||
|
return fmt.Errorf("account.directory must be letsencrypt, letsencrypt-staging or an https:// URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.Account.Email != "" && !strings.Contains(a.Account.Email, "@") {
|
||||||
|
return fmt.Errorf("account.email %q isn't an email address", a.Account.Email)
|
||||||
|
}
|
||||||
|
if (a.Account.EABKeyID == "") != (a.Account.EABHMAC == "") {
|
||||||
|
return fmt.Errorf("account: set both eab_kid and eab_hmac, or neither")
|
||||||
|
}
|
||||||
|
for _, r := range a.Resolvers {
|
||||||
|
if _, _, err := net.SplitHostPort(r); err != nil {
|
||||||
|
return fmt.Errorf("resolvers: %q must be address:port, e.g. 1.1.1.1:53 or [2606:4700:4700::1111]:53", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.CABundle != "" && !filepath.IsAbs(a.CABundle) {
|
||||||
|
return fmt.Errorf("ca_bundle must be an absolute path")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertDir is certificates.dir, or <bind.config_dir>/tls.
|
||||||
|
func (c *Config) CertDir() string {
|
||||||
|
if c.Certificates.Dir != "" {
|
||||||
|
return c.Certificates.Dir
|
||||||
|
}
|
||||||
|
return filepath.Join(c.BIND.ConfigDir, "tls")
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPReq configures the endpoint for lego's httpreq DNS provider
|
// HTTPReq configures the endpoint for lego's httpreq DNS provider
|
||||||
@@ -372,7 +459,8 @@ func Default() *Config {
|
|||||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
|
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
|
||||||
TopN: 100,
|
TopN: 100,
|
||||||
},
|
},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
|
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},
|
Blocking: Blocking{Refresh: Duration(24 * time.Hour), MaxDownloadMB: 64, MaxEntries: 1_000_000},
|
||||||
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
|
Metrics: Metrics{Allow: []string{"127.0.0.1", "::1"}},
|
||||||
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
|
||||||
@@ -449,6 +537,12 @@ func (c *Config) check() error {
|
|||||||
if err := c.Metrics.check(); err != nil {
|
if err := c.Metrics.check(); err != nil {
|
||||||
return fmt.Errorf("metrics: %w", err)
|
return fmt.Errorf("metrics: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := c.ACME.check(); err != nil {
|
||||||
|
return fmt.Errorf("acme: %w", err)
|
||||||
|
}
|
||||||
|
if d := c.Certificates.Dir; d != "" && !filepath.IsAbs(d) {
|
||||||
|
return fmt.Errorf("certificates.dir must be an absolute path")
|
||||||
|
}
|
||||||
switch c.Log.Format {
|
switch c.Log.Format {
|
||||||
case "text", "json":
|
case "text", "json":
|
||||||
default:
|
default:
|
||||||
@@ -552,8 +646,10 @@ const Example = `# Jarl daemon configuration (/etc/jarl/jarl.yaml).
|
|||||||
listen:
|
listen:
|
||||||
- "127.0.0.1:8053"
|
- "127.0.0.1:8053"
|
||||||
|
|
||||||
# HTTPS. The certificate is reread when the file changes (or on SIGHUP),
|
# HTTPS from a certificate file you renew yourself. The certificate is
|
||||||
# so renewed Let's Encrypt certificates are picked up automatically.
|
# reread when the file changes (or on SIGHUP). Easier: let Jarl obtain one
|
||||||
|
# ('jarl cert create ... --use web', docs/certificates.md), which then
|
||||||
|
# takes over from this.
|
||||||
tls:
|
tls:
|
||||||
cert_file: ""
|
cert_file: ""
|
||||||
key_file: ""
|
key_file: ""
|
||||||
@@ -667,15 +763,44 @@ acme:
|
|||||||
# this server. Leave false if a reverse proxy runs on this server but
|
# this server. Leave false if a reverse proxy runs on this server but
|
||||||
# isn't in trusted_proxies: its requests would look local.
|
# isn't in trusted_proxies: its requests would look local.
|
||||||
allow_loopback_http: false
|
allow_loopback_http: false
|
||||||
|
# The account Jarl obtains certificates with (docs/certificates.md).
|
||||||
|
# Nothing is registered until you create a certificate. Read the CA's
|
||||||
|
# terms (https://letsencrypt.org/repository/) and set agree_tos: true.
|
||||||
|
# Try letsencrypt-staging first: its certificates aren't trusted, but
|
||||||
|
# its rate limits are generous.
|
||||||
|
account:
|
||||||
|
directory: letsencrypt # letsencrypt, letsencrypt-staging or a directory URL
|
||||||
|
email: "" # optional
|
||||||
|
agree_tos: false
|
||||||
|
eab_kid: "" # External Account Binding, only for CAs that need it
|
||||||
|
eab_hmac: ""
|
||||||
|
# Resolvers to see your domains as the CA does (their public name
|
||||||
|
# servers, a CNAME on _acme-challenge). Not this server: it may answer
|
||||||
|
# from an internal view.
|
||||||
|
resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]
|
||||||
|
ca_bundle: "" # extra CA certificates for a private ACME server
|
||||||
|
# Wait until every name server of the zone serves a challenge before
|
||||||
|
# telling the CA to look (they are asked directly, on port 53).
|
||||||
|
check_propagation: true
|
||||||
|
|
||||||
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
|
# Where certificates with the "dns" use are written for named, one
|
||||||
# show and set a domain's name servers, glue records and DNSSEC DS records
|
# directory per certificate (fullchain.pem, privkey.pem), readable by
|
||||||
# there, after checking that the change won't break the domain. Off without
|
# bind.file_group. Default: <bind.config_dir>/tls.
|
||||||
# a key. Create the key at porkbun.com (Account -> API Access), switch on
|
certificates:
|
||||||
# "API Access" for the domain, and limit the key to that domain if you can.
|
dir: ""
|
||||||
# The key can move your domain elsewhere: it lives only in this file (keep
|
|
||||||
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
|
# Porkbun, as registrar and as DNS host. With an API key, Jarl can:
|
||||||
# Jarl's database or backups.
|
# - show and set a domain's name servers, glue records and DNSSEC DS
|
||||||
|
# records there, after checking that the change won't break the domain
|
||||||
|
# (docs/dnssec.md#porkbun);
|
||||||
|
# - publish the DNS-01 challenges of certificates Jarl obtains while
|
||||||
|
# Porkbun still serves the domain's DNS (docs/certificates.md): it adds
|
||||||
|
# and removes TXT records at _acme-challenge names only.
|
||||||
|
# Off without a key. Create the key at porkbun.com (Account -> API Access),
|
||||||
|
# switch on "API Access" for the domain, and limit the key to that domain
|
||||||
|
# if you can. The key can move your domain elsewhere: it lives only in this
|
||||||
|
# file (keep it readable by root and jarld only: chown root:jarl, chmod
|
||||||
|
# 640), never in Jarl's database or backups.
|
||||||
porkbun:
|
porkbun:
|
||||||
api_key: "" # pk1_...
|
api_key: "" # pk1_...
|
||||||
secret_api_key: "" # sk1_...
|
secret_api_key: "" # sk1_...
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -18,7 +19,7 @@ func TestExampleParses(t *testing.T) {
|
|||||||
if c.BIND.ZoneDir != d.BIND.ZoneDir || time.Duration(c.Session.IdleTimeout) != 2*time.Hour || c.Listen[0] != "127.0.0.1:8053" {
|
if c.BIND.ZoneDir != d.BIND.ZoneDir || time.Duration(c.Session.IdleTimeout) != 2*time.Hour || c.Listen[0] != "127.0.0.1:8053" {
|
||||||
t.Errorf("example differs from defaults: %+v", c)
|
t.Errorf("example differs from defaults: %+v", c)
|
||||||
}
|
}
|
||||||
if c.ACME != d.ACME || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
|
if !reflect.DeepEqual(c.ACME, d.ACME) || !reflect.DeepEqual(c.Certificates, d.Certificates) || c.ACME.HTTPReq.Enabled || !c.ACME.HTTPReq.RequireHTTPS {
|
||||||
t.Errorf("acme = %+v, want the defaults %+v", c.ACME, d.ACME)
|
t.Errorf("acme = %+v, want the defaults %+v", c.ACME, d.ACME)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
// Package encdns sets up DNS over TLS (DoT) and DNS over HTTPS (DoH) in
|
||||||
|
// named (docs/encrypted-dns.md): a tls statement naming a certificate Jarl
|
||||||
|
// manages, and listen-on statements for port 853 (DoT) and the DoH port.
|
||||||
|
// Its state is read from named's configuration, so what the setup stages
|
||||||
|
// stays ordinary, visible configuration.
|
||||||
|
package encdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix starts the names of the tls statements the setup writes.
|
||||||
|
const Prefix = "jarl-"
|
||||||
|
|
||||||
|
// DoTPort is the port for DNS over TLS (RFC 7858).
|
||||||
|
const DoTPort = 853
|
||||||
|
|
||||||
|
// Listener is a listen-on or listen-on-v6 statement.
|
||||||
|
type Listener struct {
|
||||||
|
OptionID int64 `json:"option_id"`
|
||||||
|
V6 bool `json:"v6"`
|
||||||
|
Port int `json:"port"` // as written; 0 when it isn't
|
||||||
|
TLS string `json:"tls,omitempty"` // tls statement ("" for plain DNS)
|
||||||
|
HTTP string `json:"http,omitempty"` // http endpoints: DNS over HTTPS
|
||||||
|
Addrs []string `json:"addresses"` // the address match list
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectivePort is the port named listens on: as written, or its
|
||||||
|
// default for the kind of listener.
|
||||||
|
func (l Listener) EffectivePort() int {
|
||||||
|
switch {
|
||||||
|
case l.Port != 0:
|
||||||
|
return l.Port
|
||||||
|
case l.HTTP != "" && l.TLS != "" && l.TLS != "none":
|
||||||
|
return 443
|
||||||
|
case l.HTTP != "":
|
||||||
|
return 80
|
||||||
|
case l.TLS != "":
|
||||||
|
return DoTPort
|
||||||
|
}
|
||||||
|
return 53
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind is plain, dot or doh.
|
||||||
|
func (l Listener) Kind() string {
|
||||||
|
switch {
|
||||||
|
case l.HTTP != "":
|
||||||
|
return "doh"
|
||||||
|
case l.TLS != "" && l.TLS != "none":
|
||||||
|
return "dot"
|
||||||
|
}
|
||||||
|
return "plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managed reports whether the setup wrote the listener.
|
||||||
|
func (l Listener) Managed() bool { return strings.HasPrefix(l.TLS, Prefix) }
|
||||||
|
|
||||||
|
// Listeners returns the server's listen-on and listen-on-v6 statements.
|
||||||
|
func Listeners(st *model.State) []Listener {
|
||||||
|
var out []Listener
|
||||||
|
for _, o := range st.ScopedOptions(model.ScopeOptions, 0) {
|
||||||
|
if o.Name != "listen-on" && o.Name != "listen-on-v6" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l, err := parseListener(o.Name, o.Value)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l.OptionID, l.V6 = o.ID, o.Name == "listen-on-v6"
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseListener(name, value string) (Listener, error) {
|
||||||
|
var l Listener
|
||||||
|
stmt, err := namedconf.ParseOptionValue(name, value)
|
||||||
|
if err != nil {
|
||||||
|
return l, err
|
||||||
|
}
|
||||||
|
items := stmt.Items[1:]
|
||||||
|
for i := 0; i < len(items); i++ {
|
||||||
|
it := items[i]
|
||||||
|
if it.Block != nil {
|
||||||
|
for _, s := range it.Block.Stmts {
|
||||||
|
l.Addrs = append(l.Addrs, namedconf.Compact(s.Items))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Value == nil || i+1 >= len(items) || items[i+1].Value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
arg := items[i+1].Value.Text
|
||||||
|
switch strings.ToLower(it.Value.Text) {
|
||||||
|
case "port":
|
||||||
|
l.Port, _ = strconv.Atoi(arg)
|
||||||
|
case "tls":
|
||||||
|
l.TLS = arg
|
||||||
|
case "http":
|
||||||
|
l.HTTP = arg
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSBlock is a top-level tls statement.
|
||||||
|
type TLSBlock struct {
|
||||||
|
OptionID int64 `json:"option_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CertFile string `json:"cert_file"`
|
||||||
|
KeyFile string `json:"key_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSBlocks returns the top-level tls statements.
|
||||||
|
func TLSBlocks(st *model.State) []TLSBlock {
|
||||||
|
var out []TLSBlock
|
||||||
|
for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name != "tls" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stmt, err := namedconf.ParseOptionValue("tls", o.Value)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := TLSBlock{OptionID: o.ID, Name: stmt.Arg(0)}
|
||||||
|
if blk := stmt.FirstBlock(); blk != nil {
|
||||||
|
for _, s := range blk.Stmts {
|
||||||
|
switch s.Keyword() {
|
||||||
|
case "cert-file":
|
||||||
|
b.CertFile = s.Arg(0)
|
||||||
|
case "key-file":
|
||||||
|
b.KeyFile = s.Arg(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint is an address and port named serves DoT or DoH on.
|
||||||
|
type Endpoint struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what is set up.
|
||||||
|
type Status struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// TLS is the tls statement the setup wrote, and Certificate the
|
||||||
|
// certificate it names (a Jarl certificate's name, when it is one).
|
||||||
|
TLS *TLSBlock `json:"tls,omitempty"`
|
||||||
|
Certificate string `json:"certificate,omitempty"`
|
||||||
|
DoT []Endpoint `json:"dot"`
|
||||||
|
DoH []Endpoint `json:"doh"`
|
||||||
|
// Listeners are all listen-on statements, the setup's and others.
|
||||||
|
Listeners []Listener `json:"listeners"`
|
||||||
|
// Other are DoT or DoH listeners written by hand.
|
||||||
|
Other bool `json:"other,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus reads the setup from the configuration. certDir is where Jarl
|
||||||
|
// writes certificates for named (certificates.dir).
|
||||||
|
func GetStatus(st *model.State, certDir string) Status {
|
||||||
|
s := Status{Listeners: Listeners(st), DoT: []Endpoint{}, DoH: []Endpoint{}}
|
||||||
|
if s.Listeners == nil {
|
||||||
|
s.Listeners = []Listener{}
|
||||||
|
}
|
||||||
|
for _, b := range TLSBlocks(st) {
|
||||||
|
if strings.HasPrefix(b.Name, Prefix) {
|
||||||
|
b := b
|
||||||
|
s.TLS = &b
|
||||||
|
if dir := filepath.Dir(b.CertFile); filepath.Dir(dir) == filepath.Clean(certDir) {
|
||||||
|
s.Certificate = filepath.Base(dir)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range s.Listeners {
|
||||||
|
if l.Kind() == "plain" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !l.Managed() {
|
||||||
|
s.Other = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range l.Addrs {
|
||||||
|
e := Endpoint{Address: a, Port: l.EffectivePort()}
|
||||||
|
if l.Kind() == "doh" {
|
||||||
|
s.DoH = append(s.DoH, e)
|
||||||
|
} else {
|
||||||
|
s.DoT = append(s.DoT, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Enabled = len(s.DoT)+len(s.DoH) > 0
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup is what to set up.
|
||||||
|
type Setup struct {
|
||||||
|
// Hostname is the name clients connect to (the certificate's).
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
// Certificate is the Jarl certificate (with the dns use) to serve.
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
// Addresses are the addresses of this server to listen on.
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
DoT bool `json:"dot"`
|
||||||
|
DoH bool `json:"doh"`
|
||||||
|
DoHPort int `json:"doh_port"` // default 443
|
||||||
|
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
|
||||||
|
// Views get the hostname's A/AAAA records (default: none). In a
|
||||||
|
// linked zone they are local to the view.
|
||||||
|
Views []string `json:"views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan returns the requests that set up encrypted DNS as s says,
|
||||||
|
// replacing an earlier setup, and notes for the user. certDir is where
|
||||||
|
// the certificate's files are.
|
||||||
|
func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string, error) {
|
||||||
|
var notes []string
|
||||||
|
if !s.DoT && !s.DoH {
|
||||||
|
return nil, nil, fmt.Errorf("choose DNS over TLS, DNS over HTTPS or both")
|
||||||
|
}
|
||||||
|
if s.DoHPort == 0 {
|
||||||
|
s.DoHPort = 443
|
||||||
|
}
|
||||||
|
if s.DoTPort == 0 {
|
||||||
|
s.DoTPort = DoTPort
|
||||||
|
}
|
||||||
|
for _, p := range []int{s.DoHPort, s.DoTPort} {
|
||||||
|
if p < 1 || p > 65535 || p == 53 {
|
||||||
|
return nil, nil, fmt.Errorf("port %d: use a free port other than 53", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.DoT && s.DoH && s.DoHPort == s.DoTPort {
|
||||||
|
return nil, nil, fmt.Errorf("DoT and DoH need different ports (both are %d)", s.DoHPort)
|
||||||
|
}
|
||||||
|
if s.Certificate == "" {
|
||||||
|
return nil, nil, fmt.Errorf("choose the certificate to serve")
|
||||||
|
}
|
||||||
|
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), "."))
|
||||||
|
if host == "" || !strings.Contains(host, ".") {
|
||||||
|
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
|
||||||
|
}
|
||||||
|
var v4, v6 []string
|
||||||
|
for _, a := range s.Addresses {
|
||||||
|
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%q isn't an address of this server", a)
|
||||||
|
}
|
||||||
|
ip = ip.Unmap()
|
||||||
|
if ip.IsUnspecified() {
|
||||||
|
return nil, nil, fmt.Errorf("listen on this server's addresses, not on %s (that would include public ones)", ip)
|
||||||
|
}
|
||||||
|
if ip.Is4() {
|
||||||
|
v4 = append(v4, ip.String())
|
||||||
|
} else {
|
||||||
|
v6 = append(v6, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(v4)+len(v6) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("choose at least one address to listen on")
|
||||||
|
}
|
||||||
|
tlsName := Prefix + s.Certificate
|
||||||
|
dir := filepath.Join(certDir, s.Certificate)
|
||||||
|
tlsValue := fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
||||||
|
tlsName, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
||||||
|
|
||||||
|
var reqs []engine.Request
|
||||||
|
// Take out the earlier setup's listeners and tls statements.
|
||||||
|
current := Listeners(st)
|
||||||
|
for _, l := range current {
|
||||||
|
if l.Managed() {
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
haveTLS := false
|
||||||
|
for _, b := range TLSBlocks(st) {
|
||||||
|
switch {
|
||||||
|
case b.Name == tlsName:
|
||||||
|
haveTLS = true
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(tlsValue)}))
|
||||||
|
case strings.HasPrefix(b.Name, Prefix):
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !haveTLS {
|
||||||
|
reqs = append(reqs, option(model.ScopeTopLevel, "tls", tlsValue))
|
||||||
|
}
|
||||||
|
// named listens on port 53 everywhere only while there is no
|
||||||
|
// listen-on statement at all: adding the first one for DoT would end
|
||||||
|
// that, so keep it explicitly.
|
||||||
|
plain := func(v6 bool) bool {
|
||||||
|
for _, l := range current {
|
||||||
|
if l.V6 == v6 && !l.Managed() && l.Kind() == "plain" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
add := func(name string, addrs []string) {
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := "{ " + strings.Join(addrs, "; ") + "; }"
|
||||||
|
if !plain(name == "listen-on-v6") {
|
||||||
|
reqs = append(reqs, option(model.ScopeOptions, name, "{ any; }"))
|
||||||
|
notes = append(notes, fmt.Sprintf("named listened on port 53 on every address by default; %s { any; } keeps that, as the first %s statement would otherwise end it", name, name))
|
||||||
|
}
|
||||||
|
if s.DoT {
|
||||||
|
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s %s", s.DoTPort, tlsName, list)))
|
||||||
|
}
|
||||||
|
if s.DoH {
|
||||||
|
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s http default %s", s.DoHPort, tlsName, list)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("listen-on", v4)
|
||||||
|
add("listen-on-v6", v6)
|
||||||
|
|
||||||
|
// The host name's addresses, in the views asked for.
|
||||||
|
fqdn := host + "."
|
||||||
|
for _, vn := range s.Views {
|
||||||
|
v := st.ViewByName(vn)
|
||||||
|
if v == nil {
|
||||||
|
return nil, nil, fmt.Errorf("view %q does not exist", vn)
|
||||||
|
}
|
||||||
|
z := zoneFor(st, fqdn, v.ID)
|
||||||
|
if z == nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var local *bool
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local = model.Ptr(true)
|
||||||
|
}
|
||||||
|
for _, set := range []struct {
|
||||||
|
typ string
|
||||||
|
addrs []string
|
||||||
|
}{{"A", v4}, {"AAAA", v6}} {
|
||||||
|
if len(set.addrs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reqs, notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func option(scope, name, value string) engine.Request {
|
||||||
|
return engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(scope), Name: model.Ptr(name), Value: model.Ptr(value)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneFor returns the primary zone in a view that holds name.
|
||||||
|
func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
|
||||||
|
var best *model.Zone
|
||||||
|
for _, z := range st.ZonesInView(viewID) {
|
||||||
|
if z.Type == model.ZonePrimary && (name == z.Name || strings.HasSuffix(name, "."+z.Name)) && (best == nil || len(z.Name) > len(best.Name)) {
|
||||||
|
best = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable returns the requests that take the setup out: its listeners and
|
||||||
|
// tls statement. Plain listeners and the host name's records stay.
|
||||||
|
func Disable(st *model.State) []engine.Request {
|
||||||
|
var reqs []engine.Request
|
||||||
|
for _, l := range Listeners(st) {
|
||||||
|
if l.Managed() {
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range TLSBlocks(st) {
|
||||||
|
if strings.HasPrefix(b.Name, Prefix) {
|
||||||
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reqs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private reports whether an address is private (RFC 1918, ULA, ...).
|
||||||
|
func Private(a netip.Addr) bool {
|
||||||
|
a = a.Unmap()
|
||||||
|
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || netip.MustParsePrefix("100.64.0.0/10").Contains(a)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package encdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/render"
|
||||||
|
"jarl/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
t *testing.T
|
||||||
|
eng *engine.Engine
|
||||||
|
st *store.Store
|
||||||
|
certDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnv(t *testing.T) *env {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
eng := engine.New(st, nil, engine.Config{
|
||||||
|
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||||
|
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
||||||
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
e := &env{t: t, eng: eng, st: st, certDir: filepath.Join(dir, "tls")}
|
||||||
|
os.MkdirAll(filepath.Join(e.certDir, "dns"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(e.certDir, "dns", "fullchain.pem"), []byte("x"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(e.certDir, "dns", "privkey.pem"), []byte("x"), 0o600)
|
||||||
|
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
|
||||||
|
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
|
||||||
|
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||||
|
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) submit(reqs ...engine.Request) *engine.Result {
|
||||||
|
e.t.Helper()
|
||||||
|
res, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{})
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatalf("%v (%+v)", err, res)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) state() *model.State {
|
||||||
|
st, err := e.st.LoadState(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func values(st *model.State, scope, name string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, o := range st.ScopedOptions(scope, 0) {
|
||||||
|
if o.Name == name {
|
||||||
|
out = append(out, o.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
setup := Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5", "2001:db8::5"}, DoT: true, DoH: true, Views: []string{"internal", "external"}}
|
||||||
|
reqs, notes, err := Plan(e.state(), setup, e.certDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.submit(reqs...)
|
||||||
|
st := e.state()
|
||||||
|
tls := values(st, model.ScopeTopLevel, "tls")
|
||||||
|
if len(tls) != 1 || !strings.HasPrefix(tls[0], "jarl-dns {") || !strings.Contains(tls[0], filepath.Join(e.certDir, "dns", "fullchain.pem")) {
|
||||||
|
t.Errorf("tls = %q", tls)
|
||||||
|
}
|
||||||
|
// The plain listeners named had by default are kept explicitly.
|
||||||
|
want4 := []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }", "port 443 tls jarl-dns http default { 10.0.10.5; }"}
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, want4) {
|
||||||
|
t.Errorf("listen-on = %q, want %q", got, want4)
|
||||||
|
}
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on-v6"); len(got) != 3 || got[0] != "{ any; }" {
|
||||||
|
t.Errorf("listen-on-v6 = %q", got)
|
||||||
|
}
|
||||||
|
if len(notes) != 3 || !strings.Contains(strings.Join(notes, " "), "external has no zone") {
|
||||||
|
t.Errorf("notes = %q", notes)
|
||||||
|
}
|
||||||
|
// The host name's addresses in the internal view.
|
||||||
|
z := st.FindZone("example.com.", st.ViewByName("internal").ID)
|
||||||
|
var addrs []string
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
if r.Name == "dns.example.com." {
|
||||||
|
addrs = append(addrs, r.Type+" "+r.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(addrs, []string{"A 10.0.10.5", "AAAA 2001:db8::5"}) {
|
||||||
|
t.Errorf("records = %q", addrs)
|
||||||
|
}
|
||||||
|
s := GetStatus(st, e.certDir)
|
||||||
|
if !s.Enabled || s.Certificate != "dns" || len(s.DoT) != 2 || len(s.DoH) != 2 || s.DoH[0].Port != 443 || s.Other {
|
||||||
|
t.Errorf("status = %+v", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting up again replaces the setup: DoT only, one address, and the
|
||||||
|
// plain listeners aren't doubled.
|
||||||
|
reqs, _, err = Plan(st, Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, e.certDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e.submit(reqs...)
|
||||||
|
st = e.state()
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }"}) {
|
||||||
|
t.Errorf("after the second setup: %q", got)
|
||||||
|
}
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on-v6"); !slices.Equal(got, []string{"{ any; }"}) {
|
||||||
|
t.Errorf("listen-on-v6 after the second setup: %q", got)
|
||||||
|
}
|
||||||
|
if got := values(st, model.ScopeTopLevel, "tls"); len(got) != 1 {
|
||||||
|
t.Errorf("tls statements: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off: the setup's listeners and tls statement go, the rest stays.
|
||||||
|
e.submit(Disable(st)...)
|
||||||
|
st = e.state()
|
||||||
|
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }"}) || len(values(st, model.ScopeTopLevel, "tls")) != 0 || GetStatus(st, e.certDir).Enabled {
|
||||||
|
t.Errorf("after disabling: listen-on %q tls %q", got, values(st, model.ScopeTopLevel, "tls"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupRefuses(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
st := e.state()
|
||||||
|
for _, s := range []Setup{
|
||||||
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}}, // neither DoT nor DoH
|
||||||
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"0.0.0.0"}, DoT: true}, // every address
|
||||||
|
{Hostname: "dns.example.com", Certificate: "dns", DoT: true}, // no address
|
||||||
|
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoH: true, DoHPort: 53}, // port 53
|
||||||
|
{Hostname: "dns", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, // not a host name
|
||||||
|
} {
|
||||||
|
if _, _, err := Plan(st, s, e.certDir); err == nil {
|
||||||
|
t.Errorf("accepted %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A tls statement whose files are missing is refused by validation.
|
||||||
|
_, err := e.eng.Submit(context.Background(), []engine.Request{engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{
|
||||||
|
Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(`x { cert-file "/nonexistent/c.pem"; key-file "/nonexistent/k.pem"; }`)})},
|
||||||
|
auth.System, "test", "", engine.ApplyOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "doesn't exist") {
|
||||||
|
t.Errorf("missing files: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPorts(t *testing.T) {
|
||||||
|
s, err := parseProcAddr("0100007F:0035")
|
||||||
|
if err != nil || s.Addr != netip.MustParseAddr("127.0.0.1") || s.Port != 53 {
|
||||||
|
t.Errorf("v4: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
s, err = parseProcAddr("00000000000000000000000001000000:01BB")
|
||||||
|
if err != nil || s.Addr != netip.MustParseAddr("::1") || s.Port != 443 {
|
||||||
|
t.Errorf("v6: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
e := newEnv(t)
|
||||||
|
old := ListeningSockets
|
||||||
|
t.Cleanup(func() { ListeningSockets = old })
|
||||||
|
ListeningSockets = func() ([]Socket, error) {
|
||||||
|
return []Socket{{netip.MustParseAddr("0.0.0.0"), 443}, {netip.MustParseAddr("10.0.10.5"), 8443}, {netip.MustParseAddr("::"), 853}}, nil
|
||||||
|
}
|
||||||
|
c, err := PortCheck(e.state(), []string{"10.0.10.5"}, []int{853, 443, 8443}, []string{"0.0.0.0:443"})
|
||||||
|
if err != nil || len(c) != 3 || c[1].What != "jarld (Jarl's web UI and API)" || c[2].What != "another program" {
|
||||||
|
t.Errorf("conflicts = %+v %v", c, err)
|
||||||
|
}
|
||||||
|
if p := FreeDoHPort(e.state(), []string{"10.0.10.5"}, nil); p != 4443 {
|
||||||
|
t.Errorf("free DoH port = %d, want 4443", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package encdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Socket is a listening TCP socket on this host.
|
||||||
|
type Socket struct {
|
||||||
|
Addr netip.Addr
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListeningSockets reads the host's listening TCP sockets from
|
||||||
|
// /proc/net/tcp and tcp6 (Linux; nil elsewhere). It needs no privileges
|
||||||
|
// and binds nothing. Tests replace it.
|
||||||
|
var ListeningSockets = func() ([]Socket, error) {
|
||||||
|
var out []Socket
|
||||||
|
for _, f := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
|
||||||
|
socks, err := parseProcNet(f)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, socks...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProcNet(path string) ([]Socket, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []Socket
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Scan() // header
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
if len(fields) < 4 || fields[3] != "0A" { // 0A: LISTEN
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, err := parseProcAddr(fields[1])
|
||||||
|
if err == nil {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProcAddr parses "0100007F:0035": the address in 32-bit words of
|
||||||
|
// host byte order (little-endian here), then the port.
|
||||||
|
func parseProcAddr(s string) (Socket, error) {
|
||||||
|
ha, hp, ok := strings.Cut(s, ":")
|
||||||
|
if !ok {
|
||||||
|
return Socket{}, fmt.Errorf("bad address %q", s)
|
||||||
|
}
|
||||||
|
b, err := hex.DecodeString(ha)
|
||||||
|
if err != nil || (len(b) != 4 && len(b) != 16) {
|
||||||
|
return Socket{}, fmt.Errorf("bad address %q", s)
|
||||||
|
}
|
||||||
|
for i := 0; i+4 <= len(b); i += 4 {
|
||||||
|
b[i], b[i+1], b[i+2], b[i+3] = b[i+3], b[i+2], b[i+1], b[i]
|
||||||
|
}
|
||||||
|
addr, _ := netip.AddrFromSlice(b)
|
||||||
|
port, err := strconv.ParseUint(hp, 16, 16)
|
||||||
|
if err != nil {
|
||||||
|
return Socket{}, err
|
||||||
|
}
|
||||||
|
return Socket{Addr: addr.Unmap(), Port: int(port)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict is something else listening where a listener would go.
|
||||||
|
type Conflict struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
What string `json:"what"` // who it is, when Jarl knows
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortCheck says which of addresses × ports something else listens on
|
||||||
|
// already. named's own listeners (the setup's, now) and jarld's (known
|
||||||
|
// listen addresses) are told apart.
|
||||||
|
func PortCheck(st *model.State, addresses []string, ports []int, jarld []string) ([]Conflict, error) {
|
||||||
|
socks, err := ListeningSockets()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// What named listens on now with DoT or DoH (it will again).
|
||||||
|
ours := map[string]bool{}
|
||||||
|
for _, l := range Listeners(st) {
|
||||||
|
if l.Kind() != "plain" {
|
||||||
|
for _, a := range l.Addrs {
|
||||||
|
ours[a+"#"+strconv.Itoa(l.EffectivePort())] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jarldOn := map[string]bool{}
|
||||||
|
for _, l := range jarld {
|
||||||
|
jarldOn[l] = true
|
||||||
|
}
|
||||||
|
var out []Conflict
|
||||||
|
for _, as := range addresses {
|
||||||
|
a, err := netip.ParseAddr(as)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a = a.Unmap()
|
||||||
|
for _, p := range ports {
|
||||||
|
if ours[a.String()+"#"+strconv.Itoa(p)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range socks {
|
||||||
|
if s.Port != p || !(s.Addr == a || s.Addr.IsUnspecified() && s.Addr.Is4() == a.Is4() || s.Addr.IsUnspecified() && s.Addr.Is6()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
what := "another program"
|
||||||
|
for l := range jarldOn {
|
||||||
|
if strings.HasSuffix(l, ":"+strconv.Itoa(p)) {
|
||||||
|
what = "jarld (Jarl's web UI and API)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Conflict{Address: a.String(), Port: p, What: what})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreeDoHPort returns the first of the usual DoH ports nothing listens on
|
||||||
|
// at the addresses (0 when none is free).
|
||||||
|
func FreeDoHPort(st *model.State, addresses []string, jarld []string) int {
|
||||||
|
for _, p := range []int{443, 8443, 4443, 9443} {
|
||||||
|
if c, err := PortCheck(st, addresses, []int{p}, jarld); err == nil && len(c) == 0 {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
@@ -23,7 +24,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
||||||
s := to.Settings
|
s := to.Settings
|
||||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
||||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings")
|
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
|
||||||
|
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, k := range sortedKeys(to.Keys) {
|
for _, k := range sortedKeys(to.Keys) {
|
||||||
@@ -47,7 +49,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
for _, k := range sortedKeys(to.Views) {
|
for _, k := range sortedKeys(to.Views) {
|
||||||
n := to.Views[k]
|
n := to.Views[k]
|
||||||
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
||||||
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)}
|
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
|
||||||
|
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
|
||||||
if n.MatchRecursiveOnly != nil {
|
if n.MatchRecursiveOnly != nil {
|
||||||
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
||||||
} else {
|
} else {
|
||||||
@@ -63,7 +66,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
n := to.Zones[k]
|
n := to.Zones[k]
|
||||||
s := n.SOA
|
s := n.SOA
|
||||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
|
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)}
|
||||||
if n.Type == model.ZonePrimary {
|
if n.Type == model.ZonePrimary {
|
||||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
}
|
}
|
||||||
for _, k := range sortedKeys(to.Records) {
|
for _, k := range sortedKeys(to.Records) {
|
||||||
n := to.Records[k]
|
n := to.Records[k]
|
||||||
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
|
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled, SharedID: model.Ptr(n.SharedID)}
|
||||||
if n.TTL != nil {
|
if n.TTL != nil {
|
||||||
in.TTL = model.Some(*n.TTL)
|
in.TTL = model.Some(*n.TTL)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+79
-12
@@ -15,6 +15,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -56,6 +57,10 @@ type Config struct {
|
|||||||
NoReload bool
|
NoReload bool
|
||||||
// SkipCheck disables named-checkconf (only for tests without BIND).
|
// SkipCheck disables named-checkconf (only for tests without BIND).
|
||||||
SkipCheck bool
|
SkipCheck bool
|
||||||
|
// LocalAddrs returns this host's addresses, for working out whether a
|
||||||
|
// server without views is reachable from the internet
|
||||||
|
// (docs/exposure.md). nil: none known.
|
||||||
|
LocalAddrs func() []netip.Addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine applies changesets.
|
// Engine applies changesets.
|
||||||
@@ -110,17 +115,20 @@ type Result struct {
|
|||||||
DryRun bool `json:"dry_run,omitempty"`
|
DryRun bool `json:"dry_run,omitempty"`
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
Problems []validate.Problem `json:"problems"` // problems this change introduces
|
Problems []validate.Problem `json:"problems"` // problems this change introduces
|
||||||
Existing []validate.Problem `json:"existing_problems,omitempty"`
|
// Risks are the introduced problems that open the server up; applying
|
||||||
Check []CheckMessage `json:"check,omitempty"`
|
// needs AcceptRisk (docs/exposure.md).
|
||||||
CheckOK bool `json:"check_ok"`
|
Risks []validate.Problem `json:"risks,omitempty"`
|
||||||
Diff string `json:"diff"`
|
Existing []validate.Problem `json:"existing_problems,omitempty"`
|
||||||
Files []string `json:"files_changed"`
|
Check []CheckMessage `json:"check,omitempty"`
|
||||||
Serials []SerialChange `json:"serials,omitempty"`
|
CheckOK bool `json:"check_ok"`
|
||||||
Reloaded []string `json:"reloaded,omitempty"`
|
Diff string `json:"diff"`
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
Files []string `json:"files_changed"`
|
||||||
Drift []DriftEntry `json:"drift,omitempty"`
|
Serials []SerialChange `json:"serials,omitempty"`
|
||||||
Ops []model.Op `json:"ops,omitempty"`
|
Reloaded []string `json:"reloaded,omitempty"`
|
||||||
Error *Error `json:"error,omitempty"`
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
Drift []DriftEntry `json:"drift,omitempty"`
|
||||||
|
Ops []model.Op `json:"ops,omitempty"`
|
||||||
|
Error *Error `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// stored is what is kept in a changeset's result column.
|
// stored is what is kept in a changeset's result column.
|
||||||
@@ -138,6 +146,9 @@ type ApplyOptions struct {
|
|||||||
Force bool
|
Force bool
|
||||||
// NoReload writes the files without reloading named.
|
// NoReload writes the files without reloading named.
|
||||||
NoReload bool
|
NoReload bool
|
||||||
|
// AcceptRisk confirms a change that opens the server up, such as an
|
||||||
|
// open resolver on a public view (validate.RiskCodes).
|
||||||
|
AcceptRisk bool
|
||||||
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
|
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
|
||||||
wipeZone int64
|
wipeZone int64
|
||||||
}
|
}
|
||||||
@@ -180,7 +191,46 @@ func ValidateOptions(st *model.State) validate.Options {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) validate(st *model.State) []validate.Problem {
|
func (e *Engine) validate(st *model.State) []validate.Problem {
|
||||||
return validate.State(st, ValidateOptions(st))
|
return validate.State(st, e.validateOptions(st))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) validateOptions(st *model.State) validate.Options {
|
||||||
|
opts := ValidateOptions(st)
|
||||||
|
if e.cfg.LocalAddrs != nil {
|
||||||
|
opts.LocalAddrs = e.cfg.LocalAddrs()
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks a state as applying it would.
|
||||||
|
func (e *Engine) Validate(st *model.State) []validate.Problem { return e.validate(st) }
|
||||||
|
|
||||||
|
// Exposures says which views (or the server, without views) are
|
||||||
|
// reachable from the internet, and why (docs/exposure.md).
|
||||||
|
func (e *Engine) Exposures(st *model.State) []validate.Exposure {
|
||||||
|
return validate.Exposures(st, e.validateOptions(st))
|
||||||
|
}
|
||||||
|
|
||||||
|
// risks returns the introduced problems that open a public view (or a
|
||||||
|
// public server) up: risky warnings that weren't there before at all. One
|
||||||
|
// that was there already, acknowledged (taking the acknowledgement back)
|
||||||
|
// or not, opens nothing new.
|
||||||
|
func risks(before, introduced []validate.Problem, public map[string]bool) []validate.Problem {
|
||||||
|
type key struct {
|
||||||
|
code, objType, view string
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
had := map[key]bool{}
|
||||||
|
for _, p := range before {
|
||||||
|
had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] = true
|
||||||
|
}
|
||||||
|
var out []validate.Problem
|
||||||
|
for _, p := range introduced {
|
||||||
|
if p.Severity == validate.Warning && validate.RiskCodes[p.Code] && public[p.View] && !had[key{p.Code, p.ObjectType, p.View, p.ObjectID}] {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- changeset management
|
// ---- changeset management
|
||||||
@@ -389,6 +439,8 @@ type plan struct {
|
|||||||
base, work *model.State
|
base, work *model.State
|
||||||
introduced []validate.Problem
|
introduced []validate.Problem
|
||||||
existing []validate.Problem
|
existing []validate.Problem
|
||||||
|
before []validate.Problem // the problems before the change
|
||||||
|
public map[string]bool // public views by name ("": the server), after the change
|
||||||
serials []SerialChange
|
serials []SerialChange
|
||||||
out *render.Output
|
out *render.Output
|
||||||
changed []*render.File
|
changed []*render.File
|
||||||
@@ -419,6 +471,13 @@ func (pl *plan) files() []string {
|
|||||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
||||||
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
||||||
before := e.validate(base)
|
before := e.validate(base)
|
||||||
|
pl.before = before
|
||||||
|
pl.public = map[string]bool{}
|
||||||
|
for _, x := range e.Exposures(work) {
|
||||||
|
pl.public[x.View] = x.Public
|
||||||
|
// Server-wide findings (no view) count when anything is public.
|
||||||
|
pl.public[""] = pl.public[""] || x.Public
|
||||||
|
}
|
||||||
after := e.validate(work)
|
after := e.validate(work)
|
||||||
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
||||||
introducedKeys := map[string]bool{}
|
introducedKeys := map[string]bool{}
|
||||||
@@ -652,6 +711,7 @@ func (pl *plan) result() *Result {
|
|||||||
Files: pl.files(),
|
Files: pl.files(),
|
||||||
Serials: pl.serials,
|
Serials: pl.serials,
|
||||||
Drift: pl.drift,
|
Drift: pl.drift,
|
||||||
|
Risks: risks(pl.before, pl.introduced, pl.public),
|
||||||
}
|
}
|
||||||
r.Valid = r.Valid && r.CheckOK
|
r.Valid = r.Valid && r.CheckOK
|
||||||
if r.Files == nil {
|
if r.Files == nil {
|
||||||
@@ -781,6 +841,13 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
|||||||
if opts.DryRun {
|
if opts.DryRun {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
if rs := risks(pl.before, pl.introduced, pl.public); len(rs) > 0 && !opts.AcceptRisk {
|
||||||
|
msg := rs[0].Message
|
||||||
|
if len(rs) > 1 {
|
||||||
|
msg += fmt.Sprintf(" (and %d more)", len(rs)-1)
|
||||||
|
}
|
||||||
|
return res, &Error{Kind: KindRisk, Message: "this change opens the server up: " + msg + "; apply it again with accept_risk to confirm (jarl: --accept-risk), or acknowledge the finding for good", Problems: rs}
|
||||||
|
}
|
||||||
if len(pl.drift) > 0 && !opts.Force {
|
if len(pl.drift) > 0 && !opts.Force {
|
||||||
var paths []string
|
var paths []string
|
||||||
for _, d := range pl.drift {
|
for _, d := range pl.drift {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const (
|
|||||||
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
|
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
|
||||||
KindDrift ErrorKind = "drift" // files were changed outside Jarl
|
KindDrift ErrorKind = "drift" // files were changed outside Jarl
|
||||||
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
|
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
|
||||||
|
KindRisk ErrorKind = "confirm_risk" // the change opens the server up and needs confirming
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error is an engine error with details for the caller.
|
// Error is an engine error with details for the caller.
|
||||||
|
|||||||
+288
-21
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -131,7 +132,24 @@ func dateSerial(now time.Time) uint32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) expand(req Request) ([]model.Op, error) {
|
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||||
|
ops, err := x.expandOne(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
switch req.Op {
|
switch req.Op {
|
||||||
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
||||||
|
// Changes to linked zones reach the other zones of the link.
|
||||||
|
return x.linkSync(ops)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
||||||
|
switch req.Op {
|
||||||
|
case OpZoneLink:
|
||||||
|
return x.zoneLink(req)
|
||||||
|
case OpZoneUnlink:
|
||||||
|
return x.zoneUnlink(req)
|
||||||
case OpZoneCreate:
|
case OpZoneCreate:
|
||||||
return x.zoneCreate(req)
|
return x.zoneCreate(req)
|
||||||
case OpZoneUpdate, OpZoneDelete:
|
case OpZoneUpdate, OpZoneDelete:
|
||||||
@@ -152,7 +170,24 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
ops := []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}
|
||||||
|
if req.Op == OpZoneDelete {
|
||||||
|
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||||
|
ops = append(ops, x.unlinkOps(rest[0], "unlink zone "+zoneDesc(x.st, rest[0])+": the zone it was linked with is deleted")...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var in ZoneInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if in.LinkGroup != nil && *in.LinkGroup != z.LinkGroup {
|
||||||
|
return nil, errf(KindBadRequest, "link zones with zone.link and zone.unlink (jarl zone link / unlink)")
|
||||||
|
}
|
||||||
|
if in.LinkWith != "" {
|
||||||
|
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
case OpRecordCreate:
|
case OpRecordCreate:
|
||||||
return x.recordCreate(req)
|
return x.recordCreate(req)
|
||||||
case OpRecordUpdate:
|
case OpRecordUpdate:
|
||||||
@@ -228,6 +263,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
||||||
case OpSettingsUpdate:
|
case OpSettingsUpdate:
|
||||||
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
||||||
|
case OpExposureAck, OpExposureUnack:
|
||||||
|
return x.exposureAck(req, req.Op == OpExposureAck)
|
||||||
case OpBlocklistCreate:
|
case OpBlocklistCreate:
|
||||||
return x.blocklistCreate(req)
|
return x.blocklistCreate(req)
|
||||||
case OpBlocklistUpdate, OpBlocklistDelete:
|
case OpBlocklistUpdate, OpBlocklistDelete:
|
||||||
@@ -353,7 +390,25 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
records := in.Records
|
records := in.Records
|
||||||
cloneFrom := in.CloneFrom
|
cloneFrom := in.CloneFrom
|
||||||
zoneOpts := in.Options
|
zoneOpts := in.Options
|
||||||
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
|
linkWith := in.LinkWith
|
||||||
|
in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil
|
||||||
|
if linkWith != "" {
|
||||||
|
if cloneFrom != 0 {
|
||||||
|
return nil, errf(KindBadRequest, "give clone_from or link_with, not both")
|
||||||
|
}
|
||||||
|
v := x.st.ViewByName(linkWith)
|
||||||
|
if v == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %q does not exist", linkWith)
|
||||||
|
}
|
||||||
|
o := x.st.FindZone(name, v.ID)
|
||||||
|
if o == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name))
|
||||||
|
}
|
||||||
|
if o.Type != model.ZonePrimary || zt != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "only primary zones can be linked")
|
||||||
|
}
|
||||||
|
cloneFrom = o.ID
|
||||||
|
}
|
||||||
|
|
||||||
set := x.st.Settings
|
set := x.st.Settings
|
||||||
var src *model.Zone
|
var src *model.Zone
|
||||||
@@ -478,9 +533,30 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var linkOps []model.Op
|
||||||
|
if src != nil && linkWith != "" {
|
||||||
|
// The new zone joins src's link, or starts one with src: then
|
||||||
|
// all of src's records become shared.
|
||||||
|
group := src.LinkGroup
|
||||||
|
if group == 0 {
|
||||||
|
group = src.ID
|
||||||
|
linkOps = x.shareAllOps(src, group)
|
||||||
|
}
|
||||||
|
in.LinkGroup = &group
|
||||||
|
}
|
||||||
if src != nil {
|
if src != nil {
|
||||||
for _, r := range x.st.ZoneRecords(src.ID) {
|
for _, r := range x.st.ZoneRecords(src.ID) {
|
||||||
|
if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 {
|
||||||
|
continue // local to src's view
|
||||||
|
}
|
||||||
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
||||||
|
if linkWith != "" {
|
||||||
|
id := r.SharedID
|
||||||
|
if id == 0 {
|
||||||
|
id = r.ID
|
||||||
|
}
|
||||||
|
ri.SharedID = &id
|
||||||
|
}
|
||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
ri.Disabled = model.Ptr(true)
|
ri.Disabled = model.Ptr(true)
|
||||||
}
|
}
|
||||||
@@ -510,10 +586,12 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
summary += " in view " + v.Name
|
summary += " in view " + v.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if src != nil {
|
if src != nil && linkWith != "" {
|
||||||
|
summary += " (linked with " + zoneDesc(x.st, src) + ")"
|
||||||
|
} else if src != nil {
|
||||||
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
|
ops := append(linkOps, newOp(OpZoneCreate, ids[0], 0, in, summary))
|
||||||
for i, zo := range zoneOpts {
|
for i, zo := range zoneOpts {
|
||||||
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
||||||
if zo.Comment != "" {
|
if zo.Comment != "" {
|
||||||
@@ -565,11 +643,42 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
found := false
|
||||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil, nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
more, err := x.ptrCreateIn(rz, ptrName, owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) ([]model.Op, error) {
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" {
|
if r.Name == ptrName && r.Type == "PTR" {
|
||||||
if strings.EqualFold(r.Data, owner) {
|
if strings.EqualFold(r.Data, owner) {
|
||||||
@@ -597,10 +706,18 @@ func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, dis
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil
|
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
|
var ops []model.Op
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
||||||
@@ -623,12 +740,16 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil
|
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)))
|
||||||
}
|
}
|
||||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
return ops
|
||||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||||
@@ -664,7 +785,27 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
|
summary := fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data)
|
||||||
|
in.SharedID = nil
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local, err := x.localFor(z, owner, p.Type, in.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if local {
|
||||||
|
in.SharedID = model.Ptr(int64(0))
|
||||||
|
summary += " (local to view " + x.st.ViewName(z) + ")"
|
||||||
|
} else {
|
||||||
|
if x.st.HasLocal(z.ID, owner, p.Type) {
|
||||||
|
return nil, errf(KindConflict, "%s %s is local in view %s; share the set first (jarl record update ID --shared) or add this one as local", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||||
|
}
|
||||||
|
in.SharedID = &ids[0]
|
||||||
|
}
|
||||||
|
} else if in.Local != nil && *in.Local {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
in.Local = nil
|
||||||
|
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||||
if ptr {
|
if ptr {
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
more, err := x.ptrCreate(z, owner, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -688,6 +829,35 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
ptr := in.PTR
|
ptr := in.PTR
|
||||||
in.PTR = false
|
in.PTR = false
|
||||||
in.Zone, in.View = "", ""
|
in.Zone, in.View = "", ""
|
||||||
|
if in.SharedID != nil {
|
||||||
|
return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared")
|
||||||
|
}
|
||||||
|
var pre []model.Op
|
||||||
|
if in.Local != nil {
|
||||||
|
if z.LinkGroup == 0 {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
if want := *in.Local; want != (r.SharedID == 0) {
|
||||||
|
// The whole set of this name and type changes sides.
|
||||||
|
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||||
|
id := int64(0)
|
||||||
|
what := fmt.Sprintf("make %s %s local to view %s", o.Name, o.Type, x.st.ViewName(z))
|
||||||
|
if !want {
|
||||||
|
id = o.ID
|
||||||
|
what = fmt.Sprintf("share %s %s %s with the linked views", o.Name, o.Type, o.Data)
|
||||||
|
}
|
||||||
|
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||||
|
}
|
||||||
|
if !want {
|
||||||
|
// Sharing a version makes it every view's.
|
||||||
|
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.Local = nil
|
||||||
|
if string(mustJSON(in)) == "{}" {
|
||||||
|
return pre, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
name, typ, data := r.Name, r.Type, r.Data
|
name, typ, data := r.Name, r.Type, r.Data
|
||||||
if in.Name != nil {
|
if in.Name != nil {
|
||||||
name = *in.Name
|
name = *in.Name
|
||||||
@@ -749,11 +919,20 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
case len(changes) > 0:
|
case len(changes) > 0:
|
||||||
summary += ": " + strings.Join(changes, ", ")
|
summary += ": " + strings.Join(changes, ", ")
|
||||||
}
|
}
|
||||||
|
if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) {
|
||||||
|
return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||||
|
}
|
||||||
base := req.BaseVersion
|
base := req.BaseVersion
|
||||||
if base == 0 {
|
if base == 0 {
|
||||||
base = r.Version
|
base = r.Version
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
|
if len(pre) > 0 {
|
||||||
|
base = 0 // the record was just updated by pre
|
||||||
|
}
|
||||||
|
ops := pre
|
||||||
|
if string(mustJSON(in)) != "{}" || !ptr {
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||||
|
}
|
||||||
disabled := r.Disabled
|
disabled := r.Disabled
|
||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
disabled = *in.Disabled
|
disabled = *in.Disabled
|
||||||
@@ -777,6 +956,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,17 +1139,35 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
|||||||
canon = append(canon, p.Data)
|
canon = append(canon, p.Data)
|
||||||
}
|
}
|
||||||
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
||||||
|
where := ""
|
||||||
|
var pre []model.Op
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
local, err := x.localFor(z, owner, typ, in.Local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in.Shared = model.Ptr(!local)
|
||||||
|
if local {
|
||||||
|
where = " (local to view " + x.st.ViewName(z) + ")"
|
||||||
|
} else if in.Local != nil {
|
||||||
|
// Explicitly shared: this version becomes every view's.
|
||||||
|
pre = x.dropOverrides(z, owner, typ)
|
||||||
|
}
|
||||||
|
} else if in.Local != nil && *in.Local {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
in.Local = nil
|
||||||
if len(canon) > 0 {
|
if len(canon) > 0 {
|
||||||
in.NewIDs, err = x.ids(len(canon))
|
in.NewIDs, err = x.ids(len(canon))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
|
summary := fmt.Sprintf("set %s%s IN %s to [%s]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where)
|
||||||
if len(canon) == 0 {
|
if len(canon) == 0 {
|
||||||
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
|
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
||||||
}
|
}
|
||||||
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
|
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
||||||
@@ -1089,3 +1295,64 @@ func disabledZoneEffect(st *model.State, z *model.Zone) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
|
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exposureAck acknowledges an exposure finding of a view or the server,
|
||||||
|
// or takes the acknowledgement back: the view's (or the settings')
|
||||||
|
// acknowledgements are updated as a whole.
|
||||||
|
func (x *expander) exposureAck(req Request, ack bool) ([]model.Op, error) {
|
||||||
|
var in AckInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in.Code = strings.TrimSpace(in.Code)
|
||||||
|
if !validate.Ackable(in.Code) {
|
||||||
|
return nil, errf(KindBadRequest, "%q is not an exposure finding that can be acknowledged (exposure_... or security_open_...)", in.Code)
|
||||||
|
}
|
||||||
|
if ack && strings.TrimSpace(in.Reason) == "" {
|
||||||
|
return nil, errf(KindBadRequest, "say why %s is accepted (a reason is required)", in.Code)
|
||||||
|
}
|
||||||
|
var v *model.View
|
||||||
|
switch {
|
||||||
|
case in.ViewID != 0:
|
||||||
|
v = x.st.Views[in.ViewID]
|
||||||
|
case in.View != "":
|
||||||
|
v = x.st.ViewByName(in.View)
|
||||||
|
}
|
||||||
|
if (in.ViewID != 0 || in.View != "") && v == nil {
|
||||||
|
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
|
||||||
|
}
|
||||||
|
acks := x.st.Settings.Acknowledged
|
||||||
|
where := "the server"
|
||||||
|
if v != nil {
|
||||||
|
acks, where = v.Acknowledged, "view "+v.Name
|
||||||
|
}
|
||||||
|
var out []model.Acknowledgement
|
||||||
|
for _, a := range acks {
|
||||||
|
if a.Code != in.Code {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary := "acknowledge " + in.Code + " for " + where + ": " + strings.TrimSpace(in.Reason)
|
||||||
|
if ack {
|
||||||
|
out = append(out, model.Acknowledgement{Code: in.Code, Reason: strings.TrimSpace(in.Reason), By: x.p.Name(), At: x.now})
|
||||||
|
} else {
|
||||||
|
if len(out) == len(acks) {
|
||||||
|
return nil, errf(KindConflict, "%s isn't acknowledged for %s", in.Code, where)
|
||||||
|
}
|
||||||
|
summary = "take back the acknowledgement of " + in.Code + " for " + where
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []model.Acknowledgement{}
|
||||||
|
}
|
||||||
|
if v != nil {
|
||||||
|
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{Acknowledged: &out}, summary)}, nil
|
||||||
|
}
|
||||||
|
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{Acknowledged: &out}, summary)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStr(a, b string) string {
|
||||||
|
if a != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExposureFix returns the requests that fix an exposure finding of a view
|
||||||
|
// (viewID 0: the server), and what they do, or nil when there is no
|
||||||
|
// automatic fix: what to allow instead (your secondaries, your hosts)
|
||||||
|
// is for you to decide. The requests go through a changeset like any
|
||||||
|
// edit (docs/exposure.md).
|
||||||
|
func ExposureFix(st *model.State, viewID int64, code string) ([]Request, string) {
|
||||||
|
v := st.Views[viewID]
|
||||||
|
scope, scopeID := model.ScopeOptions, int64(0)
|
||||||
|
if v != nil {
|
||||||
|
scope, scopeID = model.ScopeView, v.ID
|
||||||
|
}
|
||||||
|
set := func(name, value string) Request {
|
||||||
|
if o := st.FindOption(scope, scopeID, name); o != nil {
|
||||||
|
return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})
|
||||||
|
}
|
||||||
|
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, ScopeID: &scopeID, Name: &name, Value: &value})
|
||||||
|
}
|
||||||
|
server := func(name, value string) Request {
|
||||||
|
if o := st.FindOption(model.ScopeOptions, 0, name); o != nil {
|
||||||
|
return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})
|
||||||
|
}
|
||||||
|
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value})
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case "exposure_rate_limit":
|
||||||
|
value := RecommendedRateLimit(st, v)
|
||||||
|
return []Request{set("rate-limit", value)}, "start rate limiting in log-only mode: rate-limit " + value
|
||||||
|
case "exposure_rate_limit_log_only":
|
||||||
|
o := st.FindOption(scope, scopeID, "rate-limit")
|
||||||
|
if o == nil {
|
||||||
|
o = st.FindOption(model.ScopeOptions, 0, "rate-limit")
|
||||||
|
}
|
||||||
|
if o == nil {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
value := withoutLogOnly(o.Value)
|
||||||
|
return []Request{NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})}, "start enforcing the rate limit: rate-limit " + value
|
||||||
|
case "exposure_minimal_any":
|
||||||
|
return []Request{set("minimal-any", "yes")}, "answer ANY queries minimally: minimal-any yes"
|
||||||
|
case "exposure_cache_snooping":
|
||||||
|
return []Request{set("allow-query-cache", "{ none; }")}, "close the cache to everyone: allow-query-cache { none; }"
|
||||||
|
case "exposure_version":
|
||||||
|
return []Request{server("version", "none")}, "hide BIND's version: version none"
|
||||||
|
case "exposure_hostname":
|
||||||
|
return []Request{server("hostname", "none")}, "hide the host name: hostname none"
|
||||||
|
case "security_open_resolver":
|
||||||
|
if v == nil {
|
||||||
|
return nil, "" // a server without views is also your own resolver
|
||||||
|
}
|
||||||
|
return []Request{set("recursion", "no")}, "turn recursion off in view " + v.Name + ": recursion no"
|
||||||
|
}
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecommendedRateLimit is the rate-limit block Jarl suggests for a public
|
||||||
|
// view (v nil: the server): ISC's example of 10 identical responses per
|
||||||
|
// second per client network, in log-only mode until you have looked at
|
||||||
|
// what it would limit, with your secondaries exempt so zone transfers
|
||||||
|
// and their queries are never limited.
|
||||||
|
func RecommendedRateLimit(st *model.State, v *model.View) string {
|
||||||
|
parts := []string{"responses-per-second 10;", "log-only yes;"}
|
||||||
|
if ex := secondaries(st, v); len(ex) > 0 {
|
||||||
|
parts = append(parts, "exempt-clients { "+strings.Join(ex, "; ")+"; };")
|
||||||
|
}
|
||||||
|
return "{ " + strings.Join(parts, " ") + " }"
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondaries returns the addresses the view's zones notify and allow
|
||||||
|
// transfers to (ACLs resolved), the servers that rate limiting must leave
|
||||||
|
// alone.
|
||||||
|
func secondaries(st *model.State, v *model.View) []string {
|
||||||
|
var opts []*model.Option
|
||||||
|
add := func(scope string, id int64) {
|
||||||
|
for _, name := range []string{"also-notify", "allow-transfer"} {
|
||||||
|
if o := st.FindOption(scope, id, name); o != nil {
|
||||||
|
opts = append(opts, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(model.ScopeOptions, 0)
|
||||||
|
vid := int64(0)
|
||||||
|
if v != nil {
|
||||||
|
vid = v.ID
|
||||||
|
add(model.ScopeView, v.ID)
|
||||||
|
}
|
||||||
|
for _, z := range st.SortedZones() {
|
||||||
|
if z.ViewID == vid {
|
||||||
|
add(model.ScopeZone, z.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
var addElems func(list []string, depth int)
|
||||||
|
addElems = func(list []string, depth int) {
|
||||||
|
for _, s := range list {
|
||||||
|
e, err := namedconf.ParseAMElement(s)
|
||||||
|
if err != nil || e.Negated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch e.Kind {
|
||||||
|
case namedconf.AMPrefix, namedconf.AMRange:
|
||||||
|
if t := e.String(); !seen[t] {
|
||||||
|
seen[t] = true
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
case namedconf.AMName:
|
||||||
|
if a := st.ACLByName(e.Name); a != nil && depth < 10 {
|
||||||
|
addElems(a.Elements, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
|
if err != nil || stmt.FirstBlock() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
for _, s := range stmt.FirstBlock().Stmts {
|
||||||
|
if o.Name == "also-notify" {
|
||||||
|
// "ADDRESS [port N] [key K]": the address only.
|
||||||
|
if len(s.Items) > 0 && s.Items[0].Value != nil {
|
||||||
|
if _, err := netip.ParseAddr(s.Items[0].Value.Text); err == nil {
|
||||||
|
list = append(list, s.Items[0].Value.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, namedconf.Compact(s.Items))
|
||||||
|
}
|
||||||
|
addElems(list, 0)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutLogOnly removes "log-only ...;" from a rate-limit block.
|
||||||
|
func withoutLogOnly(value string) string {
|
||||||
|
st, err := namedconf.ParseOptionValue("rate-limit", value)
|
||||||
|
if err != nil || st.FirstBlock() == nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
b := st.FirstBlock()
|
||||||
|
var keep []*namedconf.Stmt
|
||||||
|
for _, s := range b.Stmts {
|
||||||
|
if s.Keyword() != "log-only" {
|
||||||
|
keep = append(keep, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.Stmts = keep
|
||||||
|
return namedconf.Compact(st.Items[1:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func problemFor(ps []validate.Problem, code, view string) *validate.Problem {
|
||||||
|
for i, p := range ps {
|
||||||
|
if p.Code == code && (view == "" || p.View == view) {
|
||||||
|
return &ps[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExposure(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin,
|
||||||
|
// An internal network with a global IPv6 prefix is still yours.
|
||||||
|
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("internal-clients"), Elements: &[]string{"2a02:8013:7f3d:10::/64", "192.168.1.0/24"}}),
|
||||||
|
optionReq("allow-transfer", "{ 192.0.2.53; }"),
|
||||||
|
)
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"internal-clients", "localhost"}, AdoptZones: true}),
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"!internal-clients", "any"}}),
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("dmz"), MatchClients: &[]string{"any"}, MatchDestinations: &[]string{"10.9.0.53"}}),
|
||||||
|
)
|
||||||
|
st := env.state()
|
||||||
|
exps := map[string]validate.Exposure{}
|
||||||
|
for _, x := range env.e.Exposures(st) {
|
||||||
|
exps[x.View] = x
|
||||||
|
}
|
||||||
|
if x := exps["internal"]; x.Public || !strings.Contains(x.Reason, "no catch-all") {
|
||||||
|
t.Errorf("internal = %+v", x)
|
||||||
|
}
|
||||||
|
if x := exps["external"]; !x.Public || x.Reason != "match-clients ends in any" {
|
||||||
|
t.Errorf("external = %+v", x)
|
||||||
|
}
|
||||||
|
if x := exps["dmz"]; x.Public || !strings.Contains(x.Reason, "match-destinations") {
|
||||||
|
t.Errorf("dmz = %+v", x)
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := env.e.validate(st)
|
||||||
|
for _, code := range []string{"exposure_rate_limit", "exposure_minimal_any"} {
|
||||||
|
if problemFor(ps, code, "external") == nil {
|
||||||
|
t.Errorf("no %s for external", code)
|
||||||
|
}
|
||||||
|
if problemFor(ps, code, "internal") != nil || problemFor(ps, code, "dmz") != nil {
|
||||||
|
t.Errorf("%s for a private view", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range []string{"exposure_version", "exposure_hostname"} {
|
||||||
|
if problemFor(ps, code, "") == nil {
|
||||||
|
t.Errorf("no %s", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rate limit fix starts in log-only mode and exempts the
|
||||||
|
// secondaries.
|
||||||
|
ext := st.ViewByName("external")
|
||||||
|
reqs, desc := ExposureFix(st, ext.ID, "exposure_rate_limit")
|
||||||
|
if len(reqs) != 1 || !strings.Contains(desc, "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.53; }; }") {
|
||||||
|
t.Fatalf("fix = %v %q", reqs, desc)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, reqs...)
|
||||||
|
ps = env.e.validate(env.state())
|
||||||
|
if problemFor(ps, "exposure_rate_limit", "external") != nil {
|
||||||
|
t.Error("rate limit warning stays after the fix")
|
||||||
|
}
|
||||||
|
if p := problemFor(ps, "exposure_rate_limit_log_only", "external"); p == nil || p.Severity != validate.Info {
|
||||||
|
t.Errorf("log-only = %+v", p)
|
||||||
|
}
|
||||||
|
reqs, desc = ExposureFix(env.state(), ext.ID, "exposure_rate_limit_log_only")
|
||||||
|
if !strings.Contains(desc, "{ responses-per-second 10; exempt-clients { 192.0.2.53; }; }") {
|
||||||
|
t.Errorf("enforce = %q", desc)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, reqs...)
|
||||||
|
if p := problemFor(env.e.validate(env.state()), "exposure_rate_limit_log_only", "external"); p != nil {
|
||||||
|
t.Errorf("still log-only: %+v", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the server up needs confirming.
|
||||||
|
open := optionReqView("allow-recursion", "{ any; }", ext.ID)
|
||||||
|
if e := env.submitErr(env.admin, open); e.Kind != KindRisk || !strings.Contains(e.Message, "open resolver") {
|
||||||
|
t.Fatalf("open resolver without confirming: %+v", e)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Submit(env.ctx, []Request{open}, env.admin, "test", "", ApplyOptions{AcceptRisk: true}); err != nil {
|
||||||
|
t.Fatalf("with accept risk: %v", err)
|
||||||
|
}
|
||||||
|
// Acknowledged, it stays as info, with who and why.
|
||||||
|
env.submit(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver", Reason: "public resolver for the club"}))
|
||||||
|
p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external")
|
||||||
|
if p == nil || p.Severity != validate.Info || p.Acknowledged == nil || !strings.Contains(p.Message, "acknowledged by admin on 7 Oct 2026: public resolver for the club") {
|
||||||
|
t.Errorf("acknowledged = %+v", p)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver"})); e.Kind != KindBadRequest {
|
||||||
|
t.Errorf("ack without a reason: %+v", e)
|
||||||
|
}
|
||||||
|
// Reverting the acknowledgement brings the warning back.
|
||||||
|
env.submit(env.admin, NewRequest(OpExposureUnack, 0, AckInput{View: "external", Code: "security_open_resolver"}))
|
||||||
|
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p == nil || p.Severity != validate.Warning {
|
||||||
|
t.Errorf("after unack = %+v", p)
|
||||||
|
}
|
||||||
|
// Marking a view private takes it out of the checks.
|
||||||
|
env.submit(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("private")}))
|
||||||
|
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p != nil {
|
||||||
|
t.Errorf("private view still checked: %+v", p)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("internet")})); e.Kind != KindBadRequest {
|
||||||
|
t.Errorf("bad exposure: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A server without views is public only when named listens on an
|
||||||
|
// address that isn't private.
|
||||||
|
func TestServerExposure(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
if x := env.e.Exposures(env.state())[0]; x.Public {
|
||||||
|
t.Errorf("no addresses known = %+v", x)
|
||||||
|
}
|
||||||
|
env.e.cfg.LocalAddrs = func() []netip.Addr {
|
||||||
|
return []netip.Addr{netip.MustParseAddr("192.168.1.2"), netip.MustParseAddr("2a02:8013:7f3d:10::53")}
|
||||||
|
}
|
||||||
|
if x := env.e.Exposures(env.state())[0]; !x.Public || !strings.Contains(x.Reason, "2a02:8013:7f3d:10::53") {
|
||||||
|
t.Errorf("GUA address = %+v", x)
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Exposure: model.Ptr("private")}))
|
||||||
|
if x := env.e.Exposures(env.state())[0]; x.Public || x.Override != "private" {
|
||||||
|
t.Errorf("marked private = %+v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionReqView(name, value string, viewID int64) Request {
|
||||||
|
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &viewID, Name: &name, Value: &value})
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteFile writes a file outside the rendered configuration (a
|
||||||
|
// certificate for named, docs/certificates.md) the way deployed files are
|
||||||
|
// written: atomically, with bind.file_group, creating its directory.
|
||||||
|
func (e *Engine) WriteFile(path string, content []byte, mode fs.FileMode) error {
|
||||||
|
return e.deploy.writeAtomic(path, content, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconfig makes named reread its configuration, and with it the files
|
||||||
|
// its tls blocks name (nothing with bind.no_reload).
|
||||||
|
func (e *Engine) Reconfig(ctx context.Context) error {
|
||||||
|
if e.cfg.NoReload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.tools.Reconfig(ctx)
|
||||||
|
}
|
||||||
@@ -51,8 +51,25 @@ const (
|
|||||||
// operations.
|
// operations.
|
||||||
OpRecordDisable = "record.disable"
|
OpRecordDisable = "record.disable"
|
||||||
OpRecordEnable = "record.enable"
|
OpRecordEnable = "record.enable"
|
||||||
|
// Acknowledging an exposure finding (docs/exposure.md), and taking it
|
||||||
|
// back, become view.update or settings.update operations.
|
||||||
|
OpExposureAck = "exposure.ack"
|
||||||
|
OpExposureUnack = "exposure.unack"
|
||||||
|
// Linked zones (docs/views.md#linked-zones): requests that expand
|
||||||
|
// into zone and record updates.
|
||||||
|
OpZoneLink = "zone.link"
|
||||||
|
OpZoneUnlink = "zone.unlink"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||||
|
// (by ID or name), or of the server (neither given).
|
||||||
|
type AckInput struct {
|
||||||
|
ViewID int64 `json:"view_id,omitempty"`
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// OpKinds lists all operation kinds.
|
// OpKinds lists all operation kinds.
|
||||||
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
||||||
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
||||||
@@ -118,6 +135,13 @@ type ZoneInput struct {
|
|||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
// Disabled keeps the zone in Jarl but out of named's configuration.
|
// Disabled keeps the zone in Jarl but out of named's configuration.
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
|
// LinkGroup is the zone's link group (0: not linked). Set by zone.link
|
||||||
|
// and zone.unlink; requests use those.
|
||||||
|
LinkGroup *int64 `json:"link_group,omitempty"`
|
||||||
|
// LinkWith (create only) links the new zone with the zone of the same
|
||||||
|
// name in this view, copying its shared records (or all of its
|
||||||
|
// records, when it isn't linked yet).
|
||||||
|
LinkWith string `json:"link_with,omitempty"`
|
||||||
|
|
||||||
// Create only: name servers for the apex NS records (default: the
|
// Create only: name servers for the apex NS records (default: the
|
||||||
// configured default name servers), additional records, and a zone to
|
// configured default name servers), additional records, and a zone to
|
||||||
@@ -150,10 +174,31 @@ type RecordInput struct {
|
|||||||
// Disabled keeps the record in Jarl but out of the zone.
|
// Disabled keeps the record in Jarl but out of the zone.
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
// PTR also creates (or deletes, disables or enables) the matching PTR
|
// PTR also creates (or deletes, disables or enables) the matching PTR
|
||||||
// record in the reverse zone, for A and AAAA records.
|
// record in the reverse zone, for A and AAAA records. On an update that
|
||||||
|
// changes nothing else it adds the PTR record if it is missing. In a
|
||||||
|
// linked zone, every linked view's reverse zone counts.
|
||||||
PTR bool `json:"ptr,omitempty"`
|
PTR bool `json:"ptr,omitempty"`
|
||||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||||
MissingOK bool `json:"missing_ok,omitempty"`
|
MissingOK bool `json:"missing_ok,omitempty"`
|
||||||
|
// Local (requests, linked zones): true keeps the record in this view
|
||||||
|
// only (an override: the whole name and type becomes local here);
|
||||||
|
// false shares it with the linked zones. Unset: like the records of
|
||||||
|
// the same name and type, shared when there are none.
|
||||||
|
Local *bool `json:"local,omitempty"`
|
||||||
|
// SharedID (stored operations) ties the copies of a shared record
|
||||||
|
// together; 0 makes it local.
|
||||||
|
SharedID *int64 `json:"shared_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkInput links zones of the same name in several views, or (unlink)
|
||||||
|
// takes one out of its link.
|
||||||
|
type LinkInput struct {
|
||||||
|
ZoneID int64 `json:"zone_id,omitempty"`
|
||||||
|
Zone string `json:"zone,omitempty"`
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
// With are the views whose zone of the same name to link with (link
|
||||||
|
// only; default: every view that has one).
|
||||||
|
With []string `json:"with,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RRSetInput replaces all records of one name and type.
|
// RRSetInput replaces all records of one name and type.
|
||||||
@@ -168,6 +213,11 @@ type RRSetInput struct {
|
|||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
// NewIDs are allocated for records that need to be created.
|
// NewIDs are allocated for records that need to be created.
|
||||||
NewIDs []int64 `json:"new_ids,omitempty"`
|
NewIDs []int64 `json:"new_ids,omitempty"`
|
||||||
|
// Local (requests): like RecordInput.Local, for the whole set.
|
||||||
|
Local *bool `json:"local,omitempty"`
|
||||||
|
// Shared (stored operations, linked zones): the records are shared
|
||||||
|
// (true) or local (false).
|
||||||
|
Shared *bool `json:"shared,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ViewInput creates or updates a view.
|
// ViewInput creates or updates a view.
|
||||||
@@ -184,6 +234,9 @@ type ViewInput struct {
|
|||||||
AdoptZones bool `json:"adopt_zones,omitempty"`
|
AdoptZones bool `json:"adopt_zones,omitempty"`
|
||||||
// Cascade (delete) also deletes the view's zones.
|
// Cascade (delete) also deletes the view's zones.
|
||||||
Cascade bool `json:"cascade,omitempty"`
|
Cascade bool `json:"cascade,omitempty"`
|
||||||
|
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
|
||||||
|
Exposure *string `json:"exposure,omitempty"`
|
||||||
|
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReorderInput sets the order of views.
|
// ReorderInput sets the order of views.
|
||||||
@@ -227,6 +280,9 @@ type SettingsInput struct {
|
|||||||
Expire *uint32 `json:"expire,omitempty"`
|
Expire *uint32 `json:"expire,omitempty"`
|
||||||
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
|
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
|
||||||
SerialPolicy *string `json:"serial_policy,omitempty"`
|
SerialPolicy *string `json:"serial_policy,omitempty"`
|
||||||
|
// Exposure and Acknowledged are the server's (docs/exposure.md).
|
||||||
|
Exposure *string `json:"exposure,omitempty"`
|
||||||
|
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteInput carries options for delete operations.
|
// DeleteInput carries options for delete operations.
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
// Linked zones (docs/views.md#linked-zones). Zones of the same name in
|
||||||
|
// different views can be linked: their shared records are kept in step,
|
||||||
|
// and each zone can override a name and type with records local to its
|
||||||
|
// view. Every zone keeps its own copy of each shared record (with the
|
||||||
|
// same SharedID), so the rest of Jarl sees ordinary zones.
|
||||||
|
//
|
||||||
|
// Keeping them in step happens when requests are expanded: the request's
|
||||||
|
// operations are applied to a scratch copy, and linkSync adds explicit
|
||||||
|
// operations for the other zones of the link. So a change to a shared
|
||||||
|
// record is one changeset with one operation per zone: the diff, the
|
||||||
|
// permission checks, history and undo all see every zone.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rrKey struct {
|
||||||
|
zone int64
|
||||||
|
name, typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkSync returns ops followed by the operations that bring the other
|
||||||
|
// zones of each affected link in step.
|
||||||
|
func (x *expander) linkSync(ops []model.Op) ([]model.Op, error) {
|
||||||
|
if !x.st.AnyLinked() {
|
||||||
|
return ops, 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)
|
||||||
|
var extra []model.Op
|
||||||
|
for _, k := range keys {
|
||||||
|
z := work.Zones[k.zone]
|
||||||
|
if z == nil || z.LinkGroup == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
more, err := x.syncKey(work, z, k.name, k.typ)
|
||||||
|
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...)
|
||||||
|
}
|
||||||
|
return append(ops, extra...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// affectedKeys lists the (zone, name, type) sets that ops touch, before
|
||||||
|
// and after.
|
||||||
|
func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
|
||||||
|
var out []rrKey
|
||||||
|
seen := map[rrKey]bool{}
|
||||||
|
add := func(zone int64, name, typ string) {
|
||||||
|
k := rrKey{zone, name, typ}
|
||||||
|
if zone != 0 && name != "" && !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fromRecord := func(id int64) {
|
||||||
|
for _, st := range []*model.State{pre, post} {
|
||||||
|
if r := st.Records[id]; r != nil {
|
||||||
|
add(r.ZoneID, r.Name, r.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, op := range ops {
|
||||||
|
switch op.Kind {
|
||||||
|
case OpRecordCreate, OpRecordUpdate, OpRecordDelete:
|
||||||
|
if op.ID != 0 {
|
||||||
|
fromRecord(op.ID)
|
||||||
|
}
|
||||||
|
if op.Kind == OpRecordDelete && op.ID == 0 {
|
||||||
|
var in RecordInput
|
||||||
|
if decode(op.Data, &in) == nil && in.Name != nil && in.Type != nil {
|
||||||
|
add(in.ZoneID, *in.Name, *in.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case OpRRSetReplace:
|
||||||
|
var in RRSetInput
|
||||||
|
if decode(op.Data, &in) == nil {
|
||||||
|
add(in.ZoneID, in.Name, in.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncKey brings the other zones of z's link in step with z for one name
|
||||||
|
// and type, after z was changed:
|
||||||
|
//
|
||||||
|
// - 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.
|
||||||
|
// - 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).
|
||||||
|
func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]model.Op, error) {
|
||||||
|
var ops []model.Op
|
||||||
|
where := func(o *model.Zone) string { return zoneDesc(st, o) }
|
||||||
|
if st.HasLocal(z.ID, name, typ) {
|
||||||
|
for _, r := range st.SharedRecords(z.ID, name, typ) {
|
||||||
|
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil,
|
||||||
|
fmt.Sprintf("linked: %s %s is local in %s now, so its shared %s goes there", name, typ, where(z), r.Data)))
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
truth := st.SharedRecords(z.ID, name, typ)
|
||||||
|
members := st.LinkedWith(z)
|
||||||
|
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
||||||
|
// The override was removed: back to the shared records.
|
||||||
|
for _, o := range members {
|
||||||
|
if st.HasLocal(o.ID, name, typ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src := st.SharedRecords(o.ID, name, typ)
|
||||||
|
if len(src) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
for _, o := range members {
|
||||||
|
if st.HasLocal(o.ID, name, typ) {
|
||||||
|
continue // o overrides this name and type
|
||||||
|
}
|
||||||
|
copies := map[int64]*model.Record{}
|
||||||
|
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
||||||
|
copies[r.SharedID] = r
|
||||||
|
}
|
||||||
|
var more []model.Op
|
||||||
|
for _, t := range truth {
|
||||||
|
cp := copies[t.SharedID]
|
||||||
|
delete(copies, t.SharedID)
|
||||||
|
if cp == nil {
|
||||||
|
op, err := x.copyRecord(st, o, t, fmt.Sprintf("linked: add %s %s %s in %s", name, typ, t.Data, where(o)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
more = append(more, op)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in, changes := recordDiff(cp, t)
|
||||||
|
if len(changes) > 0 {
|
||||||
|
more = append(more, newOp(OpRecordUpdate, cp.ID, cp.Version, in,
|
||||||
|
fmt.Sprintf("linked: change %s %s %s in %s: %s", name, typ, cp.Data, where(o), strings.Join(changes, ", "))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range sortedKeys(copies) {
|
||||||
|
cp := copies[id]
|
||||||
|
more = append(more, newOp(OpRecordDelete, cp.ID, cp.Version, nil, fmt.Sprintf("linked: delete %s %s %s in %s", name, typ, cp.Data, where(o))))
|
||||||
|
}
|
||||||
|
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
||||||
|
return nil, errf(KindForbidden, "%s %s is shared with %s, which you may not change; to change it in %s only, make it local to that view (local: true, or --local)",
|
||||||
|
dnsname.Display(name), typ, where(o), where(z))
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyRecord returns an operation that creates a copy of shared record r
|
||||||
|
// in zone z.
|
||||||
|
func (x *expander) copyRecord(st *model.State, z *model.Zone, r *model.Record, summary string) (model.Op, error) {
|
||||||
|
if !x.p.Can(recordAction(st, z, r.Name, r.Type, true)) {
|
||||||
|
return model.Op{}, errf(KindForbidden, "%s %s is shared with %s, which you may not change", dnsname.Display(r.Name), r.Type, zoneDesc(st, z))
|
||||||
|
}
|
||||||
|
ids, err := x.ids(1)
|
||||||
|
if err != nil {
|
||||||
|
return model.Op{}, err
|
||||||
|
}
|
||||||
|
return newOp(OpRecordCreate, ids[0], 0, recordCopyInput(z.ID, r), summary), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordCopyInput(zoneID int64, r *model.Record) RecordInput {
|
||||||
|
in := RecordInput{ZoneID: zoneID, Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data),
|
||||||
|
Comment: model.Ptr(r.Comment), Disabled: model.Ptr(r.Disabled), SharedID: model.Ptr(r.SharedID)}
|
||||||
|
if r.TTL != nil {
|
||||||
|
in.TTL = model.Some(*r.TTL)
|
||||||
|
} else {
|
||||||
|
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordDiff returns the update that makes cp like t, and what changes.
|
||||||
|
func recordDiff(cp, t *model.Record) (RecordInput, []string) {
|
||||||
|
var in RecordInput
|
||||||
|
var changes []string
|
||||||
|
if cp.Data != t.Data {
|
||||||
|
in.Data = model.Ptr(t.Data)
|
||||||
|
changes = append(changes, "data "+t.Data)
|
||||||
|
}
|
||||||
|
if !sameTTL(cp.TTL, t.TTL) {
|
||||||
|
if t.TTL != nil {
|
||||||
|
in.TTL = model.Some(*t.TTL)
|
||||||
|
changes = append(changes, fmt.Sprintf("ttl %d", *t.TTL))
|
||||||
|
} else {
|
||||||
|
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||||
|
changes = append(changes, "ttl default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cp.Comment != t.Comment {
|
||||||
|
in.Comment = model.Ptr(t.Comment)
|
||||||
|
changes = append(changes, "comment")
|
||||||
|
}
|
||||||
|
if cp.Disabled != t.Disabled {
|
||||||
|
in.Disabled = model.Ptr(t.Disabled)
|
||||||
|
changes = append(changes, enableWord(t.Disabled)+"d")
|
||||||
|
}
|
||||||
|
return in, changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkTarget resolves the zone a link request names.
|
||||||
|
func (x *expander) linkTarget(in LinkInput) (*model.Zone, error) {
|
||||||
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if z.Type != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, z), z.Type)
|
||||||
|
}
|
||||||
|
return z, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneLink links zones of the same name in several views. It never
|
||||||
|
// changes what named answers: a name and type with the same records in
|
||||||
|
// every zone becomes shared; anything that differs stays local to each
|
||||||
|
// view (and can be shared later, from the view whose version is right).
|
||||||
|
func (x *expander) zoneLink(req Request) ([]model.Op, error) {
|
||||||
|
var in LinkInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
z, err := x.linkTarget(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
members := []*model.Zone{z}
|
||||||
|
add := func(o *model.Zone) {
|
||||||
|
if !slices.ContainsFunc(members, func(m *model.Zone) bool { return m.ID == o.ID }) {
|
||||||
|
members = append(members, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(in.With) == 0 {
|
||||||
|
for _, o := range x.st.ZonesNamed(z.Name) {
|
||||||
|
if o.Type == model.ZonePrimary && o.ID != z.ID {
|
||||||
|
add(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, vn := range in.With {
|
||||||
|
v := x.st.ViewByName(vn)
|
||||||
|
if v == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %q does not exist", vn)
|
||||||
|
}
|
||||||
|
o := x.st.FindZone(z.Name, v.ID)
|
||||||
|
if o == nil {
|
||||||
|
return nil, errf(KindNotFound, "view %s has no zone %s (create it linked: jarl zone create %s --view %s --link-with %s)", v.Name, dnsname.Display(z.Name), dnsname.Display(z.Name), v.Name, x.st.ViewName(z))
|
||||||
|
}
|
||||||
|
if o.Type != model.ZonePrimary {
|
||||||
|
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, o), o.Type)
|
||||||
|
}
|
||||||
|
add(o)
|
||||||
|
}
|
||||||
|
// Zones already linked with any of them join too.
|
||||||
|
for _, m := range slices.Clone(members) {
|
||||||
|
for _, o := range x.st.LinkMembers(m.LinkGroup) {
|
||||||
|
add(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(members) < 2 {
|
||||||
|
return nil, errf(KindBadRequest, "zone %s exists in no other view to link it with", dnsname.Display(z.Name))
|
||||||
|
}
|
||||||
|
x.st.SortByView(members)
|
||||||
|
group := members[0].ID
|
||||||
|
for _, m := range members {
|
||||||
|
if m.LinkGroup != 0 {
|
||||||
|
group = m.LinkGroup
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is the same everywhere?
|
||||||
|
type key struct{ name, typ string }
|
||||||
|
sig := func(rs []*model.Record) string {
|
||||||
|
var parts []string
|
||||||
|
for _, r := range rs {
|
||||||
|
ttl := "-"
|
||||||
|
if r.TTL != nil {
|
||||||
|
ttl = fmt.Sprint(*r.TTL)
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s|%s|%v", strings.ToLower(r.Data), ttl, r.Disabled))
|
||||||
|
}
|
||||||
|
slices.Sort(parts)
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
keys := map[key]bool{}
|
||||||
|
var order []key
|
||||||
|
for _, m := range members {
|
||||||
|
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||||
|
k := key{r.Name, r.Type}
|
||||||
|
if !keys[k] {
|
||||||
|
keys[k] = true
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ops []model.Op
|
||||||
|
var local []string
|
||||||
|
shared := 0
|
||||||
|
for _, k := range order {
|
||||||
|
first := x.st.RRset(members[0].ID, k.name, k.typ)
|
||||||
|
same := len(first) > 0
|
||||||
|
for _, m := range members[1:] {
|
||||||
|
if rs := x.st.RRset(m.ID, k.name, k.typ); len(rs) == 0 || sig(rs) != sig(first) {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
shared++
|
||||||
|
} else {
|
||||||
|
var in []string
|
||||||
|
for _, m := range members {
|
||||||
|
if len(x.st.RRset(m.ID, k.name, k.typ)) > 0 {
|
||||||
|
in = append(in, x.st.ViewName(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local = append(local, fmt.Sprintf("%s %s (%s)", dnsname.Display(k.name), k.typ, strings.Join(in, ", ")))
|
||||||
|
}
|
||||||
|
// Pair the copies: record i of the first zone's set (by data) with
|
||||||
|
// the same data in the others.
|
||||||
|
byData := func(rs []*model.Record) []*model.Record {
|
||||||
|
out := slices.Clone(rs)
|
||||||
|
slices.SortStableFunc(out, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ref := byData(first)
|
||||||
|
for _, m := range members {
|
||||||
|
rs := byData(x.st.RRset(m.ID, k.name, k.typ))
|
||||||
|
for i, r := range rs {
|
||||||
|
want := int64(0)
|
||||||
|
var comment *string
|
||||||
|
if same {
|
||||||
|
want = ref[i].ID
|
||||||
|
if r.Comment != ref[i].Comment && ref[i].Comment != "" {
|
||||||
|
comment = model.Ptr(ref[i].Comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.SharedID == want && comment == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in := RecordInput{SharedID: model.Ptr(want), Comment: comment}
|
||||||
|
what := "local to view " + x.st.ViewName(m)
|
||||||
|
if same {
|
||||||
|
what = "shared"
|
||||||
|
}
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s %s: %s", r.Name, r.Type, r.Data, what)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var views []string
|
||||||
|
for _, m := range members {
|
||||||
|
views = append(views, x.st.ViewName(m))
|
||||||
|
}
|
||||||
|
var zoneOps []model.Op
|
||||||
|
for _, m := range members {
|
||||||
|
if m.LinkGroup != group {
|
||||||
|
zoneOps = append(zoneOps, newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(group)},
|
||||||
|
fmt.Sprintf("link zone %s in views %s", zoneDesc(x.st, m), strings.Join(views, ", "))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(local) > 0 {
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("%d names differ between the views and stay local to each: %s. Share one from the view whose version is right (jarl record update ID --shared, or Share in the web UI).", len(local), strings.Join(local, "; ")))
|
||||||
|
}
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("%d names and types are shared between views %s", shared, strings.Join(views, ", ")))
|
||||||
|
return append(zoneOps, ops...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneUnlink takes a zone out of its link: its records stay as they are,
|
||||||
|
// all local. A link left with one zone is dissolved.
|
||||||
|
func (x *expander) zoneUnlink(req Request) ([]model.Op, error) {
|
||||||
|
var in LinkInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if z.LinkGroup == 0 {
|
||||||
|
return nil, errf(KindConflict, "zone %s isn't linked", zoneDesc(x.st, z))
|
||||||
|
}
|
||||||
|
out := []*model.Zone{z}
|
||||||
|
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||||
|
out = append(out, rest[0])
|
||||||
|
}
|
||||||
|
var ops []model.Op
|
||||||
|
for _, m := range out {
|
||||||
|
ops = append(ops, x.unlinkOps(m, "unlink zone "+zoneDesc(x.st, m))...)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlinkOps returns the operations that take zone m out of its link.
|
||||||
|
func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
|
||||||
|
ops := []model.Op{newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(int64(0))}, summary)}
|
||||||
|
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||||
|
if r.SharedID != 0 {
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))}, fmt.Sprintf("%s %s %s: no longer shared", r.Name, r.Type, r.Data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return *want, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shareAllOps returns the operations that start a link with zone z: it
|
||||||
|
// joins group and all its records become shared.
|
||||||
|
func (x *expander) shareAllOps(z *model.Zone, group int64) []model.Op {
|
||||||
|
ops := []model.Op{newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{LinkGroup: model.Ptr(group)}, "link zone "+zoneDesc(x.st, z))}
|
||||||
|
for _, r := range x.st.ZoneRecords(z.ID) {
|
||||||
|
if r.SharedID == 0 {
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(r.ID)}, fmt.Sprintf("%s %s %s: shared", r.Name, r.Type, r.Data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropOverrides returns the operations that delete the other linked
|
||||||
|
// zones' local records of a name and type, so the shared version reaches
|
||||||
|
// them (sharing a version makes it every view's).
|
||||||
|
func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
||||||
|
var ops []model.Op
|
||||||
|
for _, o := range x.st.LinkedWith(z) {
|
||||||
|
for _, r := range x.st.RRset(o.ID, name, typ) {
|
||||||
|
if r.SharedID == 0 {
|
||||||
|
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))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// linkedEnv has views internal and external and zone example.com in
|
||||||
|
// internal with a few records.
|
||||||
|
func linkedEnv(t *testing.T) *testEnv {
|
||||||
|
env := newEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||||
|
env.submit(env.admin,
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
|
||||||
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("internal", "www", "A", "192.0.2.10"),
|
||||||
|
recIn("internal", "mail", "A", "192.0.2.25"),
|
||||||
|
recIn("internal", "@", "MX", "10 mail"))
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func recIn(view, name, typ, data string) Request {
|
||||||
|
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: view, Name: &name, Type: &typ, Data: &data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// view returns a view's records of example.com as "name type data S|L",
|
||||||
|
// S for shared, L for local.
|
||||||
|
func (env *testEnv) view(name string) []string {
|
||||||
|
env.t.Helper()
|
||||||
|
s := env.state()
|
||||||
|
v := s.ViewByName(name)
|
||||||
|
z := s.FindZone("example.com.", v.ID)
|
||||||
|
var out []string
|
||||||
|
for _, r := range s.ZoneRecords(z.ID) {
|
||||||
|
if r.Type == "NS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mark := "L"
|
||||||
|
if r.SharedID != 0 {
|
||||||
|
mark = "S"
|
||||||
|
}
|
||||||
|
if r.Disabled {
|
||||||
|
mark += " disabled"
|
||||||
|
}
|
||||||
|
out = append(out, strings.TrimSuffix(r.Name, ".example.com.")+" "+r.Type+" "+r.Data+" "+mark)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (env *testEnv) recordID(view, name, typ string) int64 {
|
||||||
|
env.t.Helper()
|
||||||
|
s := env.state()
|
||||||
|
z := s.FindZone("example.com.", s.ViewByName(view).ID)
|
||||||
|
for _, r := range s.ZoneRecords(z.ID) {
|
||||||
|
if r.Name == name+".example.com." && r.Type == typ {
|
||||||
|
return r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.t.Fatalf("no %s %s in %s", name, typ, view)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func want(t *testing.T, what string, got []string, w ...string) {
|
||||||
|
t.Helper()
|
||||||
|
if !slices.Equal(got, w) {
|
||||||
|
t.Errorf("%s:\n got %q\n want %q", what, got, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkedZones(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
// A zone created linked copies everything; both are linked and shared.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
all := []string{"mail A 192.0.2.25 S", "www A 192.0.2.10 S", "example.com. MX 10 mail.example.com. S"}
|
||||||
|
slices.Sort(all)
|
||||||
|
got := env.view("external")
|
||||||
|
slices.Sort(got)
|
||||||
|
want(t, "linked copy", got, all...)
|
||||||
|
s := env.state()
|
||||||
|
if zs := s.ZonesNamed("example.com."); zs[0].LinkGroup == 0 || zs[0].LinkGroup != zs[1].LinkGroup {
|
||||||
|
t.Fatalf("not linked: %+v %+v", zs[0], zs[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding, changing, disabling and deleting a shared record reaches the other view.
|
||||||
|
env.submit(env.admin, recIn("external", "ftp", "CNAME", "www"))
|
||||||
|
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S") {
|
||||||
|
t.Errorf("shared add didn't reach internal: %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("192.0.2.26")}))
|
||||||
|
if !slices.Contains(env.view("external"), "mail A 192.0.2.26 S") {
|
||||||
|
t.Errorf("shared change didn't reach external: %q", env.view("external"))
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDisable, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
|
||||||
|
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S disabled") {
|
||||||
|
t.Errorf("disable didn't reach internal: %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
for _, r := range env.view(v) {
|
||||||
|
if strings.HasPrefix(r, "ftp ") {
|
||||||
|
t.Errorf("delete by match left %s in %s", r, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A local override: www in internal points elsewhere; external keeps
|
||||||
|
// the shared one.
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.10"), Local: model.Ptr(true)}))
|
||||||
|
if !slices.Contains(env.view("internal"), "www A 10.0.0.10 L") || slices.Contains(env.view("internal"), "www A 192.0.2.10 S") {
|
||||||
|
t.Errorf("override: internal %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||||
|
t.Errorf("override changed external: %q", env.view("external"))
|
||||||
|
}
|
||||||
|
_ = res
|
||||||
|
// A new record of an overridden set joins it as local.
|
||||||
|
env.submit(env.admin, recIn("internal", "www", "A", "10.0.0.11"))
|
||||||
|
if !slices.Contains(env.view("internal"), "www A 10.0.0.11 L") || len(env.view("external")) != 3 {
|
||||||
|
t.Errorf("join override: internal %q external %q", env.view("internal"), env.view("external"))
|
||||||
|
}
|
||||||
|
// Changing the shared www in external doesn't touch internal's override.
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"192.0.2.11"}}))
|
||||||
|
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.
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a shared set local, then share another view's version.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(true)}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.25")}))
|
||||||
|
if !slices.Contains(env.view("external"), "mail A 198.51.100.25 L") || !slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
|
||||||
|
t.Errorf("local edit: internal %q external %q", env.view("internal"), env.view("external"))
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||||
|
if !slices.Contains(env.view("internal"), "mail A 198.51.100.25 S") || slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
|
||||||
|
t.Errorf("share external's version: internal %q", env.view("internal"))
|
||||||
|
}
|
||||||
|
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||||
|
t.Errorf("validation: %+v", probs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undoing a changeset undoes it in both views.
|
||||||
|
r := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.99")}))
|
||||||
|
cs, err := env.e.Revert(env.ctx, r.ChangesetID, env.admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
if !slices.Contains(env.view(v), "mail A 198.51.100.25 S") {
|
||||||
|
t.Errorf("revert in %s: %q", v, env.view(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlinking keeps every record, all local.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneUnlink, 0, LinkInput{Zone: "example.com", View: "external"}))
|
||||||
|
for _, z := range env.state().ZonesNamed("example.com.") {
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
t.Errorf("still linked: %s", zoneDesc(env.state(), z))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
for _, r := range env.view(v) {
|
||||||
|
if strings.HasSuffix(r, " S") {
|
||||||
|
t.Errorf("still shared after unlink in %s: %s", v, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.submit(env.admin, recIn("internal", "solo", "A", "10.0.0.5"))
|
||||||
|
if slices.Contains(env.view("external"), "solo A 10.0.0.5 L") {
|
||||||
|
t.Error("an unlinked zone was changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linking again works out what is shared: same in both, or not.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "internal"}))
|
||||||
|
in, ex := env.view("internal"), env.view("external")
|
||||||
|
if !slices.Contains(in, "mail A 198.51.100.25 S") || !slices.Contains(ex, "mail A 198.51.100.25 S") {
|
||||||
|
t.Errorf("relink: same records not shared: internal %q external %q", in, ex)
|
||||||
|
}
|
||||||
|
if !slices.Contains(in, "solo A 10.0.0.5 L") {
|
||||||
|
t.Errorf("relink: internal-only record should be local: %q", in)
|
||||||
|
}
|
||||||
|
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||||
|
t.Errorf("validation after relink: %+v", probs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting one zone of two dissolves the link.
|
||||||
|
z := env.state().FindZone("example.com.", env.state().ViewByName("external").ID)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||||
|
if zs := env.state().ZonesNamed("example.com."); len(zs) != 1 || zs[0].LinkGroup != 0 {
|
||||||
|
t.Errorf("after deleting external: %+v", zs)
|
||||||
|
}
|
||||||
|
for _, r := range env.view("internal") {
|
||||||
|
if strings.HasSuffix(r, " S") {
|
||||||
|
t.Errorf("shared record left in an unlinked zone: %s", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCode(probs []validate.Problem, code string) bool {
|
||||||
|
for _, p := range probs {
|
||||||
|
if p.Code == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkedPermissions(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: "lan", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Views: []string{"internal"}}, {Resource: auth.ResZones, Access: "read"}}}
|
||||||
|
if err := env.st.CreateUser(env.ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lan := &auth.Principal{User: u, Method: "token"}
|
||||||
|
// A shared change would reach external, which this user may not change.
|
||||||
|
e := env.submitErr(lan, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Data: model.Ptr("10.9.9.9")}))
|
||||||
|
if e.Kind != KindForbidden || !strings.Contains(e.Message, "shared with example.com [external]") || !strings.Contains(e.Message, "local") {
|
||||||
|
t.Errorf("shared change: %v", e)
|
||||||
|
}
|
||||||
|
// A local override is theirs to make.
|
||||||
|
env.submit(lan, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.9.9.9"), Local: model.Ptr(true)}))
|
||||||
|
if !slices.Contains(env.view("internal"), "www A 10.9.9.9 L") || !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||||
|
t.Errorf("override by a view-restricted user: internal %q external %q", env.view("internal"), env.view("external"))
|
||||||
|
}
|
||||||
|
// Records of a zone that isn't linked can't be "local".
|
||||||
|
e = env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.org"), View: model.Ptr("internal"), LinkWith: "external"}))
|
||||||
|
if e.Kind != KindNotFound {
|
||||||
|
t.Errorf("link_with a view without the zone: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkExisting(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
// external was made by hand: the same apart from www.
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external")}))
|
||||||
|
env.submit(env.admin,
|
||||||
|
recIn("external", "www", "A", "203.0.113.10"),
|
||||||
|
recIn("external", "mail", "A", "192.0.2.25"),
|
||||||
|
recIn("external", "@", "MX", "10 mail"))
|
||||||
|
before := []string{}
|
||||||
|
for _, v := range []string{"internal", "external"} {
|
||||||
|
before = append(before, strings.Join(env.view(v), ";"))
|
||||||
|
}
|
||||||
|
res := env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "external", With: []string{"internal"}}))
|
||||||
|
in, ex := env.view("internal"), env.view("external")
|
||||||
|
if !slices.Contains(in, "www A 192.0.2.10 L") || !slices.Contains(ex, "www A 203.0.113.10 L") {
|
||||||
|
t.Errorf("different www should stay local: internal %q external %q", in, ex)
|
||||||
|
}
|
||||||
|
if !slices.Contains(in, "mail A 192.0.2.25 S") || !slices.Contains(ex, "mail A 192.0.2.25 S") {
|
||||||
|
t.Errorf("same mail should be shared: internal %q external %q", in, ex)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(res.Warnings, " "), "www.example.com A (internal, external)") {
|
||||||
|
t.Errorf("warnings: %q", res.Warnings)
|
||||||
|
}
|
||||||
|
// Linking never changes what named answers: the same data as before.
|
||||||
|
for i, v := range []string{"internal", "external"} {
|
||||||
|
var data []string
|
||||||
|
for _, r := range env.view(v) {
|
||||||
|
data = append(data, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
|
||||||
|
}
|
||||||
|
var was []string
|
||||||
|
for _, r := range strings.Split(before[i], ";") {
|
||||||
|
was = append(was, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
|
||||||
|
}
|
||||||
|
want(t, "answers in "+v, data, was...)
|
||||||
|
}
|
||||||
|
// Sharing internal's www replaces external's.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||||
|
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||||
|
t.Errorf("shared from internal: external %q", env.view("external"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
-2
@@ -265,6 +265,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
z.Comment = *in.Comment
|
z.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.LinkGroup != nil {
|
||||||
|
z.LinkGroup = *in.LinkGroup
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +450,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
r.Disabled = *in.Disabled
|
r.Disabled = *in.Disabled
|
||||||
}
|
}
|
||||||
|
if in.SharedID != nil {
|
||||||
|
r.SharedID = *in.SharedID
|
||||||
|
}
|
||||||
for _, w := range p.Warnings {
|
for _, w := range p.Warnings {
|
||||||
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
||||||
}
|
}
|
||||||
@@ -526,6 +532,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
r.Disabled = *in.Disabled
|
r.Disabled = *in.Disabled
|
||||||
}
|
}
|
||||||
|
if in.SharedID != nil {
|
||||||
|
r.SharedID = *in.SharedID
|
||||||
|
}
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -662,16 +671,34 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
k := strings.ToLower(r.Data)
|
k := strings.ToLower(r.Data)
|
||||||
if seen[k] && !keep[k] {
|
if seen[k] && !keep[k] {
|
||||||
keep[k] = true
|
keep[k] = true
|
||||||
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled {
|
shared := r.SharedID
|
||||||
|
if in.Shared != nil {
|
||||||
|
shared = 0
|
||||||
|
if *in.Shared {
|
||||||
|
shared = r.SharedID
|
||||||
|
if shared == 0 {
|
||||||
|
shared = r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled || shared != r.SharedID {
|
||||||
if ttlKnown {
|
if ttlKnown {
|
||||||
r.TTL = copyTTL(ttl)
|
r.TTL = copyTTL(ttl)
|
||||||
}
|
}
|
||||||
r.Disabled = false
|
r.Disabled, r.SharedID = false, shared
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if r.Disabled {
|
if r.Disabled {
|
||||||
|
// Kept, but shared or local like the rest of the set.
|
||||||
|
if in.Shared != nil && (*in.Shared) != (r.SharedID != 0) {
|
||||||
|
r.SharedID = 0
|
||||||
|
if *in.Shared {
|
||||||
|
r.SharedID = r.ID
|
||||||
|
}
|
||||||
|
r.Touch(c.user(), c.now)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
delete(st.Records, id)
|
delete(st.Records, id)
|
||||||
@@ -692,6 +719,9 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
r.Comment = *in.Comment
|
r.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.Shared != nil && *in.Shared {
|
||||||
|
r.SharedID = r.ID
|
||||||
|
}
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
st.Records[r.ID] = r
|
st.Records[r.ID] = r
|
||||||
}
|
}
|
||||||
@@ -765,9 +795,41 @@ func setViewFields(v *model.View, in *ViewInput) error {
|
|||||||
if in.Position != nil {
|
if in.Position != nil {
|
||||||
v.Position = *in.Position
|
v.Position = *in.Position
|
||||||
}
|
}
|
||||||
|
if in.Exposure != nil {
|
||||||
|
e, err := normalizeExposure(*in.Exposure)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v.Exposure = e
|
||||||
|
}
|
||||||
|
if in.Acknowledged != nil {
|
||||||
|
v.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeExposure checks an exposure override.
|
||||||
|
func normalizeExposure(s string) (string, error) {
|
||||||
|
switch e := strings.ToLower(strings.TrimSpace(s)); e {
|
||||||
|
case "", "auto":
|
||||||
|
return model.ExposureAuto, nil
|
||||||
|
case model.ExposurePublic, model.ExposurePrivate:
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
return "", errf(KindBadRequest, "exposure must be auto, public or private, not %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanAcks drops empty acknowledgements (nil for none).
|
||||||
|
func cleanAcks(acks []model.Acknowledgement) []model.Acknowledgement {
|
||||||
|
var out []model.Acknowledgement
|
||||||
|
for _, a := range acks {
|
||||||
|
if a.Code != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error {
|
func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error {
|
||||||
var in ViewInput
|
var in ViewInput
|
||||||
if err := decode(op.Data, &in); err != nil {
|
if err := decode(op.Data, &in); err != nil {
|
||||||
@@ -1329,6 +1391,16 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.SerialPolicy != nil {
|
if in.SerialPolicy != nil {
|
||||||
s.SerialPolicy = strings.ToLower(*in.SerialPolicy)
|
s.SerialPolicy = strings.ToLower(*in.SerialPolicy)
|
||||||
}
|
}
|
||||||
|
if in.Exposure != nil {
|
||||||
|
e, err := normalizeExposure(*in.Exposure)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Exposure = e
|
||||||
|
}
|
||||||
|
if in.Acknowledged != nil {
|
||||||
|
s.Acknowledged = cleanAcks(*in.Acknowledged)
|
||||||
|
}
|
||||||
s.Version++
|
s.Version++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ticking "update PTR" on an unchanged address record adds its PTR when
|
||||||
|
// the reverse zone is there (it was created after the record).
|
||||||
|
func TestUpdatePTRAddsMissing(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}))
|
||||||
|
env.submit(env.admin, rec("example.com", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32")}))
|
||||||
|
var id int64
|
||||||
|
for _, r := range env.state().Records {
|
||||||
|
if r.Name == "ldn.example.com." {
|
||||||
|
id = r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, id, RecordInput{PTR: true}))
|
||||||
|
got := env.records("154.144.144.139.in-addr.arpa.")
|
||||||
|
if !slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "PTR ldn.example.com.") }) {
|
||||||
|
t.Errorf("reverse zone = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In linked zones the PTR goes to the reverse zone of whichever linked
|
||||||
|
// view has one.
|
||||||
|
func TestUpdatePTRLinked(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", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32"), View: model.Ptr("external")}))
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true}))
|
||||||
|
st := env.state()
|
||||||
|
z := st.FindZone("154.144.144.139.in-addr.arpa.", st.ViewByName("external").ID)
|
||||||
|
ok := false
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
ok = ok || r.Type == "PTR" && r.Data == "ldn.example.com."
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("no PTR in external's reverse zone (warnings %q)", res.Warnings)
|
||||||
|
}
|
||||||
|
// Again: already there, nothing to do.
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true})); !strings.Contains(e.Message, "nothing to do") {
|
||||||
|
t.Errorf("second time: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificate sources.
|
||||||
|
const (
|
||||||
|
CertSourceACME = "acme" // Jarl obtains and renews it (docs/certificates.md)
|
||||||
|
CertSourceFile = "file" // you renew it; Jarl reads it from cert_file and key_file
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificate uses.
|
||||||
|
const (
|
||||||
|
CertUseWeb = "web" // jarld's own HTTPS
|
||||||
|
CertUseDNS = "dns" // written where named can read it, for DNS over TLS/HTTPS
|
||||||
|
)
|
||||||
|
|
||||||
|
// Challenge routes: where Jarl publishes the DNS-01 challenge.
|
||||||
|
const (
|
||||||
|
ChallengeAuto = "auto" // decided at every issue from the domain's public name servers
|
||||||
|
ChallengeJarl = "jarl" // in a zone Jarl serves (following a CNAME on _acme-challenge)
|
||||||
|
ChallengePorkbun = "porkbun" // through Porkbun's DNS API
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificate is a TLS certificate Jarl manages. Certificates are not
|
||||||
|
// named configuration: they are kept outside changesets, like users.
|
||||||
|
type Certificate struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Domains []string `json:"domains"` // acme: the names to ask for; file: the names in the certificate
|
||||||
|
CertFile string `json:"cert_file,omitempty"` // file source
|
||||||
|
KeyFile string `json:"key_file,omitempty"` // file source
|
||||||
|
Uses []string `json:"uses"`
|
||||||
|
Challenge string `json:"challenge,omitempty"` // acme source
|
||||||
|
KeyType string `json:"key_type,omitempty"` // acme source: ec256, ec384, rsa2048, rsa3072, rsa4096
|
||||||
|
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.
|
||||||
|
CertPEM string `json:"-"`
|
||||||
|
KeyPEM string `json:"-"`
|
||||||
|
|
||||||
|
// What the current certificate says (both sources).
|
||||||
|
Names []string `json:"names,omitempty"` // the names in it
|
||||||
|
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||||
|
NotAfter *time.Time `json:"not_after,omitempty"`
|
||||||
|
Issuer string `json:"issuer,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
|
||||||
|
// Renewal (acme source). RenewAt is the CA's suggested time (ARI) or,
|
||||||
|
// without one, two thirds into the certificate's lifetime.
|
||||||
|
RenewAt *time.Time `json:"renew_at,omitempty"`
|
||||||
|
ARICheckedAt *time.Time `json:"-"`
|
||||||
|
LastAttempt *time.Time `json:"last_attempt,omitempty"`
|
||||||
|
LastSuccess *time.Time `json:"last_success,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Failures int `json:"failures,omitempty"`
|
||||||
|
// LastRoutes says where the challenges went the last time ("adbraun.lol: porkbun").
|
||||||
|
LastRoutes []string `json:"last_routes,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
UpdatedBy string `json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUse reports whether c is used for use.
|
||||||
|
func (c *Certificate) HasUse(use string) bool { return slices.Contains(c.Uses, use) }
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Linked zones (docs/views.md#linked-zones): zones of the same name in
|
||||||
|
// different views whose shared records Jarl keeps in step. Each zone
|
||||||
|
// keeps its own copy of every shared record (with the same SharedID), so
|
||||||
|
// named, validation and rendering see ordinary zones. A record with
|
||||||
|
// SharedID 0 in a linked zone is local to its view: an override of the
|
||||||
|
// shared records of the same name and type there.
|
||||||
|
|
||||||
|
// LinkMembers returns the zones of a link group in view order.
|
||||||
|
func (s *State) LinkMembers(group int64) []*Zone {
|
||||||
|
if group == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []*Zone
|
||||||
|
for _, z := range s.Zones {
|
||||||
|
if z.LinkGroup == group {
|
||||||
|
out = append(out, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.SortByView(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedWith returns the other zones z is linked with, in view order.
|
||||||
|
func (s *State) LinkedWith(z *Zone) []*Zone {
|
||||||
|
var out []*Zone
|
||||||
|
for _, o := range s.LinkMembers(z.LinkGroup) {
|
||||||
|
if o.ID != z.ID {
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyLinked reports whether any zone is linked.
|
||||||
|
func (s *State) AnyLinked() bool {
|
||||||
|
for _, z := range s.Zones {
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortByView sorts zones in view order.
|
||||||
|
func (s *State) SortByView(zs []*Zone) {
|
||||||
|
pos := func(z *Zone) int {
|
||||||
|
if v := s.Views[z.ViewID]; v != nil {
|
||||||
|
return v.Position
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
sort.Slice(zs, func(i, j int) bool {
|
||||||
|
if a, b := pos(zs[i]), pos(zs[j]); a != b {
|
||||||
|
return a < b
|
||||||
|
}
|
||||||
|
return zs[i].ID < zs[j].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RRset returns the records of one name and type in a zone, by ID.
|
||||||
|
func (s *State) RRset(zoneID int64, name, typ string) []*Record {
|
||||||
|
var out []*Record
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b *Record) int { return int(a.ID - b.ID) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLocal reports whether a linked zone overrides the shared records of
|
||||||
|
// one name and type with records of its own.
|
||||||
|
func (s *State) HasLocal(zoneID int64, name, typ string) bool {
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.SharedID == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedRecords returns a zone's shared records of one name and type,
|
||||||
|
// by SharedID.
|
||||||
|
func (s *State) SharedRecords(zoneID int64, name, typ string) []*Record {
|
||||||
|
var out []*Record
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.SharedID != 0 {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b *Record) int { return int(a.SharedID - b.SharedID) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
+49
-1
@@ -50,9 +50,43 @@ type View struct {
|
|||||||
MatchDestinations []string `json:"match_destinations"`
|
MatchDestinations []string `json:"match_destinations"`
|
||||||
MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"`
|
MatchRecursiveOnly *bool `json:"match_recursive_only,omitempty"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
// Exposure overrides whether the view counts as reachable from the
|
||||||
|
// internet (docs/exposure.md): "" works it out, ExposurePublic or
|
||||||
|
// ExposurePrivate say so.
|
||||||
|
Exposure string `json:"exposure,omitempty"`
|
||||||
|
// Acknowledged are exposure findings accepted on purpose; they are
|
||||||
|
// shown as info instead of warnings.
|
||||||
|
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exposure overrides.
|
||||||
|
const (
|
||||||
|
ExposureAuto = ""
|
||||||
|
ExposurePublic = "public"
|
||||||
|
ExposurePrivate = "private"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Acknowledgement accepts one exposure finding (by its code) for a view,
|
||||||
|
// or for the server: the finding stays, as info, with who accepted it,
|
||||||
|
// when and why.
|
||||||
|
type Acknowledgement struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
By string `json:"by"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAck returns the acknowledgement of code in acks, if any.
|
||||||
|
func FindAck(acks []Acknowledgement, code string) *Acknowledgement {
|
||||||
|
for i := range acks {
|
||||||
|
if acks[i].Code == code {
|
||||||
|
return &acks[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ACL is a named address match list.
|
// ACL is a named address match list.
|
||||||
type ACL struct {
|
type ACL struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -132,6 +166,10 @@ type Zone struct {
|
|||||||
// Disabled zones stay in Jarl, records and all, but named doesn't
|
// Disabled zones stay in Jarl, records and all, but named doesn't
|
||||||
// load them: their zone statement is left out of named.conf.
|
// load them: their zone statement is left out of named.conf.
|
||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
// LinkGroup links zones of the same name in different views: Jarl
|
||||||
|
// keeps their shared records in step (docs/views.md#linked-zones). 0
|
||||||
|
// for a zone that isn't linked; linked zones share the ID.
|
||||||
|
LinkGroup int64 `json:"link_group,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +199,10 @@ type Record struct {
|
|||||||
// Disabled records stay in Jarl but are left out of the zone (the
|
// Disabled records stay in Jarl but are left out of the zone (the
|
||||||
// zone file lists them as comments).
|
// zone file lists them as comments).
|
||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
// SharedID ties the copies of a shared record in linked zones
|
||||||
|
// together (the same in every copy). 0 for a record of a zone that
|
||||||
|
// isn't linked, and for a record local to its view in one that is.
|
||||||
|
SharedID int64 `json:"shared_id,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +259,11 @@ type Settings struct {
|
|||||||
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
|
||||||
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
|
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
|
||||||
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
|
||||||
Version int `json:"version"`
|
// Exposure and Acknowledged are the server's own (without views, and
|
||||||
|
// for server-wide findings such as a visible version): see View.
|
||||||
|
Exposure string `json:"exposure,omitempty"`
|
||||||
|
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockingPaused reports whether blocking is paused, for a time or until
|
// BlockingPaused reports whether blocking is paused, for a time or until
|
||||||
@@ -289,10 +335,12 @@ func (s *State) Clone() *State {
|
|||||||
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
|
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
|
||||||
}
|
}
|
||||||
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
|
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
|
||||||
|
c.Settings.Acknowledged = slices.Clone(s.Settings.Acknowledged)
|
||||||
for id, v := range s.Views {
|
for id, v := range s.Views {
|
||||||
n := *v
|
n := *v
|
||||||
n.MatchClients = slices.Clone(v.MatchClients)
|
n.MatchClients = slices.Clone(v.MatchClients)
|
||||||
n.MatchDestinations = slices.Clone(v.MatchDestinations)
|
n.MatchDestinations = slices.Clone(v.MatchDestinations)
|
||||||
|
n.Acknowledged = slices.Clone(v.Acknowledged)
|
||||||
if v.MatchRecursiveOnly != nil {
|
if v.MatchRecursiveOnly != nil {
|
||||||
b := *v.MatchRecursiveOnly
|
b := *v.MatchRecursiveOnly
|
||||||
n.MatchRecursiveOnly = &b
|
n.MatchRecursiveOnly = &b
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package porkbun
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,7 +16,8 @@ type Fake struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
Domains map[string]*FakeDomain
|
Domains map[string]*FakeDomain
|
||||||
// Calls lists the paths called, in order.
|
// Calls lists the paths called, in order.
|
||||||
Calls []string
|
Calls []string
|
||||||
|
nextID int
|
||||||
}
|
}
|
||||||
|
|
||||||
// FakeDomain is a domain in a Fake.
|
// FakeDomain is a domain in a Fake.
|
||||||
@@ -24,6 +26,14 @@ type FakeDomain struct {
|
|||||||
Glue map[string][]string // host -> addresses
|
Glue map[string][]string // host -> addresses
|
||||||
DS map[uint16]DS
|
DS map[uint16]DS
|
||||||
APIAccess bool
|
APIAccess bool
|
||||||
|
// Records are the DNS records created through the API, by ID.
|
||||||
|
Records map[string]FakeRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// FakeRecord is a DNS record in a Fake.
|
||||||
|
type FakeRecord struct {
|
||||||
|
Name, Type, Content string // Name is the full name, without a trailing dot
|
||||||
|
TTL int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
||||||
@@ -31,7 +41,7 @@ type FakeDomain struct {
|
|||||||
func NewFake(key, secret string, domains ...string) *Fake {
|
func NewFake(key, secret string, domains ...string) *Fake {
|
||||||
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true}
|
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true, Records: map[string]FakeRecord{}}
|
||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
@@ -158,7 +168,48 @@ func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
tag, _ := strconv.Atoi(parts[3])
|
tag, _ := strconv.Atoi(parts[3])
|
||||||
delete(d.DS, uint16(tag))
|
delete(d.DS, uint16(tag))
|
||||||
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
||||||
|
case "dns/create":
|
||||||
|
name, _ := body["name"].(string)
|
||||||
|
full := domain
|
||||||
|
if name != "" {
|
||||||
|
full = name + "." + domain
|
||||||
|
}
|
||||||
|
ttl, _ := strconv.Atoi(fmt.Sprint(body["ttl"]))
|
||||||
|
if ttl < MinTTL {
|
||||||
|
ttl = MinTTL
|
||||||
|
}
|
||||||
|
f.nextID++
|
||||||
|
id := strconv.Itoa(f.nextID)
|
||||||
|
d.Records[id] = FakeRecord{Name: full, Type: fmt.Sprint(body["type"]), Content: fmt.Sprint(body["content"]), TTL: ttl}
|
||||||
|
reply(200, map[string]any{"status": "SUCCESS", "id": f.nextID})
|
||||||
|
case "dns/delete":
|
||||||
|
if len(parts) < 4 || d.Records == nil {
|
||||||
|
fail(400, "INVALID_RECORD_ID", "Invalid record ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := d.Records[parts[3]]; !ok {
|
||||||
|
fail(400, "INVALID_RECORD_ID", "Invalid record ID.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(d.Records, parts[3])
|
||||||
|
reply(200, map[string]any{"status": "SUCCESS"})
|
||||||
default:
|
default:
|
||||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TXT returns the values of the TXT records at name (without a trailing
|
||||||
|
// dot) in the fake's domains.
|
||||||
|
func (f *Fake) TXT(name string) []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, d := range f.Domains {
|
||||||
|
for _, r := range d.Records {
|
||||||
|
if r.Type == "TXT" && strings.EqualFold(r.Name, strings.TrimSuffix(name, ".")) {
|
||||||
|
out = append(out, r.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package porkbun is a small client for the parts of Porkbun's API v3
|
// Package porkbun is a small client for the parts of Porkbun's API v3
|
||||||
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
||||||
// a domain's delegation: its name servers, glue records (host objects at
|
// a domain's delegation: its name servers, glue records (host objects at
|
||||||
// the registry) and DNSSEC DS records.
|
// the registry) and DNSSEC DS records, and the TXT records of DNS-01
|
||||||
|
// challenges while Porkbun still serves a domain (docs/certificates.md).
|
||||||
//
|
//
|
||||||
// Every call is a POST with the API key and secret in the JSON body. API
|
// Every call is a POST with the API key and secret in the JSON body. API
|
||||||
// access must be switched on for each domain in Porkbun's web interface,
|
// access must be switched on for each domain in Porkbun's web interface,
|
||||||
@@ -79,6 +80,7 @@ type response struct {
|
|||||||
Valid bool `json:"credentialsValid"`
|
Valid bool `json:"credentialsValid"`
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
|
ID json.Number `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
||||||
@@ -282,6 +284,37 @@ func (c *Client) DeleteDS(ctx context.Context, domain string, keyTag uint16) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinTTL is the lowest TTL Porkbun accepts for a DNS record.
|
||||||
|
const MinTTL = 600
|
||||||
|
|
||||||
|
// CreateTXT adds a TXT record at name (a name inside domain, or the
|
||||||
|
// domain itself) in Porkbun's DNS and returns its ID.
|
||||||
|
func (c *Client) CreateTXT(ctx context.Context, domain, name, value string) (string, error) {
|
||||||
|
sub := ""
|
||||||
|
if normName(name) != normName(domain) {
|
||||||
|
var err error
|
||||||
|
if sub, err = subdomain(domain, name); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, err := c.call(ctx, domainPath("/dns/create", domain), map[string]any{
|
||||||
|
"name": sub, "type": "TXT", "content": value, "ttl": strconv.Itoa(MinTTL),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if r.ID == "" {
|
||||||
|
return "", &Error{Message: "the record was created but Porkbun didn't say its ID"}
|
||||||
|
}
|
||||||
|
return r.ID.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRecord deletes the DNS record with id.
|
||||||
|
func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error {
|
||||||
|
_, err := c.call(ctx, domainPath("/dns/delete", domain, id), nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// subdomain returns host's label(s) below domain ("ns1" for
|
// subdomain returns host's label(s) below domain ("ns1" for
|
||||||
// ns1.example.com in example.com).
|
// ns1.example.com in example.com).
|
||||||
func subdomain(domain, host string) (string, error) {
|
func subdomain(domain, host string) (string, error) {
|
||||||
|
|||||||
@@ -272,6 +272,12 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
pzs := PolicyZones(s, p)
|
pzs := PolicyZones(s, p)
|
||||||
out.Policy = pzs
|
out.Policy = pzs
|
||||||
logRewrites := logResponses && len(pzs) > 0
|
logRewrites := logResponses && len(pzs) > 0
|
||||||
|
// Rate limiting logs what it limits (or would, in log-only mode) as
|
||||||
|
// query-errors; the statistics count it (docs/exposure.md).
|
||||||
|
logRRL := false
|
||||||
|
for _, o := range s.Options {
|
||||||
|
logRRL = logRRL || logResponses && o.Name == "rate-limit"
|
||||||
|
}
|
||||||
sawStats, sawLogging := false, false
|
sawStats, sawLogging := false, false
|
||||||
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
@@ -282,7 +288,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write(st.statsStatement(o))
|
w.write(st.statsStatement(o))
|
||||||
sawStats = true
|
sawStats = true
|
||||||
case logResponses && o.Name == "logging" && !sawLogging:
|
case logResponses && o.Name == "logging" && !sawLogging:
|
||||||
w.write(st.loggingStatement(o, logRewrites))
|
w.write(st.loggingStatement(o, logRewrites, logRRL))
|
||||||
sawLogging = true
|
sawLogging = true
|
||||||
default:
|
default:
|
||||||
w.write(optionText(o, 0))
|
w.write(optionText(o, 0))
|
||||||
@@ -297,7 +303,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
|
|||||||
w.write(st.statsStatement(nil))
|
w.write(st.statsStatement(nil))
|
||||||
}
|
}
|
||||||
if logResponses && !sawLogging {
|
if logResponses && !sawLogging {
|
||||||
w.write(st.loggingStatement(nil, logRewrites))
|
w.write(st.loggingStatement(nil, logRewrites, logRRL))
|
||||||
}
|
}
|
||||||
w.end()
|
w.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,9 +72,10 @@ func (h *StatsHooks) statsStatement(user *model.Option) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loggingStatement returns the logging statement with Jarl's channel and
|
// loggingStatement returns the logging statement with Jarl's channel and
|
||||||
// the responses category (and rpz, for blocking's rewrites), merged into
|
// the responses category (rpz too, for blocking's rewrites, and
|
||||||
// the user's own if there is one.
|
// query-errors for rate limiting), merged into the user's own if there is
|
||||||
func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
|
// one.
|
||||||
|
func (h *StatsHooks) loggingStatement(user *model.Option, rpz, rrl bool) string {
|
||||||
var st *namedconf.Stmt
|
var st *namedconf.Stmt
|
||||||
if user != nil {
|
if user != nil {
|
||||||
var err error
|
var err error
|
||||||
@@ -92,6 +93,9 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
|
|||||||
if rpz {
|
if rpz {
|
||||||
categories = append(categories, "rpz")
|
categories = append(categories, "rpz")
|
||||||
}
|
}
|
||||||
|
if rrl {
|
||||||
|
categories = append(categories, "query-errors")
|
||||||
|
}
|
||||||
for _, cat := range categories {
|
for _, cat := range categories {
|
||||||
merged := false
|
merged := false
|
||||||
for _, s := range b.Stmts {
|
for _, s := range b.Stmts {
|
||||||
@@ -101,7 +105,13 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !merged {
|
if !merged {
|
||||||
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))))
|
channels := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))
|
||||||
|
if cat == "query-errors" {
|
||||||
|
// Naming the category takes it away from named's
|
||||||
|
// default destination: keep that too.
|
||||||
|
channels.Stmts = append(channels.Stmts, namedconf.NewStmt("default_syslog"))
|
||||||
|
}
|
||||||
|
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", cat, channels))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefix := ""
|
prefix := ""
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// selfSigned writes a self-signed certificate for names into dir.
|
||||||
|
func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile string) {
|
||||||
|
t.Helper()
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||||
|
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(30 * 24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
certFile, keyFile = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||||
|
os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
|
||||||
|
os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
env.srv.cfg.Certs = m
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertificatesAPI(t *testing.T) {
|
||||||
|
env := newAPIEnv(t)
|
||||||
|
m := withCerts(env)
|
||||||
|
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
||||||
|
|
||||||
|
// A user who isn't an administrator sees nothing.
|
||||||
|
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})
|
||||||
|
env.do("GET", "/certificates", tok, nil, 403)
|
||||||
|
|
||||||
|
out := env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "source": "file", "cert_file": certFile, "key_file": keyFile, "uses": []string{"web"}}, 201)
|
||||||
|
c := out["certificate"].(map[string]any)
|
||||||
|
if c["serving"] != true || c["status"].(map[string]any)["state"] != "ok" || c["domains"].([]any)[0] != "jarl.example.com" {
|
||||||
|
t.Errorf("created: %v", c)
|
||||||
|
}
|
||||||
|
if _, has := c["key_pem"]; has {
|
||||||
|
t.Error("the key left the server")
|
||||||
|
}
|
||||||
|
if m.WebCertificate() == nil {
|
||||||
|
t.Error("not serving")
|
||||||
|
}
|
||||||
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "domains": []string{"a.example.com"}}, 409)
|
||||||
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "domains": []string{"a.example.com"}, "uses": []string{"web"}}, 400)
|
||||||
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "source": "file", "cert_file": "/nonexistent.pem", "key_file": keyFile}, 400)
|
||||||
|
list := env.do("GET", "/certificates", env.token, nil, 200)
|
||||||
|
if n := len(list["certificates"].([]any)); n != 1 || list["account"].(map[string]any)["directory"] != "https://acme.invalid/dir" {
|
||||||
|
t.Errorf("list: %v", list)
|
||||||
|
}
|
||||||
|
env.do("PATCH", "/certificates/own", env.token, map[string]any{"uses": []string{"dns"}}, 200)
|
||||||
|
if m.WebCertificate() != nil {
|
||||||
|
t.Error("still serving after the web use was taken off")
|
||||||
|
}
|
||||||
|
got := env.do("GET", "/certificates/own", env.token, nil, 200)
|
||||||
|
if files, ok := got["files"].(map[string]any); !ok || !strings.HasSuffix(files["key"].(string), "/own/privkey.pem") {
|
||||||
|
t.Errorf("files: %v", got["files"])
|
||||||
|
}
|
||||||
|
env.do("POST", "/certificates/own/issue", env.token, map[string]any{}, 200)
|
||||||
|
env.do("DELETE", "/certificates/own", env.token, nil, 200)
|
||||||
|
env.do("GET", "/certificates/own", env.token, nil, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
||||||
|
env := newAPIEnv(t)
|
||||||
|
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
||||||
|
s := env.srv
|
||||||
|
cr, err := newCertReloader(certFile, keyFile, s.log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.certs = cr
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &tls.Config{GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
||||||
|
hs := &http.Server{Handler: s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.TLS != nil {
|
||||||
|
io.WriteString(w, "tls "+r.Proto)
|
||||||
|
} else {
|
||||||
|
io.WriteString(w, "plain")
|
||||||
|
}
|
||||||
|
}))}
|
||||||
|
go hs.Serve(newSniffListener(ln, cfg))
|
||||||
|
t.Cleanup(func() { hs.Close() })
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
|
||||||
|
get := func(c *http.Client, url string) string {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := c.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
tlsClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, ForceAttemptHTTP2: true}}
|
||||||
|
if got := get(tlsClient, "https://"+addr+"/"); got != "tls HTTP/2.0" {
|
||||||
|
t.Errorf("HTTPS: %q", got)
|
||||||
|
}
|
||||||
|
// Loopback may still use plain HTTP (tools on the server itself).
|
||||||
|
if got := get(http.DefaultClient, "http://"+addr+"/"); got != "plain" {
|
||||||
|
t.Errorf("plain from loopback: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From elsewhere: browsers are redirected, API calls refused.
|
||||||
|
h := s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "served") }))
|
||||||
|
req := httptest.NewRequest("GET", "http://dns.example.com:8053/zones?x=1", nil)
|
||||||
|
req.RemoteAddr = "192.0.2.10:5555"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != "https://dns.example.com:8053/zones?x=1" {
|
||||||
|
t.Errorf("GET from elsewhere: %d %s", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
req = httptest.NewRequest("POST", "http://dns.example.com:8053/api/v1/records", strings.NewReader("{}"))
|
||||||
|
req.RemoteAddr = "192.0.2.10:5555"
|
||||||
|
req.Header.Set("Authorization", "Bearer x")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "https_required") || !strings.Contains(rec.Body.String(), "rotate the token") {
|
||||||
|
t.Errorf("POST from elsewhere: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// Without any certificate, plain HTTP is served as before.
|
||||||
|
s.certs = nil
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest("GET", "http://dns.example.com:8053/", nil)
|
||||||
|
req.RemoteAddr = "192.0.2.10:5555"
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Body.String() != "served" {
|
||||||
|
t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExposureAPI(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"192.168.1.0/24", "localhost"}, "adopt_zones": true}, 201)
|
||||||
|
ext := e.do("POST", "/views", e.token, map[string]any{"name": "external", "match_clients": []string{"any"}}, 201)["object"].(map[string]any)["id"]
|
||||||
|
|
||||||
|
type report struct {
|
||||||
|
Scopes []struct {
|
||||||
|
View string `json:"view"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
RateLimit string `json:"rate_limit"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
Findings []struct {
|
||||||
|
Code, Severity, Fix string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Server []struct{ Code, Fix string }
|
||||||
|
}
|
||||||
|
get := func() report {
|
||||||
|
b, status := e.req("GET", "/exposure", e.token, nil)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("exposure = %d %s", status, b)
|
||||||
|
}
|
||||||
|
var r report
|
||||||
|
json.Unmarshal(b, &r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
r := get()
|
||||||
|
if len(r.Scopes) != 2 || r.Scopes[0].View != "internal" || r.Scopes[0].Public || !r.Scopes[1].Public {
|
||||||
|
t.Fatalf("scopes = %+v", r.Scopes)
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
for _, f := range r.Scopes[1].Findings {
|
||||||
|
codes = append(codes, f.Code)
|
||||||
|
if f.Code == "exposure_rate_limit" && !strings.Contains(f.Fix, "log-only yes") {
|
||||||
|
t.Errorf("rate limit fix = %q", f.Fix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(codes, " "), "exposure_rate_limit") || len(r.Server) < 2 {
|
||||||
|
t.Errorf("findings = %v, server = %+v", codes, r.Server)
|
||||||
|
}
|
||||||
|
e.do("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_rate_limit"}, 201)
|
||||||
|
if r := get(); r.Scopes[1].RateLimit != "log-only" {
|
||||||
|
t.Errorf("rate limit = %q", r.Scopes[1].RateLimit)
|
||||||
|
}
|
||||||
|
if _, status := e.req("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_open_notify"}); status != 400 {
|
||||||
|
t.Errorf("fix without an automatic fix = %d", status)
|
||||||
|
}
|
||||||
|
// Opening a public view up needs confirming.
|
||||||
|
b, status := e.req("POST", "/options", e.token, map[string]any{"scope": "view", "scope_id": ext, "name": "allow-recursion", "value": "any"})
|
||||||
|
if status != 409 || !strings.Contains(string(b), "confirm_risk") {
|
||||||
|
t.Errorf("open resolver = %d %s", status, b)
|
||||||
|
}
|
||||||
|
e.do("POST", "/exposure/ack", e.token, map[string]any{"view": "external", "code": "exposure_minimal_any", "reason": "testing"}, 201)
|
||||||
|
for _, f := range get().Scopes[1].Findings {
|
||||||
|
if f.Code == "exposure_minimal_any" && f.Severity != "info" {
|
||||||
|
t.Errorf("acknowledged finding = %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,7 +158,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
|
|||||||
for _, z := range st.SortedZones() {
|
for _, z := range st.SortedZones() {
|
||||||
vn := st.ViewName(z)
|
vn := st.ViewName(z)
|
||||||
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
|
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
|
||||||
out = append(out, acme.Zone{Name: z.Name, View: vn})
|
out = append(out, acme.Zone{Name: z.Name, View: vn, Link: z.LinkGroup})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLS certificates (docs/certificates.md). Administrators only: a
|
||||||
|
// certificate's key can impersonate the server.
|
||||||
|
|
||||||
|
type certView struct {
|
||||||
|
*model.Certificate
|
||||||
|
Status certs.Status `json:"status"`
|
||||||
|
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
||||||
|
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
||||||
|
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
||||||
|
// IssuingSince is set while Jarl is obtaining it.
|
||||||
|
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type certFiles struct {
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) presentCert(c *model.Certificate) certView {
|
||||||
|
m := s.cfg.Certs
|
||||||
|
v := certView{Certificate: c, Status: m.StatusOf(c), Serving: m.WebName() == c.Name && c.HasUse(model.CertUseWeb)}
|
||||||
|
if c.Source == model.CertSourceACME {
|
||||||
|
t := m.Due(c)
|
||||||
|
v.NextTry = &t
|
||||||
|
}
|
||||||
|
if t := m.Issuing(c.Name); !t.IsZero() {
|
||||||
|
v.IssuingSince = &t
|
||||||
|
}
|
||||||
|
if c.HasUse(model.CertUseDNS) && c.NotAfter != nil {
|
||||||
|
dir := m.FilesDir(c.Name)
|
||||||
|
v.Files = &certFiles{Chain: dir + "/" + certs.ChainFile, Key: dir + "/" + certs.KeyFile}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) certsReady(p *auth.Principal) error {
|
||||||
|
if err := requireAdmin(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func certErr(err error) error {
|
||||||
|
var ie *certs.InputError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &ie):
|
||||||
|
return badRequest("%s", ie.Msg)
|
||||||
|
case errors.Is(err, certs.ErrNotFound):
|
||||||
|
return notFound("no such certificate")
|
||||||
|
case errors.Is(err, certs.ErrConflict):
|
||||||
|
return conflict("a certificate with that name exists")
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
all, err := s.cfg.Certs.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := []certView{}
|
||||||
|
for _, c := range all {
|
||||||
|
out = append(out, s.presentCert(c))
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.presentCert(c))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
certs.Input
|
||||||
|
Issue *bool `json:"issue"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Create(r.Context(), in.Input, p)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceACME && (in.Issue == nil || *in.Issue) {
|
||||||
|
return s.writeIssue(w, r, c.Name, p, http.StatusCreated)
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceACME {
|
||||||
|
s.cfg.Certs.Kick()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"certificate": s.presentCert(c)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certUpdate: PATCH /certificates/{name}.
|
||||||
|
func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in certs.Input
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Update(r.Context(), r.PathValue("name"), in, p)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"certificate": s.presentCert(c)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certIssue: POST /certificates/{name}/issue starts obtaining it now
|
||||||
|
// (202), or rereads the files of a file-source certificate (200).
|
||||||
|
func (s *Server) certIssue(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeIssue(w, r, r.PathValue("name"), p, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeIssue starts obtaining the certificate (it takes a while: poll
|
||||||
|
// GET /certificates/{name} until issuing_since is gone).
|
||||||
|
func (s *Server) writeIssue(w http.ResponseWriter, r *http.Request, name string, p *auth.Principal, status int) error {
|
||||||
|
c, err := s.cfg.Certs.IssueAsync(r.Context(), name, p)
|
||||||
|
if c == nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
if c.Source == model.CertSourceACME && status == http.StatusOK {
|
||||||
|
status = http.StatusAccepted
|
||||||
|
}
|
||||||
|
out := map[string]any{"certificate": s.presentCert(c)}
|
||||||
|
if err != nil {
|
||||||
|
out["error"] = err.Error()
|
||||||
|
}
|
||||||
|
writeJSON(w, status, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certCheck: POST /certificates/{name}/check (or POST /certificates/check
|
||||||
|
// with a certificate's fields) says where each challenge would go, without
|
||||||
|
// asking the CA for anything.
|
||||||
|
func (s *Server) certCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.certsReady(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var c *model.Certificate
|
||||||
|
if name := r.PathValue("name"); name != "" {
|
||||||
|
var err error
|
||||||
|
if c, err = s.cfg.Certs.Get(r.Context(), name); err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var in certs.Input
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.Name == nil {
|
||||||
|
in.Name = model.Ptr("check")
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if c, err = s.cfg.Certs.Preview(in); err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routes, err := s.cfg.Certs.Check(r.Context(), c)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
ok := true
|
||||||
|
for _, rt := range routes {
|
||||||
|
ok = ok && rt.Error == ""
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "routes": routes, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certSummary sums up the certificates for the dashboard (nil without any).
|
||||||
|
func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
all, err := s.cfg.Certs.List(ctx)
|
||||||
|
if err != nil || len(all) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &api.CertSummary{Total: len(all), Problems: []api.CertProblem{}, Serving: s.cfg.Certs.WebName()}
|
||||||
|
for _, c := range all {
|
||||||
|
if st := s.cfg.Certs.StatusOf(c); st.State != "ok" && st.State != "pending" {
|
||||||
|
out.Problems = append(out.Problems, api.CertProblem{Name: c.Name, State: st.State, Message: st.Message})
|
||||||
|
}
|
||||||
|
if c.NotAfter != nil && (out.NextAfter == nil || c.NotAfter.Before(*out.NextAfter)) {
|
||||||
|
out.Next, out.NextAfter = c.Name, c.NotAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
if in.Force {
|
if in.Force {
|
||||||
q.Set("force", "1")
|
q.Set("force", "1")
|
||||||
}
|
}
|
||||||
|
if in.AcceptRisk {
|
||||||
|
q.Set("accept_risk", "1")
|
||||||
|
}
|
||||||
r.URL.RawQuery = q.Encode()
|
r.URL.RawQuery = q.Encode()
|
||||||
return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil)
|
return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil)
|
||||||
}
|
}
|
||||||
@@ -106,7 +109,7 @@ func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth
|
|||||||
}
|
}
|
||||||
cs = res.Changeset
|
cs = res.Changeset
|
||||||
if in.Apply {
|
if in.Apply {
|
||||||
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force})
|
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force, AcceptRisk: in.AcceptRisk})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeErr(w, r, err, out)
|
s.writeErr(w, r, err, out)
|
||||||
return nil
|
return nil
|
||||||
@@ -214,8 +217,9 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var in struct {
|
var in struct {
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
Force bool `json:"force"`
|
Force bool `json:"force"`
|
||||||
|
AcceptRisk bool `json:"accept_risk"`
|
||||||
}
|
}
|
||||||
if body, err := readBody(r); err != nil {
|
if body, err := readBody(r); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -224,7 +228,8 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return badRequest("invalid JSON: %v", err)
|
return badRequest("invalid JSON: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force")})
|
res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force"),
|
||||||
|
AcceptRisk: in.AcceptRisk || queryBool(r, "accept_risk")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeErr(w, r, err, res)
|
s.writeErr(w, r, err, res)
|
||||||
return nil
|
return nil
|
||||||
@@ -278,6 +283,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
if p.IsAdmin() {
|
if p.IsAdmin() {
|
||||||
resp.Backup = s.backupStatus(r.Context(), now)
|
resp.Backup = s.backupStatus(r.Context(), now)
|
||||||
resp.TOTP = s.totpOverview(r.Context())
|
resp.TOTP = s.totpOverview(r.Context())
|
||||||
|
resp.Certificates = s.certSummary(r.Context())
|
||||||
}
|
}
|
||||||
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
|
"jarl/internal/encdns"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/nscheck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNS over TLS and HTTPS (docs/encrypted-dns.md).
|
||||||
|
|
||||||
|
type encAddress struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
HasZone bool `json:"has_zone,omitempty"` // has a zone for the host name
|
||||||
|
}
|
||||||
|
|
||||||
|
type encCert struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
DNS bool `json:"dns"` // has the dns use
|
||||||
|
Status certs.Status `json:"status"`
|
||||||
|
Obtained bool `json:"obtained"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type encStatus struct {
|
||||||
|
encdns.Status
|
||||||
|
// Hostname is the name clients connect to (the certificate's first).
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
// DoTHost and DoHURL are what to give clients.
|
||||||
|
DoTHost string `json:"dot_host,omitempty"`
|
||||||
|
DoHURL string `json:"doh_url,omitempty"`
|
||||||
|
// For the setup form.
|
||||||
|
Addresses []encAddress `json:"addresses"`
|
||||||
|
Views []encView `json:"views"`
|
||||||
|
Certificates []encCert `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) encReady(p *auth.Principal, write bool) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions, Write: write}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.cfg.Certs == nil {
|
||||||
|
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localAddrs returns this host's addresses (a test may replace it).
|
||||||
|
var localAddrs = nscheck.LocalAddrs
|
||||||
|
|
||||||
|
// encryptedDNS: GET /encrypted-dns.
|
||||||
|
func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := s.cfg.Certs
|
||||||
|
out := encStatus{Status: encdns.GetStatus(st, m.FilesDir("")), Addresses: []encAddress{}, Views: []encView{}, Certificates: []encCert{}}
|
||||||
|
all, err := m.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range all {
|
||||||
|
out.Certificates = append(out.Certificates, encCert{Name: c.Name, Names: c.Names, Domains: c.Domains, DNS: c.HasUse(model.CertUseDNS),
|
||||||
|
Status: m.StatusOf(c), Obtained: c.NotAfter != nil})
|
||||||
|
if c.Name == out.Certificate {
|
||||||
|
for _, n := range append(c.Names, c.Domains...) {
|
||||||
|
if !strings.HasPrefix(n, "*.") {
|
||||||
|
out.Hostname = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Hostname != "" {
|
||||||
|
if len(out.DoT) > 0 {
|
||||||
|
out.DoTHost = out.Hostname
|
||||||
|
if out.DoT[0].Port != encdns.DoTPort {
|
||||||
|
out.DoTHost += ":" + strconv.Itoa(out.DoT[0].Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out.DoH) > 0 {
|
||||||
|
out.DoHURL = "https://" + out.Hostname
|
||||||
|
if out.DoH[0].Port != 443 {
|
||||||
|
out.DoHURL += ":" + strconv.Itoa(out.DoH[0].Port)
|
||||||
|
}
|
||||||
|
out.DoHURL += "/dns-query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[netip.Addr]bool{}
|
||||||
|
for _, a := range localAddrs() {
|
||||||
|
a = a.Unmap()
|
||||||
|
if seen[a] || a.IsLinkLocalUnicast() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[a] = true
|
||||||
|
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
|
||||||
|
}
|
||||||
|
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
|
||||||
|
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".")
|
||||||
|
if host == "" {
|
||||||
|
host = out.Hostname
|
||||||
|
}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
if x.View == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := encView{Name: x.View, Public: x.Public}
|
||||||
|
if host != "" {
|
||||||
|
if vv := st.ViewByName(x.View); vv != nil {
|
||||||
|
for _, z := range st.ZonesInView(vv.ID) {
|
||||||
|
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
|
||||||
|
v.HasZone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Views = append(out.Views, v)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSCheck: POST /encrypted-dns/check {"addresses", "dot", "doh",
|
||||||
|
// "doh_port"} says what already listens where the listeners would go.
|
||||||
|
func (s *Server) encryptedDNSCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in encdns.Setup
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.DoHPort == 0 {
|
||||||
|
in.DoHPort = 443
|
||||||
|
}
|
||||||
|
if in.DoTPort == 0 {
|
||||||
|
in.DoTPort = encdns.DoTPort
|
||||||
|
}
|
||||||
|
var ports []int
|
||||||
|
if in.DoT {
|
||||||
|
ports = append(ports, in.DoTPort)
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
ports = append(ports, in.DoHPort)
|
||||||
|
}
|
||||||
|
conflicts, err := encdns.PortCheck(st, in.Addresses, ports, s.cfg.Listen)
|
||||||
|
out := map[string]any{"conflicts": conflicts}
|
||||||
|
if err != nil {
|
||||||
|
out["error"] = "can't see which ports are in use: " + err.Error()
|
||||||
|
}
|
||||||
|
if conflicts == nil {
|
||||||
|
out["conflicts"] = []encdns.Conflict{}
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
out["free_doh_port"] = encdns.FreeDoHPort(st, in.Addresses, s.cfg.Listen)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSSetup: POST /encrypted-dns stages (or applies) the setup.
|
||||||
|
func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in encdns.Setup
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.cfg.Certs.Get(r.Context(), in.Certificate)
|
||||||
|
if err != nil {
|
||||||
|
return certErr(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !c.HasUse(model.CertUseDNS):
|
||||||
|
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
|
||||||
|
case c.NotAfter == nil:
|
||||||
|
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
|
||||||
|
case in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}):
|
||||||
|
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname)
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
|
||||||
|
if err != nil {
|
||||||
|
return badRequest("%v", err)
|
||||||
|
}
|
||||||
|
what := []string{}
|
||||||
|
if in.DoT {
|
||||||
|
what = append(what, "DNS over TLS")
|
||||||
|
}
|
||||||
|
if in.DoH {
|
||||||
|
what = append(what, "DNS over HTTPS")
|
||||||
|
}
|
||||||
|
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedDNSDisable: DELETE /encrypted-dns.
|
||||||
|
func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := s.encReady(p, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs := encdns.Disable(st)
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
return conflict("encrypted DNS isn't set up")
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/engine"
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/stats"
|
||||||
|
"jarl/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exposureFinding is an exposure problem with the fix Jarl can stage for
|
||||||
|
// it ("" when there is none).
|
||||||
|
type exposureFinding struct {
|
||||||
|
validate.Problem
|
||||||
|
Fix string `json:"fix,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type exposureScope struct {
|
||||||
|
validate.Exposure
|
||||||
|
RateLimit string `json:"rate_limit"` // off, log-only or enforcing
|
||||||
|
Findings []exposureFinding `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type exposureReport struct {
|
||||||
|
Scopes []exposureScope `json:"scopes"`
|
||||||
|
// Server holds server-wide findings (version, host name, management
|
||||||
|
// interfaces).
|
||||||
|
Server []exposureFinding `json:"server"`
|
||||||
|
// RateLimiting is what rate limiting limited, or would have, in the
|
||||||
|
// last 24 hours (with query statistics on).
|
||||||
|
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateLimitActivity struct {
|
||||||
|
Since string `json:"since"`
|
||||||
|
Counts []stats.Entry `json:"counts"` // drop, slip, would-drop, would-slip
|
||||||
|
Networks []stats.Entry `json:"networks"` // client networks, most limited first
|
||||||
|
Names []stats.Entry `json:"names"` // "name TYPE"
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposure: GET /exposure reports which views are reachable from the
|
||||||
|
// internet and what the exposure checks found (docs/exposure.md).
|
||||||
|
func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
problems := s.eng.Validate(st)
|
||||||
|
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
|
||||||
|
finding := func(pr validate.Problem, viewID int64) exposureFinding {
|
||||||
|
_, desc := engine.ExposureFix(st, viewID, pr.Code)
|
||||||
|
return exposureFinding{Problem: pr, Fix: desc}
|
||||||
|
}
|
||||||
|
scopes := map[string]int{}
|
||||||
|
for _, x := range s.eng.Exposures(st) {
|
||||||
|
sc := exposureScope{Exposure: x, RateLimit: "off", Findings: []exposureFinding{}}
|
||||||
|
v := st.Views[x.ViewID]
|
||||||
|
o := st.FindOption(model.ScopeOptions, 0, "rate-limit")
|
||||||
|
if v != nil {
|
||||||
|
if vo := st.FindOption(model.ScopeView, v.ID, "rate-limit"); vo != nil {
|
||||||
|
o = vo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o != nil {
|
||||||
|
sc.RateLimit = "enforcing"
|
||||||
|
if validate.LogOnly(o) {
|
||||||
|
sc.RateLimit = "log-only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scopes[x.View] = len(out.Scopes)
|
||||||
|
out.Scopes = append(out.Scopes, sc)
|
||||||
|
}
|
||||||
|
for _, pr := range problems {
|
||||||
|
if !validate.Ackable(pr.Code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pr.ObjectType == "view" || pr.ObjectType == "zone" {
|
||||||
|
if i, ok := scopes[pr.View]; ok {
|
||||||
|
out.Scopes[i].Findings = append(out.Scopes[i].Findings, finding(pr, out.Scopes[i].ViewID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Server = append(out.Server, finding(pr, 0))
|
||||||
|
}
|
||||||
|
if s.statsQ != nil && p.Can(auth.Action{Resource: auth.ResStats}) {
|
||||||
|
ctx, since := r.Context(), 24*time.Hour
|
||||||
|
a := &rateLimitActivity{Since: "24h"}
|
||||||
|
a.Counts, _ = s.statsQ.Breakdown(ctx, "rrl", since)
|
||||||
|
a.Networks, _ = s.statsQ.Top(ctx, stats.KindRateLimited, since, 10)
|
||||||
|
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
|
||||||
|
out.RateLimiting = a
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposureFix: POST /exposure/fix {"view_id": 3, "code": "..."} stages (or
|
||||||
|
// applies) the fix for a finding, like any other change.
|
||||||
|
func (s *Server) exposureFix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
var in struct {
|
||||||
|
ViewID int64 `json:"view_id"`
|
||||||
|
View string `json:"view"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.store.LoadState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if in.View != "" {
|
||||||
|
v := st.ViewByName(in.View)
|
||||||
|
if v == nil {
|
||||||
|
return notFound("no view %s", in.View)
|
||||||
|
}
|
||||||
|
in.ViewID = v.ID
|
||||||
|
}
|
||||||
|
reqs, desc := engine.ExposureFix(st, in.ViewID, in.Code)
|
||||||
|
if reqs == nil {
|
||||||
|
return badRequest("there is no automatic fix for %s: what to allow is yours to decide; change the option yourself, or acknowledge the finding", in.Code)
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, desc, reqs, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exposureAck: POST /exposure/ack {"view": "external", "code": "...",
|
||||||
|
// "reason": "..."} accepts a finding (unack takes it back).
|
||||||
|
func (s *Server) exposureAck(ack bool) handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
var in engine.AckInput
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
op := engine.OpExposureUnack
|
||||||
|
if ack {
|
||||||
|
op = engine.OpExposureAck
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,14 +15,24 @@ import (
|
|||||||
"jarl/internal/stats"
|
"jarl/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// statsQuerier checks permission and that statistics are enabled.
|
// statsQuerier checks permission and that statistics are enabled. With
|
||||||
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
|
// ?views=internal,vps the figures are for those views only.
|
||||||
|
func (s *Server) statsQuerier(r *http.Request, p *auth.Principal) (*stats.Querier, error) {
|
||||||
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
|
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if s.statsQ == nil {
|
if s.statsQ == nil {
|
||||||
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
|
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
|
||||||
}
|
}
|
||||||
|
var views []string
|
||||||
|
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
views = append(views, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(views) > 0 {
|
||||||
|
return s.statsQ.ForViews(views), nil
|
||||||
|
}
|
||||||
return s.statsQ, nil
|
return s.statsQ, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +57,7 @@ func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,7 +77,7 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,7 +104,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -124,7 +135,7 @@ func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.P
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -143,7 +154,7 @@ func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
q, err := s.statsQuerier(p)
|
q, err := s.statsQuerier(r, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -152,6 +163,9 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
|
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
|
||||||
|
if errors.Is(err, stats.ErrNotPerView) {
|
||||||
|
return &httpError{http.StatusBadRequest, "not_per_view", r.URL.Query().Get("group") + ": " + err.Error()}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return badRequest("%v", err)
|
return badRequest("%v", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +261,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
|
|||||||
// limits (rndc status), and the space left on the file systems Jarl and
|
// limits (rndc status), and the space left on the file systems Jarl and
|
||||||
// named write to.
|
// named write to.
|
||||||
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if _, err := s.statsQuerier(p); err != nil {
|
if _, err := s.statsQuerier(r, p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out := map[string]any{"server": s.cfg.Stats.Server()}
|
out := map[string]any{"server": s.cfg.Stats.Server()}
|
||||||
|
|||||||
@@ -157,6 +157,37 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// zoneLink: POST /zones/{zone}/link {"with": ["external"]} links the zone
|
||||||
|
// with the zones of the same name in those views (default: every view
|
||||||
|
// that has one). zoneUnlink: POST /zones/{zone}/unlink.
|
||||||
|
func (s *Server) zoneLink(link bool) handler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
z, err := s.zoneByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in := engine.LinkInput{ZoneID: z.ID}
|
||||||
|
op := engine.OpZoneUnlink
|
||||||
|
if link {
|
||||||
|
op = engine.OpZoneLink
|
||||||
|
var body struct {
|
||||||
|
With []string `json:"with"`
|
||||||
|
}
|
||||||
|
if r.ContentLength != 0 {
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.With = body.With
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
st, err := s.state(r)
|
st, err := s.state(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -462,11 +493,12 @@ func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
||||||
Data []string `json:"data"`
|
Data []string `json:"data"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
Local *bool `json:"local,omitempty"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &in); err != nil {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment})
|
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment, Local: in.Local})
|
||||||
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
||||||
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
||||||
z := st.Zones[zid]
|
z := st.Zones[zid]
|
||||||
|
|||||||
@@ -274,6 +274,21 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
|
|||||||
"zone", engine.Display(z.Name), "view", state.ViewName(z), "phase", ph.Code)
|
"zone", engine.Display(z.Name), "view", state.ViewName(z), "phase", ph.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.cfg.Certs != nil {
|
||||||
|
if list, err := s.cfg.Certs.List(ctx); err == nil {
|
||||||
|
for _, c := range list {
|
||||||
|
if c.NotAfter != nil {
|
||||||
|
pw.gauge("jarl_certificate_expiry_timestamp_seconds", "When each certificate Jarl manages expires (Unix time; docs/certificates.md).", unix(*c.NotAfter), "certificate", c.Name, "source", c.Source)
|
||||||
|
}
|
||||||
|
st := s.cfg.Certs.StatusOf(c)
|
||||||
|
pw.gauge("jarl_certificate_failing", "1 when obtaining or reading a certificate failed the last time.", b2f(st.State == "failing" || st.State == "expired"), "certificate", c.Name, "source", c.Source)
|
||||||
|
if c.Source == model.CertSourceACME {
|
||||||
|
pw.gauge("jarl_certificate_renewal_failures", "Failed attempts to obtain the certificate since the last success.", float64(c.Failures), "certificate", c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
||||||
@@ -297,6 +312,7 @@ var counterMetrics = map[string]struct{ name, label, help string }{
|
|||||||
"answer": {"jarl_answers_total", "source", "Answers that were authoritative or not (recursive)."},
|
"answer": {"jarl_answers_total", "source", "Answers that were authoritative or not (recursive)."},
|
||||||
"transport": {"jarl_queries_by_transport_total", "transport", "Queries over UDP and TCP."},
|
"transport": {"jarl_queries_by_transport_total", "transport", "Queries over UDP and TCP."},
|
||||||
"ratelimit": {"jarl_ratelimit_responses_total", "action", "Responses rate limiting dropped or slipped (truncated)."},
|
"ratelimit": {"jarl_ratelimit_responses_total", "action", "Responses rate limiting dropped or slipped (truncated)."},
|
||||||
|
"rrl": {"jarl_ratelimit_logged_total", "action", "Rate limited responses from named's log: drop and slip, or would-drop and would-slip in log-only mode."},
|
||||||
"rtt": {"jarl_upstream_responses_by_rtt_total", "rtt", "Responses from other servers during recursion, by round-trip time."},
|
"rtt": {"jarl_upstream_responses_by_rtt_total", "rtt", "Responses from other servers during recursion, by round-trip time."},
|
||||||
"dnssec": {"jarl_dnssec_validations_total", "result", "DNSSEC validations of recursive answers by result."},
|
"dnssec": {"jarl_dnssec_validations_total", "result", "DNSSEC validations of recursive answers by result."},
|
||||||
"cache": {"jarl_cache_lookups_total", "result", "Cache lookups that hit or missed."},
|
"cache": {"jarl_cache_lookups_total", "result", "Cache lookups that hit or missed."},
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
|||||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||||
}
|
}
|
||||||
out.Registrar = s.registrarFor(z)
|
out.Registrar = s.registrarFor(z)
|
||||||
|
for _, o := range st.LinkedWith(z) {
|
||||||
|
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
||||||
|
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +34,12 @@ func presentRecord(st *model.State, r *model.Record) api.Record {
|
|||||||
out := api.Record{Record: *r}
|
out := api.Record{Record: *r}
|
||||||
if z != nil {
|
if z != nil {
|
||||||
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
|
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
out.Linked = "local"
|
||||||
|
if r.SharedID != 0 {
|
||||||
|
out.Linked = "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
if rr.IsTextType(r.Type) {
|
if rr.IsTextType(r.Type) {
|
||||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||||
if t, ok := rr.TextValue(p.RR); ok {
|
if t, ok := rr.TextValue(p.RR); ok {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func engineStatus(k engine.ErrorKind) int {
|
|||||||
return http.StatusForbidden
|
return http.StatusForbidden
|
||||||
case engine.KindNotFound:
|
case engine.KindNotFound:
|
||||||
return http.StatusNotFound
|
return http.StatusNotFound
|
||||||
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted:
|
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted, engine.KindRisk:
|
||||||
return http.StatusConflict
|
return http.StatusConflict
|
||||||
case engine.KindValidation:
|
case engine.KindValidation:
|
||||||
return http.StatusUnprocessableEntity
|
return http.StatusUnprocessableEntity
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
|
||||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||||
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
||||||
@@ -162,6 +164,22 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
|
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
|
||||||
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
|
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
|
||||||
// Query statistics
|
// Query statistics
|
||||||
|
m.HandleFunc("GET "+p+"/certificates", s.authed(s.certList))
|
||||||
|
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("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))
|
||||||
|
m.HandleFunc("POST "+p+"/certificates/{name}/check", s.authed(s.certCheck))
|
||||||
|
m.HandleFunc("GET "+p+"/encrypted-dns", s.authed(s.encryptedDNS))
|
||||||
|
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
|
||||||
|
m.HandleFunc("DELETE "+p+"/encrypted-dns", s.authed(s.encryptedDNSDisable))
|
||||||
|
m.HandleFunc("POST "+p+"/encrypted-dns/check", s.authed(s.encryptedDNSCheck))
|
||||||
|
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
|
||||||
|
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
|
||||||
|
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
|
||||||
|
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
|
||||||
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
|
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
|
||||||
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
|
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
|
||||||
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
|
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
|
||||||
@@ -239,6 +257,11 @@ func changesetTarget(r *http.Request) (int64, error) {
|
|||||||
// mutate applies (or stages, or previews) requests and writes the
|
// mutate applies (or stages, or previews) requests and writes the
|
||||||
// response. object, if set, returns the resulting object to include.
|
// response. object, if set, returns the resulting object to include.
|
||||||
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
|
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
|
||||||
|
return s.mutateNotes(w, r, p, description, reqs, object, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutateNotes is mutate with notes for the user, shown as warnings.
|
||||||
|
func (s *Server) mutateNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any, notes []string) error {
|
||||||
csID, err := changesetTarget(r)
|
csID, err := changesetTarget(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -249,16 +272,16 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
|
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
|
||||||
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
|
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: append(notes, res.Warnings...), Problems: raw(res.Problems)})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")}
|
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")}
|
||||||
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
|
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeErr(w, r, err, res)
|
s.writeErr(w, r, err, res)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings}
|
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: append(notes, res.Warnings...)}
|
||||||
if object != nil && res.Applied {
|
if object != nil && res.Applied {
|
||||||
st, err := s.store.LoadState(r.Context())
|
st, err := s.store.LoadState(r.Context())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
+23
-13
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/certs"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/nscheck"
|
"jarl/internal/nscheck"
|
||||||
"jarl/internal/porkbun"
|
"jarl/internal/porkbun"
|
||||||
@@ -60,6 +61,9 @@ type Config struct {
|
|||||||
TOTPIssuer string
|
TOTPIssuer string
|
||||||
// Metrics configures /metrics (metrics.go).
|
// Metrics configures /metrics (metrics.go).
|
||||||
Metrics Metrics
|
Metrics Metrics
|
||||||
|
// Certs manages TLS certificates (nil in tests that don't need it),
|
||||||
|
// including the one jarld serves (h_certs.go, sniff.go).
|
||||||
|
Certs *certs.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the HTTP server.
|
// Server is the HTTP server.
|
||||||
@@ -143,14 +147,19 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
if len(s.cfg.Listen) == 0 {
|
if len(s.cfg.Listen) == 0 {
|
||||||
return errors.New("no listen addresses configured")
|
return errors.New("no listen addresses configured")
|
||||||
}
|
}
|
||||||
var tlsCfg *tls.Config
|
|
||||||
if s.cfg.TLSCertFile != "" {
|
if s.cfg.TLSCertFile != "" {
|
||||||
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.certs = cr
|
s.certs = cr
|
||||||
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get}
|
}
|
||||||
|
// With a certificate (tls.cert_file, or one Jarl manages, which may
|
||||||
|
// turn up later), the listeners serve HTTPS and plain HTTP side by
|
||||||
|
// side (sniff.go).
|
||||||
|
var tlsCfg *tls.Config
|
||||||
|
if s.certs != nil || s.cfg.Certs != nil {
|
||||||
|
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
||||||
}
|
}
|
||||||
errc := make(chan error, len(s.cfg.Listen)+1)
|
errc := make(chan error, len(s.cfg.Listen)+1)
|
||||||
var listeners []net.Listener
|
var listeners []net.Listener
|
||||||
@@ -168,8 +177,12 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
listeners = append(listeners, ln)
|
listeners = append(listeners, ln)
|
||||||
}
|
}
|
||||||
for i, ln := range listeners {
|
for i, ln := range listeners {
|
||||||
|
handler := s.Handler()
|
||||||
|
if tlsCfg != nil {
|
||||||
|
handler = s.requireHTTPS(handler)
|
||||||
|
}
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Handler: s.Handler(),
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ReadTimeout: 2 * time.Minute,
|
ReadTimeout: 2 * time.Minute,
|
||||||
WriteTimeout: 5 * time.Minute,
|
WriteTimeout: 5 * time.Minute,
|
||||||
@@ -181,23 +194,20 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
s.srvs = append(s.srvs, srv)
|
s.srvs = append(s.srvs, srv)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if tlsCfg != nil {
|
if s.httpsAvailable() {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
||||||
|
if tlsCfg != nil {
|
||||||
|
ln = newSniffListener(ln, tlsCfg)
|
||||||
|
}
|
||||||
go func(srv *http.Server, ln net.Listener) {
|
go func(srv *http.Server, ln net.Listener) {
|
||||||
var err error
|
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||||
if tlsCfg != nil {
|
|
||||||
err = srv.ServeTLS(ln, "", "")
|
|
||||||
} else {
|
|
||||||
err = srv.Serve(ln)
|
|
||||||
}
|
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
errc <- err
|
errc <- err
|
||||||
}
|
}
|
||||||
}(srv, ln)
|
}(srv, ln)
|
||||||
}
|
}
|
||||||
if tlsCfg == nil {
|
if !s.httpsAvailable() && (s.cfg.Certs == nil || !s.cfg.Certs.HasWeb(ctx)) {
|
||||||
for _, a := range s.cfg.Listen {
|
for _, a := range s.cfg.Listen {
|
||||||
if host, _, err := net.SplitHostPort(a); err == nil {
|
if host, _, err := net.SplitHostPort(a); err == nil {
|
||||||
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
||||||
@@ -211,7 +221,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.serveSocket(errc)
|
s.serveSocket(errc)
|
||||||
s.logHTTPReq(tlsCfg != nil)
|
s.logHTTPReq(s.httpsAvailable())
|
||||||
if ready != nil {
|
if ready != nil {
|
||||||
ready()
|
ready()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sniffListener serves TLS and plain HTTP on the same port: it looks at a
|
||||||
|
// connection's first byte and hands the server a *tls.Conn when it is a
|
||||||
|
// TLS handshake (0x16), the plain connection otherwise. That way jarld
|
||||||
|
// can start serving HTTPS when a certificate turns up (one Jarl obtains,
|
||||||
|
// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets
|
||||||
|
// a redirect instead of an error.
|
||||||
|
type sniffListener struct {
|
||||||
|
net.Listener
|
||||||
|
cfg *tls.Config
|
||||||
|
ch chan net.Conn
|
||||||
|
errc chan error
|
||||||
|
done chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener {
|
||||||
|
l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})}
|
||||||
|
go l.loop()
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sniffListener) loop() {
|
||||||
|
for {
|
||||||
|
c, err := l.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) && ne.Timeout() {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case l.errc <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go l.classify(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sniffListener) classify(c net.Conn) {
|
||||||
|
c.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
br := bufio.NewReader(c)
|
||||||
|
b, err := br.Peek(1)
|
||||||
|
c.SetReadDeadline(time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
c.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var out net.Conn = &peekedConn{Conn: c, r: br}
|
||||||
|
if b[0] == 0x16 {
|
||||||
|
out = tls.Server(out, l.cfg)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case l.ch <- out:
|
||||||
|
case <-l.done:
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sniffListener) Accept() (net.Conn, error) {
|
||||||
|
select {
|
||||||
|
case c := <-l.ch:
|
||||||
|
return c, nil
|
||||||
|
case err := <-l.errc:
|
||||||
|
return nil, err
|
||||||
|
case <-l.done:
|
||||||
|
return nil, net.ErrClosed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sniffListener) Close() error {
|
||||||
|
l.once.Do(func() { close(l.done) })
|
||||||
|
return l.Listener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// peekedConn reads through the buffer that holds the peeked byte.
|
||||||
|
type peekedConn struct {
|
||||||
|
net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||||
|
|
||||||
|
var errNoCertificate = errors.New("no certificate for HTTPS yet")
|
||||||
|
|
||||||
|
// webCertificate is the certificate jarld serves: one Jarl manages with
|
||||||
|
// the web use, else tls.cert_file.
|
||||||
|
func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
if s.cfg.Certs != nil {
|
||||||
|
if c := s.cfg.Certs.WebCertificate(); c != nil {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.certs != nil {
|
||||||
|
return s.certs.get(hello)
|
||||||
|
}
|
||||||
|
return nil, errNoCertificate
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpsAvailable reports whether jarld has a certificate to serve.
|
||||||
|
func (s *Server) httpsAvailable() bool {
|
||||||
|
return s.certs != nil || s.cfg.Certs != nil && s.cfg.Certs.WebCertificate() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireHTTPS sends plain-HTTP requests to HTTPS once jarld has a
|
||||||
|
// certificate: browsers (GET and HEAD) get a redirect, anything else an
|
||||||
|
// error, so tokens and passwords aren't sent in the clear again. Requests
|
||||||
|
// from loopback (tools on this server) and from trusted proxies (which
|
||||||
|
// terminate TLS themselves) are served as they are.
|
||||||
|
func (s *Server) requireHTTPS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.TLS != nil || !s.httpsAvailable() {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := "https://" + r.Host + r.URL.RequestURI()
|
||||||
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||||
|
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "jarld serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI())
|
||||||
|
if r.Header.Get("Authorization") != "" {
|
||||||
|
msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token"
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusBadRequest, "https_required", msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,7 +35,10 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
minute := now.Unix() - now.Unix()%60 - 120
|
minute := now.Unix() - now.Unix()%60 - 120
|
||||||
hour := minute - minute%3600
|
hour := minute - minute%3600
|
||||||
b := &stats.Batch{}
|
b := &stats.Batch{}
|
||||||
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10}
|
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10,
|
||||||
|
"view:internal": 30, "view:external": 20, stats.ViewKey("internal", "responses"): 30, stats.ViewKey("external", "responses"): 20,
|
||||||
|
stats.ViewKey("internal", "rcode:NOERROR"): 30, stats.ViewKey("external", "rcode:NOERROR"): 15, stats.ViewKey("external", "rcode:NXDOMAIN"): 5,
|
||||||
|
"answer:authoritative": 40}
|
||||||
b.AddCounters(stats.ResMinute, minute, vals)
|
b.AddCounters(stats.ResMinute, minute, vals)
|
||||||
b.AddCounters(stats.ResHour, hour, vals)
|
b.AddCounters(stats.ResHour, hour, vals)
|
||||||
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
|
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
|
||||||
@@ -47,6 +50,16 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
e := newAPIEnv(t, c)
|
e := newAPIEnv(t, c)
|
||||||
|
|
||||||
|
// Some views only.
|
||||||
|
if sum := e.do("GET", "/stats/summary?since=1h&views=external", e.token, nil, 200); sum["queries"].(float64) != 20 || len(sum["rcodes"].([]any)) != 2 || sum["filter_views"] == nil {
|
||||||
|
t.Errorf("external summary = %v", sum)
|
||||||
|
}
|
||||||
|
if b := e.list("/stats/breakdown?group=rcode&since=1h&views=internal,external", e.token); len(b) != 2 || b[0]["count"].(float64) != 45 {
|
||||||
|
t.Errorf("rcodes of both views = %v", b)
|
||||||
|
}
|
||||||
|
if out, status := e.req("GET", "/stats/breakdown?group=answer&since=1h&views=internal", e.token, nil); status != 400 || !strings.Contains(string(out), "not_per_view") {
|
||||||
|
t.Errorf("answers per view = %d %s", status, out)
|
||||||
|
}
|
||||||
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
|
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
|
||||||
t.Errorf("status = %v", st)
|
t.Errorf("status = %v", st)
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-20
@@ -26,10 +26,22 @@ const (
|
|||||||
KindBlocked = "blocked" // names blocked (docs/blocking.md)
|
KindBlocked = "blocked" // names blocked (docs/blocking.md)
|
||||||
// KindBlockedClient: clients with the most blocked queries.
|
// KindBlockedClient: clients with the most blocked queries.
|
||||||
KindBlockedClient = "blocked_client"
|
KindBlockedClient = "blocked_client"
|
||||||
|
// KindRateLimited: client networks whose responses rate limiting
|
||||||
|
// limited (or would have, in log-only mode); KindRateLimitedName: the
|
||||||
|
// names and types they asked for (docs/exposure.md).
|
||||||
|
KindRateLimited = "ratelimited"
|
||||||
|
KindRateLimitedName = "ratelimited_name"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ViewKey is the key of a counter for one view: per-view copies of the
|
||||||
|
// counters that come from named's response log (docs/stats.md).
|
||||||
|
func ViewKey(view, key string) string { return "@" + view + "/" + key }
|
||||||
|
|
||||||
|
// ViewKind is the kind of a top list for one view.
|
||||||
|
func ViewKind(kind, view string) string { return kind + "@" + view }
|
||||||
|
|
||||||
// Kinds lists the top-list kinds.
|
// Kinds lists the top-list kinds.
|
||||||
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient}
|
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName}
|
||||||
|
|
||||||
// OtherZone is the zone key for names outside every local zone (answered
|
// OtherZone is the zone key for names outside every local zone (answered
|
||||||
// by recursion, or refused).
|
// by recursion, or refused).
|
||||||
@@ -110,6 +122,9 @@ type Collector struct {
|
|||||||
// rewritten holds queries named blocked (client#port|name) until their
|
// rewritten holds queries named blocked (client#port|name) until their
|
||||||
// response line arrives.
|
// response line arrives.
|
||||||
rewritten map[string]bool
|
rewritten map[string]bool
|
||||||
|
// viewsSince is set once per-view counters exist (meta views_since).
|
||||||
|
viewsSince bool
|
||||||
|
viewsSinceAt int64
|
||||||
// proc is the last sample of named's process, server the latest server
|
// proc is the last sample of named's process, server the latest server
|
||||||
// statistics (server.go).
|
// statistics (server.go).
|
||||||
proc *namedProc
|
proc *namedProc
|
||||||
@@ -207,6 +222,9 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v, _ := c.store.Meta(ctx, "views_since"); v != "" {
|
||||||
|
c.viewsSince = true
|
||||||
|
}
|
||||||
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
|
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
|
||||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
t := time.Unix(n, 0)
|
t := time.Unix(n, 0)
|
||||||
@@ -222,7 +240,8 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
|
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
|
||||||
from := max(last+3600, cur-48*3600)
|
from := max(last+3600, cur-48*3600)
|
||||||
for h := from; h < cur; h += 3600 {
|
for h := from; h < cur; h += 3600 {
|
||||||
for _, k := range Kinds {
|
kinds, _ := c.store.KindsAt(ctx, ResHour, h)
|
||||||
|
for _, k := range kinds {
|
||||||
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
|
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
|
||||||
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
|
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
|
||||||
}
|
}
|
||||||
@@ -230,7 +249,8 @@ func (c *Collector) start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
|
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
|
||||||
for _, k := range Kinds {
|
curKinds, _ := c.store.KindsAt(ctx, ResHour, cur)
|
||||||
|
for _, k := range curKinds {
|
||||||
es, err := c.store.TopsAt(ctx, ResHour, cur, k)
|
es, err := c.store.TopsAt(ctx, ResHour, cur, k)
|
||||||
if err != nil || len(es) == 0 {
|
if err != nil || len(es) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -289,7 +309,7 @@ func (c *Collector) count(minute int64, key string, n int64) {
|
|||||||
c.minutes[minute] = m
|
c.minutes[minute] = m
|
||||||
}
|
}
|
||||||
m[key] += n
|
m[key] += n
|
||||||
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") {
|
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") && !strings.HasPrefix(key, "@") {
|
||||||
c.totals[key] += n
|
c.totals[key] += n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +357,8 @@ func (c *Collector) handleLine(line string) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
if rw, ok := ParseRewrite(line, c.opts.Location); ok {
|
if rw, ok := ParseRewrite(line, c.opts.Location); ok {
|
||||||
c.handleRewrite(rw)
|
c.handleRewrite(rw)
|
||||||
|
} else if rl, ok := ParseRateLimit(line, c.opts.Location); ok {
|
||||||
|
c.handleRateLimit(rl)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -358,33 +380,55 @@ func (c *Collector) handleLine(line string) {
|
|||||||
if view == "" {
|
if view == "" {
|
||||||
view = "_default"
|
view = "_default"
|
||||||
}
|
}
|
||||||
|
zone := c.zoneOf(ev.Name, ev.View)
|
||||||
c.count(minute, "responses", 1)
|
c.count(minute, "responses", 1)
|
||||||
c.count(minute, "view:"+view, 1)
|
c.count(minute, "view:"+view, 1)
|
||||||
c.count(minute, "zone:"+c.zoneOf(ev.Name, ev.View), 1)
|
c.count(minute, "zone:"+zone, 1)
|
||||||
|
// The same, per view, for the statistics of chosen views.
|
||||||
|
c.countView(minute, view, "responses", 1)
|
||||||
|
c.countView(minute, view, "zone:"+zone, 1)
|
||||||
|
c.countView(minute, view, "rcode:"+ev.RCode, 1)
|
||||||
|
c.countView(minute, view, "qtype:"+ev.Type, 1)
|
||||||
client := clientKey(ev.Client, c.opts.Anonymize)
|
client := clientKey(ev.Client, c.opts.Anonymize)
|
||||||
hours := c.hours
|
hours := c.hours
|
||||||
if hour < c.liveHour {
|
if hour < c.liveHour {
|
||||||
hours = c.late
|
hours = c.late
|
||||||
}
|
}
|
||||||
hours[hour] = c.hourMaps(hours, hour)
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
tops := hours[hour]
|
add := func(kind, key string) {
|
||||||
tops[KindName][ev.Name]++
|
hours[hour][kind][key]++
|
||||||
tops[KindClient][client]++
|
m := c.topsMap(hours, hour, ViewKind(kind, view))
|
||||||
switch {
|
m[key]++
|
||||||
case blocked:
|
|
||||||
// Counted by handleRewrite; not a name that doesn't exist.
|
|
||||||
case ev.RCode == "NXDOMAIN":
|
|
||||||
tops[KindNXDomain][ev.Name]++
|
|
||||||
case ev.RCode == "SERVFAIL":
|
|
||||||
tops[KindServFail][ev.Name]++
|
|
||||||
case ev.RCode == "REFUSED":
|
|
||||||
tops[KindRefused][client]++
|
|
||||||
}
|
|
||||||
for _, m := range tops {
|
|
||||||
if len(m) > maxKeys {
|
if len(m) > maxKeys {
|
||||||
shrink(m)
|
shrink(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
add(KindName, ev.Name)
|
||||||
|
add(KindClient, client)
|
||||||
|
switch {
|
||||||
|
case blocked:
|
||||||
|
// Counted by handleRewrite; not a name that doesn't exist.
|
||||||
|
case ev.RCode == "NXDOMAIN":
|
||||||
|
add(KindNXDomain, ev.Name)
|
||||||
|
case ev.RCode == "SERVFAIL":
|
||||||
|
add(KindServFail, ev.Name)
|
||||||
|
case ev.RCode == "REFUSED":
|
||||||
|
add(KindRefused, client)
|
||||||
|
}
|
||||||
|
for _, k := range Kinds {
|
||||||
|
if m := hours[hour][k]; len(m) > maxKeys {
|
||||||
|
shrink(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countView counts a per-view copy of a counter.
|
||||||
|
func (c *Collector) countView(minute int64, view, key string, n int64) {
|
||||||
|
if !c.viewsSince {
|
||||||
|
c.viewsSince = true
|
||||||
|
c.viewsSinceAt = minute
|
||||||
|
}
|
||||||
|
c.count(minute, ViewKey(view, key), n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRewrite counts a query that blocking answered: "blocked", and
|
// handleRewrite counts a query that blocking answered: "blocked", and
|
||||||
@@ -405,15 +449,55 @@ func (c *Collector) handleRewrite(rw Rewrite) {
|
|||||||
}
|
}
|
||||||
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
||||||
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
||||||
|
view := rw.View
|
||||||
|
if view == "" {
|
||||||
|
view = "_default"
|
||||||
|
}
|
||||||
c.count(minute, "blocked", 1)
|
c.count(minute, "blocked", 1)
|
||||||
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
|
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
|
||||||
|
c.countView(minute, view, "blocked", 1)
|
||||||
|
c.countView(minute, view, "blocklist:"+policySource(rw.Via), 1)
|
||||||
hours := c.hours
|
hours := c.hours
|
||||||
if hour < c.liveHour {
|
if hour < c.liveHour {
|
||||||
hours = c.late
|
hours = c.late
|
||||||
}
|
}
|
||||||
hours[hour] = c.hourMaps(hours, hour)
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
|
client := clientKey(rw.Client, c.opts.Anonymize)
|
||||||
hours[hour][KindBlocked][rw.Name]++
|
hours[hour][KindBlocked][rw.Name]++
|
||||||
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
|
hours[hour][KindBlockedClient][client]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindBlocked, view))[rw.Name]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindBlockedClient, view))[client]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRateLimit counts a response rate limiting limited, or would have:
|
||||||
|
// "rrl:drop", "rrl:slip", "rrl:would-drop" or "rrl:would-slip", and the
|
||||||
|
// client network and name in the hour's top lists.
|
||||||
|
func (c *Collector) handleRateLimit(rl RateLimit) {
|
||||||
|
for _, p := range c.opts.Exclude {
|
||||||
|
if p.Contains(rl.Client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := "rrl:" + rl.Action
|
||||||
|
if rl.Would {
|
||||||
|
key = "rrl:would-" + rl.Action
|
||||||
|
}
|
||||||
|
minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time)
|
||||||
|
view := rl.View
|
||||||
|
if view == "" {
|
||||||
|
view = "_default"
|
||||||
|
}
|
||||||
|
c.count(minute, key, 1)
|
||||||
|
c.countView(minute, view, key, 1)
|
||||||
|
hours := c.hours
|
||||||
|
if hour < c.liveHour {
|
||||||
|
hours = c.late
|
||||||
|
}
|
||||||
|
hours[hour] = c.hourMaps(hours, hour)
|
||||||
|
hours[hour][KindRateLimited][rl.Prefix]++
|
||||||
|
hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindRateLimited, view))[rl.Prefix]++
|
||||||
|
c.topsMap(hours, hour, ViewKind(KindRateLimitedName, view))[rl.Name+" "+rl.Type]++
|
||||||
}
|
}
|
||||||
|
|
||||||
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
||||||
@@ -516,6 +600,10 @@ func (c *Collector) flush(ctx context.Context, final bool) {
|
|||||||
b.AddCounters(ResHour, c.hourOf(t), vals)
|
b.AddCounters(ResHour, c.hourOf(t), vals)
|
||||||
b.AddCounters(ResDay, c.dayOf(t), vals)
|
b.AddCounters(ResDay, c.dayOf(t), vals)
|
||||||
delete(c.minutes, m)
|
delete(c.minutes, m)
|
||||||
|
if c.viewsSinceAt != 0 {
|
||||||
|
b.SetMeta("views_since", strconv.FormatInt(c.viewsSinceAt, 10))
|
||||||
|
c.viewsSinceAt = 0
|
||||||
|
}
|
||||||
if first && len(vals) > 0 {
|
if first && len(vals) > 0 {
|
||||||
ft := t
|
ft := t
|
||||||
c.status.FirstData = &ft
|
c.status.FirstData = &ft
|
||||||
|
|||||||
+41
-3
@@ -33,17 +33,55 @@ type Rewrite struct {
|
|||||||
//
|
//
|
||||||
// 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)
|
// 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)
|
||||||
//
|
//
|
||||||
// The "view NAME: " part is missing when no views are configured.
|
// The "view NAME: " part is missing when no views are configured, and a
|
||||||
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `)
|
// query signed with a TSIG key has "/key NAME" after the port.
|
||||||
|
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `)
|
||||||
|
|
||||||
// rewriteRe matches named's RPZ rewrite lines (category rpz), logged just
|
// rewriteRe matches named's RPZ rewrite lines (category rpz), logged just
|
||||||
// before the response line of the same query:
|
// before the response line of the same query:
|
||||||
//
|
//
|
||||||
// 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid
|
// 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid
|
||||||
var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+) \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`)
|
var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`)
|
||||||
|
|
||||||
|
// rateLimitRe matches named's response rate limiting lines (category
|
||||||
|
// query-errors), when it limits a response or, in log-only mode, would:
|
||||||
|
//
|
||||||
|
// 12-Oct-2026 09:56:35.996 client @0x70a2... 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)
|
||||||
|
var rateLimitRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?(would )?rate limit (drop|slip) (?:\S+ )?response to (\S+) for (\S+) \S+ (\S+)`)
|
||||||
|
|
||||||
|
// RateLimit is a response that rate limiting dropped or slipped (sent
|
||||||
|
// truncated, so a real client retries over TCP), or would have in
|
||||||
|
// log-only mode.
|
||||||
|
type RateLimit struct {
|
||||||
|
Time time.Time
|
||||||
|
Client netip.Addr
|
||||||
|
View string
|
||||||
|
Would bool // log-only: nothing was limited
|
||||||
|
Action string // drop or slip
|
||||||
|
Prefix string // the client network named counts, e.g. 192.0.2.0/24
|
||||||
|
Name string // the query name, normalized like Event.Name
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
const logTimeLayout = "02-Jan-2006 15:04:05.000"
|
const logTimeLayout = "02-Jan-2006 15:04:05.000"
|
||||||
|
|
||||||
|
// ParseRateLimit parses a rate limiting log line.
|
||||||
|
func ParseRateLimit(line string, loc *time.Location) (rl RateLimit, ok bool) {
|
||||||
|
m := rateLimitRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return rl, false
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation(logTimeLayout, m[1], loc)
|
||||||
|
if err != nil {
|
||||||
|
return rl, false
|
||||||
|
}
|
||||||
|
client, err := netip.ParseAddr(m[2])
|
||||||
|
if err != nil {
|
||||||
|
return rl, false
|
||||||
|
}
|
||||||
|
return RateLimit{Time: t, Client: client.Unmap(), View: m[4], Would: m[5] != "", Action: m[6], Prefix: m[7], Name: normalizeName(m[8]), Type: m[9]}, true
|
||||||
|
}
|
||||||
|
|
||||||
// ParseResponse parses a response log line. ok is false for other lines
|
// ParseResponse parses a response log line. ok is false for other lines
|
||||||
// (named also logs to the channel when it starts and rotates files).
|
// (named also logs to the channel when it starts and rotates files).
|
||||||
func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) {
|
func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package stats
|
package stats
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -42,3 +43,44 @@ func TestParseResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRateLimit(t *testing.T) {
|
||||||
|
loc := time.UTC
|
||||||
|
for _, tc := range []struct {
|
||||||
|
line string
|
||||||
|
want RateLimit
|
||||||
|
views bool
|
||||||
|
}{
|
||||||
|
{line: "12-Oct-2026 09:56:35.996 client @0x70a247adf000 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)",
|
||||||
|
want: RateLimit{Would: true, Action: "slip", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}},
|
||||||
|
{line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 127.0.0.2#35687 (www.rrl.test): rate limit drop response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)",
|
||||||
|
want: RateLimit{Action: "drop", Prefix: "127.0.0.0/24", Name: "www.rrl.test", Type: "A"}},
|
||||||
|
{line: "12-Oct-2026 09:56:38.303 client @0x7eee390df000 2001:db8::7#35687 (Adbraun.lol): view external: rate limit drop response to 2001:db8::/56 for adbraun.lol IN ANY (0ce833a4)",
|
||||||
|
want: RateLimit{View: "external", Action: "drop", Prefix: "2001:db8::/56", Name: "adbraun.lol", Type: "ANY"}},
|
||||||
|
} {
|
||||||
|
rl, ok := ParseRateLimit(tc.line, loc)
|
||||||
|
rl.Time, rl.Client = time.Time{}, netip.Addr{}
|
||||||
|
if !ok || rl != tc.want {
|
||||||
|
t.Errorf("%s:\n got %+v %v\nwant %+v", tc.line, rl, ok, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := ParseRateLimit("12-Oct-2026 09:56:35.996 rate-limit: info: would limit responses to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)", loc); ok {
|
||||||
|
t.Error("the start-of-limiting line counted as a response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed queries have "/key NAME" after the client's port.
|
||||||
|
func TestParseSigned(t *testing.T) {
|
||||||
|
loc := time.UTC
|
||||||
|
ev, ok := ParseResponse("12-Oct-2026 10:11:06.490 client @0x7a22f754b000 127.0.0.1#34745/key jarl-view-external (ns1.adbraun.test): view external: response: ns1.adbraun.test IN A NOERROR 1 0 2 +E(0)SK (127.0.0.1)", loc)
|
||||||
|
if !ok || ev.View != "external" || ev.Name != "ns1.adbraun.test" || ev.Port != "34745" {
|
||||||
|
t.Errorf("signed response = %+v %v", ev, ok)
|
||||||
|
}
|
||||||
|
rl, ok := ParseRateLimit("12-Oct-2026 10:11:06.599 client @0x7a22f754b000 127.0.0.1#34856/key jarl-view-external (ns1.adbraun.test): view external: would rate limit slip response to 127.0.0.0/24 for ns1.adbraun.test IN A (e777e288)", loc)
|
||||||
|
if !ok || rl.View != "external" || !rl.Would || rl.Action != "slip" || rl.Prefix != "127.0.0.0/24" {
|
||||||
|
t.Errorf("signed rate limit = %+v %v", rl, ok)
|
||||||
|
}
|
||||||
|
if _, ok := ParseRewrite("09-Oct-2026 23:49:13.089 client @0x7bd607320000 127.0.0.1#56905/key k1 (x.ads.example): view internal: rpz QNAME NXDOMAIN rewrite x.ads.example/A/IN via x.ads.example.rules.jarl.invalid", loc); !ok {
|
||||||
|
t.Error("signed rewrite not parsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
+159
-11
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,110 @@ type Querier struct {
|
|||||||
Store *Store
|
Store *Store
|
||||||
Opts Options
|
Opts Options
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
// Views limits the figures to these views (none: every view). Only
|
||||||
|
// what named's response log says can be split by view; named's own
|
||||||
|
// counters (cache, transport, round trips, ...) can't.
|
||||||
|
Views []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForViews returns a querier limited to views (all views when empty).
|
||||||
|
func (q *Querier) ForViews(views []string) *Querier {
|
||||||
|
c := *q
|
||||||
|
c.Views = views
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Querier) filtered() bool { return len(q.Views) > 0 }
|
||||||
|
|
||||||
|
// perView reports whether a counter key exists per view, and its name
|
||||||
|
// there ("queries" is counted as responses).
|
||||||
|
func perView(key string) (string, bool) {
|
||||||
|
if key == "queries" || key == "responses" {
|
||||||
|
return "responses", true
|
||||||
|
}
|
||||||
|
if key == "blocked" {
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
for _, p := range []string{"rcode:", "qtype:", "zone:", "blocklist:", "rrl:"} {
|
||||||
|
if strings.HasPrefix(key, p) || key+":" == p {
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PerViewGroups are the Breakdown groups that exist per view.
|
||||||
|
var PerViewGroups = []string{"qtype", "rcode", "view", "zone", "blocklist", "rrl"}
|
||||||
|
|
||||||
|
// ErrNotPerView is returned for figures named only keeps for the whole server.
|
||||||
|
var ErrNotPerView = fmt.Errorf("named keeps this for the whole server only, not per view")
|
||||||
|
|
||||||
|
// sums is Store.sums, limited to the querier's views.
|
||||||
|
func (q *Querier) sums(ctx context.Context, res string, from, to int64, prefix string) (map[string]int64, error) {
|
||||||
|
if !q.filtered() {
|
||||||
|
return q.Store.sums(ctx, res, from, to, prefix)
|
||||||
|
}
|
||||||
|
if prefix == "view:" {
|
||||||
|
all, err := q.Store.sums(ctx, res, from, to, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]int64{}
|
||||||
|
for _, v := range q.Views {
|
||||||
|
if n := all["view:"+v]; n != 0 {
|
||||||
|
out["view:"+v] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if _, ok := perView(prefix); !ok {
|
||||||
|
// Not kept per view (named's own counters): the whole server's.
|
||||||
|
return q.Store.sums(ctx, res, from, to, prefix)
|
||||||
|
}
|
||||||
|
out := map[string]int64{}
|
||||||
|
for _, v := range q.Views {
|
||||||
|
m, err := q.Store.sums(ctx, res, from, to, ViewKey(v, prefix))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, n := range m {
|
||||||
|
out[strings.TrimPrefix(k, ViewKey(v, ""))] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// series is Store.series, limited to the querier's views.
|
||||||
|
func (q *Querier) series(ctx context.Context, res string, from, to int64, key string) (map[int64]int64, error) {
|
||||||
|
pv, ok := perView(key)
|
||||||
|
if !q.filtered() || !ok {
|
||||||
|
return q.Store.series(ctx, res, from, to, key)
|
||||||
|
}
|
||||||
|
out := map[int64]int64{}
|
||||||
|
for _, v := range q.Views {
|
||||||
|
m, err := q.Store.series(ctx, res, from, to, ViewKey(v, pv))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for ts, n := range m {
|
||||||
|
out[ts] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewsSince is when per-view figures start (nil before any).
|
||||||
|
func (q *Querier) ViewsSince(ctx context.Context) *time.Time {
|
||||||
|
v, err := q.Store.Meta(ctx, "views_since")
|
||||||
|
if err != nil || v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := time.Unix(n, 0)
|
||||||
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQuerier returns a querier for a collector's data.
|
// NewQuerier returns a querier for a collector's data.
|
||||||
@@ -105,10 +210,15 @@ func (s *Store) series(ctx context.Context, res string, from, to int64, key stri
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// tops sums top lists in [from, to).
|
// tops sums top lists (of one or more kinds) in [from, to).
|
||||||
func (s *Store) tops(ctx context.Context, res, kind string, from, to int64, limit int) ([]Entry, error) {
|
func (s *Store) tops(ctx context.Context, res string, kinds []string, from, to int64, limit int) ([]Entry, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind = ? AND ts >= ? AND ts < ?
|
args := []any{res}
|
||||||
GROUP BY key ORDER BY n DESC, key LIMIT ?`, res, kind, from, to, limit)
|
for _, k := range kinds {
|
||||||
|
args = append(args, k)
|
||||||
|
}
|
||||||
|
args = append(args, from, to, limit)
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind IN (`+strings.TrimSuffix(strings.Repeat("?,", len(kinds)), ",")+`) AND ts >= ? AND ts < ?
|
||||||
|
GROUP BY key ORDER BY n DESC, key LIMIT ?`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,6 +277,11 @@ type Summary struct {
|
|||||||
BlockedRatio *float64 `json:"blocked_ratio,omitempty"`
|
BlockedRatio *float64 `json:"blocked_ratio,omitempty"`
|
||||||
Blocklists []Entry `json:"blocklists,omitempty"`
|
Blocklists []Entry `json:"blocklists,omitempty"`
|
||||||
Tops map[string][]Entry `json:"tops,omitempty"`
|
Tops map[string][]Entry `json:"tops,omitempty"`
|
||||||
|
// Views and ViewsSince are set when the figures are for some views
|
||||||
|
// only: queries then counts responses, and named's own counters
|
||||||
|
// (answers, transport, cache) are left out.
|
||||||
|
FilterViews []string `json:"filter_views,omitempty"`
|
||||||
|
ViewsSince *time.Time `json:"views_since,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary summarizes the last since.
|
// Summary summarizes the last since.
|
||||||
@@ -179,9 +294,32 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if q.filtered() {
|
||||||
|
// The chosen views' counters, under their usual names.
|
||||||
|
views := map[string]int64{}
|
||||||
|
for _, v := range q.Views {
|
||||||
|
views["view:"+v] = all["view:"+v]
|
||||||
|
}
|
||||||
|
picked := map[string]int64{}
|
||||||
|
for k, n := range all {
|
||||||
|
for _, v := range q.Views {
|
||||||
|
if rest, ok := strings.CutPrefix(k, ViewKey(v, "")); ok {
|
||||||
|
picked[rest] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, n := range views {
|
||||||
|
picked[k] = n
|
||||||
|
}
|
||||||
|
picked["queries"] = picked["responses"]
|
||||||
|
all = picked
|
||||||
|
}
|
||||||
s := &Summary{From: time.Unix(from, 0), To: now, Resolution: res, Queries: all["queries"],
|
s := &Summary{From: time.Unix(from, 0), To: now, Resolution: res, Queries: all["queries"],
|
||||||
RCodes: sorted(all, "rcode:"), QTypes: sorted(all, "qtype:"), Views: sorted(all, "view:"), Zones: sorted(all, "zone:"),
|
RCodes: sorted(all, "rcode:"), QTypes: sorted(all, "qtype:"), Views: sorted(all, "view:"), Zones: sorted(all, "zone:"),
|
||||||
Answers: sorted(all, "answer:"), Transport: sorted(all, "transport:")}
|
Answers: sorted(all, "answer:"), Transport: sorted(all, "transport:")}
|
||||||
|
if q.filtered() {
|
||||||
|
s.FilterViews, s.ViewsSince = q.Views, q.ViewsSince(ctx)
|
||||||
|
}
|
||||||
if h, m := all["cache:hits"], all["cache:misses"]; h+m > 0 {
|
if h, m := all["cache:hits"], all["cache:misses"]; h+m > 0 {
|
||||||
r := float64(h) / float64(h+m)
|
r := float64(h) / float64(h+m)
|
||||||
s.CacheHitRatio = &r
|
s.CacheHitRatio = &r
|
||||||
@@ -195,7 +333,7 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
|
|||||||
s.BlockedRatio = &b
|
s.BlockedRatio = &b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
per, err := q.Store.series(ctx, res, from, to, "queries")
|
per, err := q.series(ctx, res, from, to, "queries")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -237,7 +375,14 @@ func (q *Querier) Top(ctx context.Context, kind string, since time.Duration, lim
|
|||||||
}
|
}
|
||||||
now := q.Now()
|
now := q.Now()
|
||||||
res := q.topsResolution(since)
|
res := q.topsResolution(since)
|
||||||
return q.Store.tops(ctx, res, kind, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
|
kinds := []string{kind}
|
||||||
|
if q.filtered() {
|
||||||
|
kinds = nil
|
||||||
|
for _, v := range q.Views {
|
||||||
|
kinds = append(kinds, ViewKind(kind, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q.Store.tops(ctx, res, kinds, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breakdown returns the totals of a counter group (qtype, rcode, view,
|
// Breakdown returns the totals of a counter group (qtype, rcode, view,
|
||||||
@@ -246,9 +391,12 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati
|
|||||||
if !slices.Contains(Groups, group) {
|
if !slices.Contains(Groups, group) {
|
||||||
return nil, fmt.Errorf("unknown group %q (use %s)", group, strings.Join(Groups, ", "))
|
return nil, fmt.Errorf("unknown group %q (use %s)", group, strings.Join(Groups, ", "))
|
||||||
}
|
}
|
||||||
|
if q.filtered() && !slices.Contains(PerViewGroups, group) {
|
||||||
|
return nil, ErrNotPerView
|
||||||
|
}
|
||||||
now := q.Now()
|
now := q.Now()
|
||||||
res := q.Resolution(since)
|
res := q.Resolution(since)
|
||||||
m, err := q.Store.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, group+":")
|
m, err := q.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, group+":")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -256,7 +404,7 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Groups are the counter groups Breakdown accepts.
|
// Groups are the counter groups Breakdown accepts.
|
||||||
var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "blocklist"}
|
var Groups = []string{"qtype", "rcode", "view", "zone", "answer", "transport", "rtt", "dnssec", "cache", "ratelimit", "rrl", "blocklist"}
|
||||||
|
|
||||||
// Timeline is a set of series over time.
|
// Timeline is a set of series over time.
|
||||||
type Timeline struct {
|
type Timeline struct {
|
||||||
@@ -329,7 +477,7 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
|||||||
var expanded []string
|
var expanded []string
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
if g, ok := strings.CutSuffix(k, ":*"); ok {
|
if g, ok := strings.CutSuffix(k, ":*"); ok {
|
||||||
m, err := q.Store.sums(ctx, st.res, from, to, g+":")
|
m, err := q.sums(ctx, st.res, from, to, g+":")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -342,7 +490,7 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
|||||||
}
|
}
|
||||||
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
||||||
bucketed := func(key string) ([]int64, error) {
|
bucketed := func(key string) ([]int64, error) {
|
||||||
raw, err := q.Store.series(ctx, st.res, from, to, key)
|
raw, err := q.series(ctx, st.res, from, to, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -404,7 +552,7 @@ func (q *Querier) Heatmap(ctx context.Context, key string, since time.Duration)
|
|||||||
since = min(since, q.Opts.RetainHour)
|
since = min(since, q.Opts.RetainHour)
|
||||||
now := q.Now()
|
now := q.Now()
|
||||||
from := q.bucketStart(ResHour, now.Add(-since))
|
from := q.bucketStart(ResHour, now.Add(-since))
|
||||||
raw, err := q.Store.series(ctx, ResHour, from, now.Unix()+1, key)
|
raw, err := q.series(ctx, ResHour, from, now.Unix()+1, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,3 +264,22 @@ func (s *Store) TopsAt(ctx context.Context, res string, ts int64, kind string) (
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KindsAt returns the top-list kinds stored for one bucket (per-view
|
||||||
|
// kinds included).
|
||||||
|
func (s *Store) KindsAt(ctx context.Context, res string, ts int64) ([]string, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT kind FROM tops WHERE res = ? AND ts = ?`, res, ts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Figures for chosen views: from the response log, per view, and added
|
||||||
|
// up for several.
|
||||||
|
func TestViewFilter(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
loc := time.FixedZone("test", 0)
|
||||||
|
dir := t.TempDir()
|
||||||
|
logFile := filepath.Join(dir, "responses.log")
|
||||||
|
store, err := OpenStore(filepath.Join(dir, "stats.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
named := newFakeNamed(t)
|
||||||
|
clk := &clock{time.Date(2026, 10, 5, 10, 0, 30, 0, loc)}
|
||||||
|
opts := Options{QueryLog: true, LogFile: logFile, StatisticsChannel: "unused", TopN: 10,
|
||||||
|
RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour, Location: loc}
|
||||||
|
zones := func() map[string][]string {
|
||||||
|
return map[string][]string{"internal": {"adbraun.lol"}, "external": {"adbraun.lol"}}
|
||||||
|
}
|
||||||
|
c := NewCollector(store, opts, zones, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
c.poller.URL = named.srv.URL
|
||||||
|
c.now = clk.now
|
||||||
|
c.start(ctx)
|
||||||
|
at := time.Date(2026, 10, 5, 10, 0, 40, 0, loc)
|
||||||
|
lines := []string{
|
||||||
|
logLine(at, "10.10.0.37", "internal", "nas.adbraun.lol", "A", "NOERROR"),
|
||||||
|
logLine(at, "10.10.0.37", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"),
|
||||||
|
logLine(at, "10.10.0.42", "internal", "nope.adbraun.lol", "A", "NXDOMAIN"),
|
||||||
|
logLine(at, "203.0.113.9", "external", "www.adbraun.lol", "A", "NOERROR"),
|
||||||
|
logLine(at, "203.0.113.9", "external", "x.example", "A", "REFUSED"),
|
||||||
|
logLine(at, "192.0.2.5", "vps", "debian.org", "AAAA", "NOERROR"),
|
||||||
|
}
|
||||||
|
f, _ := os.Create(logFile)
|
||||||
|
for _, l := range lines {
|
||||||
|
f.WriteString(l)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
c.readLog()
|
||||||
|
named.add(6, 1)
|
||||||
|
clk.t = time.Date(2026, 10, 5, 10, 1, 2, 0, loc)
|
||||||
|
c.flush(ctx, false)
|
||||||
|
clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc)
|
||||||
|
c.flush(ctx, false)
|
||||||
|
|
||||||
|
all := &Querier{Store: store, Opts: opts, Now: clk.now}
|
||||||
|
s, err := all.Summary(ctx, 24*time.Hour, 5)
|
||||||
|
if err != nil || s.Queries != 6 || s.FilterViews != nil {
|
||||||
|
t.Fatalf("all views: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
in := all.ForViews([]string{"internal"})
|
||||||
|
s, err = in.Summary(ctx, 24*time.Hour, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Queries != 3 || len(s.Views) != 1 || s.Views[0].Key != "internal" || s.ViewsSince == nil {
|
||||||
|
t.Errorf("internal: queries %d views %v since %v", s.Queries, s.Views, s.ViewsSince)
|
||||||
|
}
|
||||||
|
if len(s.RCodes) != 2 || s.RCodes[0] != (Entry{Key: "NOERROR", Count: 2}) || len(s.QTypes) != 2 {
|
||||||
|
t.Errorf("internal rcodes %v qtypes %v", s.RCodes, s.QTypes)
|
||||||
|
}
|
||||||
|
if s.Tops[KindName][0] != (Entry{Key: "nas.adbraun.lol", Count: 2}) || len(s.Tops[KindNXDomain]) != 1 || len(s.Answers) != 0 {
|
||||||
|
t.Errorf("internal tops %v answers %v", s.Tops, s.Answers)
|
||||||
|
}
|
||||||
|
// Linked views, say: two together.
|
||||||
|
two := all.ForViews([]string{"internal", "external"})
|
||||||
|
s, _ = two.Summary(ctx, 24*time.Hour, 5)
|
||||||
|
if s.Queries != 5 || len(s.Views) != 2 {
|
||||||
|
t.Errorf("internal+external: %d %v", s.Queries, s.Views)
|
||||||
|
}
|
||||||
|
if es, _ := two.Top(ctx, KindClient, 24*time.Hour, 5); len(es) != 3 || es[2].Key != "10.10.0.42" {
|
||||||
|
t.Errorf("clients of two views: %v", es)
|
||||||
|
}
|
||||||
|
if es, _ := two.Breakdown(ctx, "rcode", 24*time.Hour); len(es) != 3 {
|
||||||
|
t.Errorf("rcodes of two views: %v", es)
|
||||||
|
}
|
||||||
|
if _, err := two.Breakdown(ctx, "answer", 24*time.Hour); !errors.Is(err, ErrNotPerView) {
|
||||||
|
t.Errorf("answers per view: %v", err)
|
||||||
|
}
|
||||||
|
tl, err := all.ForViews([]string{"vps"}).Timeline(ctx, 2*time.Hour, "1m", []string{"queries", "rcode:*"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, v := range tl.Series[0].Values {
|
||||||
|
total += v
|
||||||
|
}
|
||||||
|
if total != 1 || len(tl.Series) != 2 || tl.Series[1].Key != "rcode:NOERROR" {
|
||||||
|
t.Errorf("vps timeline: %d %+v", total, tl.Series)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
func scanCert(sc interface{ Scan(...any) error }) (*model.Certificate, error) {
|
||||||
|
c := &model.Certificate{}
|
||||||
|
var domains, uses, names, routes, ca, ua string
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, f := range []struct {
|
||||||
|
raw string
|
||||||
|
dst *[]string
|
||||||
|
}{{domains, &c.Domains}, {uses, &c.Uses}, {names, &c.Names}, {routes, &c.LastRoutes}} {
|
||||||
|
if err := json.Unmarshal([]byte(f.raw), f.dst); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if *f.dst == nil {
|
||||||
|
*f.dst = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.NotBefore, c.NotAfter, c.RenewAt, c.ARICheckedAt = parseTSPtr(nb), parseTSPtr(na), parseTSPtr(ra), parseTSPtr(ac)
|
||||||
|
c.LastAttempt, c.LastSuccess = parseTSPtr(la), parseTSPtr(ls)
|
||||||
|
c.CreatedAt, c.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificates returns every certificate, by name.
|
||||||
|
func (s *Store) Certificates(ctx context.Context) ([]*model.Certificate, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT `+certCols+` FROM certificates ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []*model.Certificate{}
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanCert(rows)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, closeRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertificateByName returns the certificate called name.
|
||||||
|
func (s *Store) CertificateByName(ctx context.Context, name string) (*model.Certificate, error) {
|
||||||
|
c, err := scanCert(s.db.QueryRowContext(ctx, `SELECT `+certCols+` FROM certificates WHERE name = ?`, name))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCertificate adds a certificate (ErrConflict when the name is taken).
|
||||||
|
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)...)
|
||||||
|
if isUnique(err) {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.ID, err = res.LastInsertId()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCertificate saves every field of c.
|
||||||
|
func (s *Store) UpdateCertificate(ctx context.Context, c *model.Certificate) error {
|
||||||
|
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=?`,
|
||||||
|
append(certArgs(c), c.ID)...)
|
||||||
|
if isUnique(err) {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCertificate deletes a certificate.
|
||||||
|
func (s *Store) DeleteCertificate(ctx context.Context, id int64) error {
|
||||||
|
res, err := s.db.ExecContext(ctx, `DELETE FROM certificates WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func certArgs(c *model.Certificate) []any {
|
||||||
|
list := func(l []string) string {
|
||||||
|
if l == nil {
|
||||||
|
l = []string{}
|
||||||
|
}
|
||||||
|
return jsonText(l)
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
}
|
||||||
+23
-13
@@ -31,20 +31,23 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by FROM views`)
|
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
v := &model.View{}
|
v := &model.View{}
|
||||||
var mc, md, ca, ua string
|
var mc, md, acks, ca, ua string
|
||||||
var mro sql.NullInt64
|
var mro sql.NullInt64
|
||||||
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
|
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
json.Unmarshal([]byte(mc), &v.MatchClients)
|
json.Unmarshal([]byte(mc), &v.MatchClients)
|
||||||
json.Unmarshal([]byte(md), &v.MatchDestinations)
|
json.Unmarshal([]byte(md), &v.MatchDestinations)
|
||||||
|
if acks != "[]" && acks != "" {
|
||||||
|
json.Unmarshal([]byte(acks), &v.Acknowledged)
|
||||||
|
}
|
||||||
if mro.Valid {
|
if mro.Valid {
|
||||||
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
|
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
|
||||||
}
|
}
|
||||||
@@ -92,14 +95,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
z := &model.Zone{}
|
z := &model.Zone{}
|
||||||
var ca, ua string
|
var ca, ua string
|
||||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.LinkGroup, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -110,7 +113,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM records`)
|
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, disabled, shared_id, version, created_at, updated_at, created_by, updated_by FROM records`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -118,7 +121,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
r := &model.Record{}
|
r := &model.Record{}
|
||||||
var ttl sql.NullInt64
|
var ttl sql.NullInt64
|
||||||
var ca, ua string
|
var ca, ua string
|
||||||
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Disabled, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Disabled, &r.SharedID, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -264,8 +267,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
if v.MatchRecursiveOnly != nil {
|
if v.MatchRecursiveOnly != nil {
|
||||||
mro = boolInt(*v.MatchRecursiveOnly)
|
mro = boolInt(*v.MatchRecursiveOnly)
|
||||||
}
|
}
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
|
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -285,8 +288,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.LinkGroup, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -296,8 +299,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
if r.TTL != nil {
|
if r.TTL != nil {
|
||||||
ttl = int64(*r.TTL)
|
ttl = int64(*r.TTL)
|
||||||
}
|
}
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, disabled, shared_id, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, r.SharedID, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -344,6 +347,13 @@ func nonNilIDs(ids []int64) []int64 {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nonNilAcks(a []model.Acknowledgement) []model.Acknowledgement {
|
||||||
|
if a == nil {
|
||||||
|
return []model.Acknowledgement{}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
func nonNil(s []string) []string {
|
func nonNil(s []string) []string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return []string{}
|
return []string{}
|
||||||
|
|||||||
@@ -326,6 +326,46 @@ CREATE TABLE client_groups (
|
|||||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
`,
|
||||||
|
// 9: internet exposure (docs/exposure.md): a view's override and its
|
||||||
|
// acknowledged findings.
|
||||||
|
`
|
||||||
|
ALTER TABLE views ADD COLUMN exposure TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE views ADD COLUMN acknowledged TEXT NOT NULL DEFAULT '[]';
|
||||||
|
`,
|
||||||
|
// 10: TLS certificates (docs/certificates.md). cert_pem and key_pem
|
||||||
|
// hold the current certificate of one Jarl obtains.
|
||||||
|
`
|
||||||
|
CREATE TABLE certificates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
cert_file TEXT NOT NULL DEFAULT '',
|
||||||
|
key_file TEXT NOT NULL DEFAULT '',
|
||||||
|
uses TEXT NOT NULL DEFAULT '[]',
|
||||||
|
challenge TEXT NOT NULL DEFAULT '',
|
||||||
|
key_type TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
cert_pem TEXT NOT NULL DEFAULT '',
|
||||||
|
key_pem TEXT NOT NULL DEFAULT '',
|
||||||
|
names TEXT NOT NULL DEFAULT '[]',
|
||||||
|
not_before TEXT, not_after TEXT,
|
||||||
|
issuer TEXT NOT NULL DEFAULT '',
|
||||||
|
serial TEXT NOT NULL DEFAULT '',
|
||||||
|
renew_at TEXT, ari_checked_at TEXT,
|
||||||
|
last_attempt TEXT, last_success TEXT,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
failures INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_routes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
// 11: linked zones (docs/views.md#linked-zones).
|
||||||
|
`
|
||||||
|
ALTER TABLE zones ADD COLUMN link_group INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -563,10 +563,15 @@ func (c *checker) checkSecurity() {
|
|||||||
for _, v := range views {
|
for _, v := range views {
|
||||||
ctxs = append(ctxs, ctx{v, "view " + v.Name})
|
ctxs = append(ctxs, ctx{v, "view " + v.Name})
|
||||||
}
|
}
|
||||||
|
exposure := map[int64]Exposure{}
|
||||||
|
for _, x := range c.exposures() {
|
||||||
|
exposure[x.ViewID] = x
|
||||||
|
}
|
||||||
for _, x := range ctxs {
|
for _, x := range ctxs {
|
||||||
// A view only reachable from some clients (e.g. "internal") can
|
// A view only reachable from some clients (e.g. "internal") can
|
||||||
// recurse for everyone inside it without being an open resolver.
|
// recurse for everyone inside it without being an open resolver.
|
||||||
if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) {
|
// Without views, only a server marked private is left out.
|
||||||
|
if x := exposure[viewID(x.v)]; !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if c.effectiveBool(x.v, "recursion", true) {
|
if c.effectiveBool(x.v, "recursion", true) {
|
||||||
@@ -608,6 +613,9 @@ func (c *checker) checkSecurity() {
|
|||||||
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
|
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
|
||||||
p.Zone, p.View = z.Name, c.s.ViewName(z)
|
p.Zone, p.View = z.Name, c.s.ViewName(z)
|
||||||
}
|
}
|
||||||
|
if x, ok := exposure[z.ViewID]; ok && !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
|
||||||
|
continue // only reachable by your own clients
|
||||||
|
}
|
||||||
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
|
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
|
||||||
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
|
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
|
||||||
p.Zone, p.View = z.Name, c.s.ViewName(z)
|
p.Zone, p.View = z.Name, c.s.ViewName(z)
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exposure says whether a view, or the server without views, can be
|
||||||
|
// reached from the internet (docs/exposure.md). The exposure checks only
|
||||||
|
// look at public ones.
|
||||||
|
type Exposure struct {
|
||||||
|
ViewID int64 `json:"view_id"` // 0: the server (no views)
|
||||||
|
View string `json:"view,omitempty"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
Override string `json:"override,omitempty"` // public or private, set by hand
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label names the scope in messages: "view external" or "the server".
|
||||||
|
func (e Exposure) Label() string {
|
||||||
|
if e.ViewID == 0 {
|
||||||
|
return "the server"
|
||||||
|
}
|
||||||
|
return "view " + e.View
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskCodes are the findings that open the server up rather than leave it
|
||||||
|
// less hardened: a change that introduces one must be confirmed
|
||||||
|
// (ApplyOptions.AcceptRisk).
|
||||||
|
var RiskCodes = map[string]bool{
|
||||||
|
"security_open_resolver": true,
|
||||||
|
"security_open_transfer": true,
|
||||||
|
"exposure_cache_snooping": true,
|
||||||
|
"exposure_open_notify": true,
|
||||||
|
"exposure_management": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ackable reports whether a finding can be acknowledged.
|
||||||
|
func Ackable(code string) bool {
|
||||||
|
return strings.HasPrefix(code, "exposure_") || strings.HasPrefix(code, "security_open_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposures classifies every view (or the server, without views).
|
||||||
|
func Exposures(s *model.State, opts Options) []Exposure {
|
||||||
|
c := &checker{s: s, opts: opts}
|
||||||
|
return c.exposures()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *checker) exposures() []Exposure {
|
||||||
|
views := c.s.OrderedViews()
|
||||||
|
if len(views) == 0 {
|
||||||
|
return []Exposure{c.serverExposure()}
|
||||||
|
}
|
||||||
|
var out []Exposure
|
||||||
|
for _, v := range views {
|
||||||
|
out = append(out, c.viewExposure(v))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *checker) viewExposure(v *model.View) Exposure {
|
||||||
|
e := Exposure{ViewID: v.ID, View: v.Name}
|
||||||
|
switch v.Exposure {
|
||||||
|
case model.ExposurePublic:
|
||||||
|
e.Public, e.Override, e.Reason = true, v.Exposure, "marked public"
|
||||||
|
return e
|
||||||
|
case model.ExposurePrivate:
|
||||||
|
e.Override, e.Reason = v.Exposure, "marked private"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
clients := c.flatten(v.MatchClients, 0)
|
||||||
|
what, open := catchAll(clients)
|
||||||
|
if len(v.MatchClients) == 0 {
|
||||||
|
what, open = "nothing (so any client)", true
|
||||||
|
}
|
||||||
|
if !open {
|
||||||
|
e.Reason = "match-clients lists only " + describeList(v.MatchClients) + ", no catch-all"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if len(v.MatchDestinations) > 0 {
|
||||||
|
if dst := c.flatten(v.MatchDestinations, 0); onlyPrivate(dst) {
|
||||||
|
e.Reason = "match-destinations lists only private addresses (" + describeList(v.MatchDestinations) + ")"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Public, e.Reason = true, "match-clients ends in "+what
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverExposure classifies a server without views: public when anyone
|
||||||
|
// may query it and it listens on an address that isn't private.
|
||||||
|
func (c *checker) serverExposure() Exposure {
|
||||||
|
e := Exposure{}
|
||||||
|
switch c.s.Settings.Exposure {
|
||||||
|
case model.ExposurePublic:
|
||||||
|
e.Public, e.Override, e.Reason = true, model.ExposurePublic, "marked public"
|
||||||
|
return e
|
||||||
|
case model.ExposurePrivate:
|
||||||
|
e.Override, e.Reason = model.ExposurePrivate, "marked private"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if aml, _, set := c.effectiveAML(nil, "allow-query"); set {
|
||||||
|
if _, open := catchAll(aml); !open {
|
||||||
|
e.Reason = "allow-query has no catch-all"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addr, ok := c.publicListenAddr()
|
||||||
|
if !ok {
|
||||||
|
e.Reason = "named listens on private addresses only"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
e.Public, e.Reason = true, "anyone may query it, and named listens on "+addr
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicListenAddr returns an address named listens on that isn't
|
||||||
|
// private, from listen-on/listen-on-v6 and this host's addresses.
|
||||||
|
func (c *checker) publicListenAddr() (string, bool) {
|
||||||
|
for _, name := range []string{"listen-on", "listen-on-v6"} {
|
||||||
|
opts := []*model.Option{}
|
||||||
|
for _, o := range c.s.ScopedOptions(model.ScopeOptions, 0) {
|
||||||
|
if o.Name == name {
|
||||||
|
opts = append(opts, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(opts) == 0 {
|
||||||
|
// named listens on every interface by default.
|
||||||
|
if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok {
|
||||||
|
return a, true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
elems := c.amlOf(o)
|
||||||
|
if _, open := catchAll(elems); open {
|
||||||
|
if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok {
|
||||||
|
return a, true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range elems {
|
||||||
|
if e.Kind == namedconf.AMPrefix && !e.Negated && !privatePrefix(e.Prefix) {
|
||||||
|
return e.Prefix.Addr().String(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicLocalAddr returns one of this host's addresses that isn't
|
||||||
|
// private (none known in tests).
|
||||||
|
func (c *checker) publicLocalAddr(v6 bool) (string, bool) {
|
||||||
|
for _, a := range c.opts.LocalAddrs {
|
||||||
|
a = a.Unmap()
|
||||||
|
if a.Is6() == v6 && !privatePrefix(netip.PrefixFrom(a, a.BitLen())) {
|
||||||
|
return a.String(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// catchAll reports whether a list lets in clients from anywhere: a
|
||||||
|
// non-negated "any", or a prefix so wide (shorter than /8 for IPv4, /16
|
||||||
|
// for IPv6) that it holds most of the internet. Negated elements before
|
||||||
|
// it only take some clients out.
|
||||||
|
func catchAll(elems []namedconf.AMElement) (string, bool) {
|
||||||
|
for _, e := range elems {
|
||||||
|
if e.Negated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case e.Kind == namedconf.AMName && e.Name == "any":
|
||||||
|
return "any", true
|
||||||
|
case e.Kind == namedconf.AMName && e.Name == "none":
|
||||||
|
return "", false
|
||||||
|
case e.Kind == namedconf.AMPrefix && e.Prefix.Addr().Is4() && e.Prefix.Bits() < 8,
|
||||||
|
e.Kind == namedconf.AMPrefix && !e.Prefix.Addr().Is4() && e.Prefix.Bits() < 16:
|
||||||
|
return e.Prefix.String(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateBlocks = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("169.254.0.0/16"),
|
||||||
|
netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"),
|
||||||
|
}
|
||||||
|
|
||||||
|
func privatePrefix(p netip.Prefix) bool {
|
||||||
|
a := p.Addr().Unmap()
|
||||||
|
for _, b := range privateBlocks {
|
||||||
|
if b.Addr().Is4() == a.Is4() && b.Contains(a) && p.Bits() >= b.Bits() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyPrivate reports whether every address element is private (names
|
||||||
|
// such as localhost and localnets count as private).
|
||||||
|
func onlyPrivate(elems []namedconf.AMElement) bool {
|
||||||
|
for _, e := range elems {
|
||||||
|
if e.Negated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch e.Kind {
|
||||||
|
case namedconf.AMPrefix:
|
||||||
|
if !privatePrefix(e.Prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case namedconf.AMName:
|
||||||
|
if e.Name == "any" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case namedconf.AMOther, namedconf.AMNested:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeList(list []string) string {
|
||||||
|
if len(list) > 4 {
|
||||||
|
return strings.Join(list[:4], ", ") + ", …"
|
||||||
|
}
|
||||||
|
return strings.Join(list, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkExposure runs the exposure checks on public views (or the server).
|
||||||
|
func (c *checker) checkExposure() {
|
||||||
|
exps := c.exposures()
|
||||||
|
anyPublic := false
|
||||||
|
for _, x := range exps {
|
||||||
|
if !x.Public {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
anyPublic = true
|
||||||
|
v := c.s.Views[x.ViewID]
|
||||||
|
label := x.Label()
|
||||||
|
warn := func(code, format string, args ...any) {
|
||||||
|
p := c.warnf(code, "view", x.ViewID, "%s is reachable from the internet (%s): %s", label, x.Reason, fmt.Sprintf(format, args...))
|
||||||
|
p.View = x.View
|
||||||
|
}
|
||||||
|
if o := c.option(v, "rate-limit"); o == nil {
|
||||||
|
warn("exposure_rate_limit", "it has no rate-limit, so it can be used to flood others with its answers (DNS amplification); start response rate limiting in log-only mode")
|
||||||
|
} else if LogOnly(o) {
|
||||||
|
p := c.infof("exposure_rate_limit_log_only", "view", x.ViewID, "%s: rate limiting is in log-only mode: named only logs what it would limit; check that nobody legitimate would be limited, then start enforcing", label)
|
||||||
|
p.View = x.View
|
||||||
|
}
|
||||||
|
if !c.effectiveBool(v, "minimal-any", false) {
|
||||||
|
warn("exposure_minimal_any", "ANY queries get full answers, a favourite for amplification; set minimal-any yes")
|
||||||
|
}
|
||||||
|
if !c.effectiveBool(v, "recursion", true) {
|
||||||
|
if aml, _, set := c.effectiveAML(v, "allow-query-cache"); set {
|
||||||
|
if _, open := catchAll(aml); open {
|
||||||
|
warn("exposure_cache_snooping", "anyone may query its cache (allow-query-cache), which shows what your clients look up; set allow-query-cache { none; }")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if aml, _, set := c.effectiveAML(v, "allow-notify"); set {
|
||||||
|
if _, open := catchAll(aml); open {
|
||||||
|
warn("exposure_open_notify", "anyone may send it NOTIFY messages (allow-notify); allow only your primaries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anyPublic {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server := func(code, format string, args ...any) {
|
||||||
|
c.warnf(code, "server", 0, format, args...)
|
||||||
|
}
|
||||||
|
if c.s.FindOption(model.ScopeOptions, 0, "version") == nil {
|
||||||
|
server("exposure_version", "the server is reachable from the internet and tells anyone its exact BIND version (dig CH TXT version.bind), which helps attackers pick exploits; set version none")
|
||||||
|
}
|
||||||
|
if c.s.FindOption(model.ScopeOptions, 0, "hostname") == nil {
|
||||||
|
server("exposure_hostname", "the server is reachable from the internet and tells anyone its host name (dig CH TXT hostname.bind); set hostname none")
|
||||||
|
}
|
||||||
|
for _, name := range []string{"controls", "statistics-channels"} {
|
||||||
|
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if addr, ok := openManagement(o); ok {
|
||||||
|
c.warnf("exposure_management", "option", o.ID, "%s listens on %s and accepts anyone there; management interfaces must not be reachable from the internet: use 127.0.0.1, or an allow list of your own hosts", name, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// option returns the option name for a view, or the server's.
|
||||||
|
func (c *checker) option(v *model.View, name string) *model.Option {
|
||||||
|
if v != nil {
|
||||||
|
if o := c.s.FindOption(model.ScopeView, v.ID, name); o != nil {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.s.FindOption(model.ScopeOptions, 0, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogOnly reports whether a rate-limit option is in log-only mode.
|
||||||
|
func LogOnly(o *model.Option) bool {
|
||||||
|
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
|
if err != nil || st.FirstBlock() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, s := range st.FirstBlock().Stmts {
|
||||||
|
if s.Keyword() == "log-only" && len(s.Items) == 2 && s.Items[1].Value != nil {
|
||||||
|
switch strings.ToLower(s.Items[1].Value.Text) {
|
||||||
|
case "yes", "true", "1":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// openManagement reports an inet clause of controls or
|
||||||
|
// statistics-channels on an address that isn't loopback, open to anyone.
|
||||||
|
func openManagement(o *model.Option) (string, bool) {
|
||||||
|
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
|
if err != nil || st.FirstBlock() == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, s := range st.FirstBlock().Stmts {
|
||||||
|
if s.Keyword() != "inet" || len(s.Items) < 2 || s.Items[1].Value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr := s.Items[1].Value.Text
|
||||||
|
if a, err := netip.ParseAddr(addr); err == nil && a.IsLoopback() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, it := range s.Items {
|
||||||
|
if it.Value != nil && strings.EqualFold(it.Value.Text, "allow") && i+1 < len(s.Items) && s.Items[i+1].Block != nil {
|
||||||
|
var elems []namedconf.AMElement
|
||||||
|
for _, as := range s.Items[i+1].Block.Stmts {
|
||||||
|
if e, err := namedconf.ParseAMElement(namedconf.Compact(as.Items)); err == nil {
|
||||||
|
elems = append(elems, e.Expand()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, open := catchAll(elems); open {
|
||||||
|
return addr, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAcks turns acknowledged findings into info, saying who accepted
|
||||||
|
// them, when and why. A finding of a view (or of a zone in it) uses the
|
||||||
|
// view's acknowledgements, anything else the server's.
|
||||||
|
func (c *checker) applyAcks() {
|
||||||
|
for i := range c.problems {
|
||||||
|
p := &c.problems[i]
|
||||||
|
if p.Severity != Warning || !Ackable(p.Code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acks := c.s.Settings.Acknowledged
|
||||||
|
if v := c.problemView(p); v != nil {
|
||||||
|
acks = v.Acknowledged
|
||||||
|
}
|
||||||
|
if a := model.FindAck(acks, p.Code); a != nil {
|
||||||
|
p.Severity = Info
|
||||||
|
p.Acknowledged = a
|
||||||
|
p.Message += fmt.Sprintf(" (acknowledged by %s on %s: %s)", a.By, a.At.Format("2 Jan 2006"), a.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *checker) problemView(p *Problem) *model.View {
|
||||||
|
switch p.ObjectType {
|
||||||
|
case "view":
|
||||||
|
return c.s.Views[p.ObjectID]
|
||||||
|
case "zone":
|
||||||
|
if z := c.s.Zones[p.ObjectID]; z != nil {
|
||||||
|
return c.s.Views[z.ViewID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkLinked checks linked zones (docs/views.md#linked-zones): what can
|
||||||
|
// be linked, and that their shared records are in step. Jarl keeps them
|
||||||
|
// in step itself; they can only drift when a change was staged before the
|
||||||
|
// zones were linked (or by editing the database).
|
||||||
|
func (c *checker) checkLinked() {
|
||||||
|
groups := map[int64][]*model.Zone{}
|
||||||
|
for _, z := range c.s.SortedZones() {
|
||||||
|
if z.LinkGroup != 0 {
|
||||||
|
groups[z.LinkGroup] = append(groups[z.LinkGroup], z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range sortedGroupIDs(groups) {
|
||||||
|
zs := c.s.LinkMembers(g)
|
||||||
|
label := func(z *model.Zone) string {
|
||||||
|
if v := c.s.ViewName(z); v != "" {
|
||||||
|
return dnsname.Display(z.Name) + " [" + v + "]"
|
||||||
|
}
|
||||||
|
return dnsname.Display(z.Name)
|
||||||
|
}
|
||||||
|
bad := false
|
||||||
|
for _, z := range zs {
|
||||||
|
switch {
|
||||||
|
case z.Type != model.ZonePrimary:
|
||||||
|
c.errf("linked_zone_type", "zone", z.ID, "zone %s is linked, but only primary zones can be: unlink it", label(z))
|
||||||
|
bad = true
|
||||||
|
case z.Name != zs[0].Name:
|
||||||
|
c.errf("linked_zone_name", "zone", z.ID, "zone %s is linked with %s; only zones of the same name can be linked", label(z), label(zs[0]))
|
||||||
|
bad = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(zs) == 1 {
|
||||||
|
c.infof("linked_zone_alone", "zone", zs[0].ID, "zone %s is linked with no other zone; unlink it (jarl zone unlink)", label(zs[0]))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bad {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Shared and local records of one name and type can't mix in a zone.
|
||||||
|
type key struct{ name, typ string }
|
||||||
|
keys := map[key]bool{}
|
||||||
|
var order []key
|
||||||
|
for _, z := range zs {
|
||||||
|
for _, r := range c.s.ZoneRecords(z.ID) {
|
||||||
|
k := key{r.Name, r.Type}
|
||||||
|
if !keys[k] {
|
||||||
|
keys[k] = true
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range order {
|
||||||
|
var truth []*model.Record
|
||||||
|
var truthZone *model.Zone
|
||||||
|
for _, z := range zs {
|
||||||
|
rs := c.s.RRset(z.ID, k.name, k.typ)
|
||||||
|
local, shared := 0, 0
|
||||||
|
for _, r := range rs {
|
||||||
|
if r.SharedID == 0 {
|
||||||
|
local++
|
||||||
|
} else {
|
||||||
|
shared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if local > 0 && shared > 0 {
|
||||||
|
c.errf("linked_mixed", "zone", z.ID, "%s %s in zone %s has both shared and local records; make the set one or the other", dnsname.Display(k.name), k.typ, label(z)).Zone = z.Name
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if local > 0 {
|
||||||
|
continue // an override
|
||||||
|
}
|
||||||
|
got := c.s.SharedRecords(z.ID, k.name, k.typ)
|
||||||
|
if truthZone == nil {
|
||||||
|
truth, truthZone = got, z
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if diff := sharedDiff(truth, got); diff != "" {
|
||||||
|
p := c.errf("linked_out_of_sync", "zone", z.ID, "linked zones %s and %s differ at %s %s (%s). Jarl keeps them in step, so a change was probably staged before they were linked: link them again (jarl zone link) to work out what is shared", label(truthZone), label(z), dnsname.Display(k.name), k.typ, diff)
|
||||||
|
p.Zone = z.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedGroupIDs(m map[int64][]*model.Zone) []int64 {
|
||||||
|
var out []int64
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
for j := i + 1; j < len(out); j++ {
|
||||||
|
if out[j] < out[i] {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedDiff says how two zones' copies of a shared set differ ("" when
|
||||||
|
// they don't).
|
||||||
|
func sharedDiff(a, b []*model.Record) string {
|
||||||
|
idx := map[int64]*model.Record{}
|
||||||
|
for _, r := range b {
|
||||||
|
idx[r.SharedID] = r
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, r := range a {
|
||||||
|
o := idx[r.SharedID]
|
||||||
|
delete(idx, r.SharedID)
|
||||||
|
switch {
|
||||||
|
case o == nil:
|
||||||
|
out = append(out, "missing "+r.Data)
|
||||||
|
case o.Data != r.Data:
|
||||||
|
out = append(out, r.Data+" vs "+o.Data)
|
||||||
|
case !sameTTLPtr(o.TTL, r.TTL):
|
||||||
|
out = append(out, "TTL of "+r.Data)
|
||||||
|
case o.Disabled != r.Disabled:
|
||||||
|
out = append(out, "disabled "+r.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range idx {
|
||||||
|
out = append(out, "extra "+r.Data)
|
||||||
|
}
|
||||||
|
if len(out) > 3 {
|
||||||
|
out = append(out[:3], fmt.Sprintf("and %d more", len(out)-3))
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameTTLPtr(a, b *uint32) bool {
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return a == nil && b == nil
|
||||||
|
}
|
||||||
|
return *a == *b
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package validate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkTLSFiles checks that the files a top-level tls statement names
|
||||||
|
// exist: named fails to load the whole configuration otherwise. (Files
|
||||||
|
// jarld can't look at are left to named.)
|
||||||
|
func (c *checker) checkTLSFiles() {
|
||||||
|
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||||
|
if o.Name != "tls" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stmt, err := namedconf.ParseOptionValue("tls", o.Value)
|
||||||
|
if err != nil || stmt.FirstBlock() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, s := range stmt.FirstBlock().Stmts {
|
||||||
|
switch s.Keyword() {
|
||||||
|
case "cert-file", "key-file", "ca-file", "dhparam-file":
|
||||||
|
path := s.Arg(0)
|
||||||
|
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
||||||
|
c.errf("tls_file_missing", "option", o.ID, "tls %s: %s %s doesn't exist, and named won't load its configuration without it (a certificate Jarl obtains is written once it has been obtained: jarl cert list)", stmt.Arg(0), s.Keyword(), path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ package validate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -37,6 +38,9 @@ type Problem struct {
|
|||||||
Zone string `json:"zone,omitempty"`
|
Zone string `json:"zone,omitempty"`
|
||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
// Acknowledged is set when an exposure finding was accepted on
|
||||||
|
// purpose (it is then info, not a warning).
|
||||||
|
Acknowledged *model.Acknowledgement `json:"acknowledged,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key identifies a problem independently of object IDs, so problems can be
|
// Key identifies a problem independently of object IDs, so problems can be
|
||||||
@@ -67,6 +71,10 @@ type Options struct {
|
|||||||
// ExternalKeysUnknown is set when an included file could not be read,
|
// ExternalKeysUnknown is set when an included file could not be read,
|
||||||
// so references to unknown keys are not reported.
|
// so references to unknown keys are not reported.
|
||||||
ExternalKeysUnknown bool
|
ExternalKeysUnknown bool
|
||||||
|
// LocalAddrs are this host's addresses: a server without views is
|
||||||
|
// public when named listens on one that isn't private
|
||||||
|
// (docs/exposure.md).
|
||||||
|
LocalAddrs []netip.Addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result holds the problems found.
|
// Result holds the problems found.
|
||||||
@@ -140,8 +148,12 @@ func State(s *model.State, opts Options) []Problem {
|
|||||||
c.checkZones()
|
c.checkZones()
|
||||||
c.checkOptions()
|
c.checkOptions()
|
||||||
c.checkSecurity()
|
c.checkSecurity()
|
||||||
|
c.checkExposure()
|
||||||
c.checkDNSSEC()
|
c.checkDNSSEC()
|
||||||
c.checkBlocking()
|
c.checkBlocking()
|
||||||
|
c.checkLinked()
|
||||||
|
c.checkTLSFiles()
|
||||||
|
c.applyAcks()
|
||||||
sortProblems(c.problems)
|
sortProblems(c.problems)
|
||||||
return c.problems
|
return c.problems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import * as account from "./pages/account.js";
|
|||||||
import * as search from "./pages/search.js";
|
import * as search from "./pages/search.js";
|
||||||
import * as stats from "./pages/stats.js";
|
import * as stats from "./pages/stats.js";
|
||||||
import * as blocking from "./pages/blocking.js";
|
import * as blocking from "./pages/blocking.js";
|
||||||
|
import * as exposure from "./pages/exposure.js";
|
||||||
|
import * as certificates from "./pages/certificates.js";
|
||||||
|
import * as encdns from "./pages/encdns.js";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||||
@@ -37,20 +40,30 @@ const routes = [
|
|||||||
{ pattern: /^search$/, page: search, nav: "" },
|
{ pattern: /^search$/, page: search, nav: "" },
|
||||||
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
||||||
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
||||||
|
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
|
||||||
|
{ pattern: /^certificates$/, page: certificates, nav: "certificates" },
|
||||||
|
{ pattern: /^encrypted-dns$/, page: encdns, nav: "encdns" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The top bar: the pages used most, then a menu for the server's
|
||||||
|
// configuration and security. ["key", "#/href", "Label", "admin"?]
|
||||||
const navItems = [
|
const navItems = [
|
||||||
["dashboard", "#/", "Dashboard"],
|
["dashboard", "#/", "Dashboard"],
|
||||||
["stats", "#/stats", "Statistics"],
|
["stats", "#/stats", "Statistics"],
|
||||||
["zones", "#/zones", "Zones"],
|
["zones", "#/zones", "Zones"],
|
||||||
["blocking", "#/blocking", "Blocking"],
|
|
||||||
["views", "#/views", "Views"],
|
["views", "#/views", "Views"],
|
||||||
["acls", "#/acls", "ACLs"],
|
["blocking", "#/blocking", "Blocking"],
|
||||||
["keys", "#/keys", "TSIG keys"],
|
|
||||||
["options", "#/options", "Server options"],
|
|
||||||
["changes", "#/changes", "Changes"],
|
["changes", "#/changes", "Changes"],
|
||||||
["history", "#/history", "History"],
|
["history", "#/history", "History"],
|
||||||
["audit", "#/audit", "Audit log"],
|
{ menu: "Server", items: [
|
||||||
|
["options", "#/options", "Server options"],
|
||||||
|
["acls", "#/acls", "ACLs"],
|
||||||
|
["keys", "#/keys", "TSIG keys"],
|
||||||
|
["exposure", "#/exposure", "Exposure"],
|
||||||
|
["certificates", "#/certificates", "Certificates", "admin"],
|
||||||
|
["encdns", "#/encrypted-dns", "Encrypted DNS", "admin"],
|
||||||
|
["audit", "#/audit", "Audit log"],
|
||||||
|
] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
@@ -159,12 +172,16 @@ function layout() {
|
|||||||
logo(30),
|
logo(30),
|
||||||
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
||||||
navEl,
|
navEl,
|
||||||
h("div", { class: "spacer" }),
|
// The right-hand group stays together, on the right, also when it
|
||||||
modeEl = h("span"),
|
// has to wrap below the navigation.
|
||||||
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
h("div", { class: "header-right" },
|
||||||
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
modeEl = h("span"),
|
||||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
||||||
h("button", { class: "small", onclick: logout }, "Log out"));
|
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||||
|
h("a", { href: "#/account" }, "Account"),
|
||||||
|
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||||
|
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||||
|
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||||
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
||||||
modeHolder = modeEl;
|
modeHolder = modeEl;
|
||||||
renderNav();
|
renderNav();
|
||||||
@@ -187,9 +204,33 @@ function footer(b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderNav() {
|
function renderNav() {
|
||||||
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
const allowed = (n) => n[3] !== "admin" || state.me.admin;
|
||||||
|
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
|
||||||
|
mount(navEl, navItems.map((n) => {
|
||||||
|
if (!n.menu) return allowed(n) ? link(n) : null;
|
||||||
|
const items = n.items.filter(allowed);
|
||||||
|
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// menu is a drop-down: a button and its links. It closes when one is
|
||||||
|
// picked, on a click elsewhere and on Escape.
|
||||||
|
function menu(label, items, opts = {}) {
|
||||||
|
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
|
||||||
|
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
|
||||||
|
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
|
||||||
|
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
|
||||||
|
});
|
||||||
|
|
||||||
// renderBar shows the pending-changes bar (only while there are pending
|
// renderBar shows the pending-changes bar (only while there are pending
|
||||||
// changes) and the small review/live switch in the header.
|
// changes) and the small review/live switch in the header.
|
||||||
function renderBar() {
|
function renderBar() {
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// TLS certificates (docs/certificates.md): ones Jarl obtains and renews
|
||||||
|
// over ACME, answering the DNS-01 challenges in its own zones or at
|
||||||
|
// Porkbun, and ones you renew yourself. Each is used for jarld's HTTPS
|
||||||
|
// ("web") and/or named's DNS over TLS and HTTPS ("dns").
|
||||||
|
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, badge, copyBox } from "../ui.js";
|
||||||
|
import { api, get, state } from "../api.js";
|
||||||
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
|
const stateKind = { ok: "ok", pending: "", obtaining: "accent", failing: "err", expiring: "warn", expired: "err" };
|
||||||
|
let poll = null;
|
||||||
|
|
||||||
|
export async function render(el) {
|
||||||
|
clearTimeout(poll);
|
||||||
|
if (!state.me.admin) {
|
||||||
|
mount(el, h("h1", null, "Certificates"), h("div", { class: "msg warn" }, "Only administrators can manage certificates."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await get("/certificates");
|
||||||
|
const a = r.account;
|
||||||
|
const parts = [h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" }, h("h1", null, "Certificates"),
|
||||||
|
h("div", { class: "muted" }, "TLS certificates for Jarl's web UI and for named's DNS over TLS and HTTPS. Jarl obtains them from the CA and renews them itself, answering the DNS-01 challenge in a zone it serves or through Porkbun's API while Porkbun serves the domain. A certificate you renew yourself works too: Jarl rereads it when the files change.")),
|
||||||
|
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => createDialog(a) }, "New certificate")))];
|
||||||
|
|
||||||
|
if (!a.agree_tos) {
|
||||||
|
parts.push(h("div", { class: "msg warn" },
|
||||||
|
h("strong", null, "Jarl can't register with the CA yet. "),
|
||||||
|
"Read the CA's terms of service, then set ", h("code", null, "acme.account.agree_tos: true"), " in /etc/jarl/jarl.yaml and restart jarld (", h("code", null, "systemctl restart jarl"), "). Certificates you renew yourself work without it."));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(h("div", { class: "panel" }, table([
|
||||||
|
{ 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", "") : null,
|
||||||
|
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
||||||
|
{ 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) => {
|
||||||
|
const st = c.issuing_since ? "obtaining" : c.status.state;
|
||||||
|
return h("div", null, badge(st, stateKind[st]), c.status.message && !c.issuing_since ? h("div", { class: "small" }, c.status.message) : null);
|
||||||
|
} },
|
||||||
|
{ label: "", render: (c) => h("span", null,
|
||||||
|
h("button", { class: "small", disabled: !!c.issuing_since, title: c.source === "acme" ? "Obtain a new certificate now" : "Reread the files now", onclick: () => issue(c) }, c.source === "acme" ? "Renew" : "Reread"), " ",
|
||||||
|
h("button", { class: "small", onclick: () => editDialog(c, a) }, "Edit"), " ",
|
||||||
|
h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" },
|
||||||
|
], r.certificates, { empty: "No certificates yet." })));
|
||||||
|
|
||||||
|
parts.push(h("div", { class: "panel" }, h("h2", null, "ACME account"), h("dl", { class: "kv" },
|
||||||
|
h("dt", null, "CA"), h("dd", { class: "mono" }, a.directory),
|
||||||
|
h("dt", null, "Account"), h("dd", null, a.registered ? [h("span", { class: "mono small" }, a.uri), " since ", time(a.registered_at)] : h("span", { class: "muted" }, "registered with the first certificate")),
|
||||||
|
h("dt", null, "Email"), h("dd", null, a.email || h("span", { class: "muted" }, "none")),
|
||||||
|
h("dt", null, "Porkbun"), h("dd", null, a.porkbun ? "can publish challenges for domains Porkbun serves" : h("span", { class: "muted" }, "no API key in jarl.yaml")),
|
||||||
|
h("dt", null, "Seen through"), h("dd", { class: "mono small" }, (a.resolvers || []).join(" ")),
|
||||||
|
h("dt", null, "Files for named"), h("dd", { class: "mono small" }, a.dir + "/NAME/fullchain.pem, privkey.pem")),
|
||||||
|
h("div", { class: "small muted" }, "These are set in /etc/jarl/jarl.yaml under acme and certificates.")));
|
||||||
|
mount(el, parts);
|
||||||
|
|
||||||
|
// While a certificate is being obtained, look again every few seconds.
|
||||||
|
if (r.certificates.some((c) => c.issuing_since)) {
|
||||||
|
poll = setTimeout(() => { if (location.hash.startsWith("#/certificates")) rerender(); }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
c.source === "acme" ? ["Asked for", c.domains.join(" ")] : null,
|
||||||
|
c.names ? ["Names in it", c.names.join(" ")] : null,
|
||||||
|
c.not_after ? ["Valid", fmtTime(c.not_before) + " to " + fmtTime(c.not_after)] : null,
|
||||||
|
c.issuer ? ["Issuer", c.issuer] : null,
|
||||||
|
c.serial ? ["Serial", c.serial] : null,
|
||||||
|
c.source === "acme" && c.next_attempt ? [c.not_after ? "Renews" : "Next try", fmtTime(c.next_attempt)] : null,
|
||||||
|
c.last_routes && c.last_routes.length ? ["Challenges went", c.last_routes.join("; ")] : null,
|
||||||
|
c.last_success ? ["Last success", fmtTime(c.last_success)] : null,
|
||||||
|
c.last_error ? ["Last error", c.last_error] : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
modal("Certificate " + c.name, h("div", null,
|
||||||
|
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
|
||||||
|
c.files ? h("div", null, h("h3", null, "For named"),
|
||||||
|
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
|
||||||
|
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issue(c) {
|
||||||
|
try {
|
||||||
|
const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} });
|
||||||
|
if (r.error) toast(r.error, "err", 10000);
|
||||||
|
else toast(c.source === "acme" ? "Obtaining " + c.name + " from the CA…" : "Reread " + c.name);
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 10000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(c) {
|
||||||
|
const msg = "Delete certificate " + c.name + "?" +
|
||||||
|
(c.uses.includes("dns") ? " Its files for named are removed: take it out of any tls block first." : "") +
|
||||||
|
(c.serving ? " Jarl's web UI goes back to tls.cert_file in jarl.yaml, or to plain HTTP." : "");
|
||||||
|
if (!await confirmDialog("Delete certificate", msg, "Delete", "danger")) return;
|
||||||
|
try {
|
||||||
|
await api("DELETE", "/certificates/" + encodeURIComponent(c.name));
|
||||||
|
toast("Deleted " + c.name);
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// usesBoxes returns checkboxes for a certificate's uses.
|
||||||
|
function usesBoxes(uses) {
|
||||||
|
const web = checkbox("Jarl's web UI and API (HTTPS)", uses.includes("web"));
|
||||||
|
const dns = checkbox("named: DNS over TLS and HTTPS", uses.includes("dns"));
|
||||||
|
return { el: h("div", null, h("div", null, web), h("div", null, dns)), value: () => [web.input.checked ? "web" : null, dns.input.checked ? "dns" : null].filter(Boolean) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function challengeSelect(v, porkbun) {
|
||||||
|
return select([["auto", "Work it out at every renewal (recommended)"], ["jarl", "A zone this server serves"], ["porkbun", "Porkbun's API" + (porkbun ? "" : " (no key in jarl.yaml)")]], v || "auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyTypes = [["ec256", "ECDSA P-256 (recommended)"], ["ec384", "ECDSA P-384"], ["rsa2048", "RSA 2048 (old clients)"], ["rsa3072", "RSA 3072"], ["rsa4096", "RSA 4096"]];
|
||||||
|
|
||||||
|
async function createDialog(a) {
|
||||||
|
const name = input({ placeholder: "e.g. ns1", pattern: "[a-z0-9][a-z0-9_-]*" });
|
||||||
|
const src = select([["acme", "Jarl obtains and renews it"], ["file", "I renew it myself (files on the server)"]], "acme");
|
||||||
|
const domains = textarea("", { rows: 3, placeholder: "ns1.adbraun.lol\n*.adbraun.lol", class: "code" });
|
||||||
|
const challenge = challengeSelect("auto", a.porkbun);
|
||||||
|
const keyType = select(keyTypes, "ec256");
|
||||||
|
const certFile = input({ placeholder: "/etc/ssl/ns1/fullchain.pem", class: "code" });
|
||||||
|
const keyFile = input({ placeholder: "/etc/ssl/ns1/privkey.pem", class: "code" });
|
||||||
|
const uses = usesBoxes([]);
|
||||||
|
const comment = input();
|
||||||
|
const checkOut = h("div");
|
||||||
|
const err = h("div");
|
||||||
|
const acmeFields = h("div", { class: "form-grid" },
|
||||||
|
field("Names", domains, "One per line. *.example.com for a wildcard (which also needs example.com if you want that name too)."),
|
||||||
|
field("Challenge", challenge, "Where the DNS-01 challenge goes. Work it out: Porkbun while the domain's name servers are Porkbun's, this server once they are its own."),
|
||||||
|
field("Key", keyType));
|
||||||
|
const fileFields = h("div", { class: "form-grid" },
|
||||||
|
field("Certificate", certFile, "The full chain (certificate and intermediates), PEM. jarld must be able to read it."),
|
||||||
|
field("Key", keyFile, "Its private key, PEM."));
|
||||||
|
// display, not hidden: .form-grid sets display itself.
|
||||||
|
const sync = () => {
|
||||||
|
acmeFields.style.display = checkRow.style.display = src.value === "acme" ? "" : "none";
|
||||||
|
fileFields.style.display = src.value === "file" ? "" : "none";
|
||||||
|
};
|
||||||
|
const body = () => {
|
||||||
|
const b = { name: name.value.trim(), source: src.value, uses: uses.value(), comment: comment.value };
|
||||||
|
if (src.value === "acme") Object.assign(b, { domains: lines(domains.value), challenge: challenge.value, key_type: keyType.value });
|
||||||
|
else Object.assign(b, { cert_file: certFile.value.trim(), key_file: keyFile.value.trim() });
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
const check = h("button", { type: "button", class: "small", onclick: async () => {
|
||||||
|
mount(checkOut, h("div", { class: "muted small" }, "Looking the names up as the CA would…"));
|
||||||
|
try {
|
||||||
|
const r = await api("POST", "/certificates/check", { body: { domains: lines(domains.value), challenge: challenge.value } });
|
||||||
|
mount(checkOut, routesTable(r.routes));
|
||||||
|
} catch (e) { mount(checkOut, errorBox(e)); }
|
||||||
|
} }, "Check the names");
|
||||||
|
const checkRow = h("div", { class: "check-names" }, check, checkOut);
|
||||||
|
src.addEventListener("change", sync);
|
||||||
|
sync();
|
||||||
|
await modal("New certificate", h("div", null,
|
||||||
|
h("div", { class: "form-grid" },
|
||||||
|
field("Name", name, "Lowercase letters, digits, - and _. Also the directory of its files for named."),
|
||||||
|
field("Source", src)),
|
||||||
|
acmeFields, checkRow,
|
||||||
|
fileFields,
|
||||||
|
h("div", { class: "form-grid" }, field("Used for", uses.el, "Only one certificate can serve the web UI. Taking it on switches jarld to HTTPS on its ports at once (plain HTTP from this server itself keeps working)."), field("Comment", comment)),
|
||||||
|
err), [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "Create", kind: "primary", onclick: async () => {
|
||||||
|
try {
|
||||||
|
const r = await api("POST", "/certificates", { body: body() });
|
||||||
|
toast(r.certificate.issuing_since ? "Obtaining " + r.certificate.name + " from the CA…" : "Added " + r.certificate.name);
|
||||||
|
rerender();
|
||||||
|
return true;
|
||||||
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
|
} },
|
||||||
|
], { wide: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function routesTable(routes) {
|
||||||
|
return table([
|
||||||
|
{ label: "Name", render: (r) => h("span", { class: "mono" }, r.domain) },
|
||||||
|
{ label: "Goes to", render: (r) => r.error ? badge("nowhere", "err") : badge(r.route === "jarl" ? "this server" : "Porkbun", "ok") },
|
||||||
|
{ label: "Why", render: (r) => h("div", { class: "small" }, r.error || r.why,
|
||||||
|
r.target && r.target !== r.record ? h("div", { class: "muted" }, "via CNAME to " + r.target.replace(/\.$/, "")) : null) },
|
||||||
|
], routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editDialog(c, a) {
|
||||||
|
const domains = textarea((c.domains || []).join("\n"), { rows: 3, class: "code" });
|
||||||
|
const challenge = challengeSelect(c.challenge, a.porkbun);
|
||||||
|
const keyType = select(keyTypes, c.key_type);
|
||||||
|
const certFile = input({ value: c.cert_file || "", class: "code" });
|
||||||
|
const keyFile = input({ value: c.key_file || "", class: "code" });
|
||||||
|
const uses = usesBoxes(c.uses);
|
||||||
|
const comment = input({ value: c.comment || "" });
|
||||||
|
const err = h("div");
|
||||||
|
const acme = c.source === "acme";
|
||||||
|
await modal("Edit certificate " + c.name, h("div", null,
|
||||||
|
acme ? h("div", { class: "form-grid" },
|
||||||
|
field("Names", domains, "New names or a new key make Jarl obtain it again soon."),
|
||||||
|
field("Challenge", challenge),
|
||||||
|
field("Key", keyType))
|
||||||
|
: h("div", { class: "form-grid" }, field("Certificate", certFile), field("Key", keyFile)),
|
||||||
|
h("div", { class: "form-grid" }, field("Used for", uses.el), field("Comment", comment)),
|
||||||
|
err), [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "Save", kind: "primary", onclick: async () => {
|
||||||
|
const b = { uses: uses.value(), comment: comment.value };
|
||||||
|
if (acme) Object.assign(b, { domains: lines(domains.value), challenge: challenge.value, key_type: keyType.value });
|
||||||
|
else Object.assign(b, { cert_file: certFile.value.trim(), key_file: keyFile.value.trim() });
|
||||||
|
try {
|
||||||
|
await api("PATCH", "/certificates/" + encodeURIComponent(c.name), { body: b });
|
||||||
|
toast("Saved " + c.name);
|
||||||
|
rerender();
|
||||||
|
return true;
|
||||||
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
|
} },
|
||||||
|
], { wide: true });
|
||||||
|
}
|
||||||
@@ -43,6 +43,10 @@ async function draftPanel(d) {
|
|||||||
let valid = false;
|
let valid = false;
|
||||||
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
||||||
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
|
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
|
||||||
|
// Changes that open a public view up must be confirmed (docs/exposure.md).
|
||||||
|
const riskBox = h("label", { class: "check risk-confirm hidden" }, h("input", { type: "checkbox" }), "");
|
||||||
|
let risky = false;
|
||||||
|
riskBox.querySelector("input").addEventListener("change", () => { applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked); });
|
||||||
const ops = d.ops || [];
|
const ops = d.ops || [];
|
||||||
const removeOp = async (seq) => {
|
const removeOp = async (seq) => {
|
||||||
try {
|
try {
|
||||||
@@ -75,7 +79,10 @@ async function draftPanel(d) {
|
|||||||
try {
|
try {
|
||||||
const p = await get("/changesets/" + d.id + "/preview");
|
const p = await get("/changesets/" + d.id + "/preview");
|
||||||
valid = p.valid && !p.error;
|
valid = p.valid && !p.error;
|
||||||
applyBtn.disabled = !valid;
|
risky = (p.risks || []).length > 0;
|
||||||
|
riskBox.classList.toggle("hidden", !risky);
|
||||||
|
if (risky) riskBox.lastChild.textContent = " I understand: " + p.risks.map((x) => x.message).join("; ");
|
||||||
|
applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked);
|
||||||
const drift = (p.drift || []);
|
const drift = (p.drift || []);
|
||||||
forceBox.classList.toggle("hidden", drift.length === 0);
|
forceBox.classList.toggle("hidden", drift.length === 0);
|
||||||
mount(preview,
|
mount(preview,
|
||||||
@@ -97,7 +104,7 @@ async function draftPanel(d) {
|
|||||||
try {
|
try {
|
||||||
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
|
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
|
||||||
const force = forceBox.querySelector("input").checked;
|
const force = forceBox.querySelector("input").checked;
|
||||||
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force } });
|
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force, accept_risk: riskBox.querySelector("input").checked } });
|
||||||
const res = r.result || {};
|
const res = r.result || {};
|
||||||
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
|
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
|
||||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
@@ -119,6 +126,7 @@ async function draftPanel(d) {
|
|||||||
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
||||||
desc,
|
desc,
|
||||||
h("div", { style: { marginTop: "10px" } }, opsTable),
|
h("div", { style: { marginTop: "10px" } }, opsTable),
|
||||||
|
riskBox,
|
||||||
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
|
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
|
||||||
result),
|
result),
|
||||||
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from ".
|
|||||||
import { get, state } from "../api.js";
|
import { get, state } from "../api.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const [st, mine] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]);
|
const [st, mine, exposure] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => []), get("/exposure").catch(() => null)]);
|
||||||
const s = st.engine || {};
|
const s = st.engine || {};
|
||||||
// The user's own changesets with something in them: what the Changes page shows.
|
// The user's own changesets with something in them: what the Changes page shows.
|
||||||
const pending = mine.filter((c) => (c.ops || []).length).length;
|
const pending = mine.filter((c) => (c.ops || []).length).length;
|
||||||
@@ -16,6 +16,8 @@ export async function render(el) {
|
|||||||
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
|
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
|
||||||
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
|
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
|
||||||
st.backup ? backupCard(st.backup) : null,
|
st.backup ? backupCard(st.backup) : null,
|
||||||
|
exposure ? exposureCard(exposure) : null,
|
||||||
|
st.certificates ? certCard(st.certificates) : null,
|
||||||
);
|
);
|
||||||
const last = s.last_applied;
|
const last = s.last_applied;
|
||||||
const parts = [h("div", { class: "page-head" },
|
const parts = [h("div", { class: "page-head" },
|
||||||
@@ -41,6 +43,11 @@ export async function render(el) {
|
|||||||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||||||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||||||
}
|
}
|
||||||
|
for (const c of (st.certificates || {}).problems || []) {
|
||||||
|
parts.push(h("div", { class: "msg " + (c.state === "expiring" ? "warn" : "err") },
|
||||||
|
h("strong", null, "Certificate " + c.name + " " + (c.state === "expired" ? "has expired" : c.state === "expiring" ? "expires soon" : "can't be renewed") + ": "), c.message, " ",
|
||||||
|
h("a", { href: "#/certificates" }, "Certificates")));
|
||||||
|
}
|
||||||
if (st.blocking_paused_until) {
|
if (st.blocking_paused_until) {
|
||||||
parts.push(h("div", { class: "msg info" },
|
parts.push(h("div", { class: "msg info" },
|
||||||
h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking")));
|
h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking")));
|
||||||
@@ -106,6 +113,24 @@ function backupCard(b) {
|
|||||||
return card("Backup", value, sub);
|
return card("Backup", value, sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exposureCard sums up the internet exposure page.
|
||||||
|
function exposureCard(r) {
|
||||||
|
const pub = r.scopes.filter((s) => s.public);
|
||||||
|
let warn = r.server.filter((f) => f.severity === "warning").length;
|
||||||
|
for (const s of r.scopes) warn += s.findings.filter((f) => f.severity === "warning").length;
|
||||||
|
const value = !pub.length ? badge("nothing public", "ok") : warn ? badge(plural(warn, "warning"), "warn") : badge("hardened", "ok");
|
||||||
|
const sub = pub.length ? "public: " + pub.map((s) => s.view || "the server").join(", ") : "no view is reachable from the internet";
|
||||||
|
return h("a", { class: "card link", href: "#/exposure" }, h("div", { class: "label" }, "Exposure"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
// certCard sums up the certificates Jarl manages.
|
||||||
|
function certCard(c) {
|
||||||
|
const bad = c.problems.length;
|
||||||
|
const value = bad ? badge(bad === 1 ? "1 needs attention" : bad + " need attention", c.problems.some((p) => p.state !== "expiring") ? "err" : "warn") : badge(c.total === 1 ? "1 current" : c.total + " current", "ok");
|
||||||
|
const sub = c.next ? ["next to expire: " + c.next + " ", time(c.next_after)] : "none obtained yet";
|
||||||
|
return h("a", { class: "card link", href: "#/certificates" }, h("div", { class: "label" }, "Certificates"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
function card(label, value, sub) {
|
function card(label, value, sub) {
|
||||||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md): what is
|
||||||
|
// set up and what to give clients, and a setup that obtains the
|
||||||
|
// certificate if needed, checks the ports and stages named's
|
||||||
|
// configuration like any change.
|
||||||
|
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox } from "../ui.js";
|
||||||
|
import { api, get, change, describeResult, state } from "../api.js";
|
||||||
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
|
export async function render(el) {
|
||||||
|
if (!state.me.admin) {
|
||||||
|
mount(el, h("h1", null, "Encrypted DNS"), h("div", { class: "msg warn" }, "Only administrators can set up DNS over TLS and HTTPS."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = await get("/encrypted-dns");
|
||||||
|
const parts = [h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" }, h("h1", null, "Encrypted DNS"),
|
||||||
|
h("div", { class: "muted" }, "DNS over TLS (DoT) and DNS over HTTPS (DoH) for your own devices: phones, laptops and browsers ask this server over an encrypted connection, and get the same answers (and blocking) as over plain DNS. Clients get the view their address matches, as with plain DNS.")),
|
||||||
|
s.enabled ? h("div", { class: "actions" }, h("button", { class: "danger", onclick: disable }, "Turn off")) : null)];
|
||||||
|
|
||||||
|
if (s.enabled) parts.push(statusPanel(s));
|
||||||
|
if (s.other) parts.push(h("div", { class: "msg info" }, "named also has DoT or DoH listeners written by hand (Server options → listen-on). The setup leaves them alone."));
|
||||||
|
parts.push(setupPanel(s));
|
||||||
|
mount(el, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPanel(s) {
|
||||||
|
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
|
||||||
|
const rows = [
|
||||||
|
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?"), s.hostname ? " (" + s.hostname + ")" : null),
|
||||||
|
s.dot.length ? [h("dt", null, "DNS over TLS"), h("dd", { class: "mono" }, eps(s.dot))] : null,
|
||||||
|
s.doh.length ? [h("dt", null, "DNS over HTTPS"), h("dd", { class: "mono" }, eps(s.doh))] : null,
|
||||||
|
];
|
||||||
|
const addr = (s.dot[0] || s.doh[0] || {}).address || "ADDRESS";
|
||||||
|
const dotPortArg = s.dot[0] && s.dot[0].port !== 853 ? " -p " + s.dot[0].port : "";
|
||||||
|
const clients = [];
|
||||||
|
if (s.dot_host) {
|
||||||
|
clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host),
|
||||||
|
h("ul", { class: "small" },
|
||||||
|
h("li", null, h("strong", null, "Android: "), "Settings → Network → Private DNS → hostname: ", h("code", null, s.hostname), ". Away from home Android can't reach this server and then resolves nothing, so use a VPN home (WireGuard) or switch Private DNS to Automatic when you leave."),
|
||||||
|
h("li", null, h("strong", null, "Linux (systemd-resolved): "), h("code", null, "DNS=" + addr + "#" + s.hostname), " and ", h("code", null, "DNSOverTLS=yes"), " in /etc/systemd/resolved.conf (", h("code", null, "opportunistic"), " falls back to plain DNS away from home)."),
|
||||||
|
h("li", null, h("strong", null, "Test: "), h("code", null, "kdig +tls-ca +tls-host=" + s.hostname + dotPortArg + " @" + addr + " example.com"))));
|
||||||
|
}
|
||||||
|
if (s.doh_url) {
|
||||||
|
clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url),
|
||||||
|
h("ul", { class: "small" },
|
||||||
|
h("li", null, h("strong", null, "Firefox: "), "Settings → Privacy & Security → DNS over HTTPS → Increased protection (falls back to normal DNS) → Custom: the URL above."),
|
||||||
|
h("li", null, h("strong", null, "Chrome, Edge: "), "Settings → Privacy and security → Security → Use secure DNS → custom: the URL above."),
|
||||||
|
h("li", null, h("strong", null, "Windows 11: "), "Settings → Network → your connection → DNS server assignment → Edit: " + addr + ", DNS over HTTPS on (manual template), the URL above."),
|
||||||
|
h("li", null, h("strong", null, "Test: "), h("code", null, "curl -s -H 'accept: application/dns-json' '" + s.doh_url + "?name=example.com'"), " (or ", h("code", null, "dig +https @" + addr + " example.com"), ")")));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
|
||||||
|
h("div", { class: "panel" }, h("h2", null, "For your clients"),
|
||||||
|
h("p", { class: "muted small" }, "Clients check the certificate against the host name, so it must resolve to this server for them (Jarl added it to the private views you chose). Open the ports in the server's firewall for your networks only."),
|
||||||
|
clients),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPanel(s) {
|
||||||
|
const host = input({ value: s.hostname || "", placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "320px" } });
|
||||||
|
const certSel = h("select");
|
||||||
|
const dot = checkbox("DNS over TLS", s.enabled ? s.dot.length > 0 : true);
|
||||||
|
const doh = checkbox("DNS over HTTPS", s.enabled ? s.doh.length > 0 : true);
|
||||||
|
const dotPort = input({ type: "number", value: (s.dot[0] || {}).port || 853, style: { width: "90px" } });
|
||||||
|
const dohPort = input({ type: "number", value: (s.doh[0] || {}).port || 443, style: { width: "90px" } });
|
||||||
|
const current = new Set([...s.dot, ...s.doh].map((e) => e.address));
|
||||||
|
const picked = new Set(current);
|
||||||
|
const portOut = h("div");
|
||||||
|
const err = h("div");
|
||||||
|
const progress = h("div", { class: "muted small" });
|
||||||
|
|
||||||
|
// Addresses: one per row, nothing ticked (but the current setup's).
|
||||||
|
const addrRows = s.addresses.map((a) => pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
|
||||||
|
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
|
||||||
|
: badge("public", "warn")],
|
||||||
|
"Listen on " + a.address, () => checkPorts()));
|
||||||
|
// Views for the host name's records: the private ones that have its zone.
|
||||||
|
const viewPicked = new Set();
|
||||||
|
const viewBody = h("tbody");
|
||||||
|
const drawViews = (views) => {
|
||||||
|
viewPicked.clear();
|
||||||
|
mount(viewBody, views.map((v) => {
|
||||||
|
if (!v.public && v.has_zone) viewPicked.add(v.name);
|
||||||
|
return pickRow(v.name, viewPicked, [v.public ? badge("public", "warn") : h("span", { class: "muted" }, "private"), " ",
|
||||||
|
v.has_zone ? h("span", { class: "muted small" }, "has the zone") : h("span", { class: "muted small" }, "no zone for this name: skipped")], "Add the host name to view " + v.name);
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
drawViews(s.views);
|
||||||
|
|
||||||
|
const fillCerts = () => {
|
||||||
|
const name = host.value.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name));
|
||||||
|
mount(certSel,
|
||||||
|
fit.map((c) => h("option", { value: c.name, selected: c.name === s.certificate }, c.name + " (" + (c.names || c.domains).join(", ") + (c.obtained ? "" : ", not obtained yet") + ")")),
|
||||||
|
name ? h("option", { value: "" }, "Obtain a new certificate for " + name) : h("option", { value: "" }, "Type the host name first"));
|
||||||
|
};
|
||||||
|
fillCerts();
|
||||||
|
let viewTimer = null;
|
||||||
|
host.addEventListener("input", () => {
|
||||||
|
fillCerts();
|
||||||
|
clearTimeout(viewTimer);
|
||||||
|
viewTimer = setTimeout(async () => {
|
||||||
|
const r = await get("/encrypted-dns", { hostname: host.value.trim() }).catch(() => null);
|
||||||
|
if (r) drawViews(r.views);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
let checkTimer = null, checkSeq = 0;
|
||||||
|
const checkPorts = () => {
|
||||||
|
clearTimeout(checkTimer);
|
||||||
|
checkTimer = setTimeout(async () => {
|
||||||
|
const seq = ++checkSeq;
|
||||||
|
if (!picked.size) { mount(portOut); return; }
|
||||||
|
const r = await api("POST", "/encrypted-dns/check", { body: body(false) }).catch((e) => ({ error: e.message, conflicts: [] }));
|
||||||
|
if (seq !== checkSeq) return;
|
||||||
|
const msgs = r.conflicts.map((c) => h("div", null, c.what + " listens on " + c.address + " port " + c.port + "."));
|
||||||
|
if (r.conflicts.some((c) => c.port === Number(dohPort.value)) && r.free_doh_port) {
|
||||||
|
msgs.push(h("div", null, "Port " + r.free_doh_port + " is free for DoH: ",
|
||||||
|
h("button", { type: "button", class: "small", onclick: () => { dohPort.value = r.free_doh_port; checkPorts(); } }, "Use " + r.free_doh_port)));
|
||||||
|
}
|
||||||
|
mount(portOut, r.error ? h("div", { class: "msg warn small" }, r.error)
|
||||||
|
: msgs.length ? h("div", { class: "msg err small" }, msgs)
|
||||||
|
: h("div", { class: "msg ok small" }, "The ports are free on the chosen addresses."));
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
for (const x of [dotPort, dohPort]) x.addEventListener("input", checkPorts);
|
||||||
|
for (const x of [dot, doh]) x.input.addEventListener("change", checkPorts);
|
||||||
|
if (picked.size) checkPorts();
|
||||||
|
|
||||||
|
const body = (full) => {
|
||||||
|
const b = { hostname: host.value.trim(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 };
|
||||||
|
if (full) { b.certificate = certSel.value; b.views = [...viewPicked]; }
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (btn) => {
|
||||||
|
mount(err);
|
||||||
|
const b = body(true);
|
||||||
|
if (!b.hostname) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
|
||||||
|
if (!b.addresses.length) return mount(err, h("div", { class: "msg err" }, "Choose at least one address to listen on."));
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (!b.certificate) {
|
||||||
|
// Obtain one for the host name, for named, and wait for it.
|
||||||
|
b.certificate = b.hostname.split(".")[0].replace(/[^a-z0-9_-]/g, "-");
|
||||||
|
mount(progress, "Obtaining a certificate for " + b.hostname + " from the CA…");
|
||||||
|
await api("POST", "/certificates", { body: { name: b.certificate, domains: [b.hostname], uses: ["dns"] } });
|
||||||
|
await waitObtained(b.certificate, progress);
|
||||||
|
} else {
|
||||||
|
const c = await get("/certificates/" + encodeURIComponent(b.certificate));
|
||||||
|
if (!c.uses.includes("dns")) await api("PATCH", "/certificates/" + encodeURIComponent(b.certificate), { body: { uses: [...c.uses, "dns"] } });
|
||||||
|
if (!c.not_after) {
|
||||||
|
if (!c.issuing_since) await api("POST", "/certificates/" + encodeURIComponent(b.certificate) + "/issue", { body: {} });
|
||||||
|
await waitObtained(b.certificate, progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mount(progress, "Setting up named…");
|
||||||
|
const r = await change("POST", "/encrypted-dns", b);
|
||||||
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
|
rerender();
|
||||||
|
} catch (e) {
|
||||||
|
mount(progress);
|
||||||
|
mount(err, errorBox(e));
|
||||||
|
} finally { btn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const go = h("button", { class: "primary", onclick: (e) => submit(e.target) }, s.enabled ? "Change setup" : "Set up");
|
||||||
|
return h("div", { class: "panel" },
|
||||||
|
h("h2", null, s.enabled ? "Change the setup" : "Set up"),
|
||||||
|
h("div", { class: "form-grid" },
|
||||||
|
field("Host name", host, "The name clients connect to. The certificate is for it, and it gets this server's addresses in the views below."),
|
||||||
|
field("Certificate", certSel, "One with this name, or a new one Jarl obtains and renews (docs/certificates.md)."),
|
||||||
|
field("Listen on", h("div", null, h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, addrRows))), portOut),
|
||||||
|
"This server's addresses on your own networks. A public address works too, but then anyone who reaches it can use these listeners, as with port 53 there."),
|
||||||
|
field("Serve", h("div", { class: "enc-ports" }, h("div", null, dot, " on port ", dotPort), h("div", null, doh, " on port ", dohPort, h("span", { class: "muted small" }, " at /dns-query"))),
|
||||||
|
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there."),
|
||||||
|
field("Host name in views", h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, viewBody)),
|
||||||
|
"Where the host name gets the chosen addresses (A and AAAA), local to the view in a linked zone. Clients outside these views can't find it.")),
|
||||||
|
err, progress,
|
||||||
|
h("div", { class: "buttons" }, go));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickRow is a table row that toggles value in set when clicked.
|
||||||
|
function pickRow(value, set, extra, label, onChange) {
|
||||||
|
const box = h("input", { type: "checkbox", checked: set.has(value), "aria-label": label });
|
||||||
|
const tr = h("tr", { class: "pick" + (set.has(value) ? " selected" : "") }, h("td", null, box), h("td", { class: "mono" }, value), h("td", null, extra));
|
||||||
|
box.addEventListener("change", () => {
|
||||||
|
if (box.checked) set.add(value); else set.delete(value);
|
||||||
|
tr.classList.toggle("selected", box.checked);
|
||||||
|
if (onChange) onChange();
|
||||||
|
});
|
||||||
|
tr.addEventListener("click", (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } });
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function covers(names, host) {
|
||||||
|
if (!host) return false;
|
||||||
|
return names.some((n) => {
|
||||||
|
n = n.toLowerCase().replace(/\.$/, "");
|
||||||
|
if (n === host) return true;
|
||||||
|
return n.startsWith("*.") && host.split(".").slice(1).join(".") === n.slice(2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitObtained(name, progress) {
|
||||||
|
const started = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
|
const c = await get("/certificates/" + encodeURIComponent(name));
|
||||||
|
if (!c.issuing_since) {
|
||||||
|
if (!c.not_after) throw new Error("Obtaining the certificate failed: " + (c.last_error || "see the Certificates page"));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
mount(progress, "Obtaining a certificate for " + (c.domains || []).join(", ") + " from the CA… " + Math.round((Date.now() - started) / 1000) + "s (the challenges must reach every name server)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable() {
|
||||||
|
if (!await confirmDialog("Turn encrypted DNS off", "named stops serving DNS over TLS and HTTPS. The certificate and the host name's records stay.", "Turn off", "danger")) return;
|
||||||
|
try {
|
||||||
|
toast(describeResult(await change("DELETE", "/encrypted-dns")));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Internet exposure (docs/exposure.md): which views are reachable from
|
||||||
|
// the internet, why, and what to harden, with fixes staged like any edit
|
||||||
|
// and findings that can be accepted on purpose.
|
||||||
|
import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural } from "../ui.js";
|
||||||
|
import { get, change, describeResult, can } from "../api.js";
|
||||||
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
|
const sevKind = { warning: "warn", error: "err", info: "" };
|
||||||
|
|
||||||
|
export async function render(el) {
|
||||||
|
const r = await get("/exposure");
|
||||||
|
const writable = can("options", true);
|
||||||
|
const parts = [h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" }, h("h1", null, "Internet exposure"),
|
||||||
|
h("div", { class: "muted" }, "Which views can be reached from the internet, and what to harden on them. A view is public when its match-clients let in anyone (they end in any), whatever kind of addresses your own networks use. Fixes are staged as pending changes for you to review.")))];
|
||||||
|
|
||||||
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Views"), table([
|
||||||
|
{ label: "View", render: (s) => s.view ? h("a", { href: "#/views/" + s.view_id }, s.view) : h("span", { class: "muted" }, "the server (no views)") },
|
||||||
|
{ label: "Exposure", render: (s) => [badge(s.public ? "public" : "private", s.public ? "warn" : "ok"), s.override ? h("span", { class: "muted small" }, " set by hand") : null] },
|
||||||
|
{ label: "Why", render: (s) => h("span", { class: "small" }, s.reason) },
|
||||||
|
{ label: "Rate limiting", render: (s) => s.public ? badge(s.rate_limit, { off: "err", "log-only": "warn", enforcing: "ok" }[s.rate_limit]) : h("span", { class: "muted" }, "–") },
|
||||||
|
{ label: "", render: (s) => writable ? overrideSelect(s) : null, tdClass: "actions-cell" },
|
||||||
|
], r.scopes)));
|
||||||
|
|
||||||
|
const all = [];
|
||||||
|
for (const s of r.scopes) for (const f of s.findings) all.push({ ...f, scope: s.view || "server", viewID: s.view_id });
|
||||||
|
for (const f of r.server) all.push({ ...f, scope: "server", viewID: 0 });
|
||||||
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")),
|
||||||
|
all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check.")));
|
||||||
|
|
||||||
|
if (r.rate_limiting) parts.push(activity(r.rate_limiting, r.scopes));
|
||||||
|
mount(el, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideSelect(s) {
|
||||||
|
const sel = select([["", "Work it out"], ["public", "Public"], ["private", "Private"]], s.override || "");
|
||||||
|
sel.title = "Override the classification when Jarl gets it wrong (e.g. a port forward, where named only sees private addresses)";
|
||||||
|
sel.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
const r = s.view_id
|
||||||
|
? await change("PATCH", "/views/" + s.view_id, { exposure: sel.value || "auto" })
|
||||||
|
: await change("PATCH", "/settings", { exposure: sel.value || "auto" });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
});
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingRow(f, writable) {
|
||||||
|
const acked = f.acknowledged;
|
||||||
|
const actions = writable ? h("div", { class: "actions" },
|
||||||
|
f.fix && !acked ? h("button", { class: "small primary", title: f.fix, onclick: () => fix(f) }, "Fix") : null,
|
||||||
|
acked
|
||||||
|
? h("button", { class: "small", onclick: () => unack(f) }, "Take back")
|
||||||
|
: h("button", { class: "small", onclick: () => ack(f) }, "Acknowledge")) : null;
|
||||||
|
return h("div", { class: "finding " + (acked ? "acked" : f.severity) },
|
||||||
|
h("div", { class: "grow" },
|
||||||
|
h("div", null, badge(acked ? "acknowledged" : f.severity, acked ? "" : sevKind[f.severity]), " ", h("strong", null, f.scope), " ", h("span", { class: "mono small muted" }, f.code)),
|
||||||
|
h("div", null, f.message),
|
||||||
|
f.fix && !acked ? h("div", { class: "small muted" }, "Fix: " + f.fix) : null),
|
||||||
|
actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fix(f) {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/exposure/fix", { view_id: f.viewID, code: f.code });
|
||||||
|
toast(describeResult(r));
|
||||||
|
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 10000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ack(f) {
|
||||||
|
const reason = input({ placeholder: "e.g. public resolver for the club's network, monitored", style: { width: "100%" } });
|
||||||
|
const err = h("div");
|
||||||
|
await modal("Acknowledge " + f.code, h("div", null,
|
||||||
|
h("p", null, f.message),
|
||||||
|
h("p", { class: "muted small" }, "The finding stays visible, as info, with your name, the date and your reason. Jarl warns again if it comes up somewhere else."),
|
||||||
|
field("Why is this accepted?", reason), err), [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "Acknowledge", kind: "primary", onclick: async () => {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/exposure/ack", { view_id: f.viewID, code: f.code, reason: reason.value.trim() });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
return true;
|
||||||
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
|
} },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unack(f) {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/exposure/unack", { view_id: f.viewID, code: f.code });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// activity shows what rate limiting limited, or would have in log-only
|
||||||
|
// mode, so you can check nobody legitimate is hit before enforcing.
|
||||||
|
function activity(a, scopes) {
|
||||||
|
const counts = Object.fromEntries((a.counts || []).map((e) => [e.key, e.count]));
|
||||||
|
const would = (counts["would-drop"] || 0) + (counts["would-slip"] || 0);
|
||||||
|
const did = (counts["drop"] || 0) + (counts["slip"] || 0);
|
||||||
|
const logOnly = scopes.some((s) => s.public && s.rate_limit === "log-only");
|
||||||
|
const any = scopes.some((s) => s.public && s.rate_limit !== "off");
|
||||||
|
if (!any) return null;
|
||||||
|
return h("div", { class: "panel" }, h("h2", null, "Rate limiting, last " + a.since),
|
||||||
|
!would && !did ? h("div", { class: "empty" }, "Nothing was limited" + (logOnly ? " (or would have been)" : "") + ". If you just turned it on, give it a day.") : [
|
||||||
|
h("p", null,
|
||||||
|
did ? plural(did, "response") + " limited (" + (counts["drop"] || 0) + " dropped, " + (counts["slip"] || 0) + " slipped). " : "",
|
||||||
|
would ? plural(would, "response") + " would have been limited in log-only mode (" + (counts["would-drop"] || 0) + " dropped, " + (counts["would-slip"] || 0) + " slipped)." : ""),
|
||||||
|
h("p", { class: "muted small" }, "Dropped responses get no answer; slipped ones get a short reply asking the client to retry over TCP (or with a DNS cookie), which a real client does and a spoofed victim of an attack never sees."),
|
||||||
|
logOnly ? h("p", { class: "muted small" }, "Check the networks below: your secondaries, monitoring, or a big shared resolver shouldn't be among them. If they are, add them to exempt-clients; otherwise start enforcing.") : null,
|
||||||
|
h("div", { class: "chart-grid" },
|
||||||
|
table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []),
|
||||||
|
table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]);
|
||||||
|
}
|
||||||
@@ -31,9 +31,13 @@ function valueWidget(stmt, value, ctx) {
|
|||||||
const t = codeArea(value || "");
|
const t = codeArea(value || "");
|
||||||
// Statements such as allow-transfer and listen-on take an address
|
// Statements such as allow-transfer and listen-on take an address
|
||||||
// match list after optional parameters: suggest names at the cursor.
|
// match list after optional parameters: suggest names at the cursor.
|
||||||
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
|
// Names are suggested wherever the grammar has an address match list
|
||||||
|
// (rate-limit's exempt-clients too); the braces are only optional for
|
||||||
|
// statements that end in the list (catalog WrapList).
|
||||||
|
const hasAML = stmt && /address_match_element/.test(stmt.grammar || "");
|
||||||
|
const takesList = stmt && /\{ <address_match_element>; \.\.\. \};$/.test((stmt.grammar || "").split("\n")[0]);
|
||||||
if (takesList && !value) t.placeholder = "homelab; key xfer-key";
|
if (takesList && !value) t.placeholder = "homelab; key xfer-key";
|
||||||
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
|
return { el: h("div", null, t, hasAML && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
|
||||||
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
|
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
|
||||||
}
|
}
|
||||||
const i = input({ value: value || "", class: "mono" });
|
const i = input({ value: value || "", class: "mono" });
|
||||||
@@ -83,7 +87,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
mount(listEl, table([
|
mount(listEl, table([
|
||||||
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
|
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
|
||||||
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code", style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.formatted || o.value || "(no value)") },
|
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code optval" }, o.formatted || o.value || "(no value)") },
|
||||||
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
||||||
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
||||||
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
||||||
|
|||||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user