Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c645dae14f | ||
|
|
403f85486d | ||
|
|
4113d5d6fc | ||
|
|
f716fb8dd1 | ||
|
|
113a6913a2 | ||
|
|
295c549479 | ||
|
|
5f04ab523b | ||
|
|
c9590cc3d4 | ||
|
|
bb6b4ab64d | ||
|
|
1d6c28ba69 | ||
|
|
020223d1a5 |
No files matched your search
@@ -8,6 +8,63 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Disabled records: a record can be disabled instead of deleted. It stays
|
||||||
|
in Jarl with its ID, TTL and comment but is left out of the zone (the
|
||||||
|
zone file lists it as a comment), and enabling it puts it back exactly
|
||||||
|
as it was. Web UI: a Disable/Enable button on each record, disabled
|
||||||
|
records greyed out with a badge, a filter for them, and a count in the
|
||||||
|
zone list. CLI: `jarl record disable|enable` (by ID or by name, type and
|
||||||
|
data, `--ptr` for the PTR record, `--missing-ok`), `record list
|
||||||
|
--disabled`, and `disable:`/`enable:` entries for `jarl apply -f`. API:
|
||||||
|
`POST /records/{id}/disable|enable`, `POST
|
||||||
|
/zones/{zone}/records/disable|enable`, a `disabled` field on records,
|
||||||
|
`?disabled=` on record lists and `record.disable|enable` operations.
|
||||||
|
Validation checks the zone as it is served: the last NS record can't be
|
||||||
|
disabled, enabling a record is checked like adding it, and a disabled
|
||||||
|
record that a CNAME now blocks gets a warning. Database schema 5.
|
||||||
|
- DNSSEC for a zone whose parent zone is in Jarl: the DNSSEC tab gives the
|
||||||
|
`jarl record add PARENT NAME DS ...` command to copy and an "Add to
|
||||||
|
PARENT" button (staged like any change), links to the parent zone, and
|
||||||
|
says when the parent has the DS record. `jarl zone dnssec status` prints
|
||||||
|
the command too, and turning signing off gives the matching `jarl record
|
||||||
|
delete`.
|
||||||
|
|
||||||
|
- CLI output for scripts: `-o id` prints just the IDs of what a command
|
||||||
|
shows or lists (`jarl zone show adbraun.lol -o id`), `-o
|
||||||
|
field=view,soa.serial` just those fields, tab separated. `-q` on `show`
|
||||||
|
and `list` prints the IDs too. With a change (`jarl record add ... -o
|
||||||
|
id`) they apply to the changed object.
|
||||||
|
|
||||||
|
- Statistics: client lists (busiest and refused clients, in the web UI,
|
||||||
|
`jarl stats top clients|refused` and `jarl stats summary`) show the host
|
||||||
|
name Jarl's records have for each address: the PTR record's target, or
|
||||||
|
else the name of an A/AAAA record. Looked up in Jarl's own data, so no
|
||||||
|
DNS queries; an address without a record shows as before.
|
||||||
|
- Zones list: a padlock after each signed zone's name, linking to its
|
||||||
|
DNSSEC tab (open and amber while signing is being turned off).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The web UI and API can listen on ports below 1024, such as 443:
|
||||||
|
`jarl.service` now gives jarld the `CAP_NET_BIND_SERVICE` capability
|
||||||
|
(and only that). With a unit that doesn't, jarld's error says what's
|
||||||
|
missing. See docs/configuration.md, "HTTPS on port 443".
|
||||||
|
- Records table: names are no longer greyed out when they repeat the row
|
||||||
|
above (it made those records look inactive). Records of the same name
|
||||||
|
are separated by a faint line instead, and a full line marks where the
|
||||||
|
next name starts.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `jarl record list`: one long value (a DKIM key) padded every row to its
|
||||||
|
length, so on a terminal each row wrapped onto a second, blank-looking
|
||||||
|
line. Long data is now shortened to fit the terminal; `--wide` shows it
|
||||||
|
in full, as does output to a pipe or file.
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-10-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- DNSSEC signing per zone (docs/dnssec.md): `jarl zone dnssec
|
- DNSSEC signing per zone (docs/dnssec.md): `jarl zone dnssec
|
||||||
enable|status|check-ds|disable|wipe` and the zone page's DNSSEC tab, or
|
enable|status|check-ds|disable|wipe` and the zone page's DNSSEC tab, or
|
||||||
the zone's new `dnssec_policy` field in the API. named signs the zone
|
the zone's new `dnssec_policy` field in the API. named signs the zone
|
||||||
@@ -104,6 +161,22 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
- The view in the zone list, and on the zone page, links to that view,
|
- The view in the zone list, and on the zone page, links to that view,
|
||||||
and the views page highlights it. It used to be plain text, and the zone
|
and the views page highlights it. It used to be plain text, and the zone
|
||||||
page linked to the views page as a whole.
|
page linked to the views page as a whole.
|
||||||
|
- The zone's Name servers tab shows at once and follows the check as it
|
||||||
|
happens. Each query is listed as a dig command line (`dig @192.0.2.1
|
||||||
|
example.org. SOA +norec`) with a "waiting 1.8 s" counter until it is
|
||||||
|
answered. The finished result then replaces the progress, and the queries
|
||||||
|
stay under "Queries sent". Results, the delegation check's included, are
|
||||||
|
kept for 10 minutes, so returning to the tab shows them at once; "Check
|
||||||
|
again" asks anew. A check keeps running if you leave the tab. Behind it,
|
||||||
|
`GET /zones/{zone}/serials?stream=1` streams the queries as
|
||||||
|
newline-delimited JSON (docs/api.md).
|
||||||
|
- The delegation check streams its queries the same way, every one of
|
||||||
|
them: finding the parent zone, its servers' addresses, the NS and DS
|
||||||
|
queries to each parent server and the SOA from each delegated server.
|
||||||
|
Each shows a short answer ("referral to katelyn.ns.cloudflare.com., …",
|
||||||
|
"DS 2371", "NXDOMAIN"). The serials check now lists its address lookups
|
||||||
|
as separate A and AAAA queries. `GET /zones/{zone}/delegation?stream=1`
|
||||||
|
streams them.
|
||||||
- Switching a zone's tabs (Records, Settings, …) no longer blanks the page
|
- Switching a zone's tabs (Records, Settings, …) no longer blanks the page
|
||||||
while the tab loads: the page changes once the tab is ready, and
|
while the tab loads: the page changes once the tab is ready, and
|
||||||
"Loading…" appears only if a page takes a while.
|
"Loading…" appears only if a page takes a while.
|
||||||
|
|||||||
@@ -166,9 +166,10 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.2.0 is in testing (0.2.0-rc.1); 0.1.0 is the current release.
|
Version 0.2.0 is the current release, and 0.3.0 is in testing
|
||||||
0.2.0 adds DNSSEC signing, Porkbun support, two-factor login and view
|
(0.3.0-rc.2). 0.2.0 added DNSSEC signing, Porkbun support, two-factor
|
||||||
colours. Jarl is feature-complete for one server's configuration.
|
login and view colours; 0.3.0 adds disabled records and scripting output
|
||||||
|
for the CLI. Jarl is feature-complete for one server's configuration.
|
||||||
Managing several servers from one jarld and dynamic updates are not
|
Managing several servers from one jarld and dynamic updates are not
|
||||||
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||||
|
|
||||||
|
|||||||
+21
-9
@@ -26,6 +26,10 @@ type batchEntry struct {
|
|||||||
Delete map[string]any `yaml:"delete,omitempty" json:"delete,omitempty"`
|
Delete map[string]any `yaml:"delete,omitempty" json:"delete,omitempty"`
|
||||||
Set map[string]any `yaml:"set,omitempty" json:"set,omitempty"`
|
Set map[string]any `yaml:"set,omitempty" json:"set,omitempty"`
|
||||||
Update map[string]any `yaml:"update,omitempty" json:"update,omitempty"`
|
Update map[string]any `yaml:"update,omitempty" json:"update,omitempty"`
|
||||||
|
// Disable and Enable find records like Delete: by id, or by zone,
|
||||||
|
// name, type and optionally data.
|
||||||
|
Disable map[string]any `yaml:"disable,omitempty" json:"disable,omitempty"`
|
||||||
|
Enable map[string]any `yaml:"enable,omitempty" json:"enable,omitempty"`
|
||||||
// Any API operation: op, id, data.
|
// Any API operation: op, id, data.
|
||||||
Op string `yaml:"op,omitempty" json:"op,omitempty"`
|
Op string `yaml:"op,omitempty" json:"op,omitempty"`
|
||||||
ID int64 `yaml:"id,omitempty" json:"id,omitempty"`
|
ID int64 `yaml:"id,omitempty" json:"id,omitempty"`
|
||||||
@@ -57,25 +61,23 @@ func (b *batchFile) requests() ([]api.ChangeRequest, error) {
|
|||||||
var out []api.ChangeRequest
|
var out []api.ChangeRequest
|
||||||
for i, e := range b.Changes {
|
for i, e := range b.Changes {
|
||||||
n := 0
|
n := 0
|
||||||
for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Op != ""} {
|
for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Disable != nil, e.Enable != nil, e.Op != ""} {
|
||||||
if set {
|
if set {
|
||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n != 1 {
|
if n != 1 {
|
||||||
return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update or op", i+1)
|
return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update, disable, enable or op", i+1)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case e.Add != nil:
|
case e.Add != nil:
|
||||||
out = append(out, api.ChangeRequest{Op: "record.create", Data: rawJSON(e.Add)})
|
out = append(out, api.ChangeRequest{Op: "record.create", Data: rawJSON(e.Add)})
|
||||||
case e.Delete != nil:
|
case e.Delete != nil:
|
||||||
d := e.Delete
|
out = append(out, byIDOrMatch("record.delete", e.Delete))
|
||||||
if id, ok := toInt64(d["id"]); ok {
|
case e.Disable != nil:
|
||||||
delete(d, "id")
|
out = append(out, byIDOrMatch("record.disable", e.Disable))
|
||||||
out = append(out, api.ChangeRequest{Op: "record.delete", ID: id, Data: rawJSON(d)})
|
case e.Enable != nil:
|
||||||
} else {
|
out = append(out, byIDOrMatch("record.enable", e.Enable))
|
||||||
out = append(out, api.ChangeRequest{Op: "record.delete", Data: rawJSON(d)})
|
|
||||||
}
|
|
||||||
case e.Set != nil:
|
case e.Set != nil:
|
||||||
s := e.Set
|
s := e.Set
|
||||||
switch d := s["data"].(type) {
|
switch d := s["data"].(type) {
|
||||||
@@ -104,6 +106,16 @@ func (b *batchFile) requests() ([]api.ChangeRequest, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// byIDOrMatch is a request for the record with d's id, or for the records
|
||||||
|
// d matches.
|
||||||
|
func byIDOrMatch(op string, d map[string]any) api.ChangeRequest {
|
||||||
|
if id, ok := toInt64(d["id"]); ok {
|
||||||
|
delete(d, "id")
|
||||||
|
return api.ChangeRequest{Op: op, ID: id, Data: rawJSON(d)}
|
||||||
|
}
|
||||||
|
return api.ChangeRequest{Op: op, Data: rawJSON(d)}
|
||||||
|
}
|
||||||
|
|
||||||
// submitBatch applies requests as one changeset, or stages them into the
|
// submitBatch applies requests as one changeset, or stages them into the
|
||||||
// changeset given with --changeset.
|
// changeset given with --changeset.
|
||||||
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
|
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const completeTimeout = 3 * time.Second
|
|||||||
// setupCompletion adds value completion to the command tree.
|
// setupCompletion adds value completion to the command tree.
|
||||||
func setupCompletion(root *cobra.Command) {
|
func setupCompletion(root *cobra.Command) {
|
||||||
root.RegisterFlagCompletionFunc("server", completeServers(true))
|
root.RegisterFlagCompletionFunc("server", completeServers(true))
|
||||||
root.RegisterFlagCompletionFunc("output", cobra.FixedCompletions([]string{"table", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp))
|
root.RegisterFlagCompletionFunc("output", cobra.FixedCompletions([]string{"table", "json", "yaml", "id", "field="}, cobra.ShellCompDirectiveNoFileComp|cobra.ShellCompDirectiveNoSpace))
|
||||||
var walk func(c *cobra.Command)
|
var walk func(c *cobra.Command)
|
||||||
walk = func(c *cobra.Command) {
|
walk = func(c *cobra.Command) {
|
||||||
for _, name := range []string{"view", "user", "zone"} {
|
for _, name := range []string{"view", "user", "zone"} {
|
||||||
|
|||||||
+27
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ A zone whose DS never reached the parent can be wiped at once instead
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case st.Policy != "insecure":
|
case st.Policy != "insecure":
|
||||||
if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record at your registrar.", displayName(st.Zone)), yes) {
|
if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record from the parent zone.", displayName(st.Zone)), yes) {
|
||||||
return fmt.Errorf("canceled")
|
return fmt.Errorf("canceled")
|
||||||
}
|
}
|
||||||
next = "insecure"
|
next = "insecure"
|
||||||
@@ -133,7 +134,9 @@ A zone whose DS never reached the parent can be wiped at once instead
|
|||||||
if err := report(resp); err != nil || !resp.Applied || flagQuiet {
|
if err := report(resp); err != nil || !resp.Applied || flagQuiet {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if next == "insecure" {
|
if next == "insecure" && st.ParentZone != "" {
|
||||||
|
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
||||||
|
} else if next == "insecure" {
|
||||||
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
|
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
|
||||||
@@ -344,7 +347,7 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
|
|||||||
if len(ds) > 0 && r.Policy != "insecure" {
|
if len(ds) > 0 && r.Policy != "insecure" {
|
||||||
where := "at your registrar"
|
where := "at your registrar"
|
||||||
if r.ParentZone != "" {
|
if r.ParentZone != "" {
|
||||||
where = "in the parent zone " + displayName(r.ParentZone) + " (in Jarl: add it as a DS record there)"
|
where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
|
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
|
||||||
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
|
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
|
||||||
@@ -353,10 +356,31 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
|
|||||||
for _, k := range ds {
|
for _, k := range ds {
|
||||||
fmt.Fprintf(w, " %s\n", strings.ReplaceAll(k.DS.Record, "\t", " "))
|
fmt.Fprintf(w, " %s\n", strings.ReplaceAll(k.DS.Record, "\t", " "))
|
||||||
fmt.Fprintf(w, " key tag %d, algorithm %d (%s), digest type %d (SHA-256), digest %s\n", k.DS.KeyTag, k.DS.Algorithm, k.AlgorithmName, k.DS.DigestType, k.DS.Digest)
|
fmt.Fprintf(w, " key tag %d, algorithm %d (%s), digest type %d (SHA-256), digest %s\n", k.DS.KeyTag, k.DS.Algorithm, k.AlgorithmName, k.DS.DigestType, k.DS.Digest)
|
||||||
|
if r.ParentZone != "" {
|
||||||
|
data := fmt.Sprintf("%d %d %d %s", k.DS.KeyTag, k.DS.Algorithm, k.DS.DigestType, k.DS.Digest)
|
||||||
|
if slices.ContainsFunc(r.ParentDS, func(d string) bool { return strings.EqualFold(d, data) }) {
|
||||||
|
fmt.Fprintf(w, " %s has it.\n", displayName(r.ParentZone))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, " %s\n", parentDSCommand(r, "add", data))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
|
||||||
|
// record in its parent zone in Jarl.
|
||||||
|
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
|
||||||
|
parts := []string{"jarl record", verb, displayName(r.ParentZone), strings.TrimSuffix(r.Zone, "."+r.ParentZone), "DS"}
|
||||||
|
if data != "" {
|
||||||
|
parts = append(parts, data)
|
||||||
|
}
|
||||||
|
if r.ParentView != "" {
|
||||||
|
parts = append(parts, "--view", r.ParentView)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
func dash(s string) string {
|
func dash(s string) string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return "-"
|
return "-"
|
||||||
|
|||||||
+94
-4
@@ -3,6 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -237,10 +239,10 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
|
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
|
||||||
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
|
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
|
||||||
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
|
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
|
||||||
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json or yaml")
|
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
|
||||||
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
||||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||||
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "only print errors (and IDs where useful)")
|
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "only print errors and IDs (with show and list: just the IDs, like -o id)")
|
||||||
root.AddGroup(&cobra.Group{ID: "dns", Title: "DNS commands:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
root.AddGroup(&cobra.Group{ID: "dns", Title: "DNS commands:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
||||||
add := func(group string, cmds ...*cobra.Command) {
|
add := func(group string, cmds ...*cobra.Command) {
|
||||||
for _, c := range cmds {
|
for _, c := range cmds {
|
||||||
@@ -262,7 +264,15 @@ func ctx() context.Context { return context.Background() }
|
|||||||
// ---- output
|
// ---- output
|
||||||
|
|
||||||
func output(v any, table func(w io.Writer)) error {
|
func output(v any, table func(w io.Writer)) error {
|
||||||
switch flagOutput {
|
format := flagOutput
|
||||||
|
quietIDs := flagQuiet && (format == "table" || format == "")
|
||||||
|
if quietIDs {
|
||||||
|
format = "id"
|
||||||
|
}
|
||||||
|
if fieldFormat(format) {
|
||||||
|
return outputFields(v, format, quietIDs)
|
||||||
|
}
|
||||||
|
switch format {
|
||||||
case "json":
|
case "json":
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
@@ -286,7 +296,79 @@ func output(v any, table func(w io.Writer)) error {
|
|||||||
table(tw)
|
table(tw)
|
||||||
return tw.Flush()
|
return tw.Flush()
|
||||||
}
|
}
|
||||||
return fmt.Errorf("unknown output format %q (use table, json or yaml)", flagOutput)
|
return fmt.Errorf("unknown output format %q (use table, json, yaml, id or field=NAME)", flagOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputFields prints fields of each object (or of the one object), one
|
||||||
|
// line per object with the fields separated by tabs, for scripts:
|
||||||
|
// "id" prints the IDs, "field=view,soa.serial" those fields. Names are the
|
||||||
|
// JSON field names (see -o json); a dot reaches into nested objects. A
|
||||||
|
// field an object doesn't have prints as empty (the API leaves out empty
|
||||||
|
// fields).
|
||||||
|
func outputFields(v any, format string, quiet bool) error {
|
||||||
|
fields := []string{"id"}
|
||||||
|
if f, ok := strings.CutPrefix(format, "field="); ok {
|
||||||
|
fields = strings.Split(f, ",")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(b))
|
||||||
|
dec.UseNumber()
|
||||||
|
var x any
|
||||||
|
if err := dec.Decode(&x); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, ok := x.([]any)
|
||||||
|
if !ok {
|
||||||
|
items = []any{x}
|
||||||
|
}
|
||||||
|
if quiet && !slices.ContainsFunc(items, func(it any) bool { _, ok := lookupField(it, "id"); return ok }) {
|
||||||
|
return nil // -q on something without IDs: only errors
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
for _, it := range items {
|
||||||
|
var vals []string
|
||||||
|
for _, f := range fields {
|
||||||
|
val, _ := lookupField(it, strings.TrimSpace(f))
|
||||||
|
vals = append(vals, fieldText(val))
|
||||||
|
}
|
||||||
|
out.WriteString(strings.Join(vals, "\t") + "\n")
|
||||||
|
}
|
||||||
|
_, err = io.WriteString(os.Stdout, out.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldFormat reports whether an -o format prints fields (id, field=...).
|
||||||
|
func fieldFormat(format string) bool {
|
||||||
|
return format == "id" || strings.HasPrefix(format, "field=")
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupField(v any, path string) (any, bool) {
|
||||||
|
for _, part := range strings.Split(path, ".") {
|
||||||
|
m, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if v, ok = m[part]; !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldText(v any) string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
case json.Number, bool:
|
||||||
|
return fmt.Sprint(x)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dot(name string) string {
|
func dot(name string) string {
|
||||||
@@ -391,6 +473,14 @@ func report(resp *api.MutationResponse) error {
|
|||||||
if flagOutput == "json" || flagOutput == "yaml" {
|
if flagOutput == "json" || flagOutput == "yaml" {
|
||||||
return output(resp, nil)
|
return output(resp, nil)
|
||||||
}
|
}
|
||||||
|
if fieldFormat(flagOutput) {
|
||||||
|
// The changed object, if the response has it ("jarl record add
|
||||||
|
// ... -o id" prints the new record's ID).
|
||||||
|
if len(resp.Object) > 0 {
|
||||||
|
return outputFields(resp.Object, flagOutput, false)
|
||||||
|
}
|
||||||
|
return outputFields(resp, flagOutput, false)
|
||||||
|
}
|
||||||
w := os.Stdout
|
w := os.Stdout
|
||||||
r := client.ParseResult(resp.Result)
|
r := client.ParseResult(resp.Result)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -22,6 +23,8 @@ changes:
|
|||||||
- set: {zone: example.com, name: api, type: A, data: 192.0.2.5}
|
- set: {zone: example.com, name: api, type: A, data: 192.0.2.5}
|
||||||
- set: {zone: example.com, name: gone, type: TXT}
|
- set: {zone: example.com, name: gone, type: TXT}
|
||||||
- update: {id: 13, data: 192.0.2.9}
|
- update: {id: 13, data: 192.0.2.9}
|
||||||
|
- disable: {zone: example.com, name: www, type: A, data: 192.0.2.1, ptr: true}
|
||||||
|
- enable: {id: 14}
|
||||||
- op: zone.create
|
- op: zone.create
|
||||||
data: {name: lab.example.com}
|
data: {name: lab.example.com}
|
||||||
`
|
`
|
||||||
@@ -44,6 +47,8 @@ changes:
|
|||||||
{"rrset.replace", 0, `{"data":["192.0.2.5"],"name":"api","type":"A","zone":"example.com"}`},
|
{"rrset.replace", 0, `{"data":["192.0.2.5"],"name":"api","type":"A","zone":"example.com"}`},
|
||||||
{"rrset.replace", 0, `{"data":[],"name":"gone","type":"TXT","zone":"example.com"}`},
|
{"rrset.replace", 0, `{"data":[],"name":"gone","type":"TXT","zone":"example.com"}`},
|
||||||
{"record.update", 13, `{"data":"192.0.2.9"}`},
|
{"record.update", 13, `{"data":"192.0.2.9"}`},
|
||||||
|
{"record.disable", 0, `{"data":"192.0.2.1","name":"www","ptr":true,"type":"A","zone":"example.com"}`},
|
||||||
|
{"record.enable", 14, `{}`},
|
||||||
{"zone.create", 0, `{"name":"lab.example.com"}`},
|
{"zone.create", 0, `{"name":"lab.example.com"}`},
|
||||||
}
|
}
|
||||||
if len(reqs) != len(want) {
|
if len(reqs) != len(want) {
|
||||||
@@ -112,6 +117,38 @@ func TestListValue(t *testing.T) {
|
|||||||
// filters they send: flags bound to variables shared between subcommands
|
// filters they send: flags bound to variables shared between subcommands
|
||||||
// must not leak defaults (zone create's --type primary once hid every
|
// must not leak defaults (zone create's --type primary once hid every
|
||||||
// non-primary zone from 'zone list').
|
// non-primary zone from 'zone list').
|
||||||
|
func TestDisableCommands(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = r.Method + " " + r.URL.Path + "?" + r.URL.RawQuery
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"applied":true,"changeset_id":1}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("JARL_CLI_CONFIG", filepath.Join(t.TempDir(), "cli.yaml"))
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"record", "disable", "example.com", "www", "A"}, "POST /api/v1/zones/example.com/records/disable?name=www&type=A"},
|
||||||
|
{[]string{"record", "disable", "example.com", "www", "A", "192.0.2.1", "--ptr", "--view", "internal"}, "POST /api/v1/zones/example.com/records/disable?data=192.0.2.1&name=www&ptr=1&type=A&view=internal"},
|
||||||
|
{[]string{"record", "enable", "--id", "12", "--ptr"}, "POST /api/v1/records/12/enable?ptr=1"},
|
||||||
|
{[]string{"record", "enable", "example.com", "old", "CNAME", "--missing-ok"}, "POST /api/v1/zones/example.com/records/enable?missing_ok=1&name=old&type=CNAME"},
|
||||||
|
} {
|
||||||
|
got = ""
|
||||||
|
root := rootCmd()
|
||||||
|
root.SetArgs(append(tc.args, "--server", srv.URL, "--token", "jarl_test_token", "-o", "json"))
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Errorf("%v: %v", tc.args, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%v sent %q, want %q", tc.args, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListQueries(t *testing.T) {
|
func TestListQueries(t *testing.T) {
|
||||||
var got string
|
var got string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -154,3 +191,44 @@ func TestListQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOutputFields(t *testing.T) {
|
||||||
|
capture := func(f func() error) string {
|
||||||
|
t.Helper()
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
old := os.Stdout
|
||||||
|
os.Stdout = w
|
||||||
|
err := f()
|
||||||
|
os.Stdout = old
|
||||||
|
w.Close()
|
||||||
|
b, _ := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
zones := []map[string]any{
|
||||||
|
{"id": 12, "name": "adbraun.lol.", "view": "external", "soa": map[string]any{"serial": 2026100901}},
|
||||||
|
{"id": 3, "name": "lab.adbraun.lol.", "soa": map[string]any{"serial": 7}},
|
||||||
|
}
|
||||||
|
defer func(o string, q bool) { flagOutput, flagQuiet = o, q }(flagOutput, flagQuiet)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
format string
|
||||||
|
quiet bool
|
||||||
|
v any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"id", false, zones, "12\n3\n"},
|
||||||
|
{"id", false, zones[0], "12\n"},
|
||||||
|
{"field=name,view,soa.serial", false, zones, "adbraun.lol.\texternal\t2026100901\nlab.adbraun.lol.\t\t7\n"},
|
||||||
|
{"field=soa", false, zones[1], "{\"serial\":7}\n"},
|
||||||
|
{"table", true, zones, "12\n3\n"},
|
||||||
|
{"table", true, map[string]any{"status": "ok"}, ""}, // -q without IDs: nothing
|
||||||
|
} {
|
||||||
|
flagOutput, flagQuiet = tc.format, tc.quiet
|
||||||
|
got := capture(func() error { return output(tc.v, func(io.Writer) { t.Error("table printed") }) })
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("-o %s (quiet %v) = %q, want %q", tc.format, tc.quiet, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -109,7 +110,11 @@ export the data.`,
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names"}[k])
|
fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names"}[k])
|
||||||
for _, e := range es {
|
for _, e := range es {
|
||||||
fmt.Fprintf(w, " %s\t%d\n", e.Key, e.Count)
|
key := e.Key
|
||||||
|
if e.Name != "" {
|
||||||
|
key += " (" + e.Name + ")"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " %s\t%d\n", key, e.Count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -152,9 +157,18 @@ export the data.`,
|
|||||||
if head == "" {
|
if head == "" {
|
||||||
head = "NAME"
|
head = "NAME"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head)
|
named := slices.ContainsFunc(es, func(e stats.Entry) bool { return e.Name != "" })
|
||||||
|
if named {
|
||||||
|
fmt.Fprintf(w, "#\t%s\tHOST\tQUERIES\t\n", head)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head)
|
||||||
|
}
|
||||||
for i, e := range es {
|
for i, e := range es {
|
||||||
fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20))
|
if named {
|
||||||
|
fmt.Fprintf(w, "%d\t%s\t%s\t%d\t%s\n", i+1, e.Key, dash(e.Name), e.Count, bar(e.Count, es[0].Count, 20))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
+138
-5
@@ -3,12 +3,14 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"golang.org/x/term"
|
||||||
"io"
|
"io"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
@@ -75,7 +77,11 @@ func zoneCmd() *cobra.Command {
|
|||||||
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial)
|
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial)
|
||||||
fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum)
|
fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum)
|
||||||
fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
|
fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
|
||||||
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
|
if z.Disabled > 0 {
|
||||||
|
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.Disabled, args[0])
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if z.InView != "" {
|
if z.InView != "" {
|
||||||
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
||||||
@@ -403,7 +409,7 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
|||||||
func recordCmd() *cobra.Command {
|
func recordCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
||||||
var view, name, typ, search, comment, ttlArg string
|
var view, name, typ, search, comment, ttlArg string
|
||||||
var ptr, missingOK, yes bool
|
var ptr, missingOK, yes, onlyDisabled, wide bool
|
||||||
var id int64
|
var id int64
|
||||||
|
|
||||||
list := &cobra.Command{
|
list := &cobra.Command{
|
||||||
@@ -419,18 +425,31 @@ func recordCmd() *cobra.Command {
|
|||||||
for k, v := range q("name", name, "type", strings.ToUpper(typ), "q", search) {
|
for k, v := range q("name", name, "type", strings.ToUpper(typ), "q", search) {
|
||||||
qv[k] = v
|
qv[k] = v
|
||||||
}
|
}
|
||||||
|
if onlyDisabled {
|
||||||
|
qv.Set("disabled", "1")
|
||||||
|
}
|
||||||
var recs []api.Record
|
var recs []api.Record
|
||||||
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(recs, func(w io.Writer) {
|
return output(recs, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "ID\tNAME\tTTL\tTYPE\tDATA\tCOMMENT")
|
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
||||||
for _, r := range recs {
|
for _, r := range recs {
|
||||||
ttl := fmt.Sprint(r.EffectiveTTL)
|
ttl := fmt.Sprint(r.EffectiveTTL)
|
||||||
if r.TTL == nil {
|
if r.TTL == nil {
|
||||||
ttl += "*"
|
ttl += "*"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.RelativeName, ttl, r.Type, r.Data, r.Comment)
|
comment := r.Comment
|
||||||
|
if r.Disabled {
|
||||||
|
comment = strings.TrimSpace("[disabled] " + comment)
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
||||||
|
}
|
||||||
|
if !wide {
|
||||||
|
fitColumn(rows, 4)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -439,6 +458,8 @@ func recordCmd() *cobra.Command {
|
|||||||
list.Flags().StringVar(&name, "name", "", "only this name")
|
list.Flags().StringVar(&name, "name", "", "only this name")
|
||||||
list.Flags().StringVarP(&typ, "type", "t", "", "only this type")
|
list.Flags().StringVarP(&typ, "type", "t", "", "only this type")
|
||||||
list.Flags().StringVar(&search, "search", "", "only records whose name, data or comment contains this")
|
list.Flags().StringVar(&search, "search", "", "only records whose name, data or comment contains this")
|
||||||
|
list.Flags().BoolVar(&onlyDisabled, "disabled", false, "only disabled records")
|
||||||
|
list.Flags().BoolVarP(&wide, "wide", "w", false, "show long data in full (on a terminal it is shortened to fit)")
|
||||||
|
|
||||||
add := &cobra.Command{
|
add := &cobra.Command{
|
||||||
Use: "add ZONE NAME TYPE DATA...",
|
Use: "add ZONE NAME TYPE DATA...",
|
||||||
@@ -618,7 +639,83 @@ spaces.
|
|||||||
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
||||||
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
||||||
|
|
||||||
cmd.AddCommand(list, add, update, del, set)
|
toggle := func(disable bool) *cobra.Command {
|
||||||
|
verb := "enable"
|
||||||
|
long := `Enable disabled records again: by name and type (all of them, or only the
|
||||||
|
one with DATA), or one record by ID. The record is checked as if it were
|
||||||
|
added, so a CNAME added in the meantime at its name stops it.
|
||||||
|
|
||||||
|
jarl record enable example.com www A 192.0.2.10
|
||||||
|
jarl record enable --id 1234 --ptr`
|
||||||
|
if disable {
|
||||||
|
verb = "disable"
|
||||||
|
long = `Disable records: they stay in Jarl, with their TTL, comment and ID, but are
|
||||||
|
left out of the zone (the zone file lists them as comments) until they are
|
||||||
|
enabled again. Select them by name and type (all of them, or only the one
|
||||||
|
with DATA), or one record by ID.
|
||||||
|
|
||||||
|
jarl record disable example.com www A 192.0.2.10 --ptr # and its PTR record
|
||||||
|
jarl record disable example.com old CNAME
|
||||||
|
jarl record disable --id 1234
|
||||||
|
jarl record list example.com --disabled`
|
||||||
|
}
|
||||||
|
var tview string
|
||||||
|
var tid int64
|
||||||
|
var tptr, tmissing bool
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: verb + " ZONE NAME TYPE [DATA...] | " + verb + " --id ID",
|
||||||
|
Short: strings.ToUpper(verb[:1]) + verb[1:] + " records",
|
||||||
|
Long: long,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
qv := q()
|
||||||
|
if tptr {
|
||||||
|
qv.Set("ptr", "1")
|
||||||
|
}
|
||||||
|
if tid > 0 {
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/records/"+strconv.FormatInt(tid, 10)+"/"+verb, qv, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
}
|
||||||
|
if len(args) < 3 {
|
||||||
|
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
|
||||||
|
}
|
||||||
|
path, zq := zonePath(args[0], tview)
|
||||||
|
for k, v := range zq {
|
||||||
|
qv[k] = v
|
||||||
|
}
|
||||||
|
qv.Set("name", args[1])
|
||||||
|
qv.Set("type", args[2])
|
||||||
|
if len(args) > 3 {
|
||||||
|
qv.Set("data", strings.Join(args[3:], " "))
|
||||||
|
}
|
||||||
|
if tmissing {
|
||||||
|
qv.Set("missing_ok", "1")
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, qv, nil)
|
||||||
|
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
|
||||||
|
fmt.Println("nothing to do")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Flags().StringVar(&tview, "view", "", "view of the zone")
|
||||||
|
c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID")
|
||||||
|
c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d")
|
||||||
|
c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(list, add, update, del, set, toggle(true), toggle(false))
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,3 +733,39 @@ func setTTL(body map[string]any, ttl string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fitColumn shortens column col of a table (header row first) so that the
|
||||||
|
// rows fit the terminal: the table pads every row to its longest cell, so
|
||||||
|
// one long TXT record would make every row wrap. Nothing is shortened when
|
||||||
|
// standard output isn't a terminal.
|
||||||
|
func fitColumn(rows [][]string, col int) {
|
||||||
|
fd := int(os.Stdout.Fd())
|
||||||
|
if !term.IsTerminal(fd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
width, _, err := term.GetSize(fd)
|
||||||
|
if err != nil || width <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widths := make([]int, len(rows[0]))
|
||||||
|
for _, row := range rows {
|
||||||
|
for i, c := range row {
|
||||||
|
widths[i] = max(widths[i], utf8.RuneCountInString(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
used := 0
|
||||||
|
for i, w := range widths {
|
||||||
|
if i != col {
|
||||||
|
used += w + 2 // the table's padding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if col == len(widths)-1 {
|
||||||
|
used -= 2
|
||||||
|
}
|
||||||
|
limit := max(width-used-2, 24)
|
||||||
|
for _, row := range rows[1:] {
|
||||||
|
if r := []rune(row[col]); len(r) > limit {
|
||||||
|
row[col] = string(r[:limit-1]) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,8 +40,10 @@ ProtectHome=yes
|
|||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
PrivateDevices=yes
|
PrivateDevices=yes
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
CapabilityBoundingSet=
|
# The only privilege jarld gets: listening on ports below 1024 (443 for the
|
||||||
AmbientCapabilities=
|
# web UI). Nothing else, and NoNewPrivileges still applies.
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
ProtectKernelLogs=yes
|
ProtectKernelLogs=yes
|
||||||
|
|||||||
+37
-5
@@ -110,7 +110,8 @@ on the resources below, except users and tokens) works the same way:
|
|||||||
|
|
||||||
Reads accept `?changeset=ID` too. They then show the configuration as it
|
Reads accept `?changeset=ID` too. They then show the configuration as it
|
||||||
will be after the changeset, with `"pending": "add" | "change" | "delete"`
|
will be after the changeset, with `"pending": "add" | "change" | "delete"`
|
||||||
on affected zones and records.
|
on affected zones and records (`"disable"` or `"enable"` for a record
|
||||||
|
that is only being disabled or enabled).
|
||||||
|
|
||||||
The response of an applied change:
|
The response of an applied change:
|
||||||
|
|
||||||
@@ -220,6 +221,18 @@ when called ([operations.md](operations.md#checking-name-servers-and-the-delegat
|
|||||||
`error` (with `error`). `source` is `NS record`, `also-notify` or
|
`error` (with `error`). `source` is `NS record`, `also-notify` or
|
||||||
`primaries`; `port` is set for a non-default port.
|
`primaries`; `port` is set for a non-default port.
|
||||||
|
|
||||||
|
`/serials?stream=1` and `/delegation?stream=1` answer with
|
||||||
|
newline-delimited JSON (`application/x-ndjson`) while they work, which is
|
||||||
|
how the web UI shows a check's progress. Each DNS query is an event when it
|
||||||
|
is sent and another when it is answered: `{"kind": "start", "id": 2,
|
||||||
|
"query": "dig @192.0.2.1 example.org. SOA +norec"}`, then `{"kind": "end",
|
||||||
|
"id": 2, "answer": "serial 2026100900", "rtt_ms": 12}`. `answer` sums the
|
||||||
|
answer up (addresses, `referral to …`, `NXDOMAIN`, `no such record`);
|
||||||
|
`error` replaces it when there was none. The serials check's SOA queries
|
||||||
|
end with `result`, the `servers` entry, instead. ID 0 is `rndc zonestatus`,
|
||||||
|
which asks this server's named. The last line is `{"kind": "done",
|
||||||
|
"response": {...}}`, the endpoint's usual response.
|
||||||
|
|
||||||
`/delegation` returns `parent`, `resolver`, `delegated`, `parent_ns`,
|
`/delegation` returns `parent`, `resolver`, `delegated`, `parent_ns`,
|
||||||
`jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`,
|
`jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`,
|
||||||
`not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue`
|
`not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue`
|
||||||
@@ -321,14 +334,18 @@ registrar_error`.
|
|||||||
|
|
||||||
| Method | Path | Body / query |
|
| Method | Path | Body / query |
|
||||||
|--------|------|--------------|
|
|--------|------|--------------|
|
||||||
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=` |
|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
||||||
| POST | `/zones/{zone}/records` | a RecordInput, or an array of them (one changeset) |
|
| POST | `/zones/{zone}/records` | a RecordInput, or an array of them (one changeset) |
|
||||||
| DELETE | `/zones/{zone}/records` | `?name=&type=[&data=][&missing_ok=1][&ptr=1]`: delete by match |
|
| DELETE | `/zones/{zone}/records` | `?name=&type=[&data=][&missing_ok=1][&ptr=1]`: delete by match |
|
||||||
|
| POST | `/zones/{zone}/records/disable` | same query as delete by match: disable the matching records |
|
||||||
|
| POST | `/zones/{zone}/records/enable` | same query: enable them again |
|
||||||
| PUT | `/zones/{zone}/rrsets/{name}/{type}` | `{"data": ["192.0.2.1", "192.0.2.2"], "ttl": 60, "comment": "..."}`: make the RRset exactly this |
|
| PUT | `/zones/{zone}/rrsets/{name}/{type}` | `{"data": ["192.0.2.1", "192.0.2.2"], "ttl": 60, "comment": "..."}`: make the RRset exactly this |
|
||||||
| DELETE | `/zones/{zone}/rrsets/{name}/{type}` | delete the whole RRset |
|
| DELETE | `/zones/{zone}/rrsets/{name}/{type}` | delete the whole RRset |
|
||||||
| GET | `/records/{id}` | |
|
| GET | `/records/{id}` | |
|
||||||
| PATCH | `/records/{id}` | RecordInput fields to change (`If-Match` supported) |
|
| PATCH | `/records/{id}` | RecordInput fields to change (`If-Match` supported) |
|
||||||
| DELETE | `/records/{id}` | `?ptr=1` also deletes the matching PTR |
|
| DELETE | `/records/{id}` | `?ptr=1` also deletes the matching PTR |
|
||||||
|
| POST | `/records/{id}/disable` | `?ptr=1` also disables the matching PTR |
|
||||||
|
| POST | `/records/{id}/enable` | `?ptr=1` also enables the matching PTR |
|
||||||
|
|
||||||
RecordInput:
|
RecordInput:
|
||||||
|
|
||||||
@@ -339,12 +356,26 @@ RecordInput:
|
|||||||
"data": "192.0.2.10", // zone file syntax; TXT/SPF take plain text
|
"data": "192.0.2.10", // zone file syntax; TXT/SPF take plain text
|
||||||
"ttl": 300, // null or absent: the zone's default TTL
|
"ttl": 300, // null or absent: the zone's default TTL
|
||||||
"comment": "web server",
|
"comment": "web server",
|
||||||
"ptr": true // A/AAAA: also create/move/delete the PTR in the reverse zone
|
"disabled": false, // true: keep the record in Jarl but out of the zone
|
||||||
|
"ptr": true // A/AAAA: also create/move/delete (or disable/enable) the PTR in the reverse zone
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
|
Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
|
||||||
(the decoded value of TXT records).
|
(the decoded value of TXT records). Zones have `disabled_count` when
|
||||||
|
some of their records are disabled.
|
||||||
|
|
||||||
|
**Disabled records** stay in Jarl, with their ID, TTL and comment, but are
|
||||||
|
left out of the zone: the zone file lists them as comments (`; disabled:
|
||||||
|
www IN A 192.0.2.10`). Disabling and enabling are ordinary changes (staged,
|
||||||
|
reviewed, reverted like any other). Validation checks the zone as it is
|
||||||
|
served, so disabling the zone's last NS record is refused, and enabling a
|
||||||
|
record is checked as if it were added: a CNAME added at its name in the
|
||||||
|
meantime stops it (and gives a `disabled_conflict` warning until then).
|
||||||
|
`PUT /rrsets` makes the RRset exactly the given data: a disabled record
|
||||||
|
with one of the values is enabled, other disabled records are left alone.
|
||||||
|
Disabling a record that is already disabled is a 409; with `missing_ok=1`
|
||||||
|
(by match) it's "nothing to do".
|
||||||
|
|
||||||
### Views
|
### Views
|
||||||
|
|
||||||
@@ -424,6 +455,7 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`:
|
|||||||
| `record.create` | | RecordInput + `"zone": "adbraun.lol"` (or `zone_id`) and `"view"` |
|
| `record.create` | | RecordInput + `"zone": "adbraun.lol"` (or `zone_id`) and `"view"` |
|
||||||
| `record.update` | record | RecordInput |
|
| `record.update` | record | RecordInput |
|
||||||
| `record.delete` | record, or 0 with `zone`, `name`, `type`, optional `data`, `missing_ok`, `ptr` | |
|
| `record.delete` | record, or 0 with `zone`, `name`, `type`, optional `data`, `missing_ok`, `ptr` | |
|
||||||
|
| `record.disable` / `record.enable` | record, or 0 with the same match fields as `record.delete` | stored as `record.update` operations |
|
||||||
| `rrset.replace` | | `{"zone", "view", "name", "type", "ttl", "data": [...]}` (empty data deletes the RRset) |
|
| `rrset.replace` | | `{"zone", "view", "name", "type", "ttl", "data": [...]}` (empty data deletes the RRset) |
|
||||||
| `view.create` / `view.update` / `view.delete` / `view.reorder` | view (update/delete) | ViewInput / `{"ids": [...]}` |
|
| `view.create` / `view.update` / `view.delete` / `view.reorder` | view (update/delete) | ViewInput / `{"ids": [...]}` |
|
||||||
| `acl.create` / `acl.update` / `acl.delete` | ACL | ACL fields |
|
| `acl.create` / `acl.update` / `acl.delete` | ACL | ACL fields |
|
||||||
@@ -454,7 +486,7 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
| 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, warnings}` |
|
||||||
| 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`, `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`, `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), `since`, `limit` (default 20) | `[{key, count}]` |
|
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused), `since`, `limit` (default 20) | `[{key, count}]`; client and refused 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/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` |
|
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` |
|
||||||
|
|
||||||
|
|||||||
+37
-5
@@ -139,10 +139,11 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
|
|||||||
| Flag | Meaning |
|
| Flag | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `-o table\|json\|yaml` | Output format. JSON and YAML have the API's full objects, for scripts. |
|
| `-o table\|json\|yaml` | Output format. JSON and YAML have the API's full objects, for scripts. |
|
||||||
|
| `-o id`, `-o field=NAME[,NAME...]` | Just the IDs, or just those fields, one line per object (fields separated by tabs). See [Scripting tips](#scripting-tips). |
|
||||||
| `-n, --dry-run` | Validate and show the diff without changing anything. |
|
| `-n, --dry-run` | Validate and show the diff without changing anything. |
|
||||||
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
|
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
|
||||||
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
|
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
|
||||||
| `-q, --quiet` | Print only errors, or just the ID where a command creates something. |
|
| `-q, --quiet` | Print only errors, or just the IDs: of what a command creates, or what `show` and `list` find (like `-o id`). |
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
|
|
||||||
@@ -245,7 +246,7 @@ Jarl.)
|
|||||||
## Records
|
## Records
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
jarl record list adbraun.lol [--name www] [-t A] [--search nas]
|
jarl record list adbraun.lol [--name www] [-t A] [--search nas] [--disabled]
|
||||||
jarl record add adbraun.lol nas A 192.168.1.20 --ptr --comment "synology"
|
jarl record add adbraun.lol nas A 192.168.1.20 --ptr --comment "synology"
|
||||||
jarl record add adbraun.lol @ MX 10 mail
|
jarl record add adbraun.lol @ MX 10 mail
|
||||||
jarl record add adbraun.lol @ TXT "v=spf1 mx -all" # plain text, quoting is automatic
|
jarl record add adbraun.lol @ TXT "v=spf1 mx -all" # plain text, quoting is automatic
|
||||||
@@ -257,6 +258,9 @@ jarl record update 1234 --ttl default
|
|||||||
jarl record delete adbraun.lol www A # every A record of www
|
jarl record delete adbraun.lol www A # every A record of www
|
||||||
jarl record delete adbraun.lol www A 192.168.1.10 # just this one
|
jarl record delete adbraun.lol www A 192.168.1.10 # just this one
|
||||||
jarl record delete --id 1234 [--ptr]
|
jarl record delete --id 1234 [--ptr]
|
||||||
|
jarl record disable adbraun.lol www A 192.168.1.10 --ptr # out of the zone, kept in Jarl
|
||||||
|
jarl record enable adbraun.lol www A 192.168.1.10 --ptr # back again
|
||||||
|
jarl record disable --id 1234 # by ID; same for enable
|
||||||
|
|
||||||
# reverse zones: name records by address; the host name needs no final dot
|
# reverse zones: name records by address; the host name needs no final dot
|
||||||
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
|
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
|
||||||
@@ -273,6 +277,16 @@ by Jarl). Every record type BIND supports can be used. Jarl knows the
|
|||||||
syntax of the common ones and checks them strictly, and the rest are
|
syntax of the common ones and checks them strictly, and the rest are
|
||||||
validated by parsing.
|
validated by parsing.
|
||||||
|
|
||||||
|
**Disabling** a record takes it out of the zone without deleting it: it
|
||||||
|
keeps its ID, TTL and comment, the zone file lists it as a comment
|
||||||
|
(`; disabled: www IN A ...`), and `enable` puts it back exactly as it
|
||||||
|
was. `record list` marks disabled records `[disabled]`, and `zone show`
|
||||||
|
counts them. The zone is validated as it is served: you can't disable its
|
||||||
|
last NS record, and enabling a record is checked like adding it (a CNAME
|
||||||
|
added at the same name in the meantime stops it). `record set` enables a
|
||||||
|
disabled record whose value it is given, and leaves other disabled
|
||||||
|
records alone.
|
||||||
|
|
||||||
## Batches: `jarl apply -f`
|
## Batches: `jarl apply -f`
|
||||||
|
|
||||||
Several changes that must succeed or fail together:
|
Several changes that must succeed or fail together:
|
||||||
@@ -287,6 +301,7 @@ changes:
|
|||||||
- set: {zone: adbraun.lol, name: api, type: A, data: [192.168.1.41, 192.168.1.42], ttl: 60}
|
- set: {zone: adbraun.lol, name: api, type: A, data: [192.168.1.41, 192.168.1.42], ttl: 60}
|
||||||
- update: {id: 1235, comment: "old web server, remove in November"}
|
- update: {id: 1235, comment: "old web server, remove in November"}
|
||||||
- delete: {id: 1240}
|
- delete: {id: 1240}
|
||||||
|
- disable: {zone: adbraun.lol, name: old, type: A} # or {id: ...}; enable: likewise
|
||||||
- op: acl.update # any API operation (see api.md)
|
- op: acl.update # any API operation (see api.md)
|
||||||
id: 3
|
id: 3
|
||||||
data: {elements: [192.168.1.0/24, 10.10.0.0/16]}
|
data: {elements: [192.168.1.0/24, 10.10.0.0/16]}
|
||||||
@@ -426,8 +441,25 @@ See [acme.md](acme.md) for certbot, acme.sh and a dedicated ACME zone.
|
|||||||
|
|
||||||
## Scripting tips
|
## Scripting tips
|
||||||
|
|
||||||
* `-o json` gives full API objects. Pipe them to `jq`.
|
* `-o id` prints just the IDs of what a `show` or `list` command finds, one
|
||||||
* `-q` with `create` commands prints just the ID or token.
|
per line, and `-o field=...` picks fields (their names are those of
|
||||||
|
`-o json`; a dot reaches inside, as in `soa.serial`; a field an object
|
||||||
|
doesn't have prints as empty). For changes, they apply to the changed
|
||||||
|
object:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ZONE=$(jarl zone show adbraun.lol --view external -o id)
|
||||||
|
jarl record list adbraun.lol --name www -t A -o id | xargs -n1 jarl record disable --id
|
||||||
|
jarl zone show adbraun.lol -o field=view,soa.serial # external 2026100901
|
||||||
|
jarl zone list -o field=id,name,view
|
||||||
|
ID=$(jarl record add adbraun.lol www A 192.0.2.10 -o id) # the new record's ID
|
||||||
|
```
|
||||||
|
* `-q` with `create` commands prints just the ID or token, and with `show`
|
||||||
|
and `list` just the IDs. Commands without IDs (status, checks) print
|
||||||
|
nothing but errors.
|
||||||
|
* `jarl record list` on a terminal shortens data too long for a line
|
||||||
|
(`…`); `--wide` shows it in full, and so does output to a pipe or file.
|
||||||
* Check `$?` against the exit code table.
|
* Check `$?` against the exit code table.
|
||||||
* `jarl record delete ... --missing-ok` succeeds when nothing matches, which
|
* `jarl record delete ... --missing-ok` succeeds when nothing matches, which
|
||||||
keeps cleanup scripts idempotent. `jarl record set` is idempotent by nature.
|
keeps cleanup scripts idempotent. So does `record disable|enable
|
||||||
|
--missing-ok`, also when the records are already disabled (enabled). `jarl record set` is idempotent by nature.
|
||||||
+27
-1
@@ -18,7 +18,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
|
|||||||
|
|
||||||
| Key | Default | Meaning |
|
| Key | Default | Meaning |
|
||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. |
|
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. Ports below 1024 (`"192.168.10.2:443"`) work because `jarl.service` gives jarld the `CAP_NET_BIND_SERVICE` capability, and only that ([HTTPS on port 443](#example-https-on-port-443)). |
|
||||||
| `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. |
|
| `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. |
|
||||||
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. |
|
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. |
|
||||||
| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. |
|
| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. |
|
||||||
@@ -149,6 +149,32 @@ audit:
|
|||||||
retention_days: 730
|
retention_days: 730
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Example: HTTPS on port 443
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
listen:
|
||||||
|
- "127.0.0.1:8053" # the CLI on the server keeps using this (or the socket)
|
||||||
|
- "192.168.10.2:443"
|
||||||
|
tls:
|
||||||
|
cert_file: /etc/jarl/tls/fullchain.pem
|
||||||
|
key_file: /etc/jarl/tls/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
jarld runs as the unprivileged `jarl` user, and binding a port below 1024
|
||||||
|
needs the `CAP_NET_BIND_SERVICE` capability, which `jarl.service` grants
|
||||||
|
(`AmbientCapabilities=CAP_NET_BIND_SERVICE`; nothing else). The unit in
|
||||||
|
Jarl 0.2.0 and earlier doesn't, and jarld then fails with "bind:
|
||||||
|
permission denied". Upgrading the package installs the new unit; until
|
||||||
|
then, a drop-in does the same:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl edit jarl
|
||||||
|
# [Service]
|
||||||
|
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
# AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
sudo systemctl restart jarl
|
||||||
|
```
|
||||||
|
|
||||||
## Example: behind nginx
|
## Example: behind nginx
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+12
-2
@@ -122,8 +122,18 @@ can make validating resolvers reject cached answers. Then:
|
|||||||
[DNSViz](https://dnsviz.net/) gives an independent picture.
|
[DNSViz](https://dnsviz.net/) gives an independent picture.
|
||||||
|
|
||||||
If the parent zone is itself a primary zone in Jarl (`home.adbraun.lol`
|
If the parent zone is itself a primary zone in Jarl (`home.adbraun.lol`
|
||||||
inside `adbraun.lol`), the DNSSEC tab says so: add the DS as a record in
|
inside `adbraun.lol`), the DS goes in as a record in that zone, and Jarl
|
||||||
that zone.
|
gives you both ways to add it: an **Add to adbraun.lol** button on the
|
||||||
|
DNSSEC tab, and the command, which `jarl zone dnssec status` prints too:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jarl record add adbraun.lol home DS 51126 13 2 FFBECE3D...
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the parent zone has it, the tab and `status` say so. When turning
|
||||||
|
signing off, the steps give the matching `jarl record delete adbraun.lol
|
||||||
|
home DS`. The zone must be delegated first (NS records for `home` in
|
||||||
|
`adbraun.lol`), or validation refuses the DS.
|
||||||
|
|
||||||
## Putting your own name servers on the internet
|
## Putting your own name servers on the internet
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -88,9 +88,9 @@ password <token>
|
|||||||
Download the two `.deb` files from the release page, or build them:
|
Download the two `.deb` files from the release page, or build them:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make deb # dist/jarl_0.1.0_amd64.deb and dist/jarl-cli_0.1.0_amd64.deb
|
make deb # dist/jarl_0.2.0_amd64.deb and dist/jarl-cli_0.2.0_amd64.deb
|
||||||
make deb DEB_ARCH=arm64 # for another architecture (also armhf, i386, ...)
|
make deb DEB_ARCH=arm64 # for another architecture (also armhf, i386, ...)
|
||||||
sudo apt install ./dist/jarl_0.1.0_amd64.deb ./dist/jarl-cli_0.1.0_amd64.deb
|
sudo apt install ./dist/jarl_0.2.0_amd64.deb ./dist/jarl-cli_0.2.0_amd64.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
Give apt both files: `jarl` needs `jarl-cli` of the same version.
|
Give apt both files: `jarl` needs `jarl-cli` of the same version.
|
||||||
@@ -139,7 +139,7 @@ To manage Jarl servers from your laptop or another server, install only
|
|||||||
users, services or directories):
|
users, services or directories):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt install jarl-cli # with the registry above; or: sudo apt install ./jarl-cli_0.1.0_amd64.deb
|
sudo apt install jarl-cli # with the registry above; or: sudo apt install ./jarl-cli_0.2.0_amd64.deb
|
||||||
jarl config set server https://ns1.adbraun.lol:8053
|
jarl config set server https://ns1.adbraun.lol:8053
|
||||||
jarl config set token
|
jarl config set token
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -111,6 +111,16 @@ Nothing else is stored. In particular there's no per-query record, so you
|
|||||||
can't look up "who asked for X at 14:02". That is deliberate, both for size
|
can't look up "who asked for X at 14:02". That is deliberate, both for size
|
||||||
and privacy.
|
and privacy.
|
||||||
|
|
||||||
|
**Client names.** Where the client lists (busiest and refused clients) show
|
||||||
|
an address Jarl has a record for, they show its host name too: the PTR
|
||||||
|
record's target, or else the name of an A/AAAA record with that address
|
||||||
|
(disabled records don't count, and only records you may read). This is
|
||||||
|
looked up in Jarl's own data when the list is shown, not stored and not
|
||||||
|
asked of DNS, so it is instant and adds no queries of its own. The name is
|
||||||
|
what the records say now: with DHCP, an address seen last week may have
|
||||||
|
been another machine. Anonymized clients (`anonymize_clients`) are
|
||||||
|
prefixes and get no name.
|
||||||
|
|
||||||
## Using it
|
## Using it
|
||||||
|
|
||||||
**Web UI**: Statistics. Pick a range (1 hour to 90 days); everything on the
|
**Web UI**: Statistics. Pick a range (1 hour to 90 days); everything on the
|
||||||
|
|||||||
+13
-5
@@ -19,9 +19,11 @@ const Prefix = "/api/v1"
|
|||||||
// Zone is a zone as returned by the API.
|
// Zone is a zone as returned by the API.
|
||||||
type Zone struct {
|
type Zone struct {
|
||||||
model.Zone
|
model.Zone
|
||||||
Display string `json:"display_name"`
|
Display string `json:"display_name"`
|
||||||
View string `json:"view,omitempty"`
|
View string `json:"view,omitempty"`
|
||||||
Records int `json:"record_count"`
|
Records int `json:"record_count"`
|
||||||
|
// Disabled is how many of the records are disabled.
|
||||||
|
Disabled int `json:"disabled_count,omitempty"`
|
||||||
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
|
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
|
||||||
RName string `json:"rname_email,omitempty"`
|
RName string `json:"rname_email,omitempty"`
|
||||||
// Registrar names the registrar Jarl can manage the domain at
|
// Registrar names the registrar Jarl can manage the domain at
|
||||||
@@ -40,7 +42,8 @@ type Record struct {
|
|||||||
EffectiveTTL uint32 `json:"effective_ttl"`
|
EffectiveTTL uint32 `json:"effective_ttl"`
|
||||||
// Text is the decoded value of TXT and SPF records.
|
// Text is the decoded value of TXT and SPF records.
|
||||||
Text *string `json:"text,omitempty"`
|
Text *string `json:"text,omitempty"`
|
||||||
// Pending is set when listing with a changeset: add, change or delete.
|
// Pending is set when listing with a changeset: add, change, delete,
|
||||||
|
// disable or enable.
|
||||||
Pending string `json:"pending,omitempty"`
|
Pending string `json:"pending,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +373,12 @@ type DNSSECResponse struct {
|
|||||||
*dnssec.Status
|
*dnssec.Status
|
||||||
// ParentZone is set when the parent zone is a primary zone in Jarl
|
// ParentZone is set when the parent zone is a primary zone in Jarl
|
||||||
// (in the same view): the DS record then goes there, as a record.
|
// (in the same view): the DS record then goes there, as a record.
|
||||||
ParentZone string `json:"parent_zone,omitempty"`
|
ParentZone string `json:"parent_zone,omitempty"`
|
||||||
|
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
|
||||||
|
ParentView string `json:"parent_view,omitempty"`
|
||||||
|
// ParentDS is the DS records the parent zone in Jarl has for the
|
||||||
|
// zone (record data, e.g. "51126 13 2 FFBE...").
|
||||||
|
ParentDS []string `json:"parent_ds,omitempty"`
|
||||||
// Registrar names the registrar Jarl can publish the DS at
|
// Registrar names the registrar Jarl can publish the DS at
|
||||||
// ("porkbun"), when one is configured in jarl.yaml.
|
// ("porkbun"), when one is configured in jarl.yaml.
|
||||||
Registrar string `json:"registrar,omitempty"`
|
Registrar string `json:"registrar,omitempty"`
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg
|
|||||||
return t.RNDCRun(ctx, args...)
|
return t.RNDCRun(ctx, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZoneCommand is the rndc command line for a zone command, as shown to
|
||||||
|
// users ("rndc zonestatus example.org IN internal").
|
||||||
|
func ZoneCommand(cmd, zone, view string) string {
|
||||||
|
return "rndc " + strings.Join(zoneArgs(cmd, zone, view), " ")
|
||||||
|
}
|
||||||
|
|
||||||
func zoneArgs(cmd, zone, view string) []string {
|
func zoneArgs(cmd, zone, view string) []string {
|
||||||
z := strings.TrimSuffix(zone, ".")
|
z := strings.TrimSuffix(zone, ".")
|
||||||
if z == "" {
|
if z == "" {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
|||||||
}
|
}
|
||||||
for _, k := range sortedKeys(to.Records) {
|
for _, k := range sortedKeys(to.Records) {
|
||||||
n := to.Records[k]
|
n := to.Records[k]
|
||||||
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment}
|
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
|
||||||
if n.TTL != nil {
|
if n.TTL != nil {
|
||||||
in.TTL = model.Some(*n.TTL)
|
in.TTL = model.Some(*n.TTL)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func disableReq(id int64, ptr bool) Request {
|
||||||
|
return NewRequest(OpRecordDisable, id, RecordInput{PTR: ptr})
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableReq(id int64, ptr bool) Request {
|
||||||
|
return NewRequest(OpRecordEnable, id, RecordInput{PTR: ptr})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (env *testEnv) zoneFile(name string) string {
|
||||||
|
env.t.Helper()
|
||||||
|
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".zone"))
|
||||||
|
if err != nil {
|
||||||
|
env.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serialOf(env *testEnv, zone string) uint32 {
|
||||||
|
return env.state().ZonesNamed(zone)[0].SOA.Serial
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisableRecord(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||||
|
www := findRec(env.state(), "www.example.com.", "A")
|
||||||
|
serial := serialOf(env, "example.com")
|
||||||
|
|
||||||
|
res := env.submit(env.admin, disableReq(www.ID, false))
|
||||||
|
if len(res.Ops) != 1 || res.Ops[0].Summary != "disable www.example.com. A 192.0.2.10" {
|
||||||
|
t.Fatalf("ops = %+v", res.Ops)
|
||||||
|
}
|
||||||
|
if r := env.state().Records[www.ID]; r == nil || !r.Disabled {
|
||||||
|
t.Fatalf("record = %+v", r)
|
||||||
|
}
|
||||||
|
zf := env.zoneFile("example.com")
|
||||||
|
if !strings.Contains(zf, "; disabled: www\t\tIN\tA\t192.0.2.10\n") || strings.Contains(zf, "\nwww\t") {
|
||||||
|
t.Errorf("zone file:\n%s", zf)
|
||||||
|
}
|
||||||
|
if s := serialOf(env, "example.com"); s != serial+1 {
|
||||||
|
t.Errorf("serial after disable = %d, want %d", s, serial+1)
|
||||||
|
}
|
||||||
|
// Editing a disabled record changes nothing that is served.
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, www.ID, RecordInput{Comment: model.Ptr("old web server"), Data: model.Ptr("192.0.2.11")}))
|
||||||
|
if s := serialOf(env, "example.com"); s != serial+1 {
|
||||||
|
t.Errorf("serial after editing a disabled record = %d", s)
|
||||||
|
}
|
||||||
|
// Disabling it again says so.
|
||||||
|
if e := env.submitErr(env.admin, disableReq(www.ID, false)); e.Kind != KindConflict || !strings.Contains(e.Message, "already disabled") {
|
||||||
|
t.Errorf("second disable: %+v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.submit(env.admin, enableReq(www.ID, false))
|
||||||
|
r := env.state().Records[www.ID]
|
||||||
|
if r.Disabled || r.Data != "192.0.2.11" || r.Comment != "old web server" {
|
||||||
|
t.Errorf("enabled record = %+v", r)
|
||||||
|
}
|
||||||
|
if zf := env.zoneFile("example.com"); !strings.Contains(zf, "\nwww\t\tIN\tA\t192.0.2.11\t; old web server\n") || strings.Contains(zf, "disabled") {
|
||||||
|
t.Errorf("zone file after enable:\n%s", zf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisableValidation(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
// The zone must keep a name server.
|
||||||
|
ns := findRec(env.state(), "example.com.", "NS")
|
||||||
|
if e := env.submitErr(env.admin, disableReq(ns.ID, false)); e.Kind != KindValidation {
|
||||||
|
t.Errorf("disabling the only NS: %+v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A disabled record doesn't count for the CNAME rule...
|
||||||
|
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||||
|
www := findRec(env.state(), "www.example.com.", "A")
|
||||||
|
env.submit(env.admin, disableReq(www.ID, false))
|
||||||
|
res := env.submit(env.admin, rec("example.com", "www", "CNAME", "ns1"))
|
||||||
|
// ...but there's a warning that it can't come back,
|
||||||
|
if len(res.Problems) != 1 || res.Problems[0].Code != "disabled_conflict" || res.Problems[0].ObjectID != www.ID {
|
||||||
|
t.Errorf("problems = %+v", res.Problems)
|
||||||
|
}
|
||||||
|
// and enabling it is checked like adding it.
|
||||||
|
if e := env.submitErr(env.admin, enableReq(www.ID, false)); e.Kind != KindValidation || !strings.Contains(e.Message, "CNAME") {
|
||||||
|
t.Errorf("enabling next to a CNAME: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisableByMatchAndPTR(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.0/24")}))
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.20"), PTR: true}))
|
||||||
|
ptrDisabled := func() bool {
|
||||||
|
return findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR").Disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// By match, with the PTR record.
|
||||||
|
match := RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), PTR: true}
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordDisable, 0, match))
|
||||||
|
if len(res.Ops) != 2 || res.Ops[1].Summary != "disable 20.2.0.192.in-addr.arpa. PTR nas.example.com." {
|
||||||
|
t.Fatalf("ops = %+v", res.Ops)
|
||||||
|
}
|
||||||
|
if !findRec(env.state(), "nas.example.com.", "A").Disabled || !ptrDisabled() {
|
||||||
|
t.Fatal("A or PTR not disabled")
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordEnable, 0, match))
|
||||||
|
if findRec(env.state(), "nas.example.com.", "A").Disabled || ptrDisabled() {
|
||||||
|
t.Fatal("A or PTR not enabled")
|
||||||
|
}
|
||||||
|
// Without ptr, the PTR record stays as it is.
|
||||||
|
a := findRec(env.state(), "nas.example.com.", "A")
|
||||||
|
env.submit(env.admin, disableReq(a.ID, false))
|
||||||
|
if ptrDisabled() {
|
||||||
|
t.Error("PTR disabled without ptr")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data narrows the match; nothing matching is an error unless missing_ok.
|
||||||
|
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.30"), rec("example.com", "api", "A", "192.0.2.31"))
|
||||||
|
one := RecordInput{Zone: "example.com", Name: model.Ptr("api"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.31")}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordDisable, 0, one))
|
||||||
|
if findRecData(env.state(), "api.example.com.", "192.0.2.30").Disabled || !findRecData(env.state(), "api.example.com.", "192.0.2.31").Disabled {
|
||||||
|
t.Error("data match wrong")
|
||||||
|
}
|
||||||
|
none := RecordInput{Zone: "example.com", Name: model.Ptr("nope"), Type: model.Ptr("A")}
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpRecordDisable, 0, none)); e.Kind != KindNotFound {
|
||||||
|
t.Errorf("no match: %+v", e)
|
||||||
|
}
|
||||||
|
// missing_ok: nothing to do (an error the CLI's --missing-ok accepts).
|
||||||
|
none.MissingOK = true
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpRecordDisable, 0, none)); e.Message != "nothing to do" {
|
||||||
|
t.Errorf("missing_ok: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisableRRSetAndRevert(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
setupZone(env)
|
||||||
|
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.30"), rec("example.com", "api", "A", "192.0.2.31"))
|
||||||
|
r30 := findRecData(env.state(), "api.example.com.", "192.0.2.30")
|
||||||
|
r31 := findRecData(env.state(), "api.example.com.", "192.0.2.31")
|
||||||
|
env.submit(env.admin, disableReq(r30.ID, false), disableReq(r31.ID, false))
|
||||||
|
|
||||||
|
// Setting the RRset enables a disabled record with a given value and
|
||||||
|
// leaves the other disabled one alone.
|
||||||
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", Data: []string{"192.0.2.30", "192.0.2.40"}}))
|
||||||
|
s := env.state()
|
||||||
|
if r := s.Records[r30.ID]; r == nil || r.Disabled {
|
||||||
|
t.Errorf(".30 = %+v, want kept and enabled", r)
|
||||||
|
}
|
||||||
|
if r := s.Records[r31.ID]; r == nil || !r.Disabled {
|
||||||
|
t.Errorf(".31 = %+v, want kept and disabled", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverting a disabling changeset enables the record again.
|
||||||
|
res := env.submit(env.admin, disableReq(r30.ID, false))
|
||||||
|
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if env.state().Records[r30.ID].Disabled {
|
||||||
|
t.Error("revert did not enable the record")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1222,6 +1222,15 @@ func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal
|
|||||||
// secondaries and the rest of the world see), not that of the zone file
|
// secondaries and the rest of the world see), not that of the zone file
|
||||||
// Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't
|
// Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't
|
||||||
// report a serial (the zone isn't loaded yet).
|
// report a serial (the zone isn't loaded yet).
|
||||||
|
// LoadedSerialCommand is the command LoadedSerial runs, or "" when it
|
||||||
|
// doesn't ask named.
|
||||||
|
func (e *Engine) LoadedSerialCommand(st *model.State, z *model.Zone) string {
|
||||||
|
if e.cfg.NoReload || e.tools == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return bind.ZoneCommand("zonestatus", z.Name, e.namedView(st, z))
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) {
|
func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) {
|
||||||
if e.cfg.NoReload || e.tools == nil {
|
if e.cfg.NoReload || e.tools == nil {
|
||||||
return 0, false, nil
|
return 0, false, nil
|
||||||
|
|||||||
+139
-6
@@ -155,6 +155,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
return x.recordDelete(req)
|
return x.recordDelete(req)
|
||||||
case OpRRSetReplace:
|
case OpRRSetReplace:
|
||||||
return x.rrsetReplace(req)
|
return x.rrsetReplace(req)
|
||||||
|
case OpRecordDisable, OpRecordEnable:
|
||||||
|
return x.recordSetDisabled(req, req.Op == OpRecordDisable)
|
||||||
case OpViewCreate:
|
case OpViewCreate:
|
||||||
return x.viewCreate(req)
|
return x.viewCreate(req)
|
||||||
case OpViewUpdate, OpViewDelete:
|
case OpViewUpdate, OpViewDelete:
|
||||||
@@ -413,6 +415,9 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
|||||||
if src != nil {
|
if src != nil {
|
||||||
for _, r := range x.st.ZoneRecords(src.ID) {
|
for _, r := range x.st.ZoneRecords(src.ID) {
|
||||||
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
||||||
|
if r.Disabled {
|
||||||
|
ri.Disabled = model.Ptr(true)
|
||||||
|
}
|
||||||
if r.TTL != nil {
|
if r.TTL != nil {
|
||||||
ri.TTL = model.Some(*r.TTL)
|
ri.TTL = model.Some(*r.TTL)
|
||||||
} else {
|
} else {
|
||||||
@@ -502,6 +507,10 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
|||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" {
|
if r.Name == ptrName && r.Type == "PTR" {
|
||||||
if strings.EqualFold(r.Data, owner) {
|
if strings.EqualFold(r.Data, owner) {
|
||||||
|
if r.Disabled {
|
||||||
|
in := RecordInput{Disabled: model.Ptr(false)}
|
||||||
|
return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil
|
||||||
|
}
|
||||||
return nil, nil // already there
|
return nil, nil // already there
|
||||||
}
|
}
|
||||||
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
|
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
|
||||||
@@ -515,6 +524,34 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
|||||||
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
|
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ptrSetDisabled returns operations disabling or enabling the PTR record
|
||||||
|
// of an address record.
|
||||||
|
func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op {
|
||||||
|
addr, ok := addrOf(p)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||||
|
if rz == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ops []model.Op
|
||||||
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
|
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
||||||
|
in := RecordInput{Disabled: model.Ptr(disabled)}
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableWord(disabled bool) string {
|
||||||
|
if disabled {
|
||||||
|
return "disable"
|
||||||
|
}
|
||||||
|
return "enable"
|
||||||
|
}
|
||||||
|
|
||||||
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
|
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
|
||||||
addr, ok := addrOf(p)
|
addr, ok := addrOf(p)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -635,8 +672,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
if in.Comment != nil && *in.Comment != r.Comment {
|
if in.Comment != nil && *in.Comment != r.Comment {
|
||||||
changes = append(changes, "comment")
|
changes = append(changes, "comment")
|
||||||
}
|
}
|
||||||
|
toggled := in.Disabled != nil && *in.Disabled != r.Disabled
|
||||||
summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data)
|
summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data)
|
||||||
if len(changes) > 0 {
|
switch {
|
||||||
|
case toggled && len(changes) == 0:
|
||||||
|
summary = fmt.Sprintf("%s %s %s %s", enableWord(*in.Disabled), r.Name, r.Type, r.Data)
|
||||||
|
case toggled:
|
||||||
|
changes = append(changes, enableWord(*in.Disabled)+"d")
|
||||||
|
fallthrough
|
||||||
|
case len(changes) > 0:
|
||||||
summary += ": " + strings.Join(changes, ", ")
|
summary += ": " + strings.Join(changes, ", ")
|
||||||
}
|
}
|
||||||
base := req.BaseVersion
|
base := req.BaseVersion
|
||||||
@@ -644,15 +688,28 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
base = r.Version
|
base = r.Version
|
||||||
}
|
}
|
||||||
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
|
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
|
||||||
if ptr && (p.Data != r.Data || owner != r.Name) {
|
disabled := r.Disabled
|
||||||
|
if in.Disabled != nil {
|
||||||
|
disabled = *in.Disabled
|
||||||
|
}
|
||||||
|
moved := p.Data != r.Data || owner != r.Name
|
||||||
|
if ptr && moved {
|
||||||
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||||
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
||||||
}
|
}
|
||||||
more, err := x.ptrCreate(z, owner, p)
|
if !disabled {
|
||||||
if err != nil {
|
more, err := x.ptrCreate(z, owner, p)
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
}
|
}
|
||||||
ops = append(ops, more...)
|
}
|
||||||
|
if ptr && !moved && toggled {
|
||||||
|
if _, ok := addrOf(p); !ok {
|
||||||
|
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||||
|
}
|
||||||
|
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
||||||
}
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
@@ -725,6 +782,82 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
|||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordSetDisabled expands record.disable and record.enable: the record
|
||||||
|
// with the request's ID, or the records matching zone, name, type and
|
||||||
|
// (optionally) data.
|
||||||
|
func (x *expander) recordSetDisabled(req Request, disabled bool) ([]model.Op, error) {
|
||||||
|
var in RecordInput
|
||||||
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var recs []*model.Record
|
||||||
|
if req.ID != 0 {
|
||||||
|
r := x.st.Records[req.ID]
|
||||||
|
if r == nil {
|
||||||
|
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
|
||||||
|
}
|
||||||
|
recs = append(recs, r)
|
||||||
|
} else {
|
||||||
|
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if in.Name == nil || in.Type == nil {
|
||||||
|
return nil, errf(KindBadRequest, "finding records needs a name and a type (or give the record id)")
|
||||||
|
}
|
||||||
|
owner, err := dnsname.Owner(*in.Name, z.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errf(KindBadRequest, "%v", err)
|
||||||
|
}
|
||||||
|
typ, _, err := rr.NormalizeType(*in.Type)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errf(KindBadRequest, "%v", err)
|
||||||
|
}
|
||||||
|
data, what := "", owner+" "+typ
|
||||||
|
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
|
||||||
|
_, p, err := parseRecord(z, owner, typ, *in.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, what = p.Data, what+" "+p.Data
|
||||||
|
}
|
||||||
|
for _, r := range x.st.ZoneRecords(z.ID) {
|
||||||
|
if r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) {
|
||||||
|
recs = append(recs, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(recs) == 0 {
|
||||||
|
if in.MissingOK {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errf(KindNotFound, "no record %s in zone %s", dnsname.Display(what), dnsname.Display(z.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ops []model.Op
|
||||||
|
for _, r := range recs {
|
||||||
|
if r.Disabled == disabled {
|
||||||
|
continue // already so
|
||||||
|
}
|
||||||
|
base := 0
|
||||||
|
if req.ID != 0 {
|
||||||
|
base = req.BaseVersion
|
||||||
|
}
|
||||||
|
more, err := x.recordUpdate(Request{Op: OpRecordUpdate, ID: r.ID, BaseVersion: base, Data: mustJSON(RecordInput{Disabled: model.Ptr(disabled), PTR: in.PTR})})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
|
if len(ops) == 0 && !in.MissingOK {
|
||||||
|
what := fmt.Sprintf("%s %s %s is", recs[0].Name, recs[0].Type, recs[0].Data)
|
||||||
|
if len(recs) > 1 {
|
||||||
|
what = fmt.Sprintf("all %d %s %s records are", len(recs), recs[0].Name, recs[0].Type)
|
||||||
|
}
|
||||||
|
return nil, errf(KindConflict, "%s already %sd", what, enableWord(disabled))
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
||||||
var in RRSetInput
|
var in RRSetInput
|
||||||
if err := decode(req.Data, &in); err != nil {
|
if err := decode(req.Data, &in); err != nil {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ const (
|
|||||||
OpOptionUpdate = "option.update"
|
OpOptionUpdate = "option.update"
|
||||||
OpOptionDelete = "option.delete"
|
OpOptionDelete = "option.delete"
|
||||||
OpSettingsUpdate = "settings.update"
|
OpSettingsUpdate = "settings.update"
|
||||||
|
|
||||||
|
// Requests only: disabling and enabling records, by ID or by match
|
||||||
|
// (zone, name, type and optionally data), become record.update
|
||||||
|
// operations.
|
||||||
|
OpRecordDisable = "record.disable"
|
||||||
|
OpRecordEnable = "record.enable"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpKinds lists all operation kinds.
|
// OpKinds lists all operation kinds.
|
||||||
@@ -121,8 +127,10 @@ type RecordInput struct {
|
|||||||
TTL model.Opt[uint32] `json:"ttl,omitzero"` // null: use the zone default
|
TTL model.Opt[uint32] `json:"ttl,omitzero"` // null: use the zone default
|
||||||
Data *string `json:"data,omitempty"`
|
Data *string `json:"data,omitempty"`
|
||||||
Comment *string `json:"comment,omitempty"`
|
Comment *string `json:"comment,omitempty"`
|
||||||
// PTR also creates (or deletes) the matching PTR record in the
|
// Disabled keeps the record in Jarl but out of the zone.
|
||||||
// reverse zone, for A and AAAA records.
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
|
// PTR also creates (or deletes, disables or enables) the matching PTR
|
||||||
|
// record in the reverse zone, for A and AAAA records.
|
||||||
PTR bool `json:"ptr,omitempty"`
|
PTR bool `json:"ptr,omitempty"`
|
||||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||||
MissingOK bool `json:"missing_ok,omitempty"`
|
MissingOK bool `json:"missing_ok,omitempty"`
|
||||||
|
|||||||
+22
-4
@@ -373,7 +373,7 @@ func parseRecord(z *model.Zone, name, typ, data string) (string, *rr.Parsed, err
|
|||||||
|
|
||||||
func rrsetTTL(st *model.State, zoneID int64, name, typ string, exceptID int64) (*uint32, bool) {
|
func rrsetTTL(st *model.State, zoneID int64, name, typ string, exceptID int64) (*uint32, bool) {
|
||||||
for _, r := range st.Records {
|
for _, r := range st.Records {
|
||||||
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID {
|
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID && !r.Disabled {
|
||||||
if r.TTL == nil {
|
if r.TTL == nil {
|
||||||
return nil, true
|
return nil, true
|
||||||
}
|
}
|
||||||
@@ -422,6 +422,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
r.Comment = *in.Comment
|
r.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.Disabled != nil {
|
||||||
|
r.Disabled = *in.Disabled
|
||||||
|
}
|
||||||
for _, w := range p.Warnings {
|
for _, w := range p.Warnings {
|
||||||
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
||||||
}
|
}
|
||||||
@@ -498,6 +501,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Comment != nil {
|
if in.Comment != nil {
|
||||||
r.Comment = *in.Comment
|
r.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
|
if in.Disabled != nil {
|
||||||
|
r.Disabled = *in.Disabled
|
||||||
|
}
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -617,20 +623,32 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
} else if t, ok := rrsetTTL(st, z.ID, owner, typ, 0); ok {
|
} else if t, ok := rrsetTTL(st, z.ID, owner, typ, 0); ok {
|
||||||
ttl, ttlKnown = t, true
|
ttl, ttlKnown = t, true
|
||||||
}
|
}
|
||||||
|
// The RRset becomes exactly the given data. A disabled record with
|
||||||
|
// one of the values is enabled again; other disabled records are left
|
||||||
|
// alone.
|
||||||
keep := map[string]bool{}
|
keep := map[string]bool{}
|
||||||
for id, r := range st.Records {
|
// (In ID order, so which of two identical records is kept doesn't
|
||||||
|
// depend on map order.)
|
||||||
|
for _, id := range sortedKeys(st.Records) {
|
||||||
|
r := st.Records[id]
|
||||||
if r.ZoneID != z.ID || r.Name != owner || r.Type != typ {
|
if r.ZoneID != z.ID || r.Name != owner || r.Type != typ {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
k := strings.ToLower(r.Data)
|
k := strings.ToLower(r.Data)
|
||||||
if seen[k] && !keep[k] {
|
if seen[k] && !keep[k] {
|
||||||
keep[k] = true
|
keep[k] = true
|
||||||
if ttlKnown && !sameTTL(r.TTL, ttl) {
|
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled {
|
||||||
r.TTL = copyTTL(ttl)
|
if ttlKnown {
|
||||||
|
r.TTL = copyTTL(ttl)
|
||||||
|
}
|
||||||
|
r.Disabled = false
|
||||||
r.Touch(c.user(), c.now)
|
r.Touch(c.user(), c.now)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if r.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
delete(st.Records, id)
|
delete(st.Records, id)
|
||||||
}
|
}
|
||||||
ids := in.NewIDs
|
ids := in.NewIDs
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ type Record struct {
|
|||||||
TTL *uint32 `json:"ttl,omitempty"`
|
TTL *uint32 `json:"ttl,omitempty"`
|
||||||
Data string `json:"data"` // canonical presentation format
|
Data string `json:"data"` // canonical presentation format
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
|
// Disabled records stay in Jarl but are left out of the zone (the
|
||||||
|
// zone file lists them as comments).
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
Meta
|
Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +449,18 @@ func (s *State) ZoneRecords(zoneID int64) []*Record {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServedRecords returns the records of a zone that are in the zone file
|
||||||
|
// (not disabled), in display order.
|
||||||
|
func (s *State) ServedRecords(zoneID int64) []*Record {
|
||||||
|
var out []*Record
|
||||||
|
for _, r := range s.ZoneRecords(zoneID) {
|
||||||
|
if !r.Disabled {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// RecordsByZone groups all records by zone ID (unsorted).
|
// RecordsByZone groups all records by zone ID (unsorted).
|
||||||
func (s *State) RecordsByZone() map[int64][]*Record {
|
func (s *State) RecordsByZone() map[int64][]*Record {
|
||||||
out := map[int64][]*Record{}
|
out := map[int64][]*Record{}
|
||||||
@@ -455,6 +470,17 @@ func (s *State) RecordsByZone() map[int64][]*Record {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServedRecordsByZone is RecordsByZone without disabled records.
|
||||||
|
func (s *State) ServedRecordsByZone() map[int64][]*Record {
|
||||||
|
out := map[int64][]*Record{}
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if !r.Disabled {
|
||||||
|
out[r.ZoneID] = append(out[r.ZoneID], r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ScopedOptions returns the options of a scope, in position order.
|
// ScopedOptions returns the options of a scope, in position order.
|
||||||
func (s *State) ScopedOptions(scope string, scopeID int64) []*Option {
|
func (s *State) ScopedOptions(scope string, scopeID int64) []*Option {
|
||||||
var out []*Option
|
var out []*Option
|
||||||
|
|||||||
+152
-5
@@ -20,8 +20,10 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -93,6 +95,134 @@ type Checker struct {
|
|||||||
// IsLocal reports whether an address belongs to this host (default:
|
// IsLocal reports whether an address belongs to this host (default:
|
||||||
// its interface addresses and loopback).
|
// its interface addresses and loopback).
|
||||||
IsLocal func(netip.Addr) bool
|
IsLocal func(netip.Addr) bool
|
||||||
|
// Watch, if set, is told about each DNS query the checks send and
|
||||||
|
// its outcome as it happens (the web UI shows them while it waits).
|
||||||
|
// It is called from several goroutines at once.
|
||||||
|
Watch func(Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is a query a check starts or finishes (Checker.Watch).
|
||||||
|
type Event struct {
|
||||||
|
// Kind is "start" or "end" (of query ID).
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
// Query describes what is asked, as a dig command line.
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
// End: Answer sums up the answer ("192.0.2.1, 2001:db8::1",
|
||||||
|
// "NXDOMAIN", "referral to a.nic.lol., …"), or Error says why there
|
||||||
|
// is none. Serials' SOA queries give the server's Result instead.
|
||||||
|
Answer string `json:"answer,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
RTTMs int64 `json:"rtt_ms,omitempty"`
|
||||||
|
Result *Result `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventIDs numbers events; 0 is left for the caller's own steps.
|
||||||
|
var eventIDs atomic.Int64
|
||||||
|
|
||||||
|
func (c *Checker) startEvent(query string) int64 {
|
||||||
|
if c.Watch == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
id := eventIDs.Add(1)
|
||||||
|
c.Watch(Event{Kind: "start", ID: id, Query: query})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Checker) endEvent(e Event) {
|
||||||
|
if c.Watch != nil {
|
||||||
|
e.Kind = "end"
|
||||||
|
c.Watch(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietKey in a context stops exchangeHostPort from reporting the query:
|
||||||
|
// the caller reports it with more to say (Serials' SOA queries).
|
||||||
|
type quietKey struct{}
|
||||||
|
|
||||||
|
// digQuery is the dig command line for m sent to hostport.
|
||||||
|
func digQuery(m *dns.Msg, hostport string, key *Key) string {
|
||||||
|
host, port, err := net.SplitHostPort(hostport)
|
||||||
|
if err != nil {
|
||||||
|
host, port = hostport, ""
|
||||||
|
}
|
||||||
|
if len(m.Question) == 0 {
|
||||||
|
return "dig @" + host
|
||||||
|
}
|
||||||
|
q := m.Question[0]
|
||||||
|
s := digLine(host, port, q.Name, dns.TypeToString[q.Qtype], !m.RecursionDesired, key)
|
||||||
|
if o := m.IsEdns0(); o != nil && o.Do() {
|
||||||
|
s += " +dnssec"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumAnswer sums up a DNS answer in a few words.
|
||||||
|
func sumAnswer(resp *dns.Msg) string {
|
||||||
|
if resp.Rcode != dns.RcodeSuccess {
|
||||||
|
return dns.RcodeToString[resp.Rcode]
|
||||||
|
}
|
||||||
|
var vals []string
|
||||||
|
for _, rr := range resp.Answer {
|
||||||
|
switch v := rr.(type) {
|
||||||
|
case *dns.A:
|
||||||
|
vals = append(vals, v.A.String())
|
||||||
|
case *dns.AAAA:
|
||||||
|
vals = append(vals, v.AAAA.String())
|
||||||
|
case *dns.NS:
|
||||||
|
vals = append(vals, v.Ns)
|
||||||
|
case *dns.SOA:
|
||||||
|
vals = append(vals, "serial "+strconv.FormatUint(uint64(v.Serial), 10))
|
||||||
|
case *dns.DS:
|
||||||
|
vals = append(vals, "DS "+strconv.Itoa(int(v.KeyTag)))
|
||||||
|
case *dns.RRSIG:
|
||||||
|
default:
|
||||||
|
vals = append(vals, dns.TypeToString[rr.Header().Rrtype])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(vals) == 0 {
|
||||||
|
var ns []string
|
||||||
|
for _, rr := range resp.Ns {
|
||||||
|
switch v := rr.(type) {
|
||||||
|
case *dns.NS:
|
||||||
|
ns = append(ns, v.Ns)
|
||||||
|
case *dns.SOA:
|
||||||
|
return "no such record"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ns) > 0 {
|
||||||
|
return "referral to " + shorten(ns)
|
||||||
|
}
|
||||||
|
return "empty answer"
|
||||||
|
}
|
||||||
|
s := shorten(vals)
|
||||||
|
if !resp.Authoritative && !resp.RecursionAvailable {
|
||||||
|
s += " (not authoritative)"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func shorten(vals []string) string {
|
||||||
|
if len(vals) > 3 {
|
||||||
|
return strings.Join(vals[:3], ", ") + " and " + strconv.Itoa(len(vals)-3) + " more"
|
||||||
|
}
|
||||||
|
return strings.Join(vals, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// digLine is a dig command line for a query (no TSIG secret).
|
||||||
|
func digLine(server, port, name, types string, norec bool, key *Key) string {
|
||||||
|
s := "dig @" + server
|
||||||
|
if port != "" && port != "53" {
|
||||||
|
s += " -p " + port
|
||||||
|
}
|
||||||
|
s += " " + name + " " + types
|
||||||
|
if norec {
|
||||||
|
s += " +norec"
|
||||||
|
}
|
||||||
|
if key != nil {
|
||||||
|
s += " -y " + key.Algorithm + ":" + strings.TrimSuffix(key.Name, ".") + ":…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Checker) timeout() time.Duration {
|
func (c *Checker) timeout() time.Duration {
|
||||||
@@ -162,16 +292,25 @@ func (c *Checker) exchange(ctx context.Context, m *dns.Msg, addr string, key *Ke
|
|||||||
return c.exchangeHostPort(ctx, m, net.JoinHostPort(addr, c.port()), key)
|
return c.exchangeHostPort(ctx, m, net.JoinHostPort(addr, c.port()), key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (*dns.Msg, time.Duration, error) {
|
func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (resp *dns.Msg, rtt time.Duration, err error) {
|
||||||
|
if c.Watch != nil && ctx.Value(quietKey{}) == nil {
|
||||||
|
id := c.startEvent(digQuery(m, hostport, key))
|
||||||
|
defer func() {
|
||||||
|
e := Event{ID: id, RTTMs: rtt.Milliseconds()}
|
||||||
|
if err != nil {
|
||||||
|
e.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
e.Answer = sumAnswer(resp)
|
||||||
|
}
|
||||||
|
c.endEvent(e)
|
||||||
|
}()
|
||||||
|
}
|
||||||
cl := &dns.Client{Timeout: c.timeout()}
|
cl := &dns.Client{Timeout: c.timeout()}
|
||||||
if key != nil {
|
if key != nil {
|
||||||
name := dns.Fqdn(key.Name)
|
name := dns.Fqdn(key.Name)
|
||||||
cl.TsigSecret = map[string]string{name: key.Secret}
|
cl.TsigSecret = map[string]string{name: key.Secret}
|
||||||
m.SetTsig(name, dns.Fqdn(key.Algorithm), 300, time.Now().Unix())
|
m.SetTsig(name, dns.Fqdn(key.Algorithm), 300, time.Now().Unix())
|
||||||
}
|
}
|
||||||
var resp *dns.Msg
|
|
||||||
var rtt time.Duration
|
|
||||||
var err error
|
|
||||||
for try := 0; try < 2; try++ {
|
for try := 0; try < 2; try++ {
|
||||||
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
|
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
|
||||||
if err == nil || ctx.Err() != nil {
|
if err == nil || ctx.Err() != nil {
|
||||||
@@ -228,7 +367,7 @@ func (c *Checker) soa(ctx context.Context, zone string, key *Key, ref *uint32, r
|
|||||||
if port == "" {
|
if port == "" {
|
||||||
port = c.port()
|
port = c.port()
|
||||||
}
|
}
|
||||||
resp, rtt, err := c.exchangeHostPort(ctx, m, net.JoinHostPort(r.Address, port), key)
|
resp, rtt, err := c.exchangeHostPort(context.WithValue(ctx, quietKey{}, true), m, net.JoinHostPort(r.Address, port), key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ne net.Error
|
var ne net.Error
|
||||||
switch {
|
switch {
|
||||||
@@ -320,9 +459,17 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
port := r.Port
|
||||||
|
if port == "" {
|
||||||
|
port = c.port()
|
||||||
|
}
|
||||||
|
// Reported here rather than by exchange: the end carries the
|
||||||
|
// server's status.
|
||||||
|
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, t.Key))
|
||||||
go func(r Result, key *Key) {
|
go func(r Result, key *Key) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
c.soa(ctx, zone, key, ref, &r)
|
c.soa(ctx, zone, key, ref, &r)
|
||||||
|
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
|
||||||
add(r)
|
add(r)
|
||||||
}(r, t.Key)
|
}(r, t.Key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -307,3 +308,64 @@ func TestDelegationDS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWatch: with Watch set, every query is reported as it starts and
|
||||||
|
// ends, as a dig command line with a summary of the answer.
|
||||||
|
func TestWatch(t *testing.T) {
|
||||||
|
port, res := internet(t, func(q dns.Question) *dns.Msg {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
if q.Qtype == dns.TypeNS && q.Name == "adbraun.lol." {
|
||||||
|
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol.")}
|
||||||
|
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
var mu sync.Mutex
|
||||||
|
var events []Event
|
||||||
|
c := checker(port, res)
|
||||||
|
c.Watch = func(e Event) {
|
||||||
|
mu.Lock()
|
||||||
|
events = append(events, e)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
ref := uint32(7)
|
||||||
|
c.Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, nil)
|
||||||
|
open := map[int64]string{}
|
||||||
|
var queries, answers []string
|
||||||
|
for _, e := range events {
|
||||||
|
switch e.Kind {
|
||||||
|
case "start":
|
||||||
|
if !strings.HasPrefix(e.Query, "dig @") {
|
||||||
|
t.Errorf("query %q", e.Query)
|
||||||
|
}
|
||||||
|
open[e.ID] = e.Query
|
||||||
|
queries = append(queries, e.Query)
|
||||||
|
case "end":
|
||||||
|
q, ok := open[e.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("end without start: %+v", e)
|
||||||
|
}
|
||||||
|
delete(open, e.ID)
|
||||||
|
a := e.Answer + e.Error
|
||||||
|
if e.Result != nil {
|
||||||
|
a = e.Result.Status
|
||||||
|
}
|
||||||
|
answers = append(answers, q+" => "+a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(open) != 0 {
|
||||||
|
t.Errorf("never ended: %v", open)
|
||||||
|
}
|
||||||
|
all := strings.Join(answers, "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
" adbraun.lol. NS +norec => referral to ns1.adbraun.lol.", // asking a parent server
|
||||||
|
"@127.0.0.12 -p " + port + " adbraun.lol. SOA +norec => in_sync", // asking the delegated server
|
||||||
|
} {
|
||||||
|
if !strings.Contains(all, want) {
|
||||||
|
t.Errorf("no %q in\n%s", want, all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(queries) < 4 {
|
||||||
|
t.Errorf("only %d queries:\n%s", len(queries), all)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -461,6 +461,10 @@ func ZoneFile(s *model.State, z *model.Zone) string {
|
|||||||
if r.Comment != "" {
|
if r.Comment != "" {
|
||||||
line += "\t; " + oneLine(r.Comment)
|
line += "\t; " + oneLine(r.Comment)
|
||||||
}
|
}
|
||||||
|
if r.Disabled {
|
||||||
|
// Kept in Jarl, left out of the zone.
|
||||||
|
line = "; disabled: " + line
|
||||||
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -478,7 +482,7 @@ func ZoneContentHash(s *model.State, z *model.Zone) string {
|
|||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
soa := z.SOA
|
soa := z.SOA
|
||||||
fmt.Fprintf(h, "%s|%d|%s|%s|%d|%d|%d|%d\n", z.Name, z.DefaultTTL, soa.MName, soa.RName, soa.Refresh, soa.Retry, soa.Expire, soa.Minimum)
|
fmt.Fprintf(h, "%s|%d|%s|%s|%d|%d|%d|%d\n", z.Name, z.DefaultTTL, soa.MName, soa.RName, soa.Refresh, soa.Retry, soa.Expire, soa.Minimum)
|
||||||
for _, r := range s.ZoneRecords(z.ID) {
|
for _, r := range s.ServedRecords(z.ID) {
|
||||||
fmt.Fprintf(h, "%s|%d|%s|%s\n", r.Name, r.EffectiveTTL(z), r.Type, r.Data)
|
fmt.Fprintf(h, "%s|%d|%s|%s\n", r.Name, r.EffectiveTTL(z), r.Type, r.Data)
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientNames(t *testing.T) {
|
||||||
|
st := model.NewState()
|
||||||
|
add := func(id int64, name, typ, data string, disabled bool) {
|
||||||
|
st.Records[id] = &model.Record{ID: id, ZoneID: 1, Name: name, Type: typ, Data: data, Disabled: disabled}
|
||||||
|
}
|
||||||
|
add(1, "nas.adbraun.lol.", "A", "192.168.1.20", false)
|
||||||
|
add(2, "20.1.168.192.in-addr.arpa.", "PTR", "storage.adbraun.lol.", false) // PTR wins over A
|
||||||
|
add(3, "web.adbraun.lol.", "A", "192.168.1.30", false) // no PTR: the A record's name
|
||||||
|
add(4, "alias.adbraun.lol.", "A", "192.168.1.30", false) // first in name order wins
|
||||||
|
add(5, "old.adbraun.lol.", "A", "192.168.1.40", true) // disabled: ignored
|
||||||
|
add(6, "secret.adbraun.lol.", "A", "192.168.1.50", false) // not readable
|
||||||
|
add(7, "v6.adbraun.lol.", "AAAA", "2001:db8::7", false)
|
||||||
|
add(8, "printer.adbraun.lol.", "A", "192.168.1.60", false) // not asked for
|
||||||
|
|
||||||
|
want := map[netip.Addr]bool{}
|
||||||
|
for _, a := range []string{"192.168.1.20", "192.168.1.30", "192.168.1.40", "192.168.1.50", "2001:db8::7", "10.0.0.1"} {
|
||||||
|
want[netip.MustParseAddr(a)] = true
|
||||||
|
}
|
||||||
|
got := clientNames(st, want, func(r *model.Record) bool { return r.ID != 6 })
|
||||||
|
exp := map[string]string{"192.168.1.20": "storage.adbraun.lol", "192.168.1.30": "alias.adbraun.lol", "2001:db8::7": "v6.adbraun.lol"}
|
||||||
|
if len(got) != len(exp) {
|
||||||
|
t.Errorf("got %v", got)
|
||||||
|
}
|
||||||
|
for a, n := range exp {
|
||||||
|
if got[netip.MustParseAddr(a)] != n {
|
||||||
|
t.Errorf("%s = %q, want %q", a, got[netip.MustParseAddr(a)], n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisableRecords(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||||
|
www := e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10"}, 201)["object"].(map[string]any)
|
||||||
|
id := itoa(int64(www["id"].(float64)))
|
||||||
|
|
||||||
|
// By ID: the record comes back disabled.
|
||||||
|
if r := e.do("POST", "/records/"+id+"/disable", e.token, nil, 201)["object"].(map[string]any); r["disabled"] != true {
|
||||||
|
t.Errorf("disabled record = %v", r)
|
||||||
|
}
|
||||||
|
if _, status := e.req("POST", "/records/"+id+"/disable", e.token, nil); status != 409 {
|
||||||
|
t.Errorf("disabling twice = %d, want 409", status)
|
||||||
|
}
|
||||||
|
if z := e.do("GET", "/zones/example.com", e.token, nil, 200); z["disabled_count"] != 1.0 || z["record_count"] != 3.0 {
|
||||||
|
t.Errorf("zone counts = %v / %v", z["record_count"], z["disabled_count"])
|
||||||
|
}
|
||||||
|
off := e.list("/zones/example.com/records?disabled=1", e.token)
|
||||||
|
on := e.list("/zones/example.com/records?disabled=0", e.token)
|
||||||
|
if len(off) != 1 || off[0]["name"] != "www.example.com." || len(on) != 2 {
|
||||||
|
t.Errorf("filtered: %d disabled, %d enabled", len(off), len(on))
|
||||||
|
}
|
||||||
|
file := e.do("GET", "/zones/example.com/file", e.token, nil, 200)
|
||||||
|
if s, _ := file["_raw"].(string); !strings.Contains(s, "; disabled: www") {
|
||||||
|
t.Errorf("zone file = %v", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staged: listing with the changeset marks the record.
|
||||||
|
cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "back"}, 201)["id"].(float64)))
|
||||||
|
e.do("POST", "/records/"+id+"/enable?changeset="+cs, e.token, nil, 202)
|
||||||
|
for _, r := range e.list("/zones/example.com/records?changeset="+cs, e.token) {
|
||||||
|
if r["name"] == "www.example.com." && (r["pending"] != "enable" || r["disabled"] == true) {
|
||||||
|
t.Errorf("staged record = %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.do("POST", "/changesets/"+cs+"/discard", e.token, nil, 204)
|
||||||
|
|
||||||
|
// By match.
|
||||||
|
e.do("POST", "/zones/example.com/records/enable?name=www&type=A", e.token, nil, 201)
|
||||||
|
if _, status := e.req("POST", "/zones/example.com/records/disable?name=nope&type=A", e.token, nil); status != 404 {
|
||||||
|
t.Errorf("no match = %d", status)
|
||||||
|
}
|
||||||
|
if _, status := e.req("POST", "/zones/example.com/records/disable?name=www", e.token, nil); status != 400 {
|
||||||
|
t.Errorf("no type = %d", status)
|
||||||
|
}
|
||||||
|
e.do("POST", "/zones/example.com/records/disable?name=www&type=A&data=192.0.2.10", e.token, nil, 201)
|
||||||
|
if r := e.do("GET", "/records/"+id, e.token, nil, 200); r["disabled"] != true {
|
||||||
|
t.Errorf("after match disable = %v", r)
|
||||||
|
}
|
||||||
|
// And in a batch.
|
||||||
|
e.do("POST", "/apply", e.token, map[string]any{"ops": []map[string]any{{"op": "record.enable", "id": www["id"]}}}, 201)
|
||||||
|
if r := e.do("GET", "/records/"+id, e.token, nil, 200); r["disabled"] == true {
|
||||||
|
t.Errorf("after batch enable = %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,12 @@ func (s *Server) zoneDNSSEC(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
}
|
}
|
||||||
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
|
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
|
||||||
if pz := parentInJarl(st, z); pz != nil {
|
if pz := parentInJarl(st, z); pz != nil {
|
||||||
resp.ParentZone = pz.Name
|
resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
|
||||||
|
for _, rec := range st.ServedRecords(pz.ID) {
|
||||||
|
if rec.Name == z.Name && rec.Type == "DS" {
|
||||||
|
resp.ParentDS = append(resp.ParentDS, rec.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
@@ -58,7 +60,9 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
resp.Serial, resp.SerialSource, err = s.localSerial(ctx, st, z)
|
ck := s.checker()
|
||||||
|
emit := s.watchChecks(w, r, ck)
|
||||||
|
resp.Serial, resp.SerialSource, err = s.watchedLocalSerial(ctx, emit, st, z)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error())
|
resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error())
|
||||||
}
|
}
|
||||||
@@ -77,15 +81,72 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
if resp.View != "" {
|
if resp.View != "" {
|
||||||
resp.Notes = append(resp.Notes, fmt.Sprintf("The zone is in view %s. Other servers answer from the view that matches this server's address (or key), as they do for its zone transfers and NOTIFYs.", resp.View))
|
resp.Notes = append(resp.Notes, fmt.Sprintf("The zone is in view %s. Other servers answer from the view that matches this server's address (or key), as they do for its zone transfers and NOTIFYs.", resp.View))
|
||||||
}
|
}
|
||||||
resp.Servers = s.checker().Serials(ctx, z.Name, resp.Serial, targets)
|
resp.Servers = ck.Serials(ctx, z.Name, resp.Serial, targets)
|
||||||
if len(resp.Servers) > 0 && !hasOther(resp.Servers) {
|
if len(resp.Servers) > 0 && !hasOther(resp.Servers) {
|
||||||
resp.Notes = append(resp.Notes, "Only this server is listed, so there is nothing to compare with.")
|
resp.Notes = append(resp.Notes, "Only this server is listed, so there is nothing to compare with.")
|
||||||
}
|
}
|
||||||
resp.CheckedAt = time.Now()
|
resp.CheckedAt = time.Now()
|
||||||
|
if emit != nil {
|
||||||
|
emit(map[string]any{"kind": "done", "response": resp})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// watchChecks: with ?stream=1, /serials and /delegation answer with
|
||||||
|
// newline-delimited JSON events while they ask the servers (nscheck.Event,
|
||||||
|
// kind start/end, one pair per DNS query), then {"kind":"done",
|
||||||
|
// "response":...}. The web UI shows the queries as they go. It returns
|
||||||
|
// the function that writes an event, or nil without ?stream=1.
|
||||||
|
func (s *Server) watchChecks(w http.ResponseWriter, r *http.Request, ck *nscheck.Checker) func(any) {
|
||||||
|
if !queryBool(r, "stream") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
emit := s.streamJSON(w)
|
||||||
|
ck.Watch = func(e nscheck.Event) { emit(e) }
|
||||||
|
return emit
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchedLocalSerial is localSerial, reported as event 0 when streaming.
|
||||||
|
func (s *Server) watchedLocalSerial(ctx context.Context, emit func(any), st *model.State, z *model.Zone) (*uint32, string, error) {
|
||||||
|
cmd := s.eng.LoadedSerialCommand(st, z)
|
||||||
|
if emit != nil && cmd != "" {
|
||||||
|
emit(nscheck.Event{Kind: "start", ID: 0, Query: cmd})
|
||||||
|
}
|
||||||
|
serial, src, err := s.localSerial(ctx, st, z)
|
||||||
|
if emit != nil && cmd != "" {
|
||||||
|
e := nscheck.Event{Kind: "end", ID: 0}
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
e.Error = err.Error()
|
||||||
|
case src == "named" && serial != nil:
|
||||||
|
e.Answer = fmt.Sprintf("serial %d", *serial)
|
||||||
|
}
|
||||||
|
emit(e)
|
||||||
|
}
|
||||||
|
return serial, src, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamJSON starts a newline-delimited JSON response and returns a
|
||||||
|
// function that writes one value and flushes it; it is safe for
|
||||||
|
// concurrent use.
|
||||||
|
func (s *Server) streamJSON(w http.ResponseWriter) func(any) {
|
||||||
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // nginx: don't buffer
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
rc := http.NewResponseController(w)
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
var mu sync.Mutex
|
||||||
|
return func(v any) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
enc.Encode(v)
|
||||||
|
rc.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) zoneDelegation(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 {
|
||||||
@@ -108,7 +169,7 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
c.Resolver = net.JoinHostPort(a.String(), "53")
|
c.Resolver = net.JoinHostPort(a.String(), "53")
|
||||||
}
|
}
|
||||||
var ns []string
|
var ns []string
|
||||||
for _, rec := range st.ZoneRecords(z.ID) {
|
for _, rec := range st.ServedRecords(z.ID) {
|
||||||
if rec.Type == "NS" && rec.Name == z.Name {
|
if rec.Type == "NS" && rec.Name == z.Name {
|
||||||
ns = append(ns, rec.Data)
|
ns = append(ns, rec.Data)
|
||||||
}
|
}
|
||||||
@@ -121,11 +182,16 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
ref, _, _ := s.localSerial(ctx, st, z)
|
emit := s.watchChecks(w, r, c)
|
||||||
|
ref, _, _ := s.watchedLocalSerial(ctx, emit, st, z)
|
||||||
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref, s.signing(ctx, st, z)), View: st.ViewName(z), Notes: []string{}, CheckedAt: time.Now()}
|
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref, s.signing(ctx, st, z)), View: st.ViewName(z), Notes: []string{}, CheckedAt: time.Now()}
|
||||||
if resp.View != "" {
|
if resp.View != "" {
|
||||||
resp.Notes = append(resp.Notes, fmt.Sprintf("Compared with the zone in view %s. Resolvers on the internet get the view that matches their address; make sure that's this one.", resp.View))
|
resp.Notes = append(resp.Notes, fmt.Sprintf("Compared with the zone in view %s. Resolvers on the internet get the view that matches their address; make sure that's this one.", resp.View))
|
||||||
}
|
}
|
||||||
|
if emit != nil {
|
||||||
|
emit(map[string]any{"kind": "done", "response": resp})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -160,7 +226,7 @@ func (s *Server) localSerial(ctx context.Context, st *model.State, z *model.Zone
|
|||||||
// addresses Jarl has for them in the same view (or nil, to be looked up).
|
// addresses Jarl has for them in the same view (or nil, to be looked up).
|
||||||
func nsTargets(st *model.State, z *model.Zone) []nscheck.Target {
|
func nsTargets(st *model.State, z *model.Zone) []nscheck.Target {
|
||||||
var out []nscheck.Target
|
var out []nscheck.Target
|
||||||
for _, rec := range st.ZoneRecords(z.ID) {
|
for _, rec := range st.ServedRecords(z.ID) {
|
||||||
if rec.Type != "NS" || rec.Name != z.Name {
|
if rec.Type != "NS" || rec.Name != z.Name {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -178,7 +244,7 @@ func jarlAddresses(st *model.State, viewID int64, name string) []string {
|
|||||||
if !z.HasRecords() || !dnsname.IsSubdomain(name, z.Name) {
|
if !z.HasRecords() || !dnsname.IsSubdomain(name, z.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, rec := range st.ZoneRecords(z.ID) {
|
for _, rec := range st.ServedRecords(z.ID) {
|
||||||
if rec.Name == name && (rec.Type == "A" || rec.Type == "AAAA") {
|
if rec.Name == name && (rec.Type == "A" || rec.Type == "AAAA") {
|
||||||
out = append(out, rec.Data)
|
out = append(out, rec.Data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func lowerNoDot(n string) string { return strings.ToLower(strings.TrimSuffix(n,
|
|||||||
// trailing dots.
|
// trailing dots.
|
||||||
func zoneNS(st *model.State, z *model.Zone) []string {
|
func zoneNS(st *model.State, z *model.Zone) []string {
|
||||||
var out []string
|
var out []string
|
||||||
for _, rec := range st.ZoneRecords(z.ID) {
|
for _, rec := range st.ServedRecords(z.ID) {
|
||||||
if rec.Type == "NS" && rec.Name == z.Name {
|
if rec.Type == "NS" && rec.Name == z.Name {
|
||||||
out = append(out, lowerNoDot(rec.Data))
|
out = append(out, lowerNoDot(rec.Data))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jarl/internal/auth"
|
"jarl/internal/auth"
|
||||||
|
"jarl/internal/dnsname"
|
||||||
|
"jarl/internal/model"
|
||||||
"jarl/internal/stats"
|
"jarl/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +57,9 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.nameClients(r, p, sum.Tops[stats.KindClient]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, sum)
|
writeJSON(w, http.StatusOK, sum)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -75,6 +82,11 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return badRequest("%v", err)
|
return badRequest("%v", err)
|
||||||
}
|
}
|
||||||
|
if kind == stats.KindClient || kind == stats.KindRefused {
|
||||||
|
if err := s.nameClients(r, p, es); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, es)
|
writeJSON(w, http.StatusOK, es)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -145,3 +157,86 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
writeJSON(w, http.StatusOK, es)
|
writeJSON(w, http.StatusOK, es)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nameClients gives client entries the host name Jarl's records have for
|
||||||
|
// the address: the PTR record's target, or else the name of an A/AAAA
|
||||||
|
// record with the address. It sends no DNS queries, and uses only records
|
||||||
|
// the user may read. Anonymized clients (prefixes) get no name.
|
||||||
|
func (s *Server) nameClients(r *http.Request, p *auth.Principal, es []stats.Entry) error {
|
||||||
|
if len(es) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
want := map[netip.Addr]bool{}
|
||||||
|
for _, e := range es {
|
||||||
|
if a, err := netip.ParseAddr(e.Key); err == nil {
|
||||||
|
want[a.Unmap()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(want) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
names := clientNames(st, want, func(rec *model.Record) bool { return canReadRecord(p, st, rec) })
|
||||||
|
for i, e := range es {
|
||||||
|
if a, err := netip.ParseAddr(e.Key); err == nil {
|
||||||
|
es[i].Name = names[a.Unmap()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientNames finds names for the addresses in want (see nameClients).
|
||||||
|
// Where several records match, the first in name order wins.
|
||||||
|
func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model.Record) bool) map[netip.Addr]string {
|
||||||
|
ptr, host := map[netip.Addr]string{}, map[netip.Addr]string{}
|
||||||
|
var recs []*model.Record
|
||||||
|
for _, rec := range st.Records {
|
||||||
|
if !rec.Disabled && (rec.Type == "PTR" || rec.Type == "A" || rec.Type == "AAAA") {
|
||||||
|
recs = append(recs, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(recs, func(a, b *model.Record) int {
|
||||||
|
if a.Name != b.Name {
|
||||||
|
if model.CanonicalLess(a.Name, b.Name) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return strings.Compare(a.Data, b.Data)
|
||||||
|
})
|
||||||
|
for _, rec := range recs {
|
||||||
|
var a netip.Addr
|
||||||
|
var name string
|
||||||
|
if rec.Type == "PTR" {
|
||||||
|
var ok bool
|
||||||
|
if a, ok = dnsname.AddrFromReverse(rec.Name); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name = rec.Data
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
if a, err = netip.ParseAddr(rec.Data); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name = rec.Name
|
||||||
|
}
|
||||||
|
a = a.Unmap()
|
||||||
|
m := host
|
||||||
|
if rec.Type == "PTR" {
|
||||||
|
m = ptr
|
||||||
|
}
|
||||||
|
if !want[a] || m[a] != "" || !readable(rec) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[a] = dnsname.Display(name)
|
||||||
|
}
|
||||||
|
for a, n := range host {
|
||||||
|
if ptr[a] == "" {
|
||||||
|
ptr[a] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ptr
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -44,10 +45,17 @@ func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal)
|
|||||||
return z, nil
|
return z, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordCounts(st *model.State) map[int64]int {
|
type recordCount struct{ all, disabled int }
|
||||||
counts := map[int64]int{}
|
|
||||||
|
func recordCounts(st *model.State) map[int64]recordCount {
|
||||||
|
counts := map[int64]recordCount{}
|
||||||
for _, r := range st.Records {
|
for _, r := range st.Records {
|
||||||
counts[r.ZoneID]++
|
c := counts[r.ZoneID]
|
||||||
|
c.all++
|
||||||
|
if r.Disabled {
|
||||||
|
c.disabled++
|
||||||
|
}
|
||||||
|
counts[r.ZoneID] = c
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
@@ -190,10 +198,14 @@ func (s *Server) listRecords(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
}
|
}
|
||||||
typ := strings.ToUpper(q.Get("type"))
|
typ := strings.ToUpper(q.Get("type"))
|
||||||
search := strings.ToLower(q.Get("q"))
|
search := strings.ToLower(q.Get("q"))
|
||||||
|
disabled := q.Get("disabled") // "1": only disabled records, "0": only enabled ones
|
||||||
match := func(rec *model.Record) bool {
|
match := func(rec *model.Record) bool {
|
||||||
if name != "" && rec.Name != name || typ != "" && rec.Type != typ {
|
if name != "" && rec.Name != name || typ != "" && rec.Type != typ {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if disabled != "" && rec.Disabled != (disabled == "1" || disabled == "true") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return search == "" || strings.Contains(rec.Name, search) || strings.Contains(strings.ToLower(rec.Data), search) || strings.Contains(strings.ToLower(rec.Comment), search)
|
return search == "" || strings.Contains(rec.Name, search) || strings.Contains(strings.ToLower(rec.Data), search) || strings.Contains(strings.ToLower(rec.Comment), search)
|
||||||
}
|
}
|
||||||
recs := st.ZoneRecords(z.ID)
|
recs := st.ZoneRecords(z.ID)
|
||||||
@@ -229,6 +241,13 @@ func (s *Server) listRecords(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
ro.Pending = "add"
|
ro.Pending = "add"
|
||||||
case old.Version != rec.Version:
|
case old.Version != rec.Version:
|
||||||
ro.Pending = "change"
|
ro.Pending = "change"
|
||||||
|
if only := *old; only.Disabled != rec.Disabled {
|
||||||
|
// Just disabled or enabled: say which.
|
||||||
|
only.Disabled, only.Meta = rec.Disabled, rec.Meta
|
||||||
|
if reflect.DeepEqual(only, *rec) {
|
||||||
|
ro.Pending = map[bool]string{true: "disable", false: "enable"}[rec.Disabled]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, ro)
|
out = append(out, ro)
|
||||||
@@ -380,6 +399,56 @@ func (s *Server) deleteRecordsByMatch(w http.ResponseWriter, r *http.Request, p
|
|||||||
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpRecordDelete, 0, in)}, nil)
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpRecordDelete, 0, in)}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRecordDisabled: POST /records/{id}/disable or /enable [?ptr=1]
|
||||||
|
func (s *Server) setRecordDisabled(disabled bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
|
||||||
|
op := disableOp(disabled)
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rec, err := s.recordByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := engine.NewRequest(op, rec.ID, engine.RecordInput{PTR: queryBool(r, "ptr")})
|
||||||
|
id := rec.ID
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any { return recordObject(st, id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRecordsDisabled: POST /zones/{zone}/records/disable or /enable
|
||||||
|
// ?name=&type=[&data=][&missing_ok=1][&ptr=1]
|
||||||
|
func (s *Server) setRecordsDisabled(disabled bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
|
||||||
|
op := disableOp(disabled)
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
st, err := s.state(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
z, err := s.zoneByPath(r, st, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Get("name") == "" || q.Get("type") == "" {
|
||||||
|
return badRequest("give name and type (and optionally data) of the records")
|
||||||
|
}
|
||||||
|
in := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(q.Get("name")), Type: model.Ptr(q.Get("type")), MissingOK: queryBool(r, "missing_ok"), PTR: queryBool(r, "ptr")}
|
||||||
|
if d := q.Get("data"); d != "" {
|
||||||
|
in.Data = &d
|
||||||
|
}
|
||||||
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableOp(disabled bool) string {
|
||||||
|
if disabled {
|
||||||
|
return engine.OpRecordDisable
|
||||||
|
}
|
||||||
|
return engine.OpRecordEnable
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) putRRSet(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 {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,3 +71,59 @@ func TestZoneSerialsTargets(t *testing.T) {
|
|||||||
e.do("GET", "/zones/copy.example/delegation", e.token, nil, 400)
|
e.do("GET", "/zones/copy.example/delegation", e.token, nil, 400)
|
||||||
e.do("GET", "/zones/example.com/delegation?resolver=not-an-ip", e.token, nil, 400)
|
e.do("GET", "/zones/example.com/delegation?resolver=not-an-ip", e.token, nil, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestZoneSerialsStream(t *testing.T) {
|
||||||
|
e := newAPIEnv(t)
|
||||||
|
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.21" }}
|
||||||
|
e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{
|
||||||
|
"name": "example.com",
|
||||||
|
"nameservers": []string{"ns1.example.com.", "ns2.example.com."},
|
||||||
|
"records": []map[string]any{{"name": "ns1", "type": "A", "data": "127.0.0.21"}, {"name": "ns2", "type": "A", "data": "127.0.0.22"}},
|
||||||
|
"options": []map[string]any{{"name": "also-notify", "value": "{ 127.0.0.24 port 5300 key xfer; }"}},
|
||||||
|
}, 201)
|
||||||
|
body, status := e.req("GET", "/zones/example.com/serials?stream=1", e.token, nil)
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("stream = %d: %s", status, body)
|
||||||
|
}
|
||||||
|
var starts, ends []map[string]any
|
||||||
|
var done map[string]any
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||||
|
var ev map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||||
|
t.Fatalf("line %q: %v", line, err)
|
||||||
|
}
|
||||||
|
switch ev["kind"] {
|
||||||
|
case "start":
|
||||||
|
starts = append(starts, ev)
|
||||||
|
case "end":
|
||||||
|
ends = append(ends, ev)
|
||||||
|
case "done":
|
||||||
|
done = ev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// .21 is this server (not asked); .22 and .24 are.
|
||||||
|
queries := map[string]bool{}
|
||||||
|
for _, s := range starts {
|
||||||
|
queries[s["query"].(string)] = true
|
||||||
|
}
|
||||||
|
for _, q := range []string{
|
||||||
|
"dig @127.0.0.22 -p 1 example.com. SOA +norec",
|
||||||
|
"dig @127.0.0.24 -p 5300 example.com. SOA +norec -y hmac-sha256:xfer:…",
|
||||||
|
} {
|
||||||
|
if !queries[q] {
|
||||||
|
t.Errorf("no query %q in %v", q, queries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(starts) != 2 || len(ends) != 2 {
|
||||||
|
t.Errorf("starts %d, ends %d", len(starts), len(ends))
|
||||||
|
}
|
||||||
|
for _, e := range ends {
|
||||||
|
if r, _ := e["result"].(map[string]any); r == nil || r["status"] != "unreachable" {
|
||||||
|
t.Errorf("end event %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if done == nil || len(done["response"].(map[string]any)["servers"].([]any)) != 3 {
|
||||||
|
t.Errorf("done = %v", done)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"jarl/internal/version"
|
"jarl/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone {
|
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]recordCount) api.Zone {
|
||||||
out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID]}
|
out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID].all, Disabled: counts[z.ID].disabled}
|
||||||
out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths)
|
out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths)
|
||||||
if z.Type == model.ZonePrimary {
|
if z.Type == model.ZonePrimary {
|
||||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||||
|
|||||||
@@ -88,11 +88,15 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||||
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
||||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/records", s.authed(s.deleteRecordsByMatch))
|
m.HandleFunc("DELETE "+p+"/zones/{zone}/records", s.authed(s.deleteRecordsByMatch))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/records/disable", s.authed(s.setRecordsDisabled(true)))
|
||||||
|
m.HandleFunc("POST "+p+"/zones/{zone}/records/enable", s.authed(s.setRecordsDisabled(false)))
|
||||||
m.HandleFunc("PUT "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.putRRSet))
|
m.HandleFunc("PUT "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.putRRSet))
|
||||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.deleteRRSet))
|
m.HandleFunc("DELETE "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.deleteRRSet))
|
||||||
m.HandleFunc("GET "+p+"/records/{id}", s.authed(s.getRecord))
|
m.HandleFunc("GET "+p+"/records/{id}", s.authed(s.getRecord))
|
||||||
m.HandleFunc("PATCH "+p+"/records/{id}", s.authed(s.updateRecord))
|
m.HandleFunc("PATCH "+p+"/records/{id}", s.authed(s.updateRecord))
|
||||||
m.HandleFunc("DELETE "+p+"/records/{id}", s.authed(s.deleteRecord))
|
m.HandleFunc("DELETE "+p+"/records/{id}", s.authed(s.deleteRecord))
|
||||||
|
m.HandleFunc("POST "+p+"/records/{id}/disable", s.authed(s.setRecordDisabled(true)))
|
||||||
|
m.HandleFunc("POST "+p+"/records/{id}/enable", s.authed(s.setRecordDisabled(false)))
|
||||||
// Options and settings
|
// Options and settings
|
||||||
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions))
|
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions))
|
||||||
m.HandleFunc("POST "+p+"/options", s.authed(s.createOption))
|
m.HandleFunc("POST "+p+"/options", s.authed(s.createOption))
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jarl/internal/api"
|
"jarl/internal/api"
|
||||||
@@ -116,6 +118,16 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns the server's HTTP handler with all middleware.
|
// Handler returns the server's HTTP handler with all middleware.
|
||||||
|
// lowPort reports whether addr ("host:port") has a port below 1024.
|
||||||
|
func lowPort(addr string) bool {
|
||||||
|
_, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(port)
|
||||||
|
return err == nil && n > 0 && n < 1024
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
||||||
}
|
}
|
||||||
@@ -142,6 +154,9 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|||||||
for _, l := range listeners {
|
for _, l := range listeners {
|
||||||
l.Close()
|
l.Close()
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, syscall.EACCES) && lowPort(addr) {
|
||||||
|
return fmt.Errorf("listening on %s: %w (ports below 1024 need the CAP_NET_BIND_SERVICE capability; Jarl's jarl.service grants it, so check that the installed unit is current and no drop-in removes it: systemctl cat jarl)", addr, err)
|
||||||
|
}
|
||||||
return fmt.Errorf("listening on %s: %w", addr, err)
|
return fmt.Errorf("listening on %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
listeners = append(listeners, ln)
|
listeners = append(listeners, ln)
|
||||||
@@ -384,6 +399,10 @@ func (w *statusWriter) WriteHeader(code int) {
|
|||||||
w.ResponseWriter.WriteHeader(code)
|
w.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap lets http.ResponseController reach the connection (to flush a
|
||||||
|
// streamed response).
|
||||||
|
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||||
|
|
||||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||||
if w.status == 0 {
|
if w.status == 0 {
|
||||||
w.status = http.StatusOK
|
w.status = http.StatusOK
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
if len(tops) != 2 || tops[0]["key"] != "www.example.com" {
|
if len(tops) != 2 || tops[0]["key"] != "www.example.com" {
|
||||||
t.Errorf("top names = %v", tops)
|
t.Errorf("top names = %v", tops)
|
||||||
}
|
}
|
||||||
|
// Clients get the host name of Jarl's record for their address.
|
||||||
|
if c := e.list("/stats/top?kind=client&since=2h", e.token); len(c) != 1 || c[0]["name"] != nil {
|
||||||
|
t.Errorf("unnamed client = %v", c)
|
||||||
|
}
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201)
|
||||||
|
if c := e.list("/stats/top?kind=client&since=2h", e.token); len(c) != 1 || c[0]["name"] != "laptop.example.com" {
|
||||||
|
t.Errorf("named client = %v", c)
|
||||||
|
}
|
||||||
|
if sum := e.do("GET", "/stats/summary?since=2h", e.token, nil, 200); sum["tops"].(map[string]any)["client"].([]any)[0].(map[string]any)["name"] != "laptop.example.com" {
|
||||||
|
t.Errorf("summary clients = %v", sum["tops"])
|
||||||
|
}
|
||||||
if b := e.list("/stats/breakdown?group=zone&since=1h", e.token); len(b) != 2 || b[0]["key"] != "example.com" {
|
if b := e.list("/stats/breakdown?group=zone&since=1h", e.token); len(b) != 2 || b[0]["key"] != "example.com" {
|
||||||
t.Errorf("zones = %v", b)
|
t.Errorf("zones = %v", b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ func (c *Collector) updateWarnings() {
|
|||||||
func top(m map[string]int64, n int) []Entry {
|
func top(m map[string]int64, n int) []Entry {
|
||||||
out := make([]Entry, 0, len(m))
|
out := make([]Entry, 0, len(m))
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
out = append(out, Entry{k, v})
|
out = append(out, Entry{Key: k, Count: v})
|
||||||
}
|
}
|
||||||
slices.SortFunc(out, func(a, b Entry) int {
|
slices.SortFunc(out, func(a, b Entry) int {
|
||||||
if a.Count != b.Count {
|
if a.Count != b.Count {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func TestCollector(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
names, _ := store.TopsAt(ctx, ResHour, m, KindName)
|
names, _ := store.TopsAt(ctx, ResHour, m, KindName)
|
||||||
if len(names) != 3 || names[0] != (Entry{"nas.adbraun.lol", 2}) {
|
if len(names) != 3 || names[0] != (Entry{Key: "nas.adbraun.lol", Count: 2}) {
|
||||||
t.Errorf("hour names = %v", names)
|
t.Errorf("hour names = %v", names)
|
||||||
}
|
}
|
||||||
if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "nope.adbraun.lol" {
|
if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "nope.adbraun.lol" {
|
||||||
@@ -133,7 +133,7 @@ func TestCollector(t *testing.T) {
|
|||||||
clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc)
|
clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc)
|
||||||
c.flush(ctx, false)
|
c.flush(ctx, false)
|
||||||
day := time.Date(2026, 10, 5, 0, 0, 0, 0, loc).Unix()
|
day := time.Date(2026, 10, 5, 0, 0, 0, 0, loc).Unix()
|
||||||
if d, _ := store.TopsAt(ctx, ResDay, day, KindName); len(d) == 0 || d[0] != (Entry{"nas.adbraun.lol", 3}) {
|
if d, _ := store.TopsAt(ctx, ResDay, day, KindName); len(d) == 0 || d[0] != (Entry{Key: "nas.adbraun.lol", Count: 3}) {
|
||||||
t.Errorf("day names after the hour = %v", d)
|
t.Errorf("day names after the hour = %v", d)
|
||||||
}
|
}
|
||||||
if v, _ := store.sums(ctx, ResDay, day, day+1, "queries"); v["queries"] != 7 {
|
if v, _ := store.sums(ctx, ResDay, day, day+1, "queries"); v["queries"] != 7 {
|
||||||
@@ -149,14 +149,14 @@ func TestCollector(t *testing.T) {
|
|||||||
c2.readLog()
|
c2.readLog()
|
||||||
clk.t = time.Date(2026, 10, 5, 11, 3, 2, 0, loc)
|
clk.t = time.Date(2026, 10, 5, 11, 3, 2, 0, loc)
|
||||||
c2.flush(ctx, false)
|
c2.flush(ctx, false)
|
||||||
if h, _ := store.TopsAt(ctx, ResHour, m, KindName); len(h) == 0 || h[0] != (Entry{"nas.adbraun.lol", 4}) {
|
if h, _ := store.TopsAt(ctx, ResHour, m, KindName); len(h) == 0 || h[0] != (Entry{Key: "nas.adbraun.lol", Count: 4}) {
|
||||||
t.Errorf("hour 10 after late line = %v", h)
|
t.Errorf("hour 10 after late line = %v", h)
|
||||||
}
|
}
|
||||||
if d, _ := store.TopsAt(ctx, ResDay, day, KindName); d[0] != (Entry{"nas.adbraun.lol", 4}) {
|
if d, _ := store.TopsAt(ctx, ResDay, day, KindName); d[0] != (Entry{Key: "nas.adbraun.lol", Count: 4}) {
|
||||||
t.Errorf("day after late line = %v", d)
|
t.Errorf("day after late line = %v", d)
|
||||||
}
|
}
|
||||||
h11 := time.Date(2026, 10, 5, 11, 0, 0, 0, loc).Unix()
|
h11 := time.Date(2026, 10, 5, 11, 0, 0, 0, loc).Unix()
|
||||||
if h, _ := store.TopsAt(ctx, ResHour, h11, KindName); len(h) != 1 || h[0] != (Entry{"printer.adbraun.lol", 2}) {
|
if h, _ := store.TopsAt(ctx, ResHour, h11, KindName); len(h) != 1 || h[0] != (Entry{Key: "printer.adbraun.lol", Count: 2}) {
|
||||||
t.Errorf("hour 11 continued after restart = %v", h)
|
t.Errorf("hour 11 continued after restart = %v", h)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ func TestCollector(t *testing.T) {
|
|||||||
t.Errorf("heatmap Monday 10h=%v 11h=%v Thu 3h=%v", hm.Avg[0][10], hm.Avg[0][11], hm.Avg[3][3])
|
t.Errorf("heatmap Monday 10h=%v 11h=%v Thu 3h=%v", hm.Avg[0][10], hm.Avg[0][11], hm.Avg[3][3])
|
||||||
}
|
}
|
||||||
b, _ := q.Breakdown(ctx, "zone", 24*time.Hour)
|
b, _ := q.Breakdown(ctx, "zone", 24*time.Hour)
|
||||||
if len(b) != 2 || b[0] != (Entry{"adbraun.lol", 7}) || b[1] != (Entry{OtherZone, 1}) {
|
if len(b) != 2 || b[0] != (Entry{Key: "adbraun.lol", Count: 7}) || b[1] != (Entry{Key: OtherZone, Count: 1}) {
|
||||||
t.Errorf("zone breakdown = %v", b)
|
t.Errorf("zone breakdown = %v", b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func sorted(m map[string]int64, prefix string) []Entry {
|
|||||||
out := []Entry{}
|
out := []Entry{}
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
if strings.HasPrefix(k, prefix) && v != 0 {
|
if strings.HasPrefix(k, prefix) && v != 0 {
|
||||||
out = append(out, Entry{strings.TrimPrefix(k, prefix), v})
|
out = append(out, Entry{Key: strings.TrimPrefix(k, prefix), Count: v})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slices.SortFunc(out, func(a, b Entry) int {
|
slices.SortFunc(out, func(a, b Entry) int {
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ type topsWrite struct {
|
|||||||
type Entry struct {
|
type Entry struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
|
// Name is a client's host name, from Jarl's own records (set by the
|
||||||
|
// API for client lists).
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddCounters adds values to the counters of a bucket.
|
// AddCounters adds values to the counters of a bucket.
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by FROM records`)
|
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM records`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
r := &model.Record{}
|
r := &model.Record{}
|
||||||
var ttl sql.NullInt64
|
var ttl sql.NullInt64
|
||||||
var ca, ua string
|
var ca, ua string
|
||||||
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Disabled, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -220,8 +220,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
if r.TTL != nil {
|
if r.TTL != nil {
|
||||||
ttl = int64(*r.TTL)
|
ttl = int64(*r.TTL)
|
||||||
}
|
}
|
||||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
||||||
return err
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ CREATE TABLE recovery_codes (
|
|||||||
used_at TEXT,
|
used_at TEXT,
|
||||||
PRIMARY KEY (user_id, code_hash)
|
PRIMARY KEY (user_id, code_hash)
|
||||||
);
|
);
|
||||||
|
`,
|
||||||
|
// 5: disabled records, kept but left out of the zone.
|
||||||
|
`
|
||||||
|
ALTER TABLE records ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,31 @@ func (c *checker) zoneLimit(z *model.Zone, name string, def int) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkDisabledRecords warns about disabled records that couldn't be
|
||||||
|
// enabled again as the zone is now: the CNAME rules are the usual way a
|
||||||
|
// name changes while one of its records is disabled.
|
||||||
|
func (c *checker) checkDisabledRecords(z *model.Zone) {
|
||||||
|
served := map[string]map[string]bool{}
|
||||||
|
for _, r := range c.zoneRecords[z.ID] {
|
||||||
|
if served[r.Name] == nil {
|
||||||
|
served[r.Name] = map[string]bool{}
|
||||||
|
}
|
||||||
|
served[r.Name][r.Type] = true
|
||||||
|
}
|
||||||
|
for _, r := range c.allRecords[z.ID] {
|
||||||
|
if !r.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
types := served[r.Name]
|
||||||
|
switch {
|
||||||
|
case r.Type != "CNAME" && types["CNAME"]:
|
||||||
|
c.recProblem(z, r, Warning, "disabled_conflict", "the disabled record %s %s %s can't be enabled again while %s has a CNAME (a CNAME must be the only record at its name)", disp(r.Name), r.Type, r.Data, disp(r.Name))
|
||||||
|
case r.Type == "CNAME" && len(types) > 0:
|
||||||
|
c.recProblem(z, r, Warning, "disabled_conflict", "the disabled CNAME %s → %s can't be enabled again while %s has other records (a CNAME must be the only record at its name)", disp(r.Name), r.Data, disp(r.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *checker) checkZoneRecords(z *model.Zone) {
|
func (c *checker) checkZoneRecords(z *model.Zone) {
|
||||||
ix := &zoneIndex{
|
ix := &zoneIndex{
|
||||||
z: z,
|
z: z,
|
||||||
|
|||||||
@@ -115,13 +115,14 @@ type checker struct {
|
|||||||
opts Options
|
opts Options
|
||||||
problems []Problem
|
problems []Problem
|
||||||
// indexes
|
// indexes
|
||||||
zoneRecords map[int64][]*model.Record
|
zoneRecords map[int64][]*model.Record // served records (not disabled)
|
||||||
|
allRecords map[int64][]*model.Record
|
||||||
keyNames map[string]bool
|
keyNames map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// State validates the complete state.
|
// State validates the complete state.
|
||||||
func State(s *model.State, opts Options) []Problem {
|
func State(s *model.State, opts Options) []Problem {
|
||||||
c := &checker{s: s, opts: opts, zoneRecords: s.RecordsByZone(), keyNames: map[string]bool{}}
|
c := &checker{s: s, opts: opts, zoneRecords: s.ServedRecordsByZone(), allRecords: s.RecordsByZone(), keyNames: map[string]bool{}}
|
||||||
for _, k := range s.Keys {
|
for _, k := range s.Keys {
|
||||||
c.keyNames[k.Name] = true
|
c.keyNames[k.Name] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ func (c *checker) checkZones() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !z.HasRecords() && len(c.zoneRecords[z.ID]) > 0 {
|
if !z.HasRecords() && len(c.allRecords[z.ID]) > 0 {
|
||||||
c.zoneProblem(z, Error, "zone_records_unmanaged", "%s zones can't have records managed by Jarl (they get their data from elsewhere); delete the %d records or change the type to primary", z.Type, len(c.zoneRecords[z.ID]))
|
c.zoneProblem(z, Error, "zone_records_unmanaged", "%s zones can't have records managed by Jarl (they get their data from elsewhere); delete the %d records or change the type to primary", z.Type, len(c.allRecords[z.ID]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,6 +188,7 @@ func (c *checker) checkPrimary(z *model.Zone) {
|
|||||||
c.zoneProblem(z, Info, "zone_name_slash", "zone names with '/' (RFC 2317) are supported, but the file name is escaped")
|
c.zoneProblem(z, Info, "zone_name_slash", "zone names with '/' (RFC 2317) are supported, but the file name is escaped")
|
||||||
}
|
}
|
||||||
c.checkZoneRecords(z)
|
c.checkZoneRecords(z)
|
||||||
|
c.checkDisabledRecords(z)
|
||||||
}
|
}
|
||||||
|
|
||||||
// humanSeconds formats a duration in seconds as e.g. "1 day" or "12 hours".
|
// humanSeconds formats a duration in seconds as e.g. "1 day" or "12 hours".
|
||||||
|
|||||||
@@ -73,6 +73,34 @@ async function call(method, path, opts) {
|
|||||||
|
|
||||||
export const get = (path, query) => api("GET", path, { query });
|
export const get = (path, query) => api("GET", path, { query });
|
||||||
|
|
||||||
|
// stream GETs a newline-delimited JSON response (?stream=1 endpoints) and
|
||||||
|
// calls onEvent with each value as it arrives. It resolves at the end.
|
||||||
|
export async function stream(path, query, onEvent) {
|
||||||
|
const q = new URLSearchParams(query || {});
|
||||||
|
const resp = await fetch("/api/v1" + path + (q.toString() ? "?" + q : ""), { headers: { "Accept": "application/x-ndjson" }, credentials: "same-origin" });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const ct = resp.headers.get("Content-Type") || "";
|
||||||
|
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
||||||
|
if (resp.status === 401) onUnauthorized();
|
||||||
|
throw new APIError(resp.status, data);
|
||||||
|
}
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (value) buf += dec.decode(value, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||||
|
const line = buf.slice(0, nl).trim();
|
||||||
|
buf = buf.slice(nl + 1);
|
||||||
|
if (line) onEvent(JSON.parse(line));
|
||||||
|
}
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
if (buf.trim()) onEvent(JSON.parse(buf));
|
||||||
|
}
|
||||||
|
|
||||||
// draftQuery adds the draft changeset to a query, so reads show the
|
// draftQuery adds the draft changeset to a query, so reads show the
|
||||||
// configuration as it will be once the pending changes are applied.
|
// configuration as it will be once the pending changes are applied.
|
||||||
export function draftQuery(query = {}) {
|
export function draftQuery(query = {}) {
|
||||||
|
|||||||
@@ -324,9 +324,11 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
|
|||||||
const share = sum ? (e.count / sum) * 100 : 0;
|
const share = sum ? (e.count / sum) * 100 : 0;
|
||||||
const name = label(e.key);
|
const name = label(e.key);
|
||||||
const link = href ? href(e.key) : null;
|
const link = href ? href(e.key) : null;
|
||||||
return h("li", { title: name + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
|
// Clients Jarl has a host name for: the name, then the address.
|
||||||
|
const shown = e.name ? [e.name, h("span", { class: "muted" }, " " + name)] : name;
|
||||||
|
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
|
||||||
h("div", { class: "bar-text" },
|
h("div", { class: "bar-text" },
|
||||||
link ? h("a", { href: link, class: "bar-label" }, name) : h("span", { class: "bar-label" }, name),
|
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
||||||
h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
||||||
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -71,10 +71,34 @@ function stateBadge(st) {
|
|||||||
return badge(st, { omnipresent: "ok", rumoured: "accent", unretentive: "warn" }[st] || "");
|
return badge(st, { omnipresent: "ok", rumoured: "accent", unretentive: "warn" }[st] || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parentName is the zone's name relative to its parent zone in Jarl
|
||||||
|
// ("sectest" for sectest.adbraun.lol in adbraun.lol).
|
||||||
|
function parentName(zone, s) {
|
||||||
|
return zone.name.slice(0, -(s.parent_zone.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// jarlCmd is the jarl command for a record change in the parent zone.
|
||||||
|
function jarlCmd(zone, s, verb, data) {
|
||||||
|
return ["jarl record", verb, display(s.parent_zone), parentName(zone, s), "DS", data, s.parent_view ? "--view " + s.parent_view : ""].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dsData = (k) => [k.ds.key_tag, k.ds.algorithm, k.ds.digest_type, k.ds.digest].join(" ");
|
||||||
|
|
||||||
|
// inParent reports whether the parent zone in Jarl has the key's DS record.
|
||||||
|
const inParent = (s, k) => (s.parent_ds || []).some((d) => d.toUpperCase() === dsData(k).toUpperCase());
|
||||||
|
|
||||||
|
async function addToParent(zone, s, k) {
|
||||||
|
try {
|
||||||
|
const r = await change("POST", "/zones/" + s.parent_zone_id + "/records", { name: parentName(zone, s), type: "DS", data: dsData(k) });
|
||||||
|
toast(describeResult(r));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
}
|
||||||
|
|
||||||
function dsPanel(zone, s, keys) {
|
function dsPanel(zone, s, keys) {
|
||||||
const ph = s.phase;
|
const ph = s.phase;
|
||||||
const where = s.parent_zone
|
const where = s.parent_zone
|
||||||
? ["The parent zone ", h("a", { href: "#/zones" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, display(zone.name)), ")."]
|
? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
|
||||||
: "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields.";
|
: "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields.";
|
||||||
return h("div", { class: "panel" },
|
return h("div", { class: "panel" },
|
||||||
h("h2", null, "DS record for the parent zone"),
|
h("h2", null, "DS record for the parent zone"),
|
||||||
@@ -90,6 +114,13 @@ function dsPanel(zone, s, keys) {
|
|||||||
keys.map((k) => h("div", { class: "ds-record" },
|
keys.map((k) => h("div", { class: "ds-record" },
|
||||||
h("label", { class: "small muted" }, "Key " + k.tag + " — DS record"),
|
h("label", { class: "small muted" }, "Key " + k.tag + " — DS record"),
|
||||||
copyBox(k.ds.record.replace(/\t/g, " ")),
|
copyBox(k.ds.record.replace(/\t/g, " ")),
|
||||||
|
s.parent_zone ? [
|
||||||
|
h("label", { class: "small muted" }, "jarl command"),
|
||||||
|
copyBox(jarlCmd(zone, s, "add", dsData(k))),
|
||||||
|
inParent(s, k)
|
||||||
|
? h("p", { class: "small" }, badge("in " + display(s.parent_zone), "ok"), " ", display(s.parent_zone) + " has this DS record.")
|
||||||
|
: h("p", null, h("button", { class: ph.can_publish_ds ? "primary" : "", disabled: !ph.can_publish_ds, title: ph.can_publish_ds ? "" : "Not yet: see above", onclick: () => addToParent(zone, s, k) }, "Add to " + display(s.parent_zone))),
|
||||||
|
] : null,
|
||||||
h("dl", { class: "kv small" },
|
h("dl", { class: "kv small" },
|
||||||
h("dt", null, "Key tag"), h("dd", null, copyBox(String(k.ds.key_tag))),
|
h("dt", null, "Key tag"), h("dd", null, copyBox(String(k.ds.key_tag))),
|
||||||
h("dt", null, "Algorithm"), h("dd", null, copyBox(String(k.ds.algorithm)), h("span", { class: "muted" }, " " + (k.algorithm_name || ""))),
|
h("dt", null, "Algorithm"), h("dd", null, copyBox(String(k.ds.algorithm)), h("span", { class: "muted" }, " " + (k.algorithm_name || ""))),
|
||||||
@@ -142,7 +173,9 @@ async function disableDialog(zone, s) {
|
|||||||
h("p", null, "Signing is turned off in steps, so the zone is never unsigned while the parent zone still has its DS record (validating resolvers would then reject the whole domain):"),
|
h("p", null, "Signing is turned off in steps, so the zone is never unsigned while the parent zone still has its DS record (validating resolvers would then reject the whole domain):"),
|
||||||
steps([
|
steps([
|
||||||
[h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."],
|
[h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."],
|
||||||
[h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."],
|
s.parent_zone
|
||||||
|
? [h("strong", null, "You: "), "remove the DS record from ", display(s.parent_zone), " (", h("code", null, jarlCmd(zone, s, "delete", "")), ", or on its Records page), then click Check DS at parent so named knows it's gone."]
|
||||||
|
: [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."],
|
||||||
[h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."],
|
[h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."],
|
||||||
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
|
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
|
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
|
||||||
import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { optionsEditor } from "./options.js";
|
import { optionsEditor } from "./options.js";
|
||||||
import { emailOf, pendingBadge } from "./zones.js";
|
import { emailOf, pendingBadge } from "./zones.js";
|
||||||
@@ -89,43 +89,168 @@ function nsTable(servers, withSource) {
|
|||||||
].filter(Boolean), servers, { empty: "No other name servers to ask." });
|
].filter(Boolean), servers, { empty: "No other name servers to ask." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name server checks ask other servers over the network, which can take
|
||||||
|
// seconds. Results are kept for a while (per zone, for this page load),
|
||||||
|
// so coming back to the tab shows them at once; "Check again" asks anew.
|
||||||
|
const nsCache = new Map(); // zone id -> {serials, delegation}: {at, resp, log, resolver} once done, {job, resolver} while running
|
||||||
|
const nsFresh = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function nsEntry(zone) {
|
||||||
|
if (!nsCache.has(zone.id)) nsCache.set(zone.id, {});
|
||||||
|
return nsCache.get(zone.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCheck starts a check ("serials" or "delegation"), streamed: its log
|
||||||
|
// fills with the queries as they are sent and answered. Views subscribe
|
||||||
|
// to the job; its result is kept in nsCache.
|
||||||
|
function runCheck(zone, kind, query) {
|
||||||
|
const job = { log: [], subs: new Set(), done: null, error: null };
|
||||||
|
const entry = nsEntry(zone);
|
||||||
|
const resolver = query.resolver || "";
|
||||||
|
entry[kind] = { job, resolver };
|
||||||
|
const notify = () => job.subs.forEach((f) => f());
|
||||||
|
stream("/zones/" + zone.id + "/" + kind, { ...query, stream: 1 }, (ev) => {
|
||||||
|
if (ev.kind === "start") job.log.push({ id: ev.id, query: ev.query, started: Date.now() });
|
||||||
|
else if (ev.kind === "end") {
|
||||||
|
const q = job.log.find((q) => q.id === ev.id && !q.ended);
|
||||||
|
if (q) Object.assign(q, { ended: Date.now(), result: ev.result, answer: ev.answer, error: ev.error, rtt: ev.rtt_ms });
|
||||||
|
} else if (ev.kind === "done") job.done = ev.response;
|
||||||
|
notify();
|
||||||
|
}).then(() => {
|
||||||
|
if (job.done) entry[kind] = { at: Date.now(), resp: job.done, log: job.log, resolver };
|
||||||
|
else job.error = new Error("the check ended without a result");
|
||||||
|
notify();
|
||||||
|
}).catch((e) => {
|
||||||
|
job.error = e;
|
||||||
|
if (entry[kind] && entry[kind].job === job) delete entry[kind];
|
||||||
|
notify();
|
||||||
|
});
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
// followCheck shows a running check in el, drawing progress(job) until
|
||||||
|
// it's done and then result(response, log). It stops following once el
|
||||||
|
// has been shown and is gone (the check goes on, and its result is kept).
|
||||||
|
function followCheck(el, job, progress, result) {
|
||||||
|
let seen = false, timer = null;
|
||||||
|
const stop = () => { job.subs.delete(draw); clearInterval(timer); timer = null; };
|
||||||
|
function draw() {
|
||||||
|
if (el.isConnected) seen = true;
|
||||||
|
else if (seen) { stop(); return; }
|
||||||
|
if (job.error) mount(el, errorBox(job.error));
|
||||||
|
else if (job.done) mount(el, result(job.done, job.log));
|
||||||
|
else mount(el, progress(job));
|
||||||
|
if (job.done || job.error) stop();
|
||||||
|
}
|
||||||
|
job.subs.add(draw);
|
||||||
|
draw();
|
||||||
|
if (!job.done && !job.error) {
|
||||||
|
// Tick the "waiting n s" counters.
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (seen && !el.isConnected) { stop(); return; }
|
||||||
|
for (const s of el.querySelectorAll(".elapsed")) s.textContent = "waiting " + ((Date.now() - Number(s.dataset.started)) / 1000).toFixed(1) + " s";
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryLog lists the queries of a check: what was sent, and the answer or
|
||||||
|
// that it's still waiting.
|
||||||
|
function queryLog(log) {
|
||||||
|
const secs = (ms) => (ms / 1000).toFixed(1) + " s";
|
||||||
|
return h("ul", { class: "query-log" }, log.map((q) => {
|
||||||
|
let outcome;
|
||||||
|
if (!q.ended) outcome = [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted elapsed", dataset: { started: q.started } }, "waiting " + secs(Date.now() - q.started))];
|
||||||
|
else {
|
||||||
|
// The server's round-trip time when it has one; else how long the
|
||||||
|
// answer took to arrive here (at least the timeouts, for errors).
|
||||||
|
const ms = q.rtt || q.ended - q.started;
|
||||||
|
const took = h("span", { class: "muted" }, ms < 1 ? "<1 ms" : ms < 1000 ? ms + " ms" : secs(ms));
|
||||||
|
if (q.result) outcome = [nsBadge(q.result), took];
|
||||||
|
else if (q.error) outcome = [h("span", { class: "err-text" }, q.error), took];
|
||||||
|
else outcome = [h("span", { class: "mono" }, "→ " + (q.answer || "done")), took];
|
||||||
|
}
|
||||||
|
return h("li", { class: q.ended ? "" : "pending" }, h("code", null, q.query), " ", outcome);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// serialsView fills el with the serials check: the kept result, or the
|
||||||
|
// running check's progress until its result is in.
|
||||||
|
function serialsView(el, zone, again) {
|
||||||
|
const c = nsEntry(zone).serials;
|
||||||
|
if (!again && c && c.resp && Date.now() - c.at < nsFresh) {
|
||||||
|
mount(el, serialsResult(c.resp, c.log, true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job = !again && c && c.job ? c.job : runCheck(zone, "serials", {});
|
||||||
|
followCheck(el, job, (job) => {
|
||||||
|
const local = job.log.find((q) => q.id === 0);
|
||||||
|
return [
|
||||||
|
h("p", null, "This server: serial ", local && !local.ended ? [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted" }, "asking named")] : h("span", { class: "muted" }, "…")),
|
||||||
|
h("p", { class: "muted small" }, job.log.length ? "Waiting for the servers to answer:" : "Finding the servers to ask…"),
|
||||||
|
queryLog(job.log)];
|
||||||
|
}, (resp, log) => serialsResult(resp, log, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// delegationView fills el with the delegation check, like serialsView.
|
||||||
|
// It starts a check only when asked (run), or follows one that's running.
|
||||||
|
function delegationView(el, zone, resolver, run) {
|
||||||
|
const c = nsEntry(zone).delegation;
|
||||||
|
if (!run) {
|
||||||
|
if (c && c.job) followCheck(el, c.job, delegationProgress, (resp, log) => delegationResult(resp, log, false));
|
||||||
|
else if (c && c.resp && Date.now() - c.at < nsFresh) mount(el, delegationResult(c.resp, c.log, true, c.at));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job = runCheck(zone, "delegation", resolver ? { resolver } : {});
|
||||||
|
followCheck(el, job, delegationProgress, (resp, log) => delegationResult(resp, log, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function delegationProgress(job) {
|
||||||
|
return [
|
||||||
|
h("p", { class: "muted small" }, job.log.length
|
||||||
|
? "Finding the parent zone, asking its servers for the delegation and the DS records, then each delegated server for the zone:"
|
||||||
|
: [h("span", { class: "spinner", "aria-hidden": "true" }), "Starting…"]),
|
||||||
|
queryLog(job.log)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function delegationResult(r, log, cached, at) {
|
||||||
|
return [
|
||||||
|
delegationReport(r),
|
||||||
|
log && log.length ? h("details", { class: "query-details" }, h("summary", { class: "small muted" }, "Queries sent (" + log.length + ")"), queryLog(log)) : null,
|
||||||
|
h("p", { class: "muted small" }, "Checked ", time(r.checked_at || new Date(at).toISOString()), cached ? " (kept from earlier; Check delegation to ask anew)." : "."),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialsResult(r, log, cached) {
|
||||||
|
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
|
||||||
|
return [
|
||||||
|
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
|
||||||
|
nsTable(r.servers, true),
|
||||||
|
r.notes.map((n) => h("p", { class: "muted small" }, n)),
|
||||||
|
log && log.length ? h("details", { class: "query-details" }, h("summary", { class: "small muted" }, "Queries sent (" + log.length + ")"), queryLog(log)) : null,
|
||||||
|
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), cached ? " (kept from earlier; Check again to ask anew)." : "."),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
async function serversTab(el, zone) {
|
async function serversTab(el, zone) {
|
||||||
const serials = h("div");
|
const serials = h("div");
|
||||||
const loadSerials = async () => {
|
const again = h("button", { class: "small", onclick: () => serialsView(serials, zone, true) }, "Check again");
|
||||||
mount(serials, h("div", { class: "muted" }, "Asking the name servers…"));
|
|
||||||
try {
|
|
||||||
const r = await get("/zones/" + zone.id + "/serials");
|
|
||||||
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
|
|
||||||
mount(serials,
|
|
||||||
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
|
|
||||||
nsTable(r.servers, true),
|
|
||||||
r.notes.map((n) => h("p", { class: "muted small" }, n)),
|
|
||||||
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), "."));
|
|
||||||
} catch (err) {
|
|
||||||
mount(serials, errorBox(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const parts = [h("div", { class: "panel" },
|
const parts = [h("div", { class: "panel" },
|
||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"),
|
h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"),
|
||||||
h("button", { class: "small", onclick: loadSerials }, "Check again")),
|
again),
|
||||||
h("p", { class: "muted small" }, zone.type === "primary"
|
h("p", { class: "muted small" }, zone.type === "primary"
|
||||||
? "Asks the servers in the zone's NS records and its also-notify targets which serial they serve. A secondary that is behind hasn't picked up the latest change yet."
|
? "Asks the servers in the zone's NS records and its also-notify targets which serial they serve. A secondary that is behind hasn't picked up the latest change yet."
|
||||||
: "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."),
|
: "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."),
|
||||||
serials)];
|
serials)];
|
||||||
if (zone.type === "primary") {
|
if (zone.type === "primary") {
|
||||||
const out = h("div");
|
const out = h("div");
|
||||||
const resolver = input({ placeholder: "the server's own" });
|
const entry = nsEntry(zone);
|
||||||
const run = async (e) => {
|
const resolver = input({ placeholder: "the server's own", value: entry.delegation ? entry.delegation.resolver : "" });
|
||||||
|
const run = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
|
delegationView(out, zone, resolver.value.trim(), true);
|
||||||
try {
|
|
||||||
const q = resolver.value.trim() ? { resolver: resolver.value.trim() } : {};
|
|
||||||
mount(out, delegationReport(await get("/zones/" + zone.id + "/delegation", q)));
|
|
||||||
} catch (err) {
|
|
||||||
mount(out, errorBox(err));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
delegationView(out, zone, "", false);
|
||||||
parts.push(h("div", { class: "panel" },
|
parts.push(h("div", { class: "panel" },
|
||||||
h("h2", null, "Delegation from the parent zone"),
|
h("h2", null, "Delegation from the parent zone"),
|
||||||
h("p", { class: "muted small" }, "For zones on the internet: checks that the parent zone (for adbraun.lol, \"lol\") points to this zone's name servers with the right glue addresses, and that each of them serves the zone. A zone used only on your own network isn't delegated, and doesn't need this."),
|
h("p", { class: "muted small" }, "For zones on the internet: checks that the parent zone (for adbraun.lol, \"lol\") points to this zone's name servers with the right glue addresses, and that each of them serves the zone. A zone used only on your own network isn't delegated, and doesn't need this."),
|
||||||
@@ -135,7 +260,8 @@ async function serversTab(el, zone) {
|
|||||||
out));
|
out));
|
||||||
}
|
}
|
||||||
mount(el, parts);
|
mount(el, parts);
|
||||||
await loadSerials();
|
// Don't wait for the answers: the tab shows the check's progress.
|
||||||
|
serialsView(serials, zone, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function delegationReport(r) {
|
function delegationReport(r) {
|
||||||
@@ -310,6 +436,8 @@ async function recordsTab(el, zone, query) {
|
|||||||
// Filters and table
|
// Filters and table
|
||||||
const filter = input({ type: "search", placeholder: "Filter by name or data…", value: query.get("q") || "" });
|
const filter = input({ type: "search", placeholder: "Filter by name or data…", value: query.get("q") || "" });
|
||||||
const typeFilter = select([["", "All types"], ...[...new Set(records.map((r) => r.type))].sort()], "");
|
const typeFilter = select([["", "All types"], ...[...new Set(records.map((r) => r.type))].sort()], "");
|
||||||
|
// Shown once the zone has disabled records.
|
||||||
|
const stateFilter = select([["", "Enabled and disabled"], ["on", "Enabled only"], ["off", "Disabled only"]], "");
|
||||||
const list = h("div");
|
const list = h("div");
|
||||||
let editing = null;
|
let editing = null;
|
||||||
let current = records;
|
let current = records;
|
||||||
@@ -319,18 +447,30 @@ async function recordsTab(el, zone, query) {
|
|||||||
};
|
};
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
const q = filter.value.trim().toLowerCase();
|
const q = filter.value.trim().toLowerCase();
|
||||||
const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value));
|
const off = current.filter((r) => r.disabled).length;
|
||||||
let prevName = null;
|
stateFilter.style.display = off || stateFilter.value ? "" : "none";
|
||||||
|
stateFilter.options[2].textContent = "Disabled only (" + off + ")";
|
||||||
|
const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value) &&
|
||||||
|
(!stateFilter.value || !!r.disabled === (stateFilter.value === "off")));
|
||||||
mount(list, table([
|
mount(list, table([
|
||||||
{ label: "Name", render: (r) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
|
{ label: "Name", render: (r) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, tdClass: "name" },
|
||||||
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
|
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
|
||||||
{ label: "Type", render: (r) => h("strong", null, r.type) },
|
{ label: "Type", render: (r) => h("strong", null, r.type) },
|
||||||
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
||||||
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
{ label: "Comment", render: (r) => [r.disabled && r.pending !== "disable" ? [badge("disabled"), " "] : null, r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
||||||
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||||
|
r.disabled
|
||||||
|
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
||||||
|
: h("button", { class: "small toggle", title: "Leave the record out of the zone, but keep it here", onclick: () => toggle(r) }, "Disable"), " ",
|
||||||
h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" },
|
h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" },
|
||||||
], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [
|
||||||
|
r.pending ? "pending-" + r.pending : "",
|
||||||
|
r.disabled ? "disabled" : "",
|
||||||
|
// A record followed by more of the same name: a faint line, so the
|
||||||
|
// full line marks where the next name starts.
|
||||||
|
rows[i + 1] && rows[i + 1].name === r.name ? "same-name-next" : "",
|
||||||
|
].filter(Boolean).join(" ") }));
|
||||||
if (editing) mountEdit();
|
if (editing) mountEdit();
|
||||||
};
|
};
|
||||||
const del = async (r) => {
|
const del = async (r) => {
|
||||||
@@ -348,6 +488,17 @@ async function recordsTab(el, zone, query) {
|
|||||||
await redraw();
|
await redraw();
|
||||||
} catch (e) { toast(e.message, "err", 8000); }
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
};
|
};
|
||||||
|
// toggle disables or enables a record, and the PTR record that goes
|
||||||
|
// with an address record.
|
||||||
|
const toggle = async (r) => {
|
||||||
|
const verb = r.disabled ? "enable" : "disable";
|
||||||
|
const ptr = r.type === "A" || r.type === "AAAA";
|
||||||
|
try {
|
||||||
|
const res = await change("POST", "/records/" + r.id + "/" + verb, undefined, { query: ptr ? { ptr: 1 } : undefined });
|
||||||
|
toast(describeResult(res));
|
||||||
|
await redraw();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
const edit = (r) => { editing = r; draw(); };
|
const edit = (r) => { editing = r; draw(); };
|
||||||
const mountEdit = () => {
|
const mountEdit = () => {
|
||||||
const r = editing;
|
const r = editing;
|
||||||
@@ -395,12 +546,13 @@ async function recordsTab(el, zone, query) {
|
|||||||
};
|
};
|
||||||
filter.addEventListener("input", draw);
|
filter.addEventListener("input", draw);
|
||||||
typeFilter.addEventListener("change", draw);
|
typeFilter.addEventListener("change", draw);
|
||||||
|
stateFilter.addEventListener("change", draw);
|
||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "panel" },
|
h("div", { class: "panel" },
|
||||||
reverse && rinfo ? h("div", { class: "muted small", style: { marginBottom: "6px" } }, "Reverse zone for " + rinfo.network + ": name records by address (e.g. " + rinfo.example + ") and give the host name as data.") : null,
|
reverse && rinfo ? h("div", { class: "muted small", style: { marginBottom: "6px" } }, "Reverse zone for " + rinfo.network + ": name records by address (e.g. " + rinfo.example + ") and give the host name as data.") : null,
|
||||||
form, willAdd, suggest, hint, err),
|
form, willAdd, suggest, hint, err),
|
||||||
h("div", { class: "panel" },
|
h("div", { class: "panel" },
|
||||||
h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter,
|
h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter, stateFilter,
|
||||||
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). " +
|
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). " +
|
||||||
(reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))),
|
(reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))),
|
||||||
list));
|
list));
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export async function render(el, params, query) {
|
|||||||
lastList.zones = hash;
|
lastList.zones = hash;
|
||||||
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
|
||||||
mount(list, table([
|
mount(list, table([
|
||||||
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
|
||||||
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
|
||||||
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
|
||||||
{ label: "Records", render: (z) => z.type === "primary" ? String(z.record_count) : "", class: "right", tdClass: "right" },
|
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
|
||||||
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
|
||||||
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
|
||||||
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => z.pending ? "pending-" + z.pending : "" }));
|
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => z.pending ? "pending-" + z.pending : "" }));
|
||||||
@@ -236,6 +236,24 @@ export function emailOf(rname) {
|
|||||||
return m[1].replace(/\\\./g, ".") + "@" + m[2];
|
return m[1].replace(/\\\./g, ".") + "@" + m[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pendingBadge(kind) {
|
// dnssecLock marks a signed zone with a padlock linking to its DNSSEC tab:
|
||||||
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
// closed while signed, open while signing is being turned off (BIND's
|
||||||
|
// insecure policy).
|
||||||
|
function dnssecLock(z) {
|
||||||
|
if (!z.dnssec_policy) return null;
|
||||||
|
const off = z.dnssec_policy === "insecure";
|
||||||
|
const a = h("a", {
|
||||||
|
class: "dnssec-lock" + (off ? " off" : ""),
|
||||||
|
href: "#/zones/" + z.id + "/dnssec",
|
||||||
|
title: off ? "DNSSEC: signing is being turned off" : "DNSSEC: signed (policy " + z.dnssec_policy + ")",
|
||||||
|
"aria-label": off ? "DNSSEC being turned off" : "DNSSEC signed",
|
||||||
|
});
|
||||||
|
a.innerHTML = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="' +
|
||||||
|
(off ? "M5 7V5a3 3 0 0 1 5.8-1.1" : "M5 7V5a3 3 0 0 1 6 0v2") +
|
||||||
|
'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect x="3" y="7" width="10" height="7.5" rx="1.5" fill="currentColor"/></svg>';
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pendingBadge(kind) {
|
||||||
|
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
||||||
}
|
}
|
||||||
@@ -141,12 +141,16 @@ th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: upp
|
|||||||
tr:hover td { background: var(--info-soft); }
|
tr:hover td { background: var(--info-soft); }
|
||||||
td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; }
|
td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; }
|
||||||
td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
|
td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
|
||||||
td.name.repeat { color: var(--muted); }
|
a.dnssec-lock { display: inline-flex; vertical-align: -1px; margin-left: 6px; color: var(--ok); text-decoration: none; }
|
||||||
|
a.dnssec-lock.off { color: var(--warn); }
|
||||||
|
tr.same-name-next td { border-bottom-color: color-mix(in srgb, var(--border) 40%, transparent); }
|
||||||
td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
|
td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
|
||||||
tr.editing td { background: var(--accent-soft); }
|
tr.editing td { background: var(--accent-soft); }
|
||||||
tr.pending-add td { background: var(--add); }
|
tr.pending-add td { background: var(--add); }
|
||||||
tr.pending-change td { background: var(--warn-soft); }
|
tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); }
|
||||||
tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); }
|
tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); }
|
||||||
|
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
|
||||||
|
button.toggle { min-width: 5.2em; }
|
||||||
.empty { color: var(--muted); padding: 18px; text-align: center; }
|
.empty { color: var(--muted); padding: 18px; text-align: center; }
|
||||||
|
|
||||||
/* Forms */
|
/* Forms */
|
||||||
@@ -426,3 +430,16 @@ tr.selected td { background: var(--accent-soft); }
|
|||||||
footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); }
|
footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); }
|
||||||
footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; }
|
footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; }
|
||||||
footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; }
|
footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; }
|
||||||
|
|
||||||
|
/* a request in progress */
|
||||||
|
.spinner { display: inline-block; width: 11px; height: 11px; margin-right: 6px; vertical-align: -1px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-top-color: var(--border); background: var(--accent-soft); } }
|
||||||
|
|
||||||
|
/* the queries a name server check sends */
|
||||||
|
.query-log { list-style: none; margin: 6px 0; padding: 0; font-size: 13px; }
|
||||||
|
.query-log li { padding: 3px 0; display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
|
||||||
|
.query-log code { background: var(--code-bg); padding: 1px 6px; border-radius: 4px; overflow-wrap: anywhere; }
|
||||||
|
.query-log li.pending code { opacity: 0.85; }
|
||||||
|
.err-text { color: var(--err); }
|
||||||
|
.query-details { margin: 8px 0; }
|
||||||
@@ -162,6 +162,22 @@ contains "adbraun.test"
|
|||||||
run "record add with PTR" 0 $J record add adbraun.test web A 192.168.1.30 --ptr --ttl 300
|
run "record add with PTR" 0 $J record add adbraun.test web A 192.168.1.30 --ptr --ttl 300
|
||||||
expect_dig "web A" "192.168.1.30" web.adbraun.test A
|
expect_dig "web A" "192.168.1.30" web.adbraun.test A
|
||||||
expect_dig "PTR 30" "web.adbraun.test." 30.1.168.192.in-addr.arpa PTR
|
expect_dig "PTR 30" "web.adbraun.test." 30.1.168.192.in-addr.arpa PTR
|
||||||
|
run "record disable with PTR" 0 $J record disable adbraun.test web A --ptr
|
||||||
|
expect_dig "disabled web A gone" "" web.adbraun.test A
|
||||||
|
expect_dig "disabled PTR 30 gone" "" 30.1.168.192.in-addr.arpa PTR
|
||||||
|
run "record list marks it" 0 $J record list adbraun.test --disabled
|
||||||
|
contains "\[disabled\]"
|
||||||
|
run "disabling twice says so (exit 5)" 5 $J record disable adbraun.test web A
|
||||||
|
contains "already disabled"
|
||||||
|
run "record enable with PTR" 0 $J record enable adbraun.test web A --ptr
|
||||||
|
run "record list -o id" 0 $J record list adbraun.test --name web -t A -o id
|
||||||
|
if echo "$OUT" | grep -qx '[0-9][0-9]*'; then ok "-o id is just the ID"; else bad "-o id gave '$OUT'"; fi
|
||||||
|
run "disable by the listed ID" 0 $J record disable --id "$OUT"
|
||||||
|
run "zone show -o field" 0 $J zone show adbraun.test -o field=name,record_count,disabled_count
|
||||||
|
contains "^adbraun.test. [0-9]* 1$"
|
||||||
|
run "enable by ID" 0 $J record enable --id "$($J record list adbraun.test --name web -t A -o id)"
|
||||||
|
expect_dig "enabled web A back" "192.168.1.30" web.adbraun.test A
|
||||||
|
expect_dig "enabled PTR 30 back" "web.adbraun.test." 30.1.168.192.in-addr.arpa PTR
|
||||||
run "CNAME conflict rejected (exit 6)" 6 $J record add adbraun.test www A 192.168.1.99
|
run "CNAME conflict rejected (exit 6)" 6 $J record add adbraun.test www A 192.168.1.99
|
||||||
contains "CNAME"
|
contains "CNAME"
|
||||||
run "record add TXT with spaces" 0 $J record add adbraun.test @ TXT "v=spf1 mx -all"
|
run "record add TXT with spaces" 0 $J record add adbraun.test @ TXT "v=spf1 mx -all"
|
||||||
@@ -374,6 +390,25 @@ contains "removed .*Kadbraun.test"
|
|||||||
run "dnssec status: clean" 0 $J zone dnssec status adbraun.test --view external
|
run "dnssec status: clean" 0 $J zone dnssec status adbraun.test --view external
|
||||||
contains "Status:.*DNSSEC is off.$"
|
contains "Status:.*DNSSEC is off.$"
|
||||||
|
|
||||||
|
# DNSSEC for a zone whose parent is in Jarl: the DS goes in as a record.
|
||||||
|
run "child zone" 0 $J zone create sub.adbraun.test --view external --ns ns1.adbraun.test.
|
||||||
|
run "delegate the child" 0 $J record add adbraun.test sub NS ns1.adbraun.test. --view external
|
||||||
|
run "dnssec enable the child" 0 $J zone dnssec enable sub.adbraun.test --view external
|
||||||
|
for i in $(seq 50); do $J zone dnssec status sub.adbraun.test --view external 2>/dev/null | grep -q "signed serial" && break; sleep 0.2; done
|
||||||
|
run "child status gives the jarl command" 0 $J zone dnssec status sub.adbraun.test --view external
|
||||||
|
contains "in the parent zone adbraun.test, which is in Jarl"
|
||||||
|
contains "jarl record add adbraun.test sub DS [0-9]* 13 2 [0-9A-F]* --view external"
|
||||||
|
DSCMD=$(echo "$OUT" | grep -o "jarl record add adbraun.test sub DS .*")
|
||||||
|
run "the printed command adds the DS" 0 $J ${DSCMD#jarl }
|
||||||
|
run "the parent's zone file has the DS" 0 $J zone file adbraun.test --view external
|
||||||
|
contains "^sub.*IN.DS.$(echo "$DSCMD" | sed 's/.* DS \(.*\) --view.*/\1/')"
|
||||||
|
run "child status: parent has it" 0 $J zone dnssec status sub.adbraun.test --view external
|
||||||
|
contains "adbraun.test has it."
|
||||||
|
run "remove the child's DS" 0 $J record delete adbraun.test sub DS --view external --yes
|
||||||
|
run "wipe the child" 0 $J zone dnssec wipe sub.adbraun.test --view external --yes --force --resolver 127.0.0.1
|
||||||
|
run "delete the child" 0 $J zone delete sub.adbraun.test --view external --yes
|
||||||
|
run "remove the delegation" 0 $J record delete adbraun.test sub NS --view external --yes
|
||||||
|
|
||||||
run "status at the end" 0 $J status
|
run "status at the end" 0 $J status
|
||||||
contains "Drift:.*none"
|
contains "Drift:.*none"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user