Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95125e7f50 | ||
|
|
c10ebe0d38 | ||
|
|
3d1911dd38 | ||
|
|
310ed8061e | ||
|
|
b367e0c830 | ||
|
|
254867c61d | ||
|
|
3ddd32f97f | ||
|
|
0976faba3f | ||
|
|
1d535c8063 | ||
|
|
7f3564c0a5 | ||
|
|
71d9a06e43 | ||
|
|
2fa9828bcf | ||
|
|
a93373687e | ||
|
|
6cd3f6ef4e | ||
|
|
d18fe18ccd |
No files matched your search
@@ -6,6 +6,30 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.3] - 2026-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A docs/acme.md section: lego on a host inside a split DNS fails its own
|
||||||
|
propagation check when the challenge is written into the external view
|
||||||
|
only (`JARL_VIEW`, a one-view token, `.../httpreq/view/...`): why, and
|
||||||
|
the ways round it (no view limit, `--dns.propagation.disable-ans` or
|
||||||
|
`--dns.propagation.wait`, Traefik's `propagation.disableANSChecks`).
|
||||||
|
- docs/acme.md and `jarl-lego` show lego 5's commands (`lego run ...`,
|
||||||
|
which also renews; `lego migrate` when upgrading), with lego 4's
|
||||||
|
beside them. Jarl's side is the same for both.
|
||||||
|
|
||||||
|
## [0.6.2] - 2026-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A `certificates` role (template): `certificates:write`, for tokens and
|
||||||
|
users that get certificates for other hosts with `jarl cert get`. In
|
||||||
|
the web UI's user and token forms it is in "Insert a template…", to
|
||||||
|
narrow with `names=`; in the CLI, `--role certificates`.
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-10-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
|
- Certificates for other hosts: `jarl cert get NAME -d DOMAIN --dir DIR
|
||||||
@@ -42,6 +66,36 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- The Statistics page's range buttons follow `stats.retention`: ranges
|
||||||
|
longer than any data kept are left out, and **1y** and **2y** appear
|
||||||
|
when `retention.day` (or `retention.hour`) covers them (with the
|
||||||
|
default 730d, both do). The busiest-times heatmap says how far back it
|
||||||
|
really goes (at most `retention.hour`), and links to the Explore page
|
||||||
|
keep within `retention.queries` instead of assuming 7 days.
|
||||||
|
`GET /stats/status` and `GET /stats/explore` add `retention_seconds`.
|
||||||
|
|
||||||
|
- A reverse zone's missing PTR records count only its own view's A and
|
||||||
|
AAAA records. An address only another view's records use (an internal
|
||||||
|
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
|
||||||
|
longer reported as missing; it shows apart, as `Other views` in `jarl
|
||||||
|
zone show`, `ptr_other_views` in the API and a note on the zone's page.
|
||||||
|
Filling (`--fill`, `jarl zone fill-ptr`, the page's button) names those
|
||||||
|
from the other view only when asked, with `--other-views`
|
||||||
|
(`fill_other_views`, `?other_views=1`, a checkbox in the New reverse
|
||||||
|
zone dialog), so one view's host names don't leak into another. When
|
||||||
|
there is nothing else to fill, Jarl says how many there are and how to
|
||||||
|
add them. The page and `jarl zone show` list those addresses with the
|
||||||
|
name each would get, and the view it comes from
|
||||||
|
(`ptr_other_views_list`); `jarl zone show` lists the missing ones too.
|
||||||
|
|
||||||
|
- Records, zones and validation findings sort numeric labels by value, so
|
||||||
|
`2.10.0.10.in-addr.arpa` comes before `101.10.0.10.in-addr.arpa` in the
|
||||||
|
web UI, the CLI and the rendered zone files. Names still group by label
|
||||||
|
from the right (subdomains stay under their parent); other labels keep
|
||||||
|
the old byte order (`host10` before `host2`). Zone files are rewritten
|
||||||
|
in the new order the next time they change; named doesn't care about
|
||||||
|
record order.
|
||||||
|
|
||||||
- `cname_chain` no longer warns about one CNAME pointing at another (a
|
- `cname_chain` no longer warns about one CNAME pointing at another (a
|
||||||
service name at a host alias is common, and named answers the chain at
|
service name at a host alias is common, and named answers the chain at
|
||||||
once). A chain of three or more CNAMEs gets an info note that shows
|
once). A chain of three or more CNAMEs gets an info note that shows
|
||||||
@@ -75,6 +129,21 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- `jarl cert get NAME` no longer takes over a name this host has saved
|
||||||
|
for another Jarl server, which overwrote that certificate's settings
|
||||||
|
(and, with the default `--dir`, its files), so it stopped being
|
||||||
|
renewed. It is refused, as is a `--dir` holding another certificate's
|
||||||
|
files; `--replace` takes the name or directory over.
|
||||||
|
- The "query log is off" error named a setting that doesn't exist,
|
||||||
|
`stats.query_retention`; it's `stats.retention.queries`.
|
||||||
|
- `jarl zone show` for a reverse zone printed only "and 1 more" under
|
||||||
|
"Other views:" when the server was older than the CLI (0.6.1-rc.2
|
||||||
|
sends the count but not the addresses). It now prints just the count
|
||||||
|
line then.
|
||||||
|
- `jarl changeset show` printed its Created and Applied times in UTC,
|
||||||
|
while `jarl history` and `jarl status` print local time. It uses local
|
||||||
|
time now too.
|
||||||
|
|
||||||
- The statistics' "Busiest times" grid drew hours without data taller
|
- The statistics' "Busiest times" grid drew hours without data taller
|
||||||
than the others (they picked up the page's padding for empty
|
than the others (they picked up the page's padding for empty
|
||||||
messages).
|
messages).
|
||||||
|
|||||||
@@ -202,12 +202,15 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.6.0 is the current release, and 0.6.1 is in testing
|
Version 0.6.3 is the current release. It brings the lego docs up to
|
||||||
(0.6.1-rc.1): it gets certificates onto your other hosts (`jarl cert
|
lego 5 and explains lego with split DNS. 0.6.2 added a `certificates` role
|
||||||
get`: Jarl obtains and renews them, a host downloads them and runs a
|
for tokens that get certificates for other hosts. 0.6.1 gets certificates onto your
|
||||||
|
other hosts (`jarl cert get`: Jarl obtains and renews them, a host downloads them and runs a
|
||||||
hook, a daily timer keeps them current), exports certificates, fixes
|
hook, a daily timer keeps them current), exports certificates, fixes
|
||||||
PTR records and overrides in linked zones, and makes one-view tokens
|
PTR records and overrides in linked zones, and makes one-view tokens
|
||||||
(such as ACME tokens for the external view) work with linked zones.
|
(such as ACME tokens for the external view) work with linked zones.
|
||||||
|
Reverse zones list addresses in numeric order and no longer report
|
||||||
|
another view's hosts as missing PTR records.
|
||||||
0.6.0 makes reverse zones easy (suggested from your records,
|
0.6.0 makes reverse zones easy (suggested from your records,
|
||||||
created in the right view and filled, PTR records kept in step), rebuilds
|
created in the right view and filled, PTR records kept in step), rebuilds
|
||||||
comparing a zone's views, lets you explore the statistics down to each
|
comparing a zone's views, lets you explore the statistics down to each
|
||||||
|
|||||||
@@ -392,7 +392,8 @@ ACCESS is read or write, and filters take comma separated glob patterns:
|
|||||||
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
|
records:write zones=adbraun.lol names=_acme-challenge.* types=TXT
|
||||||
*:read
|
*:read
|
||||||
|
|
||||||
Roles are ready-made grant sets: readonly, dns-editor, operator, acme.`
|
Roles are ready-made grant sets: readonly, dns-editor, operator, acme,
|
||||||
|
certificates.`
|
||||||
|
|
||||||
func userCmd() *cobra.Command {
|
func userCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
|
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Manage users (admin)", Long: "Manage users (admin).\n\n" + grantHelp}
|
||||||
@@ -477,7 +478,7 @@ func userCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
addUserFlags := func(c *cobra.Command) {
|
addUserFlags := func(c *cobra.Command) {
|
||||||
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
|
c.Flags().BoolVar(&admin, "admin", false, "make the user an administrator (--admin=false to remove)")
|
||||||
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator or acme")
|
c.Flags().StringVar(&role, "role", "", "replace the grants with a role: readonly, dns-editor, operator, acme or certificates")
|
||||||
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
c.Flags().StringArrayVar(&grants, "grant", nil, `grant (repeatable), e.g. "records:write zones=example.com"`)
|
||||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
|
c.Flags().IntVar(&certLimit, "cert-limit", -1, "how many certificates the user may own through jarl cert get (-1: certificates.max_per_user in jarl.yaml)")
|
||||||
@@ -758,7 +759,7 @@ func tokenCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
|
create.Flags().StringVar(&user, "user", "", "create the token for this user (admin)")
|
||||||
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator or acme")
|
create.Flags().StringVar(&role, "role", "", "restrict the token to a role: readonly, dns-editor, operator, acme or certificates")
|
||||||
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
|
create.Flags().StringArrayVar(&grants, "grant", nil, "restrict the token to these grants (repeatable)")
|
||||||
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
|
create.Flags().StringVar(&expires, "expires", "", "lifetime, e.g. 90d or 720h (default: never)")
|
||||||
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
|
create.Flags().BoolVar(&save, "save", false, "store the new token in the CLI configuration file")
|
||||||
|
|||||||
+36
-2
@@ -114,6 +114,32 @@ func (sc savedCert) save(dir string) error {
|
|||||||
return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600)
|
return writeAtomic(filepath.Join(dir, sc.Name+".json"), append(b, '\n'), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkCertClash refuses a certificate that would take over what this
|
||||||
|
// host keeps for another one: a name saved for another Jarl server
|
||||||
|
// (each server has its own names, so the server can't refuse it), or a
|
||||||
|
// directory with another certificate's files. saved[i] is sc's saved
|
||||||
|
// settings (i < 0: none).
|
||||||
|
func checkCertClash(saved []savedCert, i int, sc savedCert, replace bool) error {
|
||||||
|
if i >= 0 && !replace && !sameServer(saved[i].Conn.Server, sc.Conn.Server) {
|
||||||
|
return fmt.Errorf("%s is saved here for the Jarl server %s, not %s: choose another name, or take it over with --replace", sc.Name, saved[i].Conn.Server, sc.Conn.Server)
|
||||||
|
}
|
||||||
|
for j, o := range saved {
|
||||||
|
if j != i && o.Name != sc.Name && filepath.Clean(o.Dir) == filepath.Clean(sc.Dir) {
|
||||||
|
return fmt.Errorf("%s holds the files of %s, saved for %s: give %s another --dir", sc.Dir, o.Name, o.Conn.Server, sc.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m, err := readMeta(sc.Dir); err == nil && m.Name != "" && m.Name != sc.Name && !replace {
|
||||||
|
return fmt.Errorf("%s holds the files of the certificate %s: give %s another --dir, or overwrite them with --replace", sc.Dir, m.Name, sc.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameServer reports whether two server addresses name the same jarld.
|
||||||
|
func sameServer(a, b string) bool {
|
||||||
|
norm := func(s string) string { return strings.TrimRight(strings.ToLower(s), "/") }
|
||||||
|
return norm(a) == norm(b)
|
||||||
|
}
|
||||||
|
|
||||||
func readMeta(dir string) (*localMeta, error) {
|
func readMeta(dir string) (*localMeta, error) {
|
||||||
b, err := os.ReadFile(filepath.Join(dir, metaFile))
|
b, err := os.ReadFile(filepath.Join(dir, metaFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -340,7 +366,7 @@ func runHook(sc savedCert, ci certInfo, renewed bool) error {
|
|||||||
func certGetCmd() *cobra.Command {
|
func certGetCmd() *cobra.Command {
|
||||||
var domains []string
|
var domains []string
|
||||||
var dir, hook, keyType, configDir string
|
var dir, hook, keyType, configDir string
|
||||||
var noSave, force bool
|
var noSave, force, replace bool
|
||||||
var wait time.Duration
|
var wait time.Duration
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "get [NAME]",
|
Use: "get [NAME]",
|
||||||
@@ -365,6 +391,10 @@ readable only by you). 'jarl cert get' without a name then does every
|
|||||||
saved certificate: run it daily (jarl cert schedule does that with a
|
saved certificate: run it daily (jarl cert schedule does that with a
|
||||||
systemd timer). Nothing is downloaded until jarld has renewed one.
|
systemd timer). Nothing is downloaded until jarld has renewed one.
|
||||||
|
|
||||||
|
A name saved here for another Jarl server isn't taken over, nor a --dir
|
||||||
|
holding another certificate's files, unless --replace is given; a --dir
|
||||||
|
another saved certificate uses is always refused.
|
||||||
|
|
||||||
--hook runs with sh after new files are written (and again on the next
|
--hook runs with sh after new files are written (and again on the next
|
||||||
run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT,
|
run if it failed), with JARL_CERT_NAME, JARL_CERT_DIR, JARL_CERT_CERT,
|
||||||
JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS,
|
JARL_CERT_CHAIN, JARL_CERT_FULLCHAIN, JARL_CERT_KEY, JARL_CERT_DOMAINS,
|
||||||
@@ -427,6 +457,9 @@ replaced an older certificate).`,
|
|||||||
}
|
}
|
||||||
sc.Conn = cn
|
sc.Conn = cn
|
||||||
}
|
}
|
||||||
|
if err := checkCertClash(saved, i, sc, replace); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
todo = []savedCert{sc}
|
todo = []savedCert{sc}
|
||||||
}
|
}
|
||||||
var results []*getResult
|
var results []*getResult
|
||||||
@@ -469,7 +502,7 @@ replaced an older certificate).`,
|
|||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case len(failed) == 1 && len(todo) == 1:
|
case len(failed) == 1 && len(todo) == 1:
|
||||||
return lastErr // its own exit code (3 forbidden, 6 conflict, ...)
|
return lastErr // its own exit code (3 forbidden, 5 conflict, ...)
|
||||||
case len(failed) > 0:
|
case len(failed) > 0:
|
||||||
return fmt.Errorf("failed: %s", strings.Join(failed, ", "))
|
return fmt.Errorf("failed: %s", strings.Join(failed, ", "))
|
||||||
}
|
}
|
||||||
@@ -483,6 +516,7 @@ replaced an older certificate).`,
|
|||||||
f.StringVar(&keyType, "key-type", "", "key type of a new certificate: ec256 (default), ec384, rsa2048, rsa3072, rsa4096")
|
f.StringVar(&keyType, "key-type", "", "key type of a new certificate: ec256 (default), ec384, rsa2048, rsa3072, rsa4096")
|
||||||
f.BoolVar(&force, "force", false, "download and run the hook even when this host has the current certificate")
|
f.BoolVar(&force, "force", false, "download and run the hook even when this host has the current certificate")
|
||||||
f.BoolVar(&noSave, "no-save", false, "don't save the settings for later runs")
|
f.BoolVar(&noSave, "no-save", false, "don't save the settings for later runs")
|
||||||
|
f.BoolVar(&replace, "replace", false, "take over a name saved here for another Jarl server, or a --dir holding another certificate's files")
|
||||||
f.DurationVar(&wait, "wait", 15*time.Minute, "how long to wait while jarld obtains it")
|
f.DurationVar(&wait, "wait", 15*time.Minute, "how long to wait while jarld obtains it")
|
||||||
f.StringVar(&configDir, "config-dir", "", "where the settings are saved (default /etc/jarl/cert-get as root, ~/.config/jarl/cert-get otherwise)")
|
f.StringVar(&configDir, "config-dir", "", "where the settings are saved (default /etc/jarl/cert-get as root, ~/.config/jarl/cert-get otherwise)")
|
||||||
_ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp))
|
_ = cmd.RegisterFlagCompletionFunc("key-type", cobra.FixedCompletions([]string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}, cobra.ShellCompDirectiveNoFileComp))
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckCertClash(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
a := connection{Server: "https://dns-a.example:8053"}
|
||||||
|
b := connection{Server: "https://dns-b.example:8053"}
|
||||||
|
saved := []savedCert{
|
||||||
|
{Name: "proxy", Dir: filepath.Join(tmp, "proxy"), Conn: a},
|
||||||
|
{Name: "mail", Dir: filepath.Join(tmp, "mail"), Conn: a},
|
||||||
|
}
|
||||||
|
// mail's files are on disk; "other" has files but no saved settings.
|
||||||
|
if err := (&localMeta{Name: "mail"}).write(mkdir(t, saved[1].Dir)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := (&localMeta{Name: "other"}).write(mkdir(t, filepath.Join(tmp, "other"))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
what string
|
||||||
|
i int
|
||||||
|
sc savedCert
|
||||||
|
replace bool
|
||||||
|
want string // "" = allowed
|
||||||
|
}{
|
||||||
|
{"same server again", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: a}, false, ""},
|
||||||
|
{"same server, other spelling", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: connection{Server: "HTTPS://dns-a.example:8053/"}}, false, ""},
|
||||||
|
{"other server", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, false, "saved here for the Jarl server"},
|
||||||
|
{"other server, replace", 0, savedCert{Name: "proxy", Dir: saved[0].Dir, Conn: b}, true, ""},
|
||||||
|
{"new name, saved cert's dir", -1, savedCert{Name: "web", Dir: saved[1].Dir, Conn: b}, false, "holds the files of mail"},
|
||||||
|
{"new name, saved cert's dir, replace", -1, savedCert{Name: "web", Dir: saved[1].Dir + "/", Conn: b}, true, "holds the files of mail"},
|
||||||
|
{"new name, unsaved cert's files", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, false, "certificate other"},
|
||||||
|
{"new name, unsaved cert's files, replace", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "other"), Conn: b}, true, ""},
|
||||||
|
{"new name, empty dir", -1, savedCert{Name: "web", Dir: filepath.Join(tmp, "web"), Conn: b}, false, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
err := checkCertClash(saved, c.i, c.sc, c.replace)
|
||||||
|
switch {
|
||||||
|
case c.want == "" && err != nil:
|
||||||
|
t.Errorf("%s: %v", c.what, err)
|
||||||
|
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
|
||||||
|
t.Errorf("%s: got %v, want %q", c.what, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkdir(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
+2
-2
@@ -272,9 +272,9 @@ func changesetCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(cs, func(w io.Writer) {
|
return output(cs, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
|
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Local().Format("2006-01-02 15:04"), cs.Username, cs.Via)
|
||||||
if cs.AppliedAt != nil {
|
if cs.AppliedAt != nil {
|
||||||
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
|
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Local().Format("2006-01-02 15:04"), cs.AppliedBy)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w, "\n#\tOPERATION")
|
fmt.Fprintln(w, "\n#\tOPERATION")
|
||||||
for _, op := range cs.Ops {
|
for _, op := range cs.Ops {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintPTRFills(t *testing.T) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
printPTRFills(&b, []api.PTRFill{{Address: "10.0.10.20", Target: "nextcloud.adbraun.lol.", From: "internal"}}, 3)
|
||||||
|
out := b.String()
|
||||||
|
if !strings.Contains(out, "10.0.10.20 -> nextcloud.adbraun.lol (view internal)") || !strings.Contains(out, "and 2 more") {
|
||||||
|
t.Errorf("listed fills:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An older server sends the count without the list.
|
||||||
|
b.Reset()
|
||||||
|
printPTRFills(&b, nil, 1)
|
||||||
|
if b.Len() != 0 {
|
||||||
|
t.Errorf("no list from the server printed %q, want nothing", b.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
-7
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -100,6 +101,16 @@ func zoneCmd() *cobra.Command {
|
|||||||
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
|
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
|
||||||
if z.PTRMissing > 0 {
|
if z.PTRMissing > 0 {
|
||||||
fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0])
|
fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0])
|
||||||
|
printPTRFills(w, z.PTRMissingList, z.PTRMissing)
|
||||||
|
}
|
||||||
|
if z.PTROtherViews > 0 {
|
||||||
|
what := "addresses"
|
||||||
|
if z.PTROtherViews == 1 {
|
||||||
|
what = "address"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n",
|
||||||
|
z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0])
|
||||||
|
printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(z.LinkedWith) > 0 {
|
if len(z.LinkedWith) > 0 {
|
||||||
@@ -119,7 +130,7 @@ func zoneCmd() *cobra.Command {
|
|||||||
var ns, records, primaries, forwarders, opts []string
|
var ns, records, primaries, forwarders, opts []string
|
||||||
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
||||||
var ttl uint32
|
var ttl uint32
|
||||||
var fill bool
|
var fill, fillOther bool
|
||||||
create := &cobra.Command{
|
create := &cobra.Command{
|
||||||
Use: "create NAME",
|
Use: "create NAME",
|
||||||
Short: "Create a zone",
|
Short: "Create a zone",
|
||||||
@@ -128,7 +139,8 @@ create its reverse zone: a /32 (or /128) gives a zone for that one address,
|
|||||||
and a network between octet boundaries, like a /22, gets one zone per /24
|
and a network between octet boundaries, like a /22, gets one zone per /24
|
||||||
(created together). Without --view, a reverse zone goes to the private
|
(created together). Without --view, a reverse zone goes to the private
|
||||||
view whose records use its addresses; --fill adds a PTR record for each
|
view whose records use its addresses; --fill adds a PTR record for each
|
||||||
of them (docs/reverse.md).
|
of them, and --other-views also for the addresses only other views'
|
||||||
|
records use, named from there (docs/reverse.md).
|
||||||
|
|
||||||
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
|
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
|
||||||
jarl zone create 192.168.1.0/24
|
jarl zone create 192.168.1.0/24
|
||||||
@@ -169,9 +181,12 @@ of them (docs/reverse.md).
|
|||||||
if linkWith != "" {
|
if linkWith != "" {
|
||||||
body["link_with"] = linkWith
|
body["link_with"] = linkWith
|
||||||
}
|
}
|
||||||
if fill {
|
if fill || fillOther {
|
||||||
body["fill"] = true
|
body["fill"] = true
|
||||||
}
|
}
|
||||||
|
if fillOther {
|
||||||
|
body["fill_other_views"] = true
|
||||||
|
}
|
||||||
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)
|
||||||
@@ -245,6 +260,7 @@ of them (docs/reverse.md).
|
|||||||
create.Flags().StringVar(&linkWith, "link-with", "", "link with the zone of the same name in this view: copy its shared records and keep them in step (docs/views.md#linked-zones)")
|
create.Flags().StringVar(&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")
|
||||||
create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network")
|
create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network")
|
||||||
|
create.Flags().BoolVar(&fillOther, "other-views", false, "reverse zones: --fill, also naming the addresses only other views' records use")
|
||||||
|
|
||||||
var refresh, retry, expire, minimum, serial uint32
|
var refresh, retry, expire, minimum, serial uint32
|
||||||
var mname, policy, moveView string
|
var mname, policy, moveView string
|
||||||
@@ -1166,17 +1182,61 @@ func terminalWidth() int {
|
|||||||
return width
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printPTRFills lists PTR records a reverse zone lacks under zone show's
|
||||||
|
// line about them: address -> name, and the view the name is from. A server
|
||||||
|
// older than the CLI may send the count without the list; then nothing is
|
||||||
|
// listed, rather than "and N more" under no addresses.
|
||||||
|
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
|
||||||
|
if len(fills) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range fills {
|
||||||
|
from := ""
|
||||||
|
if f.From != "" {
|
||||||
|
from = " (view " + f.From + ")"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from)
|
||||||
|
}
|
||||||
|
if total > len(fills) {
|
||||||
|
fmt.Fprintf(w, "\t and %d more\n", total-len(fills))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromViews names the views PTR fills take their names from: "view
|
||||||
|
// internal", "views internal and lab".
|
||||||
|
func fromViews(fills []api.PTRFill) string {
|
||||||
|
var vs []string
|
||||||
|
for _, f := range fills {
|
||||||
|
if f.From != "" && !slices.Contains(vs, f.From) {
|
||||||
|
vs = append(vs, f.From)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch len(vs) {
|
||||||
|
case 0:
|
||||||
|
return "other views"
|
||||||
|
case 1:
|
||||||
|
return "view " + vs[0]
|
||||||
|
}
|
||||||
|
return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1]
|
||||||
|
}
|
||||||
|
|
||||||
func zoneFillPTRCmd() *cobra.Command {
|
func zoneFillPTRCmd() *cobra.Command {
|
||||||
var view string
|
var view string
|
||||||
|
var otherViews bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "fill-ptr ZONE [--view VIEW]",
|
Use: "fill-ptr ZONE [--view VIEW] [--other-views]",
|
||||||
Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records",
|
Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records",
|
||||||
Long: `Adds a PTR record to a reverse zone for every address in its network that
|
Long: `Adds a PTR record to a reverse zone for every address in its network that
|
||||||
the view's A and AAAA records use and that has none yet. An address with
|
the view's A and AAAA records use and that has none yet. An address with
|
||||||
several names gets the mail or name server's name, else a host's rather
|
several names gets the mail or name server's name, else a host's rather
|
||||||
than the domain's, else the shortest (docs/reverse.md).`,
|
than the domain's, else the shortest (docs/reverse.md).
|
||||||
|
|
||||||
|
--other-views also adds the addresses in the network that only other
|
||||||
|
views' records use, naming them from there. That publishes those views'
|
||||||
|
host names in this one, so it is never done unasked.`,
|
||||||
Example: ` jarl zone fill-ptr 10.0.10.0/24
|
Example: ` jarl zone fill-ptr 10.0.10.0/24
|
||||||
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal`,
|
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal
|
||||||
|
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views`,
|
||||||
Args: cobra.ExactArgs(1),
|
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()
|
||||||
@@ -1184,6 +1244,9 @@ than the domain's, else the shortest (docs/reverse.md).`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
path, qv := zonePath(args[0], view)
|
path, qv := zonePath(args[0], view)
|
||||||
|
if otherViews {
|
||||||
|
qv.Set("other_views", "1")
|
||||||
|
}
|
||||||
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
|
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1192,6 +1255,7 @@ than the domain's, else the shortest (docs/reverse.md).`,
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||||
|
cmd.Flags().BoolVar(&otherViews, "other-views", false, "also name the addresses only other views' records use")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,7 +1295,9 @@ again). A view can stop suggestions: jarl view update V
|
|||||||
--all lists every network the views' records use without a reverse zone,
|
--all lists every network the views' records use without a reverse zone,
|
||||||
also where nothing is suggested unasked (a view with suggestions off, a
|
also where nothing is suggested unasked (a view with suggestions off, a
|
||||||
dismissed network, a public view), to create one by hand; --view narrows
|
dismissed network, a public view), to create one by hand; --view narrows
|
||||||
the list to one view. Change the size as you need: a /48 suggested for
|
the list to one view and adds the networks only other views' records use
|
||||||
|
(hidden "records in VIEW"): create one with --fill --other-views to name
|
||||||
|
them from there. Change the size as you need: a /48 suggested for
|
||||||
IPv6 can be created as a /56 or /64 instead.`,
|
IPv6 can be created as a /56 or /64 instead.`,
|
||||||
Example: ` jarl zone suggest-reverse
|
Example: ` jarl zone suggest-reverse
|
||||||
jarl zone suggest-reverse --create
|
jarl zone suggest-reverse --create
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
#
|
#
|
||||||
# Install it, then:
|
# Install it, then:
|
||||||
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \
|
# EXEC_PATH=/usr/local/bin/jarl-lego JARL_TOKEN=@/etc/jarl/lego.token \
|
||||||
# lego --email you@example.com --dns exec -d example.com -d '*.example.com' run
|
# lego run --accept-tos --email you@example.com --dns exec -d example.com -d '*.example.com'
|
||||||
|
# (lego 4: lego --accept-tos --email ... --dns exec -d ... run, and "renew" to renew)
|
||||||
#
|
#
|
||||||
# Options (environment):
|
# Options (environment):
|
||||||
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured)
|
# JARL_SERVER Jarl server URL, e.g. https://ns1.example.com:8053 (optional if the jarl CLI is configured)
|
||||||
# JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured)
|
# JARL_TOKEN API token, or @FILE (optional if the jarl CLI is configured)
|
||||||
# JARL_VIEW Only write into this view (optional; default every view that has the zone)
|
# JARL_VIEW Only write into this view (optional; default every view that has the zone)
|
||||||
|
# On a host inside a split DNS, lego's own propagation check then can't
|
||||||
|
# see the record: leave it unset, or run lego with
|
||||||
|
# --dns.propagation.disable-ans (lego 5; --dns.propagation-disable-ans in lego 4)
|
||||||
|
# (docs/acme.md, "Split DNS and lego's propagation check").
|
||||||
# JARL_BIN Path of the jarl binary (optional; default "jarl")
|
# JARL_BIN Path of the jarl binary (optional; default "jarl")
|
||||||
|
|
||||||
JARL_BIN="${JARL_BIN:-jarl}"
|
JARL_BIN="${JARL_BIN:-jarl}"
|
||||||
|
|||||||
+87
-5
@@ -98,10 +98,23 @@ sudo install -m 0755 contrib/lego/jarl-lego.sh /usr/local/bin/jarl-lego # or f
|
|||||||
export EXEC_PATH=/usr/local/bin/jarl-lego
|
export EXEC_PATH=/usr/local/bin/jarl-lego
|
||||||
export JARL_SERVER=https://ns1.adbraun.lol:8053
|
export JARL_SERVER=https://ns1.adbraun.lol:8053
|
||||||
export JARL_TOKEN=@/etc/jarl/certbot.token
|
export JARL_TOKEN=@/etc/jarl/certbot.token
|
||||||
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run
|
lego run --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol'
|
||||||
```
|
```
|
||||||
|
|
||||||
Renew with the same variables and `lego ... renew` in place of `run`.
|
That is lego 5. `lego run` also renews: run the same command daily (a
|
||||||
|
cron job or systemd timer), and it renews once a third of the
|
||||||
|
certificate's lifetime is left. **lego 4** takes its options before the
|
||||||
|
command, and renews with `renew`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
lego --accept-tos --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' run # first time
|
||||||
|
lego --email you@adbraun.lol --dns exec -d adbraun.lol -d '*.adbraun.lol' renew # then daily
|
||||||
|
```
|
||||||
|
|
||||||
|
Upgrading from lego 4 to 5, run `lego migrate` once (with `--path` if you
|
||||||
|
keep lego's data somewhere other than `./.lego`) before anything else:
|
||||||
|
lego 5 stores accounts and certificates differently. The exec and httpreq
|
||||||
|
providers, and so Jarl's side, are the same in both.
|
||||||
|
|
||||||
* lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`,
|
* lego runs `jarl-lego present FQDN VALUE` and `jarl-lego cleanup FQDN VALUE`,
|
||||||
and the script passes them to `jarl acme set|unset --exact`. FQDN is the
|
and the script passes them to `jarl acme set|unset --exact`. FQDN is the
|
||||||
@@ -114,7 +127,66 @@ Renew with the same variables and `lego ... renew` in place of `run`.
|
|||||||
* Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`.
|
* Leave `EXEC_MODE` unset: the script needs lego's default mode, not `RAW`.
|
||||||
* lego waits until the record is visible on the zone's name servers
|
* lego waits until the record is visible on the zone's name servers
|
||||||
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only
|
(`EXEC_PROPAGATION_TIMEOUT`, 60 seconds by default). Jarl answers only
|
||||||
once named serves the record, so this check passes on its first try.
|
once named serves the record, so this check passes on its first try,
|
||||||
|
as long as the view that answers lego's host has the record: with
|
||||||
|
`JARL_VIEW` on a host inside your network it doesn't. See
|
||||||
|
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
|
||||||
|
|
||||||
|
### Split DNS and lego's propagation check
|
||||||
|
|
||||||
|
Before lego asks the CA to look, it checks the record itself, asking the
|
||||||
|
zone's name servers (`ns1.adbraun.lol`) directly. With views, they answer
|
||||||
|
lego's host from the view that matches *it*. On a host inside your
|
||||||
|
network that is the internal view. If the challenge was written into
|
||||||
|
another view only (`JARL_VIEW=external`, `--view external`, a token
|
||||||
|
limited to `views=external`, or the httpreq endpoint
|
||||||
|
`.../httpreq/view/external`), lego never sees it, and gives up after
|
||||||
|
its propagation timeout without the CA having looked:
|
||||||
|
|
||||||
|
```
|
||||||
|
propagation: time limit exceeded: last error: authoritative nameservers:
|
||||||
|
NS ns1.adbraun.lol.:53 returned NXDOMAIN for _acme-challenge.stats.adbraun.lol.
|
||||||
|
```
|
||||||
|
|
||||||
|
A name whose authorization is still valid from an earlier run ("authorization
|
||||||
|
already valid; skipping challenge") isn't checked at all, so one name can
|
||||||
|
work and the next fail with the same settings.
|
||||||
|
|
||||||
|
The ways round it, best first:
|
||||||
|
|
||||||
|
1. **Don't limit the view.** Leave `JARL_VIEW` unset (and give the token
|
||||||
|
no `views=` filter, the httpreq endpoint no `/view/...`): the record
|
||||||
|
goes into every view that has the zone, the internal one too, and
|
||||||
|
lego's check passes at once. The challenge is a random value that is
|
||||||
|
useless once the CA has checked it, and it is removed straight after.
|
||||||
|
2. **Skip lego's check of the name servers.** The record is already
|
||||||
|
served when Jarl answers, so lego's check adds nothing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
lego run --dns exec --dns.propagation.disable-ans ... # lego 5
|
||||||
|
lego --dns exec --dns.propagation-disable-ans ... run # lego 4.20 to 4.x
|
||||||
|
lego --dns exec --dns.disable-cp ... run # older lego 4
|
||||||
|
```
|
||||||
|
|
||||||
|
or replace the checks with a fixed wait, which also gives secondary
|
||||||
|
name servers time to catch up: `--dns.propagation.wait 30s` (lego 5;
|
||||||
|
`--dns.propagation-wait 30s` in lego 4.20 and later). In lego 5 these
|
||||||
|
can also be set in the environment: `LEGO_DNS_PROPAGATION_DISABLE_ANS=true`,
|
||||||
|
`LEGO_DNS_PROPAGATION_WAIT=30s`.
|
||||||
|
3. **In Traefik**, the same under `dnsChallenge` (Traefik 3.2 and later;
|
||||||
|
older versions have `disablePropagationCheck: true`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dnsChallenge:
|
||||||
|
provider: httpreq
|
||||||
|
propagation:
|
||||||
|
disableANSChecks: true # or delayBeforeChecks: 30s
|
||||||
|
```
|
||||||
|
4. **Run lego where the external view answers it**, such as a host outside
|
||||||
|
your network. Rarely the easy option.
|
||||||
|
|
||||||
|
certbot (section 3) doesn't check by itself, and acme.sh (section 4)
|
||||||
|
checks through public DNS-over-HTTPS resolvers, so neither runs into this.
|
||||||
|
|
||||||
## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI
|
## 6. lego and Traefik over HTTP (httpreq), without the jarl CLI
|
||||||
|
|
||||||
@@ -299,14 +371,20 @@ of the `acme` role.
|
|||||||
export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
|
export HTTPREQ_ENDPOINT=https://ns1.adbraun.lol:8053/api/v1/acme/httpreq
|
||||||
export HTTPREQ_USERNAME=lego
|
export HTTPREQ_USERNAME=lego
|
||||||
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_...
|
export HTTPREQ_PASSWORD_FILE=/etc/jarl/lego.token # or HTTPREQ_PASSWORD=jarl_...
|
||||||
lego --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol' run
|
lego run --accept-tos --email you@adbraun.lol --dns httpreq -d adbraun.lol -d '*.adbraun.lol'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That is lego 5; in lego 4 the options go before the command (`lego
|
||||||
|
--email ... --dns httpreq -d ... run`, and `renew` to renew). See
|
||||||
|
[section 5](#5-lego).
|
||||||
|
|
||||||
Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which
|
Leave `HTTPREQ_MODE` unset (its `RAW` mode sends a different body, which
|
||||||
jarld refuses with a message saying so). If jarld's certificate doesn't come from a public
|
jarld refuses with a message saying so). If jarld's certificate doesn't come from a public
|
||||||
CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's
|
CA, lego has to trust yours: `SSL_CERT_FILE=/path/to/your-ca.pem` (lego's
|
||||||
`LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put
|
`LEGO_CA_CERTIFICATES` covers only the ACME server). To write into one view only, put
|
||||||
it in the endpoint: `.../api/v1/acme/httpreq/view/external`.
|
it in the endpoint: `.../api/v1/acme/httpreq/view/external`; on a host inside
|
||||||
|
your network, read [Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check)
|
||||||
|
first.
|
||||||
|
|
||||||
### 4. Traefik
|
### 4. Traefik
|
||||||
|
|
||||||
@@ -412,4 +490,8 @@ the main zone is never modified by automation.
|
|||||||
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
|
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
|
||||||
sure the record is in the view that matches the CA (it is by default)
|
sure the record is in the view that matches the CA (it is by default)
|
||||||
and that `allow-query` lets the internet query the zone.
|
and that `allow-query` lets the internet query the zone.
|
||||||
|
* lego says `propagation: time limit exceeded ... returned NXDOMAIN for
|
||||||
|
_acme-challenge...`: the record is in a view that doesn't answer lego's
|
||||||
|
host, usually `JARL_VIEW=external` on a host inside your network. See
|
||||||
|
[Split DNS and lego's propagation check](#split-dns-and-legos-propagation-check).
|
||||||
* `jarl audit --user certbot` shows every attempt.
|
* `jarl audit --user certbot` shows every attempt.
|
||||||
+10
-5
@@ -191,6 +191,7 @@ ZoneInput:
|
|||||||
{
|
{
|
||||||
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
|
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
|
||||||
"fill": true, // reverse zones: PTR records for the view's A/AAAA records there
|
"fill": true, // reverse zones: PTR records for the view's A/AAAA records there
|
||||||
|
"fill_other_views": true, // with fill: also for addresses only other views' records use
|
||||||
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
|
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
|
||||||
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
|
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
|
||||||
"in_view": "internal", // type in-view only
|
"in_view": "internal", // type in-view only
|
||||||
@@ -212,8 +213,12 @@ ZoneInput:
|
|||||||
|
|
||||||
A reverse zone created without a view goes to the private view whose
|
A reverse zone created without a view goes to the private view whose
|
||||||
records use its addresses. Zones come back with `prefix` (reverse zones:
|
records use its addresses. Zones come back with `prefix` (reverse zones:
|
||||||
the network) and `ptr_missing` (addresses in it that the view's A and AAAA
|
the network), `ptr_missing` (addresses in it that the view's A and AAAA
|
||||||
records use, without a PTR record).
|
records use, without a PTR record) and `ptr_other_views` (addresses in it
|
||||||
|
without a PTR record that only other views' records use; not missing),
|
||||||
|
with the first 25 of each in `ptr_missing_list` and
|
||||||
|
`ptr_other_views_list` (`address`, `name`, `target`, and `from`, the view
|
||||||
|
the name comes from).
|
||||||
|
|
||||||
`/serials` and `/delegation` send DNS queries from the server, and only
|
`/serials` and `/delegation` send DNS queries from the server, and only
|
||||||
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
|
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
|
||||||
@@ -372,7 +377,7 @@ and of any type, in view order (`[{"id": 289, "view": "external", "type":
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| 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}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
|
||||||
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
||||||
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) |
|
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records; `?other_views=1` also for the addresses only other views' records use, named from there ([reverse.md](reverse.md)) |
|
||||||
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
|
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack (`?all=1`: also the ones not suggested unasked, with `hidden`: public view, suggestions off, dismissed; `?view=`: one view): `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
|
||||||
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}` (or `"prefixes": [...]` for several): don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
||||||
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
|
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
|
||||||
@@ -574,12 +579,12 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
|
|
||||||
| Method | Path | Query | Returns |
|
| Method | Path | Query | Returns |
|
||||||
|--------|------|-------|---------|
|
|--------|------|-------|---------|
|
||||||
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
|
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, retention_seconds, warnings}`: `retention` has `minute`, `hour`, `day`, `queries` as `"7d"`, `retention_seconds` the same in seconds (`queries` 0 without a query log) |
|
||||||
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
|
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
|
||||||
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
||||||
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
||||||
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
||||||
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log |
|
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention` (`"7d"`) and `retention_seconds`, `client_name`. 404 `query_log_off` without a query log |
|
||||||
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}], database: {path, bytes, query_log_rows}}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
| GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}], database: {path, bytes, query_log_rows}}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
||||||
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
||||||
|
|
||||||
|
|||||||
@@ -292,6 +292,13 @@ jarl cert saved # what this host has, and when it exp
|
|||||||
`jarl cert get` without a name does every saved certificate. A token
|
`jarl cert get` without a name does every saved certificate. A token
|
||||||
given as `@FILE` is saved as that reference, not copied. `jarl cert
|
given as `@FILE` is saved as that reference, not copied. `jarl cert
|
||||||
forget NAME` stops getting one (`--delete` deletes it on jarld too).
|
forget NAME` stops getting one (`--delete` deletes it on jarld too).
|
||||||
|
* **Several Jarl servers on one host**: each server has its own
|
||||||
|
certificate names, so two servers may both have a `proxy`. The host
|
||||||
|
keeps one `proxy` per settings directory, though: asking another server
|
||||||
|
for a name saved here for the first one is refused, as is a `--dir`
|
||||||
|
holding another certificate's files. Choose another name or `--dir`, or
|
||||||
|
pass `--replace` to take the name (or directory) over. A `--dir` that
|
||||||
|
another saved certificate uses is always refused.
|
||||||
* **The hook** runs with `sh` after new files are written, with
|
* **The hook** runs with `sh` after new files are written, with
|
||||||
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
|
`JARL_CERT_NAME`, `JARL_CERT_DIR`, `JARL_CERT_CERT`, `JARL_CERT_CHAIN`,
|
||||||
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`,
|
`JARL_CERT_FULLCHAIN`, `JARL_CERT_KEY`, `JARL_CERT_DOMAINS`,
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.i
|
|||||||
jarl zone create 10.0.10.100/32 # a zone for just that address
|
jarl zone create 10.0.10.100/32 # a zone for just that address
|
||||||
jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs
|
jarl zone create 10.0.10.0/24 --fill # in the view whose records use it, with their PTRs
|
||||||
jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
|
jarl zone fill-ptr 10.0.10.0/24 # add the PTRs it lacks (zone commands take a network too)
|
||||||
|
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views # also name addresses only other views' records use
|
||||||
jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours)
|
jarl zone suggest-reverse [--create] # reverse zones your private views lack (--create: yours)
|
||||||
jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
|
jarl zone suggest-reverse --dismiss 185.199.108.153/32,185.199.109.153/32 --view internal # not yours: don't suggest them again
|
||||||
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
|
jarl zone suggest-reverse --all [--view V] # every network without a reverse zone, also the hidden ones
|
||||||
@@ -450,6 +451,7 @@ jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey
|
|||||||
jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle
|
jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle
|
||||||
jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook
|
jarl cert get mail -d mail.adbraun.lol --hook 'systemctl reload postfix' # on another host: get, download, hook
|
||||||
jarl cert get # every saved certificate (what the timer runs)
|
jarl cert get # every saved certificate (what the timer runs)
|
||||||
|
jarl cert get proxy -s other --replace # take over a name saved here for another server
|
||||||
jarl cert saved # this host's certificates and their expiry
|
jarl cert saved # this host's certificates and their expiry
|
||||||
jarl cert forget mail [--delete] # stop getting it here
|
jarl cert forget mail [--delete] # stop getting it here
|
||||||
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
|
sudo jarl cert schedule [--remove] # daily systemd timer for jarl cert get
|
||||||
@@ -535,6 +537,7 @@ jarl token list --all --expiring [60d] # expiring within 14 days
|
|||||||
jarl token create laptop --expires 365d
|
jarl token create laptop --expires 365d
|
||||||
jarl token create backup-script --grant "*:read"
|
jarl token create backup-script --grant "*:read"
|
||||||
jarl token create acme --user certbot --role acme
|
jarl token create acme --user certbot --role acme
|
||||||
|
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host (or --role certificates)
|
||||||
jarl token rotate laptop --save # new secret, same token and grants
|
jarl token rotate laptop --save # new secret, same token and grants
|
||||||
jarl token rotate --current --save --grace 10m # the token this CLI uses
|
jarl token rotate --current --save --grace 10m # the token this CLI uses
|
||||||
jarl token revoke 7
|
jarl token revoke 7
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ you can then edit.
|
|||||||
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
|
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read`, `blocking:read` |
|
||||||
| `operator` | `*:write` (everything except user management) |
|
| `operator` | `*:write` (everything except user management) |
|
||||||
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
|
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
|
||||||
|
| `certificates` | `certificates:write`: certificates for other hosts (`jarl cert get`) for any name in Jarl's zones. Narrow it to a host's names, e.g. `certificates:write names=web01.adbraun.lol` ([certificates.md](certificates.md#certificates-for-other-hosts)) |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl user create alice --role dns-editor
|
jarl user create alice --role dns-editor
|
||||||
@@ -210,6 +211,7 @@ The audit log shows the user and the token name.
|
|||||||
jarl token create laptop # full rights of the user, no expiry
|
jarl token create laptop # full rights of the user, no expiry
|
||||||
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
|
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
|
||||||
jarl token create certbot --user certbot --role acme # admin: a token for another user
|
jarl token create certbot --user certbot --role acme # admin: a token for another user
|
||||||
|
jarl token create web01 --grant "certificates:write names=web01.adbraun.lol" # jarl cert get on one host
|
||||||
jarl token list --all
|
jarl token list --all
|
||||||
jarl token revoke 12
|
jarl token revoke 12
|
||||||
```
|
```
|
||||||
|
|||||||
+29
-5
@@ -97,10 +97,19 @@ jarl zone create 2a02:8013:7f3d:10::/64 --view internal --fill
|
|||||||
the dialog's View), the list also has the addresses only other views'
|
the dialog's View), the list also has the addresses only other views'
|
||||||
records use, marked "records in external": say a VPS's AAAA record that
|
records use, marked "records in external": say a VPS's AAAA record that
|
||||||
only the external view has. Fill names those from the other view's
|
only the external view has. Fill names those from the other view's
|
||||||
record, when the zone's own view has none for the address, and says so.
|
record only when asked: `--other-views` (with `--fill` or `jarl zone
|
||||||
Keeping PTR records in step (below) stays within a zone's own view and
|
fill-ptr`), or the dialog's **Also for addresses only other views'
|
||||||
its linked views: a later change to that external record doesn't reach
|
records use**, which is ticked for you when you pick a "records in"
|
||||||
the internal reverse zone.
|
network. Otherwise one view's host names would turn up in another's
|
||||||
|
reverse zone, which is what views are for keeping apart. Jarl says which
|
||||||
|
names came from another view. Keeping PTR records in step (below) stays
|
||||||
|
within a zone's own view and its linked views: a later change to that
|
||||||
|
external record doesn't reach the internal reverse zone.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views
|
||||||
|
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views
|
||||||
|
```
|
||||||
|
|
||||||
## Creating and filling
|
## Creating and filling
|
||||||
|
|
||||||
@@ -135,7 +144,22 @@ Jarl says which name it chose, and which it didn't, so you can change it.
|
|||||||
A reverse zone's page shows the addresses that still lack a PTR record and
|
A reverse zone's page shows the addresses that still lack a PTR record and
|
||||||
the name each would get (the first 25), with a button to add them. `jarl
|
the name each would get (the first 25), with a button to add them. `jarl
|
||||||
zone show` shows how many as `Missing PTRs`; the API lists them in
|
zone show` shows how many as `Missing PTRs`; the API lists them in
|
||||||
`ptr_missing_list`.
|
`ptr_missing_list`. Only the zone's own view's records count: an address
|
||||||
|
that only another view's records use isn't missing (say internal's
|
||||||
|
`claude.adbraun.lol` and the vps view's copy of `10.0.10.in-addr.arpa`).
|
||||||
|
Those are listed apart, with the name each would get: `Other views` in
|
||||||
|
`jarl zone show`, `ptr_other_views` and `ptr_other_views_list` in the
|
||||||
|
API, and a quiet note on the page with an **Add PTR records for them in
|
||||||
|
view vps** button. Filling never adds them unasked. `jarl zone show`
|
||||||
|
lists the missing ones too:
|
||||||
|
|
||||||
|
```
|
||||||
|
Missing PTRs: 1 (addresses the view's records use here; add them: jarl zone fill-ptr 10.0.10.0/24)
|
||||||
|
10.0.10.7 -> printer.adbraun.lol
|
||||||
|
Other views: 2 addresses with A or AAAA records only in view internal, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr 10.0.10.0/24 --other-views)
|
||||||
|
10.0.10.26 -> gomft.adbraun.lol (view internal)
|
||||||
|
10.0.10.101 -> claude.adbraun.lol (view internal)
|
||||||
|
```
|
||||||
|
|
||||||
## Keeping PTR records in step
|
## Keeping PTR records in step
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -263,7 +263,11 @@ database, roughly in proportion to the number of views.
|
|||||||
lists for `retention.hour`, daily ones for `retention.day`. Older data is
|
lists for `retention.hour`, daily ones for `retention.day`. Older data is
|
||||||
deleted every hour.
|
deleted every hour.
|
||||||
- Charts use the finest resolution that covers the range: minutes up to 48
|
- Charts use the finest resolution that covers the range: minutes up to 48
|
||||||
hours, hours up to 90 days, days beyond.
|
hours (and `retention.minute`), hours up to `retention.hour`, days beyond.
|
||||||
|
- The Statistics page offers only the ranges the data is kept for: up to 90
|
||||||
|
days with the defaults, plus a year and 2 years when `retention.day` (or
|
||||||
|
`retention.hour`) is that long. The Explore page offers the ranges up to
|
||||||
|
`retention.queries`.
|
||||||
- Top lists are exact within an hour (up to 200,000 distinct names or
|
- Top lists are exact within an hour (up to 200,000 distinct names or
|
||||||
clients per hour, beyond which the rarest are dropped). Daily lists are
|
clients per hour, beyond which the rarest are dropped). Daily lists are
|
||||||
built from the hourly top lists, so for very long tails they are
|
built from the hourly top lists, so for very long tails they are
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ type Zone struct {
|
|||||||
// PTRMissingList are the first 25 of them: the PTR records adding
|
// PTRMissingList are the first 25 of them: the PTR records adding
|
||||||
// them would create.
|
// them would create.
|
||||||
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"`
|
PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"`
|
||||||
|
// PTROtherViews counts the addresses in it, without a PTR record,
|
||||||
|
// that only other views' records use: not missing, but fill-ptr with
|
||||||
|
// other_views=1 adds them, naming them from there. PTROtherViewsList
|
||||||
|
// are the first 25 (From says which view).
|
||||||
|
PTROtherViews int `json:"ptr_other_views,omitempty"`
|
||||||
|
PTROtherViewsList []PTRFill `json:"ptr_other_views_list,omitempty"`
|
||||||
// Pending is set when listing with a changeset: add, change or delete.
|
// Pending is set when listing with a changeset: add, change or delete.
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,16 @@ func TestRoleTemplatesValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCertificatesRole(t *testing.T) {
|
||||||
|
p := &Principal{User: &model.User{Grants: RoleTemplates["certificates"]}}
|
||||||
|
if !p.Can(Action{Resource: ResCerts, Write: true, Name: "web01.adbraun.lol"}) {
|
||||||
|
t.Error("the certificates role can't get a certificate")
|
||||||
|
}
|
||||||
|
if p.Can(Action{Resource: ResRecords, Write: true, Zone: "adbraun.lol."}) || p.Can(Action{Resource: ResZones, Zone: "adbraun.lol."}) {
|
||||||
|
t.Error("the certificates role reaches beyond certificates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCertificateGrants(t *testing.T) {
|
func TestCertificateGrants(t *testing.T) {
|
||||||
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
|
g, err := ParseGrant("certificates:write names=*.adbraun.lol")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -391,4 +391,7 @@ var RoleTemplates = map[string][]model.Grant{
|
|||||||
},
|
},
|
||||||
"operator": {{Resource: ResAll, Access: Write}},
|
"operator": {{Resource: ResAll, Access: Write}},
|
||||||
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
|
"acme": {{Resource: ResRecords, Access: Write, Names: []string{"_acme-challenge.*"}, Types: []string{"TXT"}}},
|
||||||
|
// certificates for other hosts (jarl cert get): narrow it with names=
|
||||||
|
// to the host's own.
|
||||||
|
"certificates": {{Resource: ResCerts, Access: Write}},
|
||||||
}
|
}
|
||||||
@@ -471,8 +471,8 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fill := in.Fill
|
fill, fillOther := in.Fill, in.FillOtherViews
|
||||||
in.Fill = false
|
in.Fill, in.FillOtherViews = false, false
|
||||||
if fill {
|
if fill {
|
||||||
if zt != model.ZonePrimary {
|
if zt != model.ZonePrimary {
|
||||||
return nil, errf(KindBadRequest, "fill only applies to primary zones")
|
return nil, errf(KindBadRequest, "fill only applies to primary zones")
|
||||||
@@ -484,10 +484,15 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
if in.ViewID != nil {
|
if in.ViewID != nil {
|
||||||
vid = *in.ViewID
|
vid = *in.ViewID
|
||||||
}
|
}
|
||||||
fills := PTRFills(x.st, vid, name, moved)
|
fills := PTRFills(x.st, vid, name, moved, fillOther)
|
||||||
if len(fills) == 0 {
|
if len(fills) == 0 {
|
||||||
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
|
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
|
||||||
}
|
}
|
||||||
|
if !fillOther {
|
||||||
|
if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" {
|
||||||
|
x.warnings = append(x.warnings, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
||||||
for _, f := range fills {
|
for _, f := range fills {
|
||||||
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
|
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
|
||||||
|
|||||||
@@ -163,8 +163,11 @@ type ZoneInput struct {
|
|||||||
// step (docs/reverse.md).
|
// step (docs/reverse.md).
|
||||||
AutoPTR *bool `json:"auto_ptr,omitempty"`
|
AutoPTR *bool `json:"auto_ptr,omitempty"`
|
||||||
// Fill (create only, reverse zones) adds a PTR record for every
|
// Fill (create only, reverse zones) adds a PTR record for every
|
||||||
// address of the view's A and AAAA records in the zone's range.
|
// address of the view's A and AAAA records in the zone's range;
|
||||||
|
// FillOtherViews also for the addresses only other views' records
|
||||||
|
// use, named from there.
|
||||||
Fill bool `json:"fill,omitempty"`
|
Fill bool `json:"fill,omitempty"`
|
||||||
|
FillOtherViews bool `json:"fill_other_views,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
|
||||||
@@ -224,6 +227,13 @@ type LinkInput struct {
|
|||||||
With []string `json:"with,omitempty"`
|
With []string `json:"with,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FillPTRInput is zone.fill_ptr: OtherViews also fills the addresses only
|
||||||
|
// other views' records use, naming them from there.
|
||||||
|
type FillPTRInput struct {
|
||||||
|
ZoneID int64 `json:"zone_id,omitempty"`
|
||||||
|
OtherViews bool `json:"other_views,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// RRSetInput replaces all records of one name and type.
|
// RRSetInput replaces all records of one name and type.
|
||||||
type RRSetInput struct {
|
type RRSetInput struct {
|
||||||
ZoneID int64 `json:"zone_id,omitempty"`
|
ZoneID int64 `json:"zone_id,omitempty"`
|
||||||
|
|||||||
+35
-10
@@ -87,11 +87,12 @@ type PTRFill struct {
|
|||||||
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
||||||
// lacks for the addresses of the view's A and AAAA records: one per
|
// lacks for the addresses of the view's A and AAAA records: one per
|
||||||
// address, naming its mail or name server host when it is one, otherwise
|
// address, naming its mail or name server host when it is one, otherwise
|
||||||
// a host rather than the domain itself, then the shortest name. An
|
// a host rather than the domain itself, then the shortest name. With
|
||||||
// address no record of the view uses is named from another view's
|
// otherViews, an address no record of the view uses is named from another
|
||||||
// records (fillAddresses), marked From. existing are the zone's records
|
// view's records (fillAddresses), marked From; that is only on request,
|
||||||
// (none for a new zone).
|
// since it publishes one view's host names in another. existing are the
|
||||||
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
|
// zone's records (none for a new zone).
|
||||||
|
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record, otherViews bool) []PTRFill {
|
||||||
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -105,7 +106,11 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
|
|||||||
servers := serverNames(st, viewID)
|
servers := serverNames(st, viewID)
|
||||||
byAddr := map[netip.Addr][]addrUse{}
|
byAddr := map[netip.Addr][]addrUse{}
|
||||||
var order []netip.Addr
|
var order []netip.Addr
|
||||||
for _, u := range fillAddresses(st, viewID) {
|
addrs := forwardAddresses(st, viewID)
|
||||||
|
if otherViews {
|
||||||
|
addrs = fillAddresses(st, viewID)
|
||||||
|
}
|
||||||
|
for _, u := range addrs {
|
||||||
if !prefix.Contains(u.addr) {
|
if !prefix.Contains(u.addr) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -195,9 +200,23 @@ func displayAll(names []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// otherViewsHint says how many addresses only other views' records use in
|
||||||
|
// a reverse zone, and how to add them; "" when there are none.
|
||||||
|
func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []*model.Record, own int, how string) string {
|
||||||
|
n := len(PTRFills(st, viewID, zoneName, existing, true)) - own
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
what, has, they := "addresses", "have", "they are"
|
||||||
|
if n == 1 {
|
||||||
|
what, has, they = "address", "has", "it is"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d %s in %s %s A or AAAA records only in other views, so %s not missing here; %s", n, what, dnsname.Display(zoneName), has, they, how)
|
||||||
|
}
|
||||||
|
|
||||||
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
|
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
|
||||||
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
||||||
var in LinkInput
|
var in FillPTRInput
|
||||||
if err := decode(req.Data, &in); err != nil {
|
if err := decode(req.Data, &in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -215,9 +234,15 @@ func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
|||||||
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
|
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
|
||||||
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
|
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
|
||||||
}
|
}
|
||||||
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID))
|
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), in.OtherViews)
|
||||||
if len(fills) == 0 {
|
if len(fills) == 0 {
|
||||||
return nil, errf(KindBadRequest, "nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
|
msg := fmt.Sprintf("nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
|
||||||
|
if !in.OtherViews {
|
||||||
|
if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to add PTR records for them in this view too: --other-views"); h != "" {
|
||||||
|
msg += "; " + h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errf(KindBadRequest, "%s", msg)
|
||||||
}
|
}
|
||||||
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
||||||
ids, err := x.ids(len(fills))
|
ids, err := x.ids(len(fills))
|
||||||
@@ -415,7 +440,7 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
|
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
|
||||||
fills := PTRFills(st, sc.id, name, nil)
|
fills := PTRFills(st, sc.id, name, nil, opt.All && opt.View != "")
|
||||||
s.Fill = len(fills)
|
s.Fill = len(fills)
|
||||||
for i, f := range fills {
|
for i, f := range fills {
|
||||||
if i == 3 {
|
if i == 3 {
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ func TestReverseFillAndSuggest(t *testing.T) {
|
|||||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
|
||||||
st := env.state()
|
st := env.state()
|
||||||
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
|
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
|
||||||
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{}))
|
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{}))
|
||||||
if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
|
if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
|
||||||
t.Errorf("ip6 fill: %q", p)
|
t.Errorf("ip6 fill: %q", p)
|
||||||
}
|
}
|
||||||
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})); !strings.Contains(e.Message, "nothing to add") {
|
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") {
|
||||||
t.Errorf("second fill: %v", e)
|
t.Errorf("second fill: %v", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,15 +288,38 @@ func TestReverseFromOtherView(t *testing.T) {
|
|||||||
t.Errorf("suggested unasked: %+v", s)
|
t.Errorf("suggested unasked: %+v", s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
||||||
|
if strings.HasPrefix(s.Prefix, "2600:") && (s.Fill != 1 || len(s.Samples) != 1) {
|
||||||
|
t.Errorf("candidate counts the other view's address: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filling doesn't take names from other views unasked: it would
|
||||||
|
// publish one view's host names in another. It says how to.
|
||||||
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
||||||
st := env.state()
|
st := env.state()
|
||||||
var zone string
|
var z *model.Zone
|
||||||
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
|
for _, o := range st.ZonesInView(st.ViewByName("internal").ID) {
|
||||||
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
|
if strings.HasSuffix(o.Name, ".ip6.arpa.") {
|
||||||
zone = z.Name
|
z = o
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
if p := env.ptrs("internal", z.Name); len(p) != 0 {
|
||||||
|
t.Errorf("filled from another view unasked: %q", p)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool {
|
||||||
|
return strings.Contains(w, "1 address in") && strings.Contains(w, "--other-views")
|
||||||
|
}) {
|
||||||
|
t.Errorf("warnings %q", res.Warnings)
|
||||||
|
}
|
||||||
|
// Not missing: no record of the zone's own view uses the address.
|
||||||
|
if f := PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false); len(f) != 0 {
|
||||||
|
t.Errorf("missing %+v", f)
|
||||||
|
}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{})); !strings.Contains(e.Message, "nothing to add") || !strings.Contains(e.Message, "--other-views") {
|
||||||
|
t.Errorf("fill-ptr: %v", e)
|
||||||
|
}
|
||||||
|
res = env.submit(env.admin, NewRequest(OpZoneFillPTR, z.ID, FillPTRInput{OtherViews: true}))
|
||||||
|
if p := env.ptrs("internal", z.Name); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
||||||
t.Errorf("filled %q", p)
|
t.Errorf("filled %q", p)
|
||||||
}
|
}
|
||||||
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ func (s *State) SortedBlockRules() []*BlockRule {
|
|||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
a, b := out[i], out[j]
|
a, b := out[i], out[j]
|
||||||
if a.Name != b.Name {
|
if a.Name != b.Name {
|
||||||
return CanonicalLess(a.Name+".", b.Name+".")
|
return NameLess(a.Name+".", b.Name+".")
|
||||||
}
|
}
|
||||||
if a.Exact != b.Exact {
|
if a.Exact != b.Exact {
|
||||||
return !a.Exact
|
return !a.Exact
|
||||||
|
|||||||
+41
-10
@@ -524,14 +524,14 @@ func (s *State) SortedZones() []*Zone {
|
|||||||
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
|
if pi, pj := pos[out[i].ViewID], pos[out[j].ViewID]; pi != pj {
|
||||||
return pi < pj
|
return pi < pj
|
||||||
}
|
}
|
||||||
return CanonicalLess(out[i].Name, out[j].Name)
|
return NameLess(out[i].Name, out[j].Name)
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// SortZones sorts zones by name in DNS canonical order.
|
// SortZones sorts zones by name (see NameLess).
|
||||||
func SortZones(zs []*Zone) {
|
func SortZones(zs []*Zone) {
|
||||||
sort.Slice(zs, func(i, j int) bool { return CanonicalLess(zs[i].Name, zs[j].Name) })
|
sort.Slice(zs, func(i, j int) bool { return NameLess(zs[i].Name, zs[j].Name) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindZone finds a zone by name within a view (viewID 0 for no view).
|
// FindZone finds a zone by name within a view (viewID 0 for no view).
|
||||||
@@ -646,7 +646,7 @@ func typeOrder(t string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SortRecords sorts records in zone-file order: apex first, then names in
|
// SortRecords sorts records in zone-file order: apex first, then names in
|
||||||
// DNS canonical order (so subdomains group together), then by type and
|
// NameLess order (so subdomains group together), then by type and
|
||||||
// data.
|
// data.
|
||||||
func SortRecords(rs []*Record, z *Zone) {
|
func SortRecords(rs []*Record, z *Zone) {
|
||||||
apex := ""
|
apex := ""
|
||||||
@@ -662,7 +662,7 @@ func SortRecords(rs []*Record, z *Zone) {
|
|||||||
if b.Name == apex {
|
if b.Name == apex {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return CanonicalLess(a.Name, b.Name)
|
return NameLess(a.Name, b.Name)
|
||||||
}
|
}
|
||||||
if a.Type != b.Type {
|
if a.Type != b.Type {
|
||||||
return typeOrder(a.Type) < typeOrder(b.Type)
|
return typeOrder(a.Type) < typeOrder(b.Type)
|
||||||
@@ -674,19 +674,50 @@ func SortRecords(rs []*Record, z *Zone) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanonicalLess compares names in DNS canonical order (RFC 4034 section
|
// NameLess compares names label by label from the right, like DNS
|
||||||
// 6.1): by label from the right.
|
// canonical order (RFC 4034 section 6.1), except that two all-digit
|
||||||
func CanonicalLess(a, b string) bool {
|
// labels compare as numbers, so 2.10.0.10.in-addr.arpa comes before
|
||||||
|
// 101.10.0.10.in-addr.arpa. It orders names for people and zone files;
|
||||||
|
// BIND does its own canonical ordering where DNSSEC needs it.
|
||||||
|
func NameLess(a, b string) bool {
|
||||||
la := strings.Split(strings.TrimSuffix(a, "."), ".")
|
la := strings.Split(strings.TrimSuffix(a, "."), ".")
|
||||||
lb := strings.Split(strings.TrimSuffix(b, "."), ".")
|
lb := strings.Split(strings.TrimSuffix(b, "."), ".")
|
||||||
for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
|
for i, j := len(la)-1, len(lb)-1; i >= 0 && j >= 0; i, j = i-1, j-1 {
|
||||||
if la[i] != lb[j] {
|
if c := compareLabel(la[i], lb[j]); c != 0 {
|
||||||
return la[i] < lb[j]
|
return c < 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(la) < len(lb)
|
return len(la) < len(lb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compareLabel(a, b string) int {
|
||||||
|
if a == b {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if allDigits(a) && allDigits(b) {
|
||||||
|
na, nb := strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0")
|
||||||
|
if len(na) != len(nb) {
|
||||||
|
return len(na) - len(nb)
|
||||||
|
}
|
||||||
|
if c := strings.Compare(na, nb); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Compare(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allDigits(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] < '0' || s[i] > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// RRSetKey identifies an RRset.
|
// RRSetKey identifies an RRset.
|
||||||
type RRSetKey struct {
|
type RRSetKey struct {
|
||||||
Name string
|
Name string
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNameLess(t *testing.T) {
|
||||||
|
want := []string{
|
||||||
|
"10.0.10.in-addr.arpa.",
|
||||||
|
"1.10.0.10.in-addr.arpa.",
|
||||||
|
"02.10.0.10.in-addr.arpa.",
|
||||||
|
"2.10.0.10.in-addr.arpa.",
|
||||||
|
"10.10.0.10.in-addr.arpa.",
|
||||||
|
"101.10.0.10.in-addr.arpa.",
|
||||||
|
"x.101.10.0.10.in-addr.arpa.",
|
||||||
|
"254.10.0.10.in-addr.arpa.",
|
||||||
|
"a.10.0.10.in-addr.arpa.",
|
||||||
|
}
|
||||||
|
got := slices.Clone(want)
|
||||||
|
slices.Reverse(got)
|
||||||
|
slices.SortFunc(got, func(a, b string) int {
|
||||||
|
if NameLess(a, b) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if NameLess(b, a) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-numeric labels keep canonical (byte) order.
|
||||||
|
if !NameLess("host10.example.", "host2.example.") {
|
||||||
|
t.Error("host10 should sort before host2")
|
||||||
|
}
|
||||||
|
if !NameLess("example.", "www.example.") {
|
||||||
|
t.Error("parent should sort before child")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,3 +93,29 @@ func TestPTRMissingList(t *testing.T) {
|
|||||||
t.Errorf("ptr_missing %v, list %v", z["ptr_missing"], z["ptr_missing_list"])
|
t.Errorf("ptr_missing %v, list %v", z["ptr_missing"], z["ptr_missing_list"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An address only another view's records use isn't missing from a
|
||||||
|
// reverse zone: it is counted apart, and filled only when asked.
|
||||||
|
func TestPTROtherViews(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"10.0.0.0/8"}}, 201)
|
||||||
|
e.do("POST", "/views", e.token, map[string]any{"name": "vps"}, 201)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "view": "internal", "nameservers": []string{"ns1.example.net."},
|
||||||
|
"records": []map[string]any{{"name": "claude", "type": "A", "data": "10.0.10.101"}}}, 201)
|
||||||
|
for _, v := range []string{"internal", "vps"} {
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "10.0.10.0/24", "view": v, "nameservers": []string{"ns1.example.net."}}, 201)
|
||||||
|
}
|
||||||
|
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=internal", e.token, nil, 200); z["ptr_missing"] != float64(1) || z["ptr_other_views"] != nil {
|
||||||
|
t.Errorf("internal: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
|
||||||
|
}
|
||||||
|
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_missing"] != nil || z["ptr_other_views"] != float64(1) {
|
||||||
|
t.Errorf("vps: ptr_missing %v, ptr_other_views %v", z["ptr_missing"], z["ptr_other_views"])
|
||||||
|
} else if l, _ := z["ptr_other_views_list"].([]any); len(l) != 1 || l[0].(map[string]any)["address"] != "10.0.10.101" || l[0].(map[string]any)["target"] != "claude.adbraun.lol." || l[0].(map[string]any)["from"] != "internal" {
|
||||||
|
t.Errorf("vps: ptr_other_views_list %v", z["ptr_other_views_list"])
|
||||||
|
}
|
||||||
|
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps", e.token, nil, 400)
|
||||||
|
e.do("POST", "/zones/10.0.10.in-addr.arpa/fill-ptr?view=vps&other_views=1", e.token, nil, 201)
|
||||||
|
if z := e.do("GET", "/zones/10.0.10.in-addr.arpa?view=vps", e.token, nil, 200); z["ptr_other_views"] != nil {
|
||||||
|
t.Errorf("vps after filling: ptr_other_views %v", z["ptr_other_views"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -637,7 +637,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
}
|
}
|
||||||
sort.Slice(recs, func(i, j int) bool {
|
sort.Slice(recs, func(i, j int) bool {
|
||||||
if recs[i].Name != recs[j].Name {
|
if recs[i].Name != recs[j].Name {
|
||||||
return model.CanonicalLess(recs[i].Name, recs[j].Name)
|
return model.NameLess(recs[i].Name, recs[j].Name)
|
||||||
}
|
}
|
||||||
return recs[i].ID < recs[j].ID
|
return recs[i].ID < recs[j].ID
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse
|
// zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse
|
||||||
// zone lacks for its view's A and AAAA records (docs/reverse.md).
|
// zone lacks for its view's A and AAAA records; ?other_views=1 also for
|
||||||
|
// the addresses only other views' records use (docs/reverse.md).
|
||||||
func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) zoneFillPTR(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 {
|
||||||
@@ -18,7 +19,7 @@ func (s *Server) zoneFillPTR(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.LinkInput{ZoneID: z.ID})}, nil)
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.FillPTRInput{ZoneID: z.ID, OtherViews: queryBool(r, "other_views")})}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
|
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
|
|||||||
}
|
}
|
||||||
slices.SortFunc(recs, func(a, b *model.Record) int {
|
slices.SortFunc(recs, func(a, b *model.Record) int {
|
||||||
if a.Name != b.Name {
|
if a.Name != b.Name {
|
||||||
if model.CanonicalLess(a.Name, b.Name) {
|
if model.NameLess(a.Name, b.Name) {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -41,11 +41,21 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
|||||||
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
|
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
|
||||||
out.Prefix = pf.String()
|
out.Prefix = pf.String()
|
||||||
if z.Type == model.ZonePrimary {
|
if z.Type == model.ZonePrimary {
|
||||||
fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID))
|
fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), false)
|
||||||
out.PTRMissing = len(fills)
|
out.PTRMissing = len(fills)
|
||||||
for _, f := range fills[:min(len(fills), 25)] {
|
for _, f := range fills[:min(len(fills), 25)] {
|
||||||
out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f))
|
out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f))
|
||||||
}
|
}
|
||||||
|
if z.ViewID != 0 {
|
||||||
|
for _, f := range engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID), true) {
|
||||||
|
if f.From == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out.PTROtherViews++; out.PTROtherViews <= 25 {
|
||||||
|
out.PTROtherViewsList = append(out.PTROtherViewsList, api.PTRFill(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, o := range st.LinkedWith(z) {
|
for _, o := range st.LinkedWith(z) {
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ type Status struct {
|
|||||||
Day string `json:"day"`
|
Day string `json:"day"`
|
||||||
Queries string `json:"queries"` // the query log ("" when off)
|
Queries string `json:"queries"` // the query log ("" when off)
|
||||||
} `json:"retention"`
|
} `json:"retention"`
|
||||||
|
// RetentionSeconds is Retention in seconds, for the web UI's ranges.
|
||||||
|
RetentionSeconds struct {
|
||||||
|
Minute int64 `json:"minute"`
|
||||||
|
Hour int64 `json:"hour"`
|
||||||
|
Day int64 `json:"day"`
|
||||||
|
Queries int64 `json:"queries"` // 0 when the query log is off
|
||||||
|
} `json:"retention_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collector gathers statistics into a Store.
|
// Collector gathers statistics into a Store.
|
||||||
@@ -164,8 +171,12 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
|
|||||||
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
||||||
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
||||||
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
||||||
|
c.status.RetentionSeconds.Minute = int64(opts.RetainMinute / time.Second)
|
||||||
|
c.status.RetentionSeconds.Hour = int64(opts.RetainHour / time.Second)
|
||||||
|
c.status.RetentionSeconds.Day = int64(opts.RetainDay / time.Second)
|
||||||
if opts.QueryLog && opts.RetainQueries > 0 {
|
if opts.QueryLog && opts.RetainQueries > 0 {
|
||||||
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
|
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
|
||||||
|
c.status.RetentionSeconds.Queries = int64(opts.RetainQueries / time.Second)
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
||||||
// last days (stats.query_retention), filtered by any mix of client, name,
|
// last days (stats.retention.queries), filtered by any mix of client, name,
|
||||||
// view, type, result and blocking, broken down by each of them.
|
// view, type, result and blocking, broken down by each of them.
|
||||||
|
|
||||||
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
||||||
@@ -47,8 +47,9 @@ type Exploration struct {
|
|||||||
Recent []QueryRow `json:"recent"`
|
Recent []QueryRow `json:"recent"`
|
||||||
// Oldest is the first answer the query log still has.
|
// Oldest is the first answer the query log still has.
|
||||||
Oldest *time.Time `json:"oldest,omitempty"`
|
Oldest *time.Time `json:"oldest,omitempty"`
|
||||||
// Retention is how long it keeps them.
|
// Retention is how long it keeps them, also in seconds.
|
||||||
Retention string `json:"retention"`
|
Retention string `json:"retention"`
|
||||||
|
RetentionSeconds int64 `json:"retention_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExplorePoint is one step of the series.
|
// ExplorePoint is one step of the series.
|
||||||
@@ -59,7 +60,7 @@ type ExplorePoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ErrNoQueryLog is returned when the query log is off.
|
// ErrNoQueryLog is returned when the query log is off.
|
||||||
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.query_retention above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.retention.queries above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
||||||
|
|
||||||
// values splits a filter field into the values it must be one of and the
|
// values splits a filter field into the values it must be one of and the
|
||||||
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
|
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
|
||||||
@@ -178,7 +179,7 @@ func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int
|
|||||||
}
|
}
|
||||||
from := now.Add(-since).Unix()
|
from := now.Add(-since).Unix()
|
||||||
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
|
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
|
||||||
Retention: humanDuration(q.Opts.RetainQueries)}
|
Retention: humanDuration(q.Opts.RetainQueries), RetentionSeconds: int64(q.Opts.RetainQueries / time.Second)}
|
||||||
cond, args := q.where(f, from)
|
cond, args := q.where(f, from)
|
||||||
db := q.Store.db
|
db := q.Store.db
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ var schema = []string{
|
|||||||
PRIMARY KEY (res, ts, kind, key)
|
PRIMARY KEY (res, ts, kind, key)
|
||||||
) WITHOUT ROWID;
|
) WITHOUT ROWID;
|
||||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
||||||
// 2: the query log, every answer for a while (stats.query_retention),
|
// 2: the query log, every answer for a while (stats.retention.queries),
|
||||||
// for drilling down: what a client asked, who asked for a name
|
// for drilling down: what a client asked, who asked for a name
|
||||||
// (docs/stats.md#exploring).
|
// (docs/stats.md#exploring).
|
||||||
`CREATE TABLE queries (
|
`CREATE TABLE queries (
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ func (c *checker) checkZoneRecords(z *model.Zone) {
|
|||||||
for n := range ix.types {
|
for n := range ix.types {
|
||||||
names = append(names, n)
|
names = append(names, n)
|
||||||
}
|
}
|
||||||
sort.Slice(names, func(i, j int) bool { return model.CanonicalLess(names[i], names[j]) })
|
sort.Slice(names, func(i, j int) bool { return model.NameLess(names[i], names[j]) })
|
||||||
|
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
types := ix.types[name]
|
types := ix.types[name]
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ func sortProblems(ps []Problem) {
|
|||||||
return a.Zone < b.Zone
|
return a.Zone < b.Zone
|
||||||
}
|
}
|
||||||
if a.Name != b.Name {
|
if a.Name != b.Name {
|
||||||
return model.CanonicalLess(a.Name, b.Name)
|
return model.NameLess(a.Name, b.Name)
|
||||||
}
|
}
|
||||||
return a.Message < b.Message
|
return a.Message < b.Message
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const filterWords = { client: "client", name: "name", domain: "domain", view: "v
|
|||||||
const keys = Object.keys(filterWords);
|
const keys = Object.keys(filterWords);
|
||||||
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
|
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
|
||||||
|
|
||||||
|
// exploreRange is the longest range here of at most secs ("1h" at least).
|
||||||
|
export function exploreRange(secs) {
|
||||||
|
let out = "1h";
|
||||||
|
for (const [k, s] of ranges) if (s <= secs) out = k;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// exploreHref is the explore page for these filters (and range).
|
// exploreHref is the explore page for these filters (and range).
|
||||||
export function exploreHref(filters, since) {
|
export function exploreHref(filters, since) {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -74,7 +81,7 @@ export async function render(el, params, query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
|
const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
|
||||||
const maxSecs = retentionSecs(ex.retention);
|
const maxSecs = ex.retention_seconds || 7 * 86400;
|
||||||
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||||
h("span", { class: "muted" }, "Range"),
|
h("span", { class: "muted" }, "Range"),
|
||||||
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
|
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
|
||||||
@@ -234,9 +241,3 @@ function stepWord(s) {
|
|||||||
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
|
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
|
||||||
return s === 60 ? "minute" : s / 60 + " minutes";
|
return s === 60 ? "minute" : s / 60 + " minutes";
|
||||||
}
|
}
|
||||||
|
|
||||||
function retentionSecs(r) {
|
|
||||||
const m = /^(\d+)([dhm])$/.exec(r || "");
|
|
||||||
if (!m) return 7 * 86400;
|
|
||||||
return Number(m[1]) * { d: 86400, h: 3600, m: 60 }[m[2]];
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,27 @@ import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
|||||||
import { get } from "../api.js";
|
import { get } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
||||||
import { exploreHref } from "./explore.js";
|
import { exploreHref, exploreRange } from "./explore.js";
|
||||||
|
|
||||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
// Ranges: the since value, its words and its button. Only the ones the
|
||||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
// statistics are kept for are offered (keptSecs).
|
||||||
|
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"],
|
||||||
|
["365d", "year", "1y"], ["730d", "2 years", "2y"]];
|
||||||
|
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400, "730d": 730 * 86400 };
|
||||||
|
|
||||||
|
// keptSecs is how far back the statistics go: the longest of the minute,
|
||||||
|
// hour and day retentions (a server older than retention_seconds: 2 years).
|
||||||
|
function keptSecs(status) {
|
||||||
|
const r = status.retention_seconds;
|
||||||
|
return r ? Math.max(r.minute, r.hour, r.day) : 730 * 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// longestWithin is the longest range of at most secs ("1h" at least).
|
||||||
|
function longestWithin(secs) {
|
||||||
|
let out = "1h";
|
||||||
|
for (const [k] of ranges) if (rangeSecs[k] <= secs) out = k;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Failed answers keep the same color whatever the range: color follows
|
// Failed answers keep the same color whatever the range: color follows
|
||||||
// the result code, never its rank.
|
// the result code, never its rank.
|
||||||
@@ -36,8 +53,9 @@ const perViewGroups = ["qtype", "rcode", "view", "zone", "blocklist"];
|
|||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const gen = ++generation;
|
const gen = ++generation;
|
||||||
const want = query && query.get ? query.get("since") : null;
|
const want = query && query.get ? query.get("since") : null;
|
||||||
const since = rangeSecs[want] ? want : "24h";
|
|
||||||
const status = await get("/stats/status");
|
const status = await get("/stats/status");
|
||||||
|
const kept = keptSecs(status);
|
||||||
|
const since = rangeSecs[want] && rangeSecs[want] <= kept ? want : longestWithin(Math.min(86400, kept));
|
||||||
const head = h("div", { class: "page-head" },
|
const head = h("div", { class: "page-head" },
|
||||||
h("div", { class: "grow" }, h("h1", null, "Statistics"),
|
h("div", { class: "grow" }, h("h1", null, "Statistics"),
|
||||||
h("div", { class: "muted" }, status.enabled
|
h("div", { class: "muted" }, status.enabled
|
||||||
@@ -50,7 +68,10 @@ export async function render(el, params, query) {
|
|||||||
mount(el, head, disabledPanel());
|
mount(el, head, disabledPanel());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since;
|
// The heatmap needs hourly counters: at least a week of them, at most
|
||||||
|
// what is kept.
|
||||||
|
const hourSecs = status.retention_seconds ? status.retention_seconds.hour : 90 * 86400;
|
||||||
|
const heatSince = longestWithin(Math.min(Math.max(rangeSecs[since], 7 * 86400), hourSecs));
|
||||||
// Which views: all of them unless some are switched off.
|
// Which views: all of them unless some are switched off.
|
||||||
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
|
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
|
||||||
const off = viewsOff();
|
const off = viewsOff();
|
||||||
@@ -58,14 +79,14 @@ export async function render(el, params, query) {
|
|||||||
const filtered = allViews.length > 0 && shown.length < allViews.length;
|
const filtered = allViews.length > 0 && shown.length < allViews.length;
|
||||||
const vf = filtered ? { views: shown.join(",") } : {};
|
const vf = filtered ? { views: shown.join(",") } : {};
|
||||||
if (filtered && !shown.length) {
|
if (filtered && !shown.length) {
|
||||||
mount(el, head, filterRow(since, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
|
mount(el, head, filterRow(since, kept, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
|
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mount(el, head, filterRow(since, allViews, off), errorBox(e));
|
mount(el, head, filterRow(since, kept, allViews, off), errorBox(e));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (gen !== generation) return; // the user moved on
|
if (gen !== generation) return; // the user moved on
|
||||||
@@ -74,7 +95,7 @@ export async function render(el, params, query) {
|
|||||||
// sudden drop in traffic.
|
// sudden drop in traffic.
|
||||||
const timeline = complete(data.timeline), errors = complete(data.errors);
|
const timeline = complete(data.timeline), errors = complete(data.errors);
|
||||||
const step = stepSeconds[timeline.step];
|
const step = stepSeconds[timeline.step];
|
||||||
const parts = [head, filterRow(since, allViews, off)];
|
const parts = [head, filterRow(since, kept, allViews, off)];
|
||||||
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
|
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
|
||||||
if (filtered) {
|
if (filtered) {
|
||||||
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
|
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
|
||||||
@@ -120,7 +141,7 @@ export async function render(el, params, query) {
|
|||||||
// Who asks for what. With the query log, every value leads to the
|
// Who asks for what. With the query log, every value leads to the
|
||||||
// explore page: a client to what it asked, a name to who asked.
|
// explore page: a client to what it asked, a name to who asked.
|
||||||
const ql = !!(status.retention || {}).queries;
|
const ql = !!(status.retention || {}).queries;
|
||||||
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since)) : null);
|
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since, status)) : null);
|
||||||
if (status.query_log) {
|
if (status.query_log) {
|
||||||
const t = data.tops;
|
const t = data.tops;
|
||||||
parts.push(h("div", { class: "chart-grid" },
|
parts.push(h("div", { class: "chart-grid" },
|
||||||
@@ -201,11 +222,11 @@ function complete(tl) {
|
|||||||
return tl;
|
return tl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRow(since, views, off) {
|
function filterRow(since, kept, views, off) {
|
||||||
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||||
h("span", { class: "muted" }, "Range"),
|
h("span", { class: "muted" }, "Range"),
|
||||||
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
|
h("div", { class: "segmented" }, ranges.filter(([k]) => rangeSecs[k] <= kept).map(([k, label, button]) =>
|
||||||
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, button || k))));
|
||||||
if (!views || views.length < 2) return range;
|
if (!views || views.length < 2) return range;
|
||||||
// A toggle per view (together: linked views, say), and all or none.
|
// A toggle per view (together: linked views, say), and all or none.
|
||||||
const set = (next) => { saveViewsOff(next); rerender(); };
|
const set = (next) => { saveViewsOff(next); rerender(); };
|
||||||
@@ -312,10 +333,11 @@ function disabledPanel() {
|
|||||||
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
||||||
}
|
}
|
||||||
|
|
||||||
// exploreSince is the Statistics range for the explore page: the query log
|
// exploreSince is the Statistics range for the explore page: at most what
|
||||||
// keeps days, not months.
|
// the query log keeps (days, usually, not months).
|
||||||
function exploreSince(since) {
|
function exploreSince(since, status) {
|
||||||
return rangeSecs[since] > 7 * 86400 ? "7d" : since;
|
const q = status.retention_seconds ? status.retention_seconds.queries : 7 * 86400;
|
||||||
|
return exploreRange(Math.min(rangeSecs[since], q));
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbTile is the statistics database's size: what the statistics cost on
|
// dbTile is the statistics database's size: what the statistics cost on
|
||||||
|
|||||||
@@ -195,7 +195,8 @@ async function rotateDialog(t, redraw) {
|
|||||||
function coveredBy(user, grant) {
|
function coveredBy(user, grant) {
|
||||||
if (!user || user.admin) return true;
|
if (!user || user.admin) return true;
|
||||||
const [res, acc] = grant.split(/\s+/)[0].split(":");
|
const [res, acc] = grant.split(/\s+/)[0].split(":");
|
||||||
return (user.grants || []).some((g) => (g.resource === "*" || g.resource === res) && (g.access === "write" || acc === "read"));
|
// "*" never covers certificates (they hand out private keys).
|
||||||
|
return (user.grants || []).some((g) => ((g.resource === "*" && res !== "certificates") || g.resource === res) && (g.access === "write" || acc === "read"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTokenForm makes a token for owner (the user it belongs to). Its
|
// newTokenForm makes a token for owner (the user it belongs to). Its
|
||||||
|
|||||||
@@ -72,7 +72,21 @@ export async function render(el, params, query) {
|
|||||||
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
|
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
|
||||||
f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null,
|
f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null,
|
||||||
f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)),
|
f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)),
|
||||||
zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null)) : null;
|
zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null))
|
||||||
|
: null;
|
||||||
|
// Addresses only other views have records for: not missing here, but
|
||||||
|
// they can be added (publishing those views' names in this one).
|
||||||
|
const others = zone.ptr_other_views_list || [];
|
||||||
|
const fromViews = [...new Set(others.map((f) => f.from))];
|
||||||
|
const otherNote = !zone.disabled && zone.ptr_other_views && can("zones", true) ? h("div", { class: "muted small" },
|
||||||
|
plural(zone.ptr_other_views, "address", "addresses") + " in " + zone.prefix + " " + (zone.ptr_other_views === 1 ? "has" : "have") + " A or AAAA records only in view " + fromViews.join(", ") +
|
||||||
|
", not in view " + zone.view + ", so " + (zone.ptr_other_views === 1 ? "it isn't" : "they aren't") + " counted as missing here. ",
|
||||||
|
h("button", { class: "small", title: "Clients of view " + zone.view + " will then get these names, which come from view " + fromViews.join(", "), onclick: () => fillPTR(zone, true) },
|
||||||
|
"Add PTR records for " + (zone.ptr_other_views === 1 ? "it" : "them") + " in view " + zone.view),
|
||||||
|
h("ul", { class: "small ptr-missing" }, others.map((f) => h("li", null,
|
||||||
|
h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)),
|
||||||
|
fromViews.length > 1 ? h("span", { class: "muted" }, " (view " + f.from + ")") : null)),
|
||||||
|
zone.ptr_other_views > others.length ? h("li", { class: "muted" }, "and " + (zone.ptr_other_views - others.length) + " more") : null)) : null;
|
||||||
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
|
||||||
// The header and tabs show at once. Below them, switching tabs keeps the
|
// The header and tabs show at once. Below them, switching tabs keeps the
|
||||||
// previous tab's content (faded) until the new one is loaded, rather
|
// previous tab's content (faded) until the new one is loaded, rather
|
||||||
@@ -83,7 +97,7 @@ export async function render(el, params, query) {
|
|||||||
holder.classList.add("stale");
|
holder.classList.add("stale");
|
||||||
body.className = "zone-body";
|
body.className = "zone-body";
|
||||||
body.dataset.zone = id;
|
body.dataset.zone = id;
|
||||||
mount(el, head, banner, tabBar, holder);
|
mount(el, head, banner, otherNote, tabBar, holder);
|
||||||
if (tab === "records") await recordsTab(body, zone, query);
|
if (tab === "records") await recordsTab(body, zone, query);
|
||||||
else if (tab === "settings") await settingsTab(body, zone);
|
else if (tab === "settings") await settingsTab(body, zone);
|
||||||
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
|
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
|
||||||
@@ -951,9 +965,9 @@ async function toggleZone(zone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
|
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
|
||||||
async function fillPTR(zone) {
|
async function fillPTR(zone, otherViews) {
|
||||||
try {
|
try {
|
||||||
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr");
|
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr", undefined, otherViews ? { query: { other_views: "1" } } : {});
|
||||||
toast(describeResult(r));
|
toast(describeResult(r));
|
||||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||||
rerender();
|
rerender();
|
||||||
|
|||||||
@@ -103,7 +103,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
cloneSel.addEventListener("change", syncLink);
|
cloneSel.addEventListener("change", syncLink);
|
||||||
const comment = input();
|
const comment = input();
|
||||||
const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true);
|
const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true);
|
||||||
const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
|
// Off unless asked (or a network only other views use is picked): it
|
||||||
|
// publishes one view's host names in another.
|
||||||
|
const fillOther = checkbox("Also for addresses only other views' records use, named from there", false);
|
||||||
|
const fillRow = field("PTR records", h("div", null, fill, views.length ? fillOther : null), "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
|
||||||
const updateClones = () => {
|
const updateClones = () => {
|
||||||
// Records can only be copied into a single zone.
|
// Records can only be copied into a single zone.
|
||||||
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
||||||
@@ -206,6 +209,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
name.value = c.prefix;
|
name.value = c.prefix;
|
||||||
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
|
sizeFrom = c.samples.length ? c.samples[0].split(" ")[0] : null;
|
||||||
if (views.length && c.view) view.value = c.view;
|
if (views.length && c.view) view.value = c.view;
|
||||||
|
if ((c.hidden || "").startsWith("records in ")) { fill.input.checked = true; fillOther.input.checked = true; }
|
||||||
changed();
|
changed();
|
||||||
} },
|
} },
|
||||||
h("span", { class: "mono" }, c.prefix),
|
h("span", { class: "mono" }, c.prefix),
|
||||||
@@ -273,7 +277,10 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
|||||||
if (view.value) data.view = view.value;
|
if (view.value) data.view = view.value;
|
||||||
const opts = [];
|
const opts = [];
|
||||||
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
||||||
if (t === "primary" && kind === "reverse" && fill.input.checked) data.fill = true;
|
if (t === "primary" && kind === "reverse" && fill.input.checked) {
|
||||||
|
data.fill = true;
|
||||||
|
if (fillOther.input.checked) data.fill_other_views = true;
|
||||||
|
}
|
||||||
if (t === "primary") {
|
if (t === "primary") {
|
||||||
data.nameservers = lines(ns.value);
|
data.nameservers = lines(ns.value);
|
||||||
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
||||||
|
|||||||
+16
-1
@@ -703,6 +703,21 @@ run "fill-ptr" 0 $J zone fill-ptr fdab:99::/48
|
|||||||
expect_dig "filled later: IPv6 PTR" "nas.revtest.test." -x fdab:99:0:10::10
|
expect_dig "filled later: IPv6 PTR" "nas.revtest.test." -x fdab:99:0:10::10
|
||||||
run "fill-ptr again: nothing to add (exit 6)" 6 $J zone fill-ptr fdab:99::/48
|
run "fill-ptr again: nothing to add (exit 6)" 6 $J zone fill-ptr fdab:99::/48
|
||||||
contains "nothing to add"
|
contains "nothing to add"
|
||||||
|
# The same network in another view: internal's hosts aren't missing there,
|
||||||
|
# and are named from internal only when asked.
|
||||||
|
run "the network in external too, filled" 0 $J zone create 10.99.1.0/24 --view external --fill
|
||||||
|
contains "A or AAAA records only in other views"
|
||||||
|
run "nothing missing there" 0 $J zone show 1.99.10.in-addr.arpa --view external
|
||||||
|
not_contains "Missing PTRs"
|
||||||
|
contains "^Other views: *2 addresses with A or AAAA records only in view internal"
|
||||||
|
contains "10.99.1.11 -> pi.revtest.test (view internal)"
|
||||||
|
run "fill-ptr there: nothing of its own (exit 6)" 6 $J zone fill-ptr 10.99.1.0/24 --view external
|
||||||
|
contains "--other-views"
|
||||||
|
run "fill-ptr --other-views" 0 $J zone fill-ptr 10.99.1.0/24 --view external --other-views
|
||||||
|
contains "only view internal has a record for it"
|
||||||
|
run "now named there" 0 $J record list 1.99.10.in-addr.arpa --view external
|
||||||
|
contains "pi.revtest.test"
|
||||||
|
run "drop the external copy" 0 $J zone delete 1.99.10.in-addr.arpa --view external --yes
|
||||||
run "keep PTRs in step" 0 $J zone update revtest.test --view internal --auto-ptr
|
run "keep PTRs in step" 0 $J zone update revtest.test --view internal --auto-ptr
|
||||||
run "zone show says so" 0 $J zone show revtest.test --view internal
|
run "zone show says so" 0 $J zone show revtest.test --view internal
|
||||||
contains "^PTR records: *kept in step"
|
contains "^PTR records: *kept in step"
|
||||||
@@ -736,7 +751,7 @@ run "and back on" 0 $J view update internal --reverse-suggestions
|
|||||||
run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external
|
run "an AAAA only in external" 0 $J record add adbraun.test vps AAAA 2600:3c01::f03c:93ff:fe2b:1 --view external
|
||||||
run "offered for internal" 0 $J zone suggest-reverse --all --view internal
|
run "offered for internal" 0 $J zone suggest-reverse --all --view internal
|
||||||
contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external"
|
contains "2600:3c01::f03c:93ff:fe2b:1/128 .*records in external"
|
||||||
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill
|
run "created in internal, filled from external's record" 0 $J zone create 2600:3c01::f03c:93ff:fe2b:1/128 --view internal --fill --other-views
|
||||||
expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1
|
expect_dig "internal answers its PTR" "vps.adbraun.test." -x 2600:3c01::f03c:93ff:fe2b:1
|
||||||
# A zone created inside another takes the parent's records under it.
|
# A zone created inside another takes the parent's records under it.
|
||||||
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal
|
run "a /16 reverse zone" 0 $J zone create 10.88.0.0/16 --view internal
|
||||||
|
|||||||
Reference in new issue
Block a user