Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e5bbb628 | ||
|
|
740f3cc328 | ||
|
|
5fcf4082c9 | ||
|
|
1cec6cc393 |
No files matched your search
@@ -8,6 +8,16 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Upgrading from 0.4
|
### Upgrading from 0.4
|
||||||
|
|
||||||
|
- The database gets three new versions (internet exposure, certificates,
|
||||||
|
linked zones), applied when jarld starts; a 0.4 jarld can't open it
|
||||||
|
afterwards, so take a backup first (`sudo jarld backup`). Backups made
|
||||||
|
by 0.4 restore into this version. Tested: 0.4.0 packages with views,
|
||||||
|
zones, records, blocking and statistics, upgraded in place and
|
||||||
|
restored from a 0.4.0 backup.
|
||||||
|
|
||||||
|
- Statistics per view start with this version: earlier figures are for
|
||||||
|
all views together.
|
||||||
|
|
||||||
- New exposure warnings (docs/exposure.md) appear for views reachable from
|
- New exposure warnings (docs/exposure.md) appear for views reachable from
|
||||||
the internet, e.g. "no rate-limit" or "version visible" on an external
|
the internet, e.g. "no rate-limit" or "version visible" on an external
|
||||||
view: fix them from the Exposure page, or acknowledge them. A change
|
view: fix them from the Exposure page, or acknowledge them. A change
|
||||||
@@ -18,8 +28,25 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
parser doesn't know them. Remove the ranges first if you need to
|
parser doesn't know them. Remove the ranges first if you need to
|
||||||
downgrade.
|
downgrade.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The web UI's top bar had grown too long, and the user name, Users and
|
||||||
|
Log out ended up on a second line on the left. The server's
|
||||||
|
configuration and security pages (Server options, ACLs, TSIG keys,
|
||||||
|
Exposure, Certificates, Encrypted DNS, Audit log) are now in a
|
||||||
|
**Server** menu, and Account, Users and Log out in a menu under your
|
||||||
|
name. On narrow screens the search box and your name stay together on
|
||||||
|
the right.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- "update PTR" when editing a record (`jarl record update ID --ptr`) did
|
||||||
|
nothing unless the name or address changed too, and the web UI didn't
|
||||||
|
even send it alone: a reverse zone created after the record never got
|
||||||
|
its PTR. Alone, it now adds the PTR record if it is missing (or enables
|
||||||
|
it). For records of linked zones, the reverse zones of every linked view
|
||||||
|
count, not only the record's own view.
|
||||||
|
|
||||||
- Option values in the web UI looked different from the editor: a long
|
- Option values in the web UI looked different from the editor: a long
|
||||||
line wrapped back to the left edge, losing its indentation, and the
|
line wrapped back to the left edge, losing its indentation, and the
|
||||||
column changed width when Edit opened. Values now keep their layout
|
column changed width when Edit opened. Values now keep their layout
|
||||||
|
|||||||
@@ -200,8 +200,8 @@ review step.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.4.0 is the current release, and 0.4.1 is in testing
|
Version 0.4.0 is the current release, and 0.5.0 is in testing
|
||||||
(0.4.1-rc.5): it adds Prometheus metrics, address ranges in ACLs and
|
(0.5.0-rc.1): it adds Prometheus metrics, address ranges in ACLs and
|
||||||
other address match lists, safer changeset handling, internet exposure
|
other address match lists, safer changeset handling, internet exposure
|
||||||
checks with response rate limiting, TLS certificates Jarl obtains and
|
checks with response rate limiting, TLS certificates Jarl obtains and
|
||||||
renews itself, linked zones for split DNS, DNS over TLS and HTTPS, and statistics
|
renews itself, linked zones for split DNS, DNS over TLS and HTTPS, and statistics
|
||||||
|
|||||||
+5
-1
@@ -108,7 +108,11 @@ Use -o json or -o yaml to export the data.`,
|
|||||||
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
|
||||||
}
|
}
|
||||||
if s.Peak != nil {
|
if s.Peak != nil {
|
||||||
fmt.Fprintf(w, "Busiest %s:\t%s with %d queries\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries)
|
what := "queries"
|
||||||
|
if len(s.FilterViews) > 0 {
|
||||||
|
what = "answers"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Busiest %s:\t%s with %d %s\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries, what)
|
||||||
}
|
}
|
||||||
if s.CacheHitRatio != nil {
|
if s.CacheHitRatio != nil {
|
||||||
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
|
||||||
|
|||||||
+3
-1
@@ -307,7 +307,9 @@ adds the quotes and splits long values into 255-byte strings. In reverse zones t
|
|||||||
host name (a bare `nas` is refused, since it would mean
|
host name (a bare `nas` is refused, since it would mean
|
||||||
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
||||||
keeps the PTR record in the reverse zone in step (when that zone is managed
|
keeps the PTR record in the reverse zone in step (when that zone is managed
|
||||||
by Jarl). Every record type BIND supports can be used. Jarl knows the
|
by Jarl); `jarl record update ID --ptr` with nothing else adds the PTR record
|
||||||
|
if it is missing, e.g. after creating the reverse zone. In linked zones, the
|
||||||
|
reverse zones of every linked view count. Every record type BIND supports can be used. Jarl knows the
|
||||||
syntax of the common ones and checks them strictly, and the rest are
|
syntax of the common ones and checks them strictly, and the rest are
|
||||||
validated by parsing.
|
validated by parsing.
|
||||||
|
|
||||||
|
|||||||
+69
-13
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -642,11 +643,42 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
found := false
|
||||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil, nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
more, err := x.ptrCreateIn(rz, ptrName, owner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrViews are the views whose reverse zones get the PTR records of zone
|
||||||
|
// z's address records: z's own, and, for a linked zone, the other views
|
||||||
|
// of the link (the record is in all of them).
|
||||||
|
func ptrViews(st *model.State, z *model.Zone) []int64 {
|
||||||
|
out := []int64{z.ViewID}
|
||||||
|
for _, o := range st.LinkedWith(z) {
|
||||||
|
if !slices.Contains(out, o.ViewID) {
|
||||||
|
out = append(out, o.ViewID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
|
||||||
|
// in reverse zone rz (none when it is there; enabling it when disabled).
|
||||||
|
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.Op, error) {
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" {
|
if r.Name == ptrName && r.Type == "PTR" {
|
||||||
if strings.EqualFold(r.Data, owner) {
|
if strings.EqualFold(r.Data, owner) {
|
||||||
@@ -674,10 +706,18 @@ func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, dis
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
|
||||||
}
|
}
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
|
||||||
var ops []model.Op
|
var ops []model.Op
|
||||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||||
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
|
||||||
@@ -700,12 +740,16 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
var ops []model.Op
|
||||||
if rz == nil {
|
for _, vid := range ptrViews(x.st, z) {
|
||||||
return nil
|
rz, ptrName := reverseZoneFor(x.st, vid, addr)
|
||||||
|
if rz == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
||||||
|
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
|
||||||
}
|
}
|
||||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
return ops
|
||||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
|
||||||
@@ -885,7 +929,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
if len(pre) > 0 {
|
if len(pre) > 0 {
|
||||||
base = 0 // the record was just updated by pre
|
base = 0 // the record was just updated by pre
|
||||||
}
|
}
|
||||||
ops := append(pre, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
ops := pre
|
||||||
|
if string(mustJSON(in)) != "{}" || !ptr {
|
||||||
|
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||||
|
}
|
||||||
disabled := r.Disabled
|
disabled := r.Disabled
|
||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
disabled = *in.Disabled
|
disabled = *in.Disabled
|
||||||
@@ -909,6 +956,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
|||||||
}
|
}
|
||||||
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
|
||||||
}
|
}
|
||||||
|
if ptr && !moved && !toggled && !disabled {
|
||||||
|
// Nothing else changes: make sure the PTR record is there (the
|
||||||
|
// reverse zone may be newer than the record).
|
||||||
|
more, err := x.ptrCreate(z, owner, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ops = append(ops, more...)
|
||||||
|
}
|
||||||
return ops, nil
|
return ops, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ type RecordInput struct {
|
|||||||
// Disabled keeps the record in Jarl but out of the zone.
|
// Disabled keeps the record in Jarl but out of the zone.
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
// PTR also creates (or deletes, disables or enables) the matching PTR
|
// PTR also creates (or deletes, disables or enables) the matching PTR
|
||||||
// record in the reverse zone, for A and AAAA records.
|
// record in the reverse zone, for A and AAAA records. On an update that
|
||||||
|
// changes nothing else it adds the PTR record if it is missing. In a
|
||||||
|
// linked zone, every linked view's reverse zone counts.
|
||||||
PTR bool `json:"ptr,omitempty"`
|
PTR bool `json:"ptr,omitempty"`
|
||||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||||
MissingOK bool `json:"missing_ok,omitempty"`
|
MissingOK bool `json:"missing_ok,omitempty"`
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"jarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ticking "update PTR" on an unchanged address record adds its PTR when
|
||||||
|
// the reverse zone is there (it was created after the record).
|
||||||
|
func TestUpdatePTRAddsMissing(t *testing.T) {
|
||||||
|
env := newEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}))
|
||||||
|
env.submit(env.admin, rec("example.com", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32")}))
|
||||||
|
var id int64
|
||||||
|
for _, r := range env.state().Records {
|
||||||
|
if r.Name == "ldn.example.com." {
|
||||||
|
id = r.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env.submit(env.admin, NewRequest(OpRecordUpdate, id, RecordInput{PTR: true}))
|
||||||
|
got := env.records("154.144.144.139.in-addr.arpa.")
|
||||||
|
if !slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "PTR ldn.example.com.") }) {
|
||||||
|
t.Errorf("reverse zone = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In linked zones the PTR goes to the reverse zone of whichever linked
|
||||||
|
// view has one.
|
||||||
|
func TestUpdatePTRLinked(t *testing.T) {
|
||||||
|
env := linkedEnv(t)
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||||
|
env.submit(env.admin, recIn("internal", "ldn", "A", "139.144.144.154"))
|
||||||
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32"), View: model.Ptr("external")}))
|
||||||
|
res := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true}))
|
||||||
|
st := env.state()
|
||||||
|
z := st.FindZone("154.144.144.139.in-addr.arpa.", st.ViewByName("external").ID)
|
||||||
|
ok := false
|
||||||
|
for _, r := range st.ZoneRecords(z.ID) {
|
||||||
|
ok = ok || r.Type == "PTR" && r.Data == "ldn.example.com."
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("no PTR in external's reverse zone (warnings %q)", res.Warnings)
|
||||||
|
}
|
||||||
|
// Again: already there, nothing to do.
|
||||||
|
if e := env.submitErr(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true})); !strings.Contains(e.Message, "nothing to do") {
|
||||||
|
t.Errorf("second time: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,21 +45,25 @@ const routes = [
|
|||||||
{ pattern: /^encrypted-dns$/, page: encdns, nav: "encdns" },
|
{ pattern: /^encrypted-dns$/, page: encdns, nav: "encdns" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The top bar: the pages used most, then a menu for the server's
|
||||||
|
// configuration and security. ["key", "#/href", "Label", "admin"?]
|
||||||
const navItems = [
|
const navItems = [
|
||||||
["dashboard", "#/", "Dashboard"],
|
["dashboard", "#/", "Dashboard"],
|
||||||
["stats", "#/stats", "Statistics"],
|
["stats", "#/stats", "Statistics"],
|
||||||
["zones", "#/zones", "Zones"],
|
["zones", "#/zones", "Zones"],
|
||||||
["blocking", "#/blocking", "Blocking"],
|
|
||||||
["views", "#/views", "Views"],
|
["views", "#/views", "Views"],
|
||||||
["exposure", "#/exposure", "Exposure"],
|
["blocking", "#/blocking", "Blocking"],
|
||||||
["certificates", "#/certificates", "Certificates", "admin"],
|
|
||||||
["encdns", "#/encrypted-dns", "Encrypted DNS", "admin"],
|
|
||||||
["acls", "#/acls", "ACLs"],
|
|
||||||
["keys", "#/keys", "TSIG keys"],
|
|
||||||
["options", "#/options", "Server options"],
|
|
||||||
["changes", "#/changes", "Changes"],
|
["changes", "#/changes", "Changes"],
|
||||||
["history", "#/history", "History"],
|
["history", "#/history", "History"],
|
||||||
["audit", "#/audit", "Audit log"],
|
{ menu: "Server", items: [
|
||||||
|
["options", "#/options", "Server options"],
|
||||||
|
["acls", "#/acls", "ACLs"],
|
||||||
|
["keys", "#/keys", "TSIG keys"],
|
||||||
|
["exposure", "#/exposure", "Exposure"],
|
||||||
|
["certificates", "#/certificates", "Certificates", "admin"],
|
||||||
|
["encdns", "#/encrypted-dns", "Encrypted DNS", "admin"],
|
||||||
|
["audit", "#/audit", "Audit log"],
|
||||||
|
] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
@@ -168,12 +172,16 @@ function layout() {
|
|||||||
logo(30),
|
logo(30),
|
||||||
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
||||||
navEl,
|
navEl,
|
||||||
h("div", { class: "spacer" }),
|
// The right-hand group stays together, on the right, also when it
|
||||||
modeEl = h("span"),
|
// has to wrap below the navigation.
|
||||||
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
h("div", { class: "header-right" },
|
||||||
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
modeEl = h("span"),
|
||||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
||||||
h("button", { class: "small", onclick: logout }, "Log out"));
|
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||||
|
h("a", { href: "#/account" }, "Account"),
|
||||||
|
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||||
|
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||||
|
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||||
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
|
||||||
modeHolder = modeEl;
|
modeHolder = modeEl;
|
||||||
renderNav();
|
renderNav();
|
||||||
@@ -196,9 +204,33 @@ function footer(b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderNav() {
|
function renderNav() {
|
||||||
mount(navEl, navItems.filter((n) => n[3] !== "admin" || state.me.admin).map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
|
const allowed = (n) => n[3] !== "admin" || state.me.admin;
|
||||||
|
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
|
||||||
|
mount(navEl, navItems.map((n) => {
|
||||||
|
if (!n.menu) return allowed(n) ? link(n) : null;
|
||||||
|
const items = n.items.filter(allowed);
|
||||||
|
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// menu is a drop-down: a button and its links. It closes when one is
|
||||||
|
// picked, on a click elsewhere and on Escape.
|
||||||
|
function menu(label, items, opts = {}) {
|
||||||
|
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
|
||||||
|
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
|
||||||
|
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
|
||||||
|
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
|
||||||
|
});
|
||||||
|
|
||||||
// renderBar shows the pending-changes bar (only while there are pending
|
// renderBar shows the pending-changes bar (only while there are pending
|
||||||
// changes) and the small review/live switch in the header.
|
// changes) and the small review/live switch in the header.
|
||||||
function renderBar() {
|
function renderBar() {
|
||||||
|
|||||||
@@ -625,7 +625,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } });
|
const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } });
|
||||||
const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } });
|
const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } });
|
||||||
const eComment = input({ value: r.comment || "", style: { width: "100%" } });
|
const eComment = input({ value: r.comment || "", style: { width: "100%" } });
|
||||||
const ePTR = checkbox("update PTR", false);
|
const ePTR = checkbox("update PTR", false, { title: "Keep the PTR record in the reverse zone in step: moved with a new name or address, or added if it is missing" });
|
||||||
const eErr = h("div");
|
const eErr = h("div");
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const body = {};
|
const body = {};
|
||||||
@@ -638,7 +638,9 @@ async function recordsTab(el, zone, query) {
|
|||||||
else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value);
|
else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value);
|
||||||
if (eComment.value !== (r.comment || "")) body.comment = eComment.value;
|
if (eComment.value !== (r.comment || "")) body.comment = eComment.value;
|
||||||
if (ePTR.input.checked) body.ptr = true;
|
if (ePTR.input.checked) body.ptr = true;
|
||||||
if (Object.keys(body).length === 0 || (Object.keys(body).length === 1 && body.ptr)) { editing = null; draw(); return; }
|
// "update PTR" alone adds a missing PTR record (the reverse zone may
|
||||||
|
// be newer than the record).
|
||||||
|
if (Object.keys(body).length === 0) { editing = null; draw(); return; }
|
||||||
try {
|
try {
|
||||||
const res = await change("PATCH", "/records/" + r.id, body);
|
const res = await change("PATCH", "/records/" + r.id, body);
|
||||||
toast(describeResult(res));
|
toast(describeResult(res));
|
||||||
|
|||||||
@@ -529,3 +529,31 @@ table.pick-list tr.pick.selected { background: var(--accent-soft); }
|
|||||||
.filter-row .chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
.filter-row .chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.chip.toggle-chip { opacity: 0.55; font-size: 13px; padding: 3px 12px; }
|
.chip.toggle-chip { opacity: 0.55; font-size: 13px; padding: 3px 12px; }
|
||||||
.chip.toggle-chip.on { opacity: 1; text-decoration: none; background: var(--accent-soft); border-color: var(--accent); }
|
.chip.toggle-chip.on { opacity: 1; text-decoration: none; background: var(--accent-soft); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Drop-down menus in the top bar (Server, the user). */
|
||||||
|
details.menu { position: relative; }
|
||||||
|
details.menu > summary { list-style: none; cursor: pointer; padding: 6px 10px; border-radius: var(--radius); color: var(--text); white-space: nowrap; }
|
||||||
|
details.menu > summary::-webkit-details-marker { display: none; }
|
||||||
|
details.menu > summary:hover, details.menu[open] > summary { background: var(--info-soft); }
|
||||||
|
details.menu > summary.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
details.menu > summary .caret { margin-left: 4px; font-size: 0.8em; opacity: 0.7; }
|
||||||
|
details.menu > summary .user { color: inherit; }
|
||||||
|
details.menu .menu-list {
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
|
||||||
|
display: flex; flex-direction: column; padding: 4px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
details.menu.right .menu-list { left: auto; right: 0; }
|
||||||
|
details.menu .menu-list a, details.menu .menu-list .menu-item {
|
||||||
|
display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text); text-align: left;
|
||||||
|
background: none; border: 0; font: inherit; cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
details.menu .menu-list a:hover, details.menu .menu-list .menu-item:hover { background: var(--info-soft); text-decoration: none; }
|
||||||
|
details.menu .menu-list a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
nav.main { align-items: center; }
|
||||||
|
header.top .header-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
header.top form.search input { width: 160px; }
|
||||||
|
header.top { gap: 10px; }
|
||||||
|
}
|
||||||
@@ -377,6 +377,15 @@ run "user delete" 0 $J user delete certbot -y
|
|||||||
run "audit" 0 $J audit --limit 10
|
run "audit" 0 $J audit --limit 10
|
||||||
contains "token.revoke"
|
contains "token.revoke"
|
||||||
|
|
||||||
|
# A reverse zone created after the record: "update PTR" alone adds it.
|
||||||
|
run "an address record" 0 $J record add adbraun.test ldn A 192.0.2.154
|
||||||
|
run "its /32 reverse zone, afterwards" 0 $J zone create 192.0.2.154/32
|
||||||
|
LDN=$($J record list adbraun.test --name ldn -o id)
|
||||||
|
run "record update --ptr adds the missing PTR" 0 $J record update $LDN --ptr
|
||||||
|
expect_dig "the PTR is served" "ldn.adbraun.test." -x 192.0.2.154
|
||||||
|
run "remove the reverse zone again" 0 $J zone delete 154.2.0.192.in-addr.arpa -y
|
||||||
|
run "and the record" 0 $J record delete adbraun.test ldn A -y
|
||||||
|
|
||||||
# Views
|
# Views
|
||||||
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
|
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
|
||||||
run "view create external" 0 $J view create external --match-clients any
|
run "view create external" 0 $J view create external --match-clients any
|
||||||
|
|||||||
Reference in new issue
Block a user