Web UI: zone naming and reverse zones, ACL suggestions, record form
- New zone dialog: choose Domain or Reverse zone. Reverse zones are created from a network and their name is generated; both kinds show the exact zone name and flag likely mistakes (addresses or URLs as names, host-like names, duplicates in the view, parent/child zones, networks off the octet/nibble boundaries) before anything is submitted. - Record form: clears every field but the type after an add; shows the record exactly as it will be stored and warns about unqualified names. In reverse zones it takes an address, defaults to PTR, suggests host names of A/AAAA records with that address and warns about a second PTR; the record list shows addresses. - Address match lists (options, view match lists, ACL elements) offer the existing ACLs, TSIG keys and built-ins as one-click suggestions and flag unknown names. - Server: in reverse zones a record can be named by its address, and a PTR target like nas.example.com without the final dot is read as the full host name (a bare label is refused). Warnings noticed while parsing record data reach the result again. - Fixes: brand link hover underline, the dashboard's "New zone" link opening the dialog twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b7bee67f8
commit
bf7f99874b
19 files changed
+577
-35
No files matched your search
@@ -10,6 +10,11 @@ The first version, to be released as 0.1.0.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Web UI: wherever an address match list is entered (server, view and zone
|
||||||
|
options such as allow-query or allow-transfer, view match lists, ACL
|
||||||
|
elements) the existing ACLs, TSIG keys and built-in names are offered as
|
||||||
|
one-click suggestions, and names that don't exist are flagged as you type.
|
||||||
|
|
||||||
- **Query statistics** (off by default; `stats` in jarl.yaml, docs/stats.md).
|
- **Query statistics** (off by default; `stats` in jarl.yaml, docs/stats.md).
|
||||||
When enabled, Jarl adds a loopback statistics channel and a response log to
|
When enabled, Jarl adds a loopback statistics channel and a response log to
|
||||||
named's configuration (merged into existing `logging` and
|
named's configuration (merged into existing `logging` and
|
||||||
@@ -101,12 +106,38 @@ The first version, to be released as 0.1.0.
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Web UI, new zone dialog: choose **Domain** or **Reverse zone**. For a
|
||||||
|
reverse zone you give the network (10.10.0.0/24, 2001:db8:1::/48) and the
|
||||||
|
zone name is generated. Either way the dialog shows the exact zone name it
|
||||||
|
will create and explains problems before submitting: an address or URL
|
||||||
|
instead of a name, a host-like name such as www.example.com, a zone that
|
||||||
|
already exists in the view, a child or parent of an existing zone, and
|
||||||
|
networks that don't follow the octet or nibble boundaries.
|
||||||
|
- Web UI, record form: after adding a record every field is cleared except
|
||||||
|
the type, and the cursor returns to the name. A "Will add:" line shows the
|
||||||
|
record exactly as it will be stored, with warnings for unqualified names
|
||||||
|
(e.g. a CNAME to readthedocs.io without the final dot). In reverse zones
|
||||||
|
the form asks for the address, defaults to PTR, offers the host names of
|
||||||
|
A/AAAA records with that address, warns about a second PTR for an
|
||||||
|
address, and the record list shows each record's address.
|
||||||
|
- Reverse zones: a record name can be given as an address (10.10.0.20) in
|
||||||
|
the API and CLI too, and a PTR target without a final dot that contains a
|
||||||
|
dot (nas.adbraun.lol) is read as the full host name (with a warning); a
|
||||||
|
bare label is refused.
|
||||||
|
|
||||||
- `jarl resync` / `jarld resync` refuse to overwrite files that were changed
|
- `jarl resync` / `jarld resync` refuse to overwrite files that were changed
|
||||||
outside Jarl unless `--force` is given, as documented. Before, they always
|
outside Jarl unless `--force` is given, as documented. Before, they always
|
||||||
overwrote them.
|
overwrote them.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Web UI: the "Jarl 0.1.0" brand link no longer underlines the gap between
|
||||||
|
the name and the version on hover.
|
||||||
|
- Web UI: creating a zone from the dashboard's "New zone" link no longer
|
||||||
|
opens the dialog a second time.
|
||||||
|
- Warnings noticed while parsing record data (a name read as absolute) were
|
||||||
|
lost before reaching the result; they are shown again.
|
||||||
|
|
||||||
- The negative-caching warning now uses the effective negative TTL, which
|
- The negative-caching warning now uses the effective negative TTL, which
|
||||||
is the smaller of the SOA minimum and the zone's default TTL (RFC 2308).
|
is the smaller of the SOA minimum and the zone's default TTL (RFC 2308).
|
||||||
It explains the real effect (new names can stay invisible to resolvers
|
It explains the real effect (new names can stay invisible to resolvers
|
||||||
|
|||||||
@@ -103,11 +103,16 @@ these pages:
|
|||||||
* **Statistics** (when enabled): queries over time, failed answers, a
|
* **Statistics** (when enabled): queries over time, failed answers, a
|
||||||
weekday x hour heatmap, top names and clients, and breakdowns. Every chart
|
weekday x hour heatmap, top names and clients, and breakdowns. Every chart
|
||||||
has a table view.
|
has a table view.
|
||||||
* **Zones**: list, create (fields adapt to the zone type), per-zone records
|
* **Zones**: list, create (choose a domain or a network, and the dialog shows
|
||||||
with inline editing and type-specific hints, settings (SOA, TTL, serial
|
the exact zone name it will create and warns about likely mistakes;
|
||||||
|
fields adapt to the zone type), per-zone records
|
||||||
|
with inline editing, type-specific hints and a preview of the exact record
|
||||||
|
that will be added (reverse zones take addresses and suggest host names), settings (SOA, TTL, serial
|
||||||
policy, view), options, the generated zone file, and pasting a zone file
|
policy, view), options, the generated zone file, and pasting a zone file
|
||||||
to merge or replace records.
|
to merge or replace records.
|
||||||
* **Views, ACLs, TSIG keys, Server options**: the options editor knows every
|
* **Views, ACLs, TSIG keys, Server options**: wherever an address match list
|
||||||
|
is entered, the existing ACLs and keys are offered and unknown names are
|
||||||
|
flagged. The options editor knows every
|
||||||
BIND 9.20 statement for the scope and shows its description, grammar and
|
BIND 9.20 statement for the scope and shows its description, grammar and
|
||||||
a value field that fits it.
|
a value field that fits it.
|
||||||
* **Changes**: pending edits with the diff, named-checkconf output and
|
* **Changes**: pending edits with the diff, named-checkconf output and
|
||||||
|
|||||||
+7
-1
@@ -98,11 +98,17 @@ 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]
|
||||||
|
|
||||||
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Names are relative to the zone (`@` is the apex) unless they end with a
|
Names are relative to the zone (`@` is the apex) unless they end with a
|
||||||
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
|
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
|
||||||
adds the quotes and splits long values into 255-byte strings. `--ptr`
|
adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
|
||||||
|
(`10.10.0.20`), and a PTR target such as `nas.adbraun.lol` is read as the full
|
||||||
|
host name (a bare `nas` is refused, since it would mean
|
||||||
|
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
||||||
keeps the PTR record in the reverse zone in step (when that zone is managed
|
keeps the PTR record in the reverse zone in step (when that zone is managed
|
||||||
by Jarl). Every record type BIND supports can be used. Jarl knows the
|
by Jarl). 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
|
||||||
|
|||||||
+3
-1
@@ -147,7 +147,9 @@ RFC 1982 arithmetic.
|
|||||||
resolved against the zone, and `www.example.com` without a final dot in a
|
resolved against the zone, and `www.example.com` without a final dot in a
|
||||||
zone `example.com` is caught as the classic `www.example.com.example.com.`
|
zone `example.com` is caught as the classic `www.example.com.example.com.`
|
||||||
mistake. Adding an A/AAAA record with `ptr` also maintains the PTR record in
|
mistake. Adding an A/AAAA record with `ptr` also maintains the PTR record in
|
||||||
the matching reverse zone.
|
the matching reverse zone. In reverse zones a record can be named by its
|
||||||
|
address (`10.10.0.20`), and a PTR target without a final dot is read as
|
||||||
|
the full host name, because a PTR never points into the reverse zone.
|
||||||
* **Options** are named.conf statements attached to a scope: server
|
* **Options** are named.conf statements attached to a scope: server
|
||||||
`options { }`, a view, a zone, or the top level (`logging`, `controls`,
|
`options { }`, a view, a zone, or the top level (`logging`, `controls`,
|
||||||
`statistics-channels`, `include`, ...). The UI and CLI know every BIND 9.20
|
`statistics-channels`, `include`, ...). The UI and CLI know every BIND 9.20
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ func Owner(input, zone string) (string, error) {
|
|||||||
if in == "" || in == "@" {
|
if in == "" || in == "@" {
|
||||||
return zone, nil
|
return zone, nil
|
||||||
}
|
}
|
||||||
|
// In reverse zones an address stands for its PTR name.
|
||||||
|
if IsReverse(zone) {
|
||||||
|
if addr, err := netip.ParseAddr(strings.TrimSuffix(in, ".")); err == nil {
|
||||||
|
return ReverseName(addr), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
a, err := ToASCII(in)
|
a, err := ToASCII(in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -172,3 +172,16 @@ func TestMisc(t *testing.T) {
|
|||||||
t.Error("IsSubdomain")
|
t.Error("IsSubdomain")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOwnerAddressInReverseZone(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, zone, want string }{
|
||||||
|
{"10.10.0.20", "0.10.10.in-addr.arpa.", "20.0.10.10.in-addr.arpa."},
|
||||||
|
{"20", "0.10.10.in-addr.arpa.", "20.0.10.10.in-addr.arpa."},
|
||||||
|
{"2001:db8::1", "8.b.d.0.1.0.0.2.ip6.arpa.", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."},
|
||||||
|
{"10.10.0.20", "adbraun.lol.", "10.10.0.20.adbraun.lol."}, // not special in forward zones
|
||||||
|
} {
|
||||||
|
if got, err := Owner(tc.in, tc.zone); err != nil || got != tc.want {
|
||||||
|
t.Errorf("Owner(%q, %q) = %q, %v; want %q", tc.in, tc.zone, got, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -450,3 +450,15 @@ func TestZoneCreateWithOptions(t *testing.T) {
|
|||||||
t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o)
|
t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseWarningsReachTheResult(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.10.0.0/24")}))
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "0.10.10.in-addr.arpa", Name: model.Ptr("10.10.0.20"), Type: model.Ptr("PTR"), Data: model.Ptr("nas.example.com")}))
|
||||||
|
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "read as the absolute name") {
|
||||||
|
t.Errorf("warnings = %q", res.Warnings)
|
||||||
|
}
|
||||||
|
if len(res.Ops) != 1 || !strings.Contains(res.Ops[0].Summary, "20.0.10.10.in-addr.arpa. IN PTR nas.example.com.") {
|
||||||
|
t.Errorf("ops = %+v", res.Ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -528,6 +528,15 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
|
|||||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||||
|
// The stored operation holds the canonical data, so applying it doesn't
|
||||||
|
// notice again.
|
||||||
|
func (x *expander) parseWarnings(owner string, p *rr.Parsed) {
|
||||||
|
for _, w := range p.Warnings {
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("%s %s: %s", dnsname.Display(owner), p.Type, w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||||
var in RecordInput
|
var in RecordInput
|
||||||
if err := decode(req.Data, &in); err != nil {
|
if err := decode(req.Data, &in); err != nil {
|
||||||
@@ -544,6 +553,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
x.parseWarnings(owner, p)
|
||||||
ptr := in.PTR
|
ptr := in.PTR
|
||||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||||
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
||||||
@@ -589,6 +599,7 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
x.parseWarnings(owner, p)
|
||||||
if in.Name != nil {
|
if in.Name != nil {
|
||||||
in.Name = &owner
|
in.Name = &owner
|
||||||
}
|
}
|
||||||
@@ -737,6 +748,7 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
x.parseWarnings(owner, p)
|
||||||
canon = append(canon, p.Data)
|
canon = append(canon, p.Data)
|
||||||
}
|
}
|
||||||
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ func Parse(zone, owner, typ, data string) (*Parsed, error) {
|
|||||||
if IsTextType(t) && !strings.HasPrefix(d, `"`) {
|
if IsTextType(t) && !strings.HasPrefix(d, `"`) {
|
||||||
d = QuoteText(d)
|
d = QuoteText(d)
|
||||||
}
|
}
|
||||||
|
// A PTR record names a host, which is never inside the reverse zone
|
||||||
|
// itself: "nas.adbraun.lol" means the absolute name.
|
||||||
|
var ptrWarning string
|
||||||
|
if t == "PTR" && dnsname.IsReverse(zone) && !strings.HasSuffix(d, ".") && !strings.ContainsAny(d, " \t") {
|
||||||
|
if !strings.Contains(d, ".") {
|
||||||
|
return nil, fmt.Errorf("PTR data %q would become %s%s; give the host's full name, e.g. %s.example.com", d, d+".", zone, d)
|
||||||
|
}
|
||||||
|
ptrWarning = fmt.Sprintf("%q was read as the absolute name %q (add a trailing dot to be explicit)", d, d+".")
|
||||||
|
d += "."
|
||||||
|
}
|
||||||
typeText := t
|
typeText := t
|
||||||
if _, known := dns.TypeToString[code]; !known {
|
if _, known := dns.TypeToString[code]; !known {
|
||||||
typeText = fmt.Sprintf("TYPE%d", code)
|
typeText = fmt.Sprintf("TYPE%d", code)
|
||||||
@@ -226,6 +236,9 @@ func Parse(zone, owner, typ, data string) (*Parsed, error) {
|
|||||||
return nil, fmt.Errorf("data does not form a %s record", t)
|
return nil, fmt.Errorf("data does not form a %s record", t)
|
||||||
}
|
}
|
||||||
p := &Parsed{RR: parsed, Type: t}
|
p := &Parsed{RR: parsed, Type: t}
|
||||||
|
if ptrWarning != "" {
|
||||||
|
p.Warnings = append(p.Warnings, ptrWarning)
|
||||||
|
}
|
||||||
if err := p.fixNames(zone, data); err != nil {
|
if err := p.fixNames(zone, data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,3 +157,21 @@ func TestParseErrorText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPTRTargetsInReverseZones(t *testing.T) {
|
||||||
|
zone := "0.10.10.in-addr.arpa."
|
||||||
|
p, err := Parse(zone, "20."+zone, "PTR", "nas.adbraun.lol")
|
||||||
|
if err != nil || p.Data != "nas.adbraun.lol." || len(p.Warnings) != 1 {
|
||||||
|
t.Errorf("unqualified PTR target: %v %+v", err, p)
|
||||||
|
}
|
||||||
|
if p, err := Parse(zone, "20."+zone, "PTR", "nas.adbraun.lol."); err != nil || p.Data != "nas.adbraun.lol." || len(p.Warnings) != 0 {
|
||||||
|
t.Errorf("qualified PTR target: %v %+v", err, p)
|
||||||
|
}
|
||||||
|
if _, err := Parse(zone, "20."+zone, "PTR", "nas"); err == nil {
|
||||||
|
t.Error("a single-label PTR target inside the reverse zone was accepted")
|
||||||
|
}
|
||||||
|
// Forward zones keep zone-file semantics.
|
||||||
|
if p, err := Parse("adbraun.lol.", "x.adbraun.lol.", "CNAME", "nas"); err != nil || p.Data != "nas.adbraun.lol." {
|
||||||
|
t.Errorf("forward CNAME: %v %+v", err, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Address match list helpers: suggestions for the ACLs, TSIG keys and
|
||||||
|
// built-in names that can be used where named expects an address match
|
||||||
|
// list (options such as allow-query, view match-clients, ACL elements),
|
||||||
|
// and a check for names that don't exist, so nobody has to switch pages
|
||||||
|
// to look up an ACL's exact name.
|
||||||
|
import { h, mount } from "./ui.js";
|
||||||
|
import { get } from "./api.js";
|
||||||
|
|
||||||
|
export const builtins = ["any", "none", "localhost", "localnets"];
|
||||||
|
|
||||||
|
// amlContext loads the names that can be referred to. Users who may not
|
||||||
|
// read ACLs or keys simply get fewer suggestions.
|
||||||
|
export async function amlContext() {
|
||||||
|
const [acls, keys] = await Promise.all([get("/acls").catch(() => []), get("/tsig-keys").catch(() => [])]);
|
||||||
|
return { acls: acls.map((a) => ({ name: a.name, detail: a.elements.join("; ") })), keys: keys.map((k) => k.name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i;
|
||||||
|
|
||||||
|
// checkElement returns a problem with one element, or "".
|
||||||
|
function checkElement(line, ctx, exclude) {
|
||||||
|
let e = line.trim().replace(/;$/, "").trim();
|
||||||
|
if (!e) return "";
|
||||||
|
if (e.startsWith("!")) e = e.slice(1).trim();
|
||||||
|
const lower = e.toLowerCase();
|
||||||
|
if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return "";
|
||||||
|
const key = e.match(/^key\s+"?([^"\s]+)"?$/i);
|
||||||
|
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
|
||||||
|
if (/\s/.test(e)) return "";
|
||||||
|
const name = e.replace(/^"|"$/g, "");
|
||||||
|
if (exclude && name.toLowerCase() === exclude.toLowerCase()) return "an ACL can't include itself";
|
||||||
|
return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestions adds clickable names below a text field. mode "lines" adds
|
||||||
|
// one element per line (and checks the names); "inline" inserts
|
||||||
|
// "name; " at the cursor in named.conf syntax.
|
||||||
|
export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
|
||||||
|
const problems = h("div", { class: "aml-problems" });
|
||||||
|
const insert = (text) => {
|
||||||
|
if (mode === "lines") {
|
||||||
|
const lines = field.value.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (!lines.includes(text)) lines.push(text);
|
||||||
|
field.value = lines.join("\n");
|
||||||
|
} else {
|
||||||
|
const at = field.selectionStart ?? field.value.length;
|
||||||
|
const before = field.value.slice(0, at), after = field.value.slice(at);
|
||||||
|
const sep = before && !/[\s{]$/.test(before) ? " " : "";
|
||||||
|
field.value = before + sep + text + "; " + after;
|
||||||
|
field.selectionStart = field.selectionEnd = (before + sep + text + "; ").length;
|
||||||
|
}
|
||||||
|
field.focus();
|
||||||
|
field.dispatchEvent(new Event("input"));
|
||||||
|
};
|
||||||
|
const chip = (label, text, title) => h("button", { type: "button", class: "chip", title: title || "", onclick: () => insert(text) }, label);
|
||||||
|
const acls = ctx.acls.filter((a) => a.name.toLowerCase() !== exclude.toLowerCase());
|
||||||
|
const row = h("div", { class: "aml-suggest" },
|
||||||
|
acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null,
|
||||||
|
ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null,
|
||||||
|
h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])),
|
||||||
|
mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it.") : null);
|
||||||
|
if (mode === "lines") {
|
||||||
|
const check = () => {
|
||||||
|
const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m);
|
||||||
|
mount(problems, msgs.map(([l, m]) => h("div", { class: "msg warn small" }, l + ": " + m)));
|
||||||
|
};
|
||||||
|
field.addEventListener("input", check);
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
return h("div", { class: "aml" }, row, problems);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// DNS name helpers for forms: reverse zones from networks, PTR names from
|
||||||
|
// addresses, and plain checks of domain names, so the UI can show what a
|
||||||
|
// name will become before anything is submitted. The server checks
|
||||||
|
// everything again; these only make mistakes visible early.
|
||||||
|
|
||||||
|
const v4Re = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||||
|
|
||||||
|
export function parseIPv4(s) {
|
||||||
|
const m = s.trim().match(v4Re);
|
||||||
|
if (!m) return null;
|
||||||
|
const b = m.slice(1, 5).map(Number);
|
||||||
|
return b.every((x) => x <= 255) ? b : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIPv6 returns the 32 hex nibbles of an address, or null.
|
||||||
|
export function parseIPv6(s) {
|
||||||
|
s = s.trim().toLowerCase();
|
||||||
|
if (!s.includes(":") || !/^[0-9a-f:.]+$/.test(s)) return null;
|
||||||
|
let tail = [];
|
||||||
|
const v4 = s.match(/:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (v4) {
|
||||||
|
const b = parseIPv4(v4[1]);
|
||||||
|
if (!b) return null;
|
||||||
|
tail = [((b[0] << 8) | b[1]).toString(16), ((b[2] << 8) | b[3]).toString(16)];
|
||||||
|
s = s.slice(0, -v4[1].length) + "0:0";
|
||||||
|
}
|
||||||
|
const halves = s.split("::");
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const head = halves[0] ? halves[0].split(":") : [];
|
||||||
|
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
||||||
|
let groups;
|
||||||
|
if (halves.length === 2) {
|
||||||
|
const fill = 8 - head.length - rest.length;
|
||||||
|
if (fill < 1) return null;
|
||||||
|
groups = [...head, ...Array(fill).fill("0"), ...rest];
|
||||||
|
} else groups = head;
|
||||||
|
if (tail.length) groups.splice(-2, 2, ...tail);
|
||||||
|
if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
|
||||||
|
return groups.map((g) => g.padStart(4, "0")).join("").split("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseZoneFor turns a network such as 10.10.0.0/24 into its reverse
|
||||||
|
// zone. It returns {name} or {error}.
|
||||||
|
export function reverseZoneFor(cidr) {
|
||||||
|
const [addr, bitsText] = cidr.trim().split("/");
|
||||||
|
if (bitsText === undefined) return { error: "give the network with its prefix length, e.g. 10.10.0.0/24" };
|
||||||
|
const bits = Number(bitsText);
|
||||||
|
const b4 = parseIPv4(addr);
|
||||||
|
if (b4) {
|
||||||
|
if (![8, 16, 24].includes(bits)) return { error: "IPv4 reverse zones follow the dots: use /8, /16 or /24 (a /" + bits + " network goes in the enclosing /" + (bits > 24 ? 24 : bits > 16 ? 16 : 8) + " zone)" };
|
||||||
|
const n = bits / 8;
|
||||||
|
const host = b4.slice(n);
|
||||||
|
if (host.some((x) => x !== 0)) return { error: "that's an address inside the network; the network is " + b4.slice(0, n).concat(host.map(() => 0)).join(".") + "/" + bits };
|
||||||
|
return { name: b4.slice(0, n).reverse().join(".") + ".in-addr.arpa", network: b4.slice(0, n).join(".") + (n < 4 ? "." + Array(4 - n).fill(0).join(".") : "") + "/" + bits };
|
||||||
|
}
|
||||||
|
const n6 = parseIPv6(addr);
|
||||||
|
if (n6) {
|
||||||
|
if (!(bits > 0 && bits <= 124 && bits % 4 === 0)) return { error: "IPv6 reverse zones need a prefix that is a multiple of 4, e.g. /48 or /64" };
|
||||||
|
const keep = n6.slice(0, bits / 4);
|
||||||
|
if (n6.slice(bits / 4).some((x) => x !== "0")) return { error: "the address has bits set after /" + bits + "; use the network address" };
|
||||||
|
return { name: keep.slice().reverse().join(".") + ".ip6.arpa", network: cidr.trim() };
|
||||||
|
}
|
||||||
|
return { error: "not an IPv4 or IPv6 network" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReverseZone(name) {
|
||||||
|
const n = name.replace(/\.$/, "").toLowerCase();
|
||||||
|
return n.endsWith(".in-addr.arpa") || n.endsWith(".ip6.arpa");
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseInfo describes the network a reverse zone covers.
|
||||||
|
export function reverseInfo(zoneName) {
|
||||||
|
const n = zoneName.replace(/\.$/, "").toLowerCase();
|
||||||
|
if (n.endsWith(".in-addr.arpa")) {
|
||||||
|
const labels = n.slice(0, -".in-addr.arpa".length).split(".").reverse();
|
||||||
|
if (labels.length > 3 || labels.some((l) => !/^\d+$/.test(l))) return null;
|
||||||
|
const pad = Array(4 - labels.length).fill("0");
|
||||||
|
return { family: 4, prefix: labels, network: [...labels, ...pad].join(".") + "/" + labels.length * 8, example: [...labels, ...pad.map((_, i) => (i === pad.length - 1 ? "20" : "0"))].join(".") };
|
||||||
|
}
|
||||||
|
if (n.endsWith(".ip6.arpa")) {
|
||||||
|
const nib = n.slice(0, -".ip6.arpa".length).split(".").reverse();
|
||||||
|
const full = [...nib, ...Array(32 - nib.length).fill("0")];
|
||||||
|
const groups = [];
|
||||||
|
for (let i = 0; i < 32; i += 4) groups.push(full.slice(i, i + 4).join("").replace(/^0+(?=.)/, ""));
|
||||||
|
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: groups.slice(0, 4).join(":") + "::20" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrOwner turns an address (or a relative label) into the record name
|
||||||
|
// inside a reverse zone: {relative, fqdn} or {error}.
|
||||||
|
export function ptrOwner(input, zoneName) {
|
||||||
|
const zone = zoneName.replace(/\.$/, "");
|
||||||
|
const info = reverseInfo(zone);
|
||||||
|
const s = input.trim();
|
||||||
|
if (!info || !s) return {};
|
||||||
|
const b4 = info.family === 4 ? parseIPv4(s) : null;
|
||||||
|
const n6 = info.family === 6 ? parseIPv6(s) : null;
|
||||||
|
if (b4 || n6) {
|
||||||
|
const parts = b4 ? b4.map(String) : n6;
|
||||||
|
const inZone = info.prefix.every((p, i) => parts[i] === p);
|
||||||
|
if (!inZone) return { error: s + " is not in " + info.network + ", the network this zone covers" };
|
||||||
|
const rel = parts.slice(info.prefix.length).reverse().join(".");
|
||||||
|
return { relative: rel, fqdn: rel + "." + zone };
|
||||||
|
}
|
||||||
|
if (s.endsWith(".")) return { relative: s, fqdn: s.replace(/\.$/, "") };
|
||||||
|
return { relative: s, fqdn: s + "." + zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressOf rebuilds the address a PTR name in a reverse zone stands for.
|
||||||
|
export function addressOf(fqdn) {
|
||||||
|
const n = fqdn.replace(/\.$/, "").toLowerCase();
|
||||||
|
if (n.endsWith(".in-addr.arpa")) {
|
||||||
|
const l = n.slice(0, -".in-addr.arpa".length).split(".");
|
||||||
|
return l.length === 4 && l.every((x) => /^\d+$/.test(x)) ? l.reverse().join(".") : "";
|
||||||
|
}
|
||||||
|
if (n.endsWith(".ip6.arpa")) {
|
||||||
|
const l = n.slice(0, -".ip6.arpa".length).split(".");
|
||||||
|
if (l.length !== 32) return "";
|
||||||
|
const hex = l.reverse().join("");
|
||||||
|
const groups = [];
|
||||||
|
for (let i = 0; i < 32; i += 4) groups.push(hex.slice(i, i + 4).replace(/^0+(?=.)/, ""));
|
||||||
|
return groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDomain checks a zone name as typed and returns {name, problems,
|
||||||
|
// notes}: name is the normalized form (lowercase, no final dot).
|
||||||
|
export function checkDomain(input) {
|
||||||
|
const raw = input.trim();
|
||||||
|
const name = raw.toLowerCase().replace(/\.$/, "");
|
||||||
|
const problems = [], notes = [];
|
||||||
|
if (!name) return { name, problems, notes };
|
||||||
|
if (/\s/.test(name)) problems.push("names can't contain spaces");
|
||||||
|
if (/^[a-z]+:\/\//.test(name) || name.includes("/")) problems.push("give just the domain name, not a URL or path");
|
||||||
|
if (name.includes("@")) problems.push("that looks like an email address");
|
||||||
|
if (parseIPv4(name) || parseIPv6(name)) problems.push("that's an address: for a reverse zone choose \"Reverse zone\" and give the network");
|
||||||
|
const labels = name.split(".");
|
||||||
|
if (labels.some((l) => l === "")) problems.push("empty label (two dots in a row)");
|
||||||
|
if (labels.some((l) => l.length > 63)) problems.push("each part can be at most 63 characters");
|
||||||
|
if (name.length > 253) problems.push("the name is too long");
|
||||||
|
if (!problems.length && labels.some((l) => !/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?$/.test(l) && !/^xn--/.test(l))) {
|
||||||
|
if (/[^\x00-\x7f]/.test(name)) notes.push("international name: it will be stored in its xn-- (punycode) form");
|
||||||
|
else problems.push("use letters, digits and hyphens (no hyphen at the start or end of a part)");
|
||||||
|
}
|
||||||
|
if (raw !== name && raw.replace(/\.$/, "") !== raw.replace(/\.$/, "").toLowerCase()) notes.push("stored in lowercase");
|
||||||
|
if (!problems.length && labels.length === 1 && name !== "") notes.push("a single label: that's a top-level domain; usually you want your domain, e.g. " + name + "." + "example");
|
||||||
|
if (!problems.length && /^(www|mail|ns\d*|smtp|vpn)\./.test(name) && labels.length > 2) notes.push("\"" + labels[0] + "\" usually names a host inside a zone; is the zone " + labels.slice(1).join(".") + "?");
|
||||||
|
if (isReverseZone(name)) notes.push("a reverse zone: choosing \"Reverse zone\" and giving the network is easier");
|
||||||
|
return { name, problems, notes };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { h, mount, table, field, input, textarea, toast, errorBox, confirmDialog, modal, lines } from "../ui.js";
|
import { h, mount, table, field, input, textarea, toast, errorBox, confirmDialog, modal, lines } from "../ui.js";
|
||||||
|
import { amlContext, suggestions } from "../aml.js";
|
||||||
import { get, change, describeResult } from "../api.js";
|
import { get, change, describeResult } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
@@ -23,12 +24,13 @@ export async function render(el) {
|
|||||||
|
|
||||||
async function aclDialog(a) {
|
async function aclDialog(a) {
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
|
const ctx = await amlContext();
|
||||||
const name = input({ value: a ? a.name : "", placeholder: "homelab", class: "mono" });
|
const name = input({ value: a ? a.name : "", placeholder: "homelab", class: "mono" });
|
||||||
const elems = textarea(a ? a.elements.join("\n") : "", { class: "mono", rows: 8, placeholder: "192.168.1.0/24\n10.0.0.0/8\n!10.0.0.66\nkey xfer-key\nlocalhost" });
|
const elems = textarea(a ? a.elements.join("\n") : "", { class: "mono", rows: 8, placeholder: "192.168.1.0/24\n10.0.0.0/8\n!10.0.0.66\nkey xfer-key\nlocalhost" });
|
||||||
const comment = input({ value: a ? a.comment : "" });
|
const comment = input({ value: a ? a.comment : "" });
|
||||||
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
|
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Name", name, a ? "Renaming updates every reference automatically." : ""),
|
field("Name", name, a ? "Renaming updates every reference automatically." : ""),
|
||||||
field("Elements", elems, "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
|
field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
|
||||||
field("Comment", comment)), err), [
|
field("Comment", comment)), err), [
|
||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
|
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { h, mount, table, field, input, select, textarea, toast, errorBox, badge
|
|||||||
import { get, change, describeResult } from "../api.js";
|
import { get, change, describeResult } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { emailOf } from "./zones.js";
|
import { emailOf } from "./zones.js";
|
||||||
|
import { amlContext, suggestions } from "../aml.js";
|
||||||
|
|
||||||
const catalogCache = {};
|
const catalogCache = {};
|
||||||
async function catalog(scope) {
|
async function catalog(scope) {
|
||||||
@@ -10,7 +11,8 @@ async function catalog(scope) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// valueWidget returns {el, get()} for editing a value of the given kind.
|
// valueWidget returns {el, get()} for editing a value of the given kind.
|
||||||
function valueWidget(stmt, value) {
|
// ctx holds the ACL and key names to suggest for address match lists.
|
||||||
|
function valueWidget(stmt, value, ctx) {
|
||||||
const kind = stmt ? stmt.kind : "raw";
|
const kind = stmt ? stmt.kind : "raw";
|
||||||
if (kind === "bool") {
|
if (kind === "bool") {
|
||||||
const s = select(["yes", "no"], value || "yes");
|
const s = select(["yes", "no"], value || "yes");
|
||||||
@@ -23,11 +25,15 @@ function valueWidget(stmt, value) {
|
|||||||
if (kind === "address_match_list") {
|
if (kind === "address_match_list") {
|
||||||
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
|
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
|
||||||
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
|
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
|
||||||
return { el: t, get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
|
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
|
||||||
}
|
}
|
||||||
if (kind === "raw") {
|
if (kind === "raw") {
|
||||||
const t = textarea(value || "", { class: "mono", rows: 3 });
|
const t = textarea(value || "", { class: "mono", rows: 3 });
|
||||||
return { el: t, get: () => t.value.trim() };
|
// Statements such as allow-transfer and listen-on take an address
|
||||||
|
// match list after optional parameters: suggest names at the cursor.
|
||||||
|
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
|
||||||
|
if (takesList && !value) t.placeholder = "{ homelab; key xfer-key; }";
|
||||||
|
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim() };
|
||||||
}
|
}
|
||||||
const i = input({ value: value || "", class: "mono" });
|
const i = input({ value: value || "", class: "mono" });
|
||||||
return { el: i, get: () => i.value.trim() };
|
return { el: i, get: () => i.value.trim() };
|
||||||
@@ -35,7 +41,7 @@ function valueWidget(stmt, value) {
|
|||||||
|
|
||||||
// optionsEditor renders the options of one scope.
|
// optionsEditor renders the options of one scope.
|
||||||
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
|
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
|
||||||
const [opts, cat] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope)]);
|
const [opts, cat, ctx] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope), amlContext()]);
|
||||||
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
|
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const listEl = h("div");
|
const listEl = h("div");
|
||||||
@@ -51,7 +57,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
|
|||||||
], opts, { empty: "No options set; BIND's defaults apply." }));
|
], opts, { empty: "No options set; BIND's defaults apply." }));
|
||||||
};
|
};
|
||||||
const editRow = (o) => {
|
const editRow = (o) => {
|
||||||
const w = valueWidget(byName[o.name], o.value);
|
const w = valueWidget(byName[o.name], o.value, ctx);
|
||||||
const e2 = h("div");
|
const e2 = h("div");
|
||||||
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
|
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
|
||||||
h("div", { class: "actions", style: { marginTop: "6px" } },
|
h("div", { class: "actions", style: { marginTop: "6px" } },
|
||||||
@@ -79,10 +85,10 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
|
|||||||
const dl = h("datalist", { id: listId }, cat.filter((s) => !s.managed).map((s) => h("option", { value: s.name }, s.description || "")));
|
const dl = h("datalist", { id: listId }, cat.filter((s) => !s.managed).map((s) => h("option", { value: s.name }, s.description || "")));
|
||||||
const info = h("div", { class: "hint" });
|
const info = h("div", { class: "hint" });
|
||||||
const valueBox = h("div");
|
const valueBox = h("div");
|
||||||
let widget = valueWidget(null, "");
|
let widget = valueWidget(null, "", ctx);
|
||||||
const showInfo = () => {
|
const showInfo = () => {
|
||||||
const st = byName[nameIn.value.trim()];
|
const st = byName[nameIn.value.trim()];
|
||||||
widget = valueWidget(st, "");
|
widget = valueWidget(st, "", ctx);
|
||||||
mount(valueBox, widget.el, widget.hint ? h("div", { class: "hint" }, widget.hint) : null);
|
mount(valueBox, widget.el, widget.hint ? h("div", { class: "hint" }, widget.hint) : null);
|
||||||
if (!st) { mount(info, nameIn.value ? "Not a known option here (see the BIND 9.20 reference)." : ""); return; }
|
if (!st) { mount(info, nameIn.value ? "Not a known option here (see the BIND 9.20 reference)." : ""); return; }
|
||||||
mount(info, st.description || "", st.status ? [" ", badge(st.status, "warn")] : null, h("pre", { style: { marginTop: "4px", whiteSpace: "pre-wrap" } }, st.grammar));
|
mount(info, st.description || "", st.status ? [" ", badge(st.status, "warn")] : null, h("pre", { style: { marginTop: "4px", whiteSpace: "pre-wrap" } }, st.grammar));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js";
|
import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js";
|
||||||
|
import { amlContext, suggestions } from "../aml.js";
|
||||||
import { get, change, describeResult } from "../api.js";
|
import { get, change, describeResult } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { optionsEditor } from "./options.js";
|
import { optionsEditor } from "./options.js";
|
||||||
@@ -44,6 +45,7 @@ export async function render(el, params, query) {
|
|||||||
|
|
||||||
async function viewDialog(v, viewless) {
|
async function viewDialog(v, viewless) {
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
|
const ctx = await amlContext();
|
||||||
const name = input({ value: v ? v.name : "", placeholder: "internal" });
|
const name = input({ value: v ? v.name : "", placeholder: "internal" });
|
||||||
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
|
const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" });
|
||||||
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
|
const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" });
|
||||||
@@ -52,8 +54,8 @@ async function viewDialog(v, viewless) {
|
|||||||
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
|
||||||
const body = h("div", null, h("div", { class: "form-grid" },
|
const body = h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Name", name),
|
field("Name", name),
|
||||||
field("Match clients", mc, "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
|
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
|
||||||
field("Match destinations", md, "Server addresses this view answers on (optional)."),
|
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
|
||||||
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
field("Recursive only", mro, "If yes, only recursive queries match this view."),
|
||||||
field("Comment", comment)),
|
field("Comment", comment)),
|
||||||
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api, get, change, describeResult, rrtypes, state, draftQuery } from "..
|
|||||||
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";
|
||||||
|
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
@@ -53,26 +54,104 @@ async function recordsTab(el, zone, query) {
|
|||||||
const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]);
|
const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]);
|
||||||
const typeInfo = Object.fromEntries(types.map((t) => [t.type, t]));
|
const typeInfo = Object.fromEntries(types.map((t) => [t.type, t]));
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
// Add form
|
const zoneName = zone.name.replace(/\.$/, "");
|
||||||
const fName = input({ placeholder: "@ or www", style: { width: "180px" }, class: "mono" });
|
const reverse = isReverseZone(zone.name);
|
||||||
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), "A", { style: { width: "110px" } });
|
const rinfo = reverse ? reverseInfo(zone.name) : null;
|
||||||
|
// Add form. In reverse zones records are named by address.
|
||||||
|
const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false" });
|
||||||
|
const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), reverse ? "PTR" : "A", { style: { width: "110px" } });
|
||||||
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
|
const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } });
|
||||||
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
||||||
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
|
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
|
||||||
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
|
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
|
||||||
const hint = h("div", { class: "hint" });
|
const hint = h("div", { class: "hint" });
|
||||||
|
const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
|
||||||
|
const suggest = h("div", { class: "aml-suggest" });
|
||||||
const showHint = () => {
|
const showHint = () => {
|
||||||
const t = typeInfo[fType.value] || {};
|
const t = typeInfo[fType.value] || {};
|
||||||
fData.placeholder = t.example || t.format || "";
|
fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || "";
|
||||||
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
|
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
|
||||||
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA"));
|
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA"));
|
||||||
};
|
};
|
||||||
fType.addEventListener("change", showHint);
|
fType.addEventListener("change", () => { showHint(); preview(); });
|
||||||
showHint();
|
showHint();
|
||||||
|
// ownerOf shows what the Name field becomes.
|
||||||
|
const ownerOf = (v) => {
|
||||||
|
v = v.trim();
|
||||||
|
if (reverse) {
|
||||||
|
const r = ptrOwner(v || "@", zone.name);
|
||||||
|
if (!v || v === "@") return { fqdn: zoneName };
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
if (!v || v === "@") return { fqdn: zoneName };
|
||||||
|
if (v.endsWith(".")) return { fqdn: v.replace(/\.$/, "").toLowerCase() };
|
||||||
|
const low = v.toLowerCase();
|
||||||
|
if (low === zoneName || low.endsWith("." + zoneName)) return { fqdn: low, note: "\"" + v + "\" already ends with the zone name, so it is read as " + low + " (end it with a dot to be explicit)" };
|
||||||
|
return { fqdn: low + "." + zoneName };
|
||||||
|
};
|
||||||
|
// Which word of the data is a domain name, per type.
|
||||||
|
const targetIndex = { CNAME: 0, DNAME: 0, NS: 0, PTR: 0, MX: 1, SRV: 3, KX: 1, AFSDB: 1 };
|
||||||
|
const dataOf = (type, data) => {
|
||||||
|
const words = data.trim().split(/\s+/);
|
||||||
|
const i = targetIndex[type];
|
||||||
|
if (i === undefined || words.length <= i || !words[i]) return { text: data.trim() };
|
||||||
|
const w = words[i];
|
||||||
|
let target, note, error;
|
||||||
|
if (w === "@") target = zoneName + ".";
|
||||||
|
else if (w.endsWith(".")) target = w;
|
||||||
|
else if (reverse && type === "PTR") {
|
||||||
|
if (w.includes(".")) target = w + ".";
|
||||||
|
else error = "give the host's full name, e.g. " + w + ".adbraun.lol (" + w + " alone would mean " + w + "." + zoneName + ")";
|
||||||
|
} else {
|
||||||
|
target = w + "." + zoneName + ".";
|
||||||
|
if (w.includes(".")) note = "\"" + w + "\" has no final dot, so it means " + target + "; write " + w + ". if you meant the name outside this zone";
|
||||||
|
}
|
||||||
|
if (error) return { error };
|
||||||
|
words[i] = target;
|
||||||
|
return { text: words.join(" "), note };
|
||||||
|
};
|
||||||
|
let lookupTimer = null, lookupFor = "";
|
||||||
|
const preview = () => {
|
||||||
|
const n = fName.value, d = fData.value;
|
||||||
|
if (!n.trim() && !d.trim()) { mount(willAdd); mount(suggest); return; }
|
||||||
|
const o = ownerOf(n);
|
||||||
|
const dd = d.trim() ? dataOf(fType.value, d) : { text: "…" };
|
||||||
|
const lines = [];
|
||||||
|
if (o.error) lines.push(h("div", { class: "msg err small" }, o.error));
|
||||||
|
if (dd.error) lines.push(h("div", { class: "msg err small" }, dd.error));
|
||||||
|
if (o.note) lines.push(h("div", { class: "msg warn small" }, o.note));
|
||||||
|
if (dd.note) lines.push(h("div", { class: "msg warn small" }, dd.note));
|
||||||
|
if (reverse && o.fqdn && fType.value === "PTR") {
|
||||||
|
const existing = current.filter((r) => r.type === "PTR" && r.name.replace(/\.$/, "") === o.fqdn && r.pending !== "delete");
|
||||||
|
if (existing.length) lines.push(h("div", { class: "msg warn small" }, "this address already has a PTR record (" + existing.map((r) => r.data).join(", ") + "); a second one gives it two names"));
|
||||||
|
}
|
||||||
|
mount(willAdd, o.fqdn && !o.error ? h("div", null, h("span", { class: "muted" }, "Will add: "),
|
||||||
|
h("code", null, o.fqdn + ". " + (fTTL.value !== "" ? fTTL.value + " " : "") + fType.value + " " + (dd.text || "")),
|
||||||
|
reverse && addressOf(o.fqdn) ? h("span", { class: "muted" }, " (address " + addressOf(o.fqdn) + ")") : null) : null, lines);
|
||||||
|
// In reverse zones, offer the names that already point to the address.
|
||||||
|
const addr = reverse && o.fqdn ? addressOf(o.fqdn) : "";
|
||||||
|
if (addr !== lookupFor) {
|
||||||
|
lookupFor = addr;
|
||||||
|
mount(suggest);
|
||||||
|
clearTimeout(lookupTimer);
|
||||||
|
if (addr && fType.value === "PTR") lookupTimer = setTimeout(async () => {
|
||||||
|
const hits = await get("/search", { q: addr }).catch(() => []);
|
||||||
|
if (lookupFor !== addr) return;
|
||||||
|
const names = [...new Set(hits.filter((x) => x.kind === "record").map((x) => x.label.split(" ")).filter((l) => (l[1] === "A" || l[1] === "AAAA") && l[2] === addr).map((l) => l[0]))];
|
||||||
|
mount(suggest, names.length ? [h("span", { class: "muted small" }, "Hosts with this address:"),
|
||||||
|
names.map((nm) => h("button", { type: "button", class: "chip", onclick: () => { fData.value = nm.replace(/\.$/, "") + "."; preview(); fData.focus(); } }, nm))]
|
||||||
|
: h("span", { class: "muted small" }, "No A/AAAA record points to " + addr + " yet. Tip: adding the A record with \"PTR\" ticked creates this PTR for you."));
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const f of [fName, fData, fTTL]) f.addEventListener("input", preview);
|
||||||
const add = async (e) => {
|
const add = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
mount(err);
|
mount(err);
|
||||||
const body = { name: fName.value.trim() || "@", type: fType.value, data: fData.value.trim() };
|
const o = ownerOf(fName.value);
|
||||||
|
const dd = dataOf(fType.value, fData.value);
|
||||||
|
if (o.error || dd.error) { mount(err, h("div", { class: "msg err" }, o.error || dd.error)); (o.error ? fName : fData).focus(); return; }
|
||||||
|
const body = { name: reverse && o.relative ? o.relative : fName.value.trim() || "@", type: fType.value, data: fData.value.trim() };
|
||||||
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
|
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
|
||||||
if (fComment.value.trim()) body.comment = fComment.value.trim();
|
if (fComment.value.trim()) body.comment = fComment.value.trim();
|
||||||
if (fPTR.input.checked) body.ptr = true;
|
if (fPTR.input.checked) body.ptr = true;
|
||||||
@@ -80,16 +159,22 @@ async function recordsTab(el, zone, query) {
|
|||||||
const r = await change("POST", "/zones/" + zone.id + "/records", body);
|
const r = await change("POST", "/zones/" + zone.id + "/records", body);
|
||||||
toast(describeResult(r));
|
toast(describeResult(r));
|
||||||
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
|
||||||
|
// Start the next record from scratch (the type stays, as records
|
||||||
|
// are often added several of a kind at a time).
|
||||||
|
fName.value = "";
|
||||||
fData.value = "";
|
fData.value = "";
|
||||||
|
fTTL.value = "";
|
||||||
fComment.value = "";
|
fComment.value = "";
|
||||||
|
lookupFor = "";
|
||||||
|
preview();
|
||||||
await redraw();
|
await redraw();
|
||||||
fData.focus();
|
fName.focus();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
mount(err, errorBox(ex));
|
mount(err, errorBox(ex));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const form = h("form", { class: "inline-form", onsubmit: add },
|
const form = h("form", { class: "inline-form", onsubmit: add },
|
||||||
h("div", { class: "f" }, h("label", null, "Name"), fName),
|
h("div", { class: "f" }, h("label", null, reverse ? "Address" : "Name"), fName),
|
||||||
h("div", { class: "f" }, h("label", null, "Type"), fType),
|
h("div", { class: "f" }, h("label", null, "Type"), fType),
|
||||||
h("div", { class: "f" }, h("label", null, "TTL"), fTTL),
|
h("div", { class: "f" }, h("label", null, "TTL"), fTTL),
|
||||||
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
|
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
|
||||||
@@ -112,7 +197,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
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 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));
|
||||||
let prevName = null;
|
let prevName = null;
|
||||||
mount(list, table([
|
mount(list, table([
|
||||||
{ label: "Name", render: (r) => 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: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } },
|
||||||
{ 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" },
|
||||||
@@ -186,10 +271,13 @@ async function recordsTab(el, zone, query) {
|
|||||||
filter.addEventListener("input", draw);
|
filter.addEventListener("input", draw);
|
||||||
typeFilter.addEventListener("change", draw);
|
typeFilter.addEventListener("change", draw);
|
||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "panel" }, form, hint, err),
|
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,
|
||||||
|
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,
|
||||||
h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute.")),
|
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."))),
|
||||||
list));
|
list));
|
||||||
draw();
|
draw();
|
||||||
fName.focus();
|
fName.focus();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
|
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
|
||||||
import { get, change, describeResult, can, draftQuery } from "../api.js";
|
import { get, change, describeResult, can, draftQuery } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
|
import { checkDomain, reverseZoneFor } from "../names.js";
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]);
|
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]);
|
||||||
@@ -30,12 +31,24 @@ export async function render(el, params, query) {
|
|||||||
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
|
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
|
||||||
h("div", { class: "panel" }, list));
|
h("div", { class: "panel" }, list));
|
||||||
draw();
|
draw();
|
||||||
if (query.get("new")) newZoneDialog(views, zones, settings);
|
if (query.get("new")) {
|
||||||
|
// Drop ?new=1 so re-rendering after the zone is created doesn't open
|
||||||
|
// the dialog again.
|
||||||
|
history.replaceState(null, "", "#/zones");
|
||||||
|
newZoneDialog(views, zones, settings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function newZoneDialog(views, zones, settings) {
|
export async function newZoneDialog(views, zones, settings) {
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const name = input({ placeholder: "example.com or 192.168.1.0/24" });
|
// The zone's name matters most and is easy to get wrong, so the dialog
|
||||||
|
// asks for a domain or a network and shows the resulting zone name.
|
||||||
|
let kind = "forward";
|
||||||
|
const name = input({ placeholder: "adbraun.lol", class: "mono", autocomplete: "off", spellcheck: "false" });
|
||||||
|
const nameLabel = h("label", { for: "zone-name" }, "Domain");
|
||||||
|
name.id = "zone-name";
|
||||||
|
const preview = h("div", { class: "name-preview", "aria-live": "polite" });
|
||||||
|
const kindButtons = h("div", { class: "segmented", role: "group", "aria-label": "Kind of zone" });
|
||||||
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
const type = select([["primary", "primary (Jarl manages the records)"], ["secondary", "secondary (copy from another server)"], ["forward", "forward (send queries to other resolvers)"], ["stub", "stub"], ["static-stub", "static-stub"], ["mirror", "mirror"], ["hint", "hint (root hints)"], ["in-view", "in-view (share a zone from an earlier view)"]], "primary");
|
||||||
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], views.length === 1 ? views[0].name : "");
|
const view = select([["", views.length ? "– choose a view –" : "(no views)"], ...views.map((v) => [v.name, v.name])], views.length === 1 ? views[0].name : "");
|
||||||
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
|
const ns = textarea((settings.nameservers || []).join("\n"), { class: "mono", rows: 3, placeholder: "ns1.example.com." });
|
||||||
@@ -50,11 +63,69 @@ export async function newZoneDialog(views, zones, settings) {
|
|||||||
const cloneSel = select([["", "– don't copy –"]], "");
|
const cloneSel = select([["", "– don't copy –"]], "");
|
||||||
const comment = input();
|
const comment = input();
|
||||||
const updateClones = () => {
|
const updateClones = () => {
|
||||||
const n = name.value.trim().toLowerCase().replace(/\.$/, "") + ".";
|
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
||||||
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
|
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
|
||||||
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
|
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
|
||||||
};
|
};
|
||||||
name.addEventListener("input", updateClones);
|
// check validates the name as typed and returns the zone name (or null).
|
||||||
|
const check = () => {
|
||||||
|
const v = name.value.trim();
|
||||||
|
const msgs = [];
|
||||||
|
let zoneName = null;
|
||||||
|
if (type.value === "hint" && (v === "." || v === "")) {
|
||||||
|
mount(preview, h("div", null, "Zone name: ", h("strong", { class: "mono" }, "."), " (the root, for root hints)"));
|
||||||
|
return ".";
|
||||||
|
}
|
||||||
|
if (!v) { mount(preview, h("div", { class: "muted" }, kind === "reverse" ? "The zone name is generated from the network." : "Records in the zone are named relative to it: www becomes www.<zone>.")); return null; }
|
||||||
|
if (kind === "reverse") {
|
||||||
|
const r = reverseZoneFor(v);
|
||||||
|
if (r.error) msgs.push(["err", r.error]);
|
||||||
|
else zoneName = r.name;
|
||||||
|
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · answers \"which host has address …?\" for " + r.network)) : null,
|
||||||
|
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
|
||||||
|
} else {
|
||||||
|
const c = checkDomain(v);
|
||||||
|
c.problems.forEach((m) => msgs.push(["err", m]));
|
||||||
|
c.notes.forEach((m) => msgs.push(["warn", m]));
|
||||||
|
if (!c.problems.length) zoneName = c.name;
|
||||||
|
}
|
||||||
|
if (zoneName && kind === "forward") {
|
||||||
|
const fq = zoneName + ".";
|
||||||
|
const inView = (z) => (z.view || "") === (view.value || "");
|
||||||
|
const same = zones.find((z) => z.name === fq && inView(z));
|
||||||
|
if (same) { msgs.unshift(["err", "zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "") + ": open it from the zone list"]); zoneName = null; }
|
||||||
|
else {
|
||||||
|
const parent = zones.filter((z) => inView(z) && z.name !== "." && fq.endsWith("." + z.name)).sort((a, b) => b.name.length - a.name.length)[0];
|
||||||
|
if (parent) msgs.push(["info", "a child of zone " + parent.display_name + ": add NS records for " + fq.replace(/\.$/, "") + " there so other servers can find it"]);
|
||||||
|
const kids = zones.filter((z) => inView(z) && z.name.endsWith("." + fq));
|
||||||
|
if (kids.length) msgs.push(["info", "contains the existing zone" + (kids.length > 1 ? "s " : " ") + kids.map((z) => z.display_name).join(", ")]);
|
||||||
|
}
|
||||||
|
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · a record named www becomes www." + zoneName)) : null,
|
||||||
|
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
|
||||||
|
}
|
||||||
|
if (zoneName && kind === "reverse") {
|
||||||
|
const fq = zoneName + ".";
|
||||||
|
if (zones.find((z) => z.name === fq && (z.view || "") === (view.value || ""))) {
|
||||||
|
mount(preview, h("div", { class: "msg err small" }, "the reverse zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "")));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zoneName;
|
||||||
|
};
|
||||||
|
const setKind = (k) => {
|
||||||
|
kind = k;
|
||||||
|
mount(err);
|
||||||
|
nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
|
||||||
|
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
|
||||||
|
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
|
||||||
|
check();
|
||||||
|
name.focus();
|
||||||
|
};
|
||||||
|
for (const [k, label, title] of [["forward", "Domain", "Names to addresses, e.g. adbraun.lol"], ["reverse", "Reverse zone", "Addresses to names (PTR records) for a network"]]) {
|
||||||
|
kindButtons.append(h("button", { type: "button", dataset: { kind: k }, title, class: k === kind ? "active" : "", onclick: () => setKind(k) }, label));
|
||||||
|
}
|
||||||
|
name.addEventListener("input", () => { mount(err); updateClones(); check(); });
|
||||||
|
view.addEventListener("change", check);
|
||||||
const rows = {
|
const rows = {
|
||||||
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
|
primary: [field("Name servers", ns, "One per line. Become the zone's NS records; the first is the SOA primary name server."),
|
||||||
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
||||||
@@ -70,12 +141,17 @@ export async function newZoneDialog(views, zones, settings) {
|
|||||||
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
|
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
|
||||||
};
|
};
|
||||||
const typeRows = h("div", { class: "form-grid" });
|
const typeRows = h("div", { class: "form-grid" });
|
||||||
const drawType = () => mount(typeRows, rows[type.value] || []);
|
const drawType = () => {
|
||||||
|
mount(typeRows, rows[type.value] || []);
|
||||||
|
if (type.value === "hint" && !name.value.trim()) name.value = ".";
|
||||||
|
check();
|
||||||
|
};
|
||||||
type.addEventListener("change", drawType);
|
type.addEventListener("change", drawType);
|
||||||
drawType();
|
drawType();
|
||||||
const body = h("div", null,
|
const body = h("div", null,
|
||||||
h("div", { class: "form-grid" },
|
h("div", { class: "form-grid" },
|
||||||
field("Name", name, "A domain name, or a network such as 192.168.1.0/24 to create its reverse zone."),
|
h("label", null, "Kind"), h("div", null, kindButtons),
|
||||||
|
nameLabel, h("div", null, name, preview),
|
||||||
views.length ? field("View", view) : null,
|
views.length ? field("View", view) : null,
|
||||||
field("Type", type),
|
field("Type", type),
|
||||||
field("Comment", comment)),
|
field("Comment", comment)),
|
||||||
@@ -85,7 +161,13 @@ export async function newZoneDialog(views, zones, settings) {
|
|||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: "Create zone", kind: "primary", onclick: async () => {
|
{ label: "Create zone", kind: "primary", onclick: async () => {
|
||||||
const t = type.value;
|
const t = type.value;
|
||||||
const data = { name: name.value.trim(), type: t, comment: comment.value || undefined };
|
const zoneName = check();
|
||||||
|
if (!zoneName) {
|
||||||
|
mount(err, h("div", { class: "msg err" }, name.value.trim() ? "Fix the " + (kind === "reverse" ? "network" : "zone name") + " first." : "Give the " + (kind === "reverse" ? "network" : "domain") + " first."));
|
||||||
|
name.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const data = { name: zoneName, type: t, comment: comment.value || undefined };
|
||||||
if (view.value) data.view = view.value;
|
if (view.value) data.view = view.value;
|
||||||
const opts = [];
|
const opts = [];
|
||||||
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ header.top {
|
|||||||
}
|
}
|
||||||
header.top .brand { font-weight: 700; font-size: 17px; letter-spacing: 0.5px; color: var(--text); }
|
header.top .brand { font-weight: 700; font-size: 17px; letter-spacing: 0.5px; color: var(--text); }
|
||||||
header.top .brand span { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 6px; }
|
header.top .brand span { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 6px; }
|
||||||
|
header.top .brand:hover { text-decoration: none; color: var(--accent); }
|
||||||
nav.main { display: flex; gap: 2px; flex-wrap: wrap; }
|
nav.main { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||||
nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); }
|
nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); }
|
||||||
nav.main a:hover { background: var(--info-soft); text-decoration: none; }
|
nav.main a:hover { background: var(--info-soft); text-decoration: none; }
|
||||||
@@ -343,3 +344,21 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|||||||
.hm-cell { height: 16px; }
|
.hm-cell { height: 16px; }
|
||||||
.chart-grid { grid-template-columns: 1fr; }
|
.chart-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Address match list suggestions. */
|
||||||
|
.aml-suggest { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin-top: 6px; }
|
||||||
|
.aml-suggest .muted { margin-left: 4px; }
|
||||||
|
.aml-suggest .muted:first-child { margin-left: 0; }
|
||||||
|
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
|
||||||
|
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||||
|
.aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; }
|
||||||
|
|
||||||
|
/* Zone name and record previews. */
|
||||||
|
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
|
||||||
|
.segmented button:last-child { border-right: none; }
|
||||||
|
.segmented button:hover { background: var(--info-soft); }
|
||||||
|
.segmented button.active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
|
||||||
|
.name-preview { margin-top: 6px; font-size: 13px; }
|
||||||
|
.name-preview .msg, .will-add .msg { margin: 4px 0 0; padding: 3px 8px; }
|
||||||
|
.will-add { margin-top: 8px; font-size: 13px; }
|
||||||
|
.will-add code { font-family: var(--mono); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; word-break: break-all; }
|
||||||
@@ -131,8 +131,10 @@ export async function confirmDialog(title, message, okLabel = "OK", kind = "prim
|
|||||||
|
|
||||||
// field returns a label + control pair for .form-grid layouts.
|
// field returns a label + control pair for .form-grid layouts.
|
||||||
export function field(label, control, help) {
|
export function field(label, control, help) {
|
||||||
const id = control.id || ("f" + Math.random().toString(36).slice(2, 9));
|
// The label points at the control itself, also when it is wrapped.
|
||||||
control.id = id;
|
const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control;
|
||||||
|
const id = target.id || ("f" + Math.random().toString(36).slice(2, 9));
|
||||||
|
target.id = id;
|
||||||
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
|
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user