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
|
||||
|
||||
- The database gets three new versions (internet exposure, certificates,
|
||||
linked zones), applied when jarld starts; a 0.4 jarld can't open it
|
||||
afterwards, so take a backup first (`sudo jarld backup`). Backups made
|
||||
by 0.4 restore into this version. Tested: 0.4.0 packages with views,
|
||||
zones, records, blocking and statistics, upgraded in place and
|
||||
restored from a 0.4.0 backup.
|
||||
|
||||
- Statistics per view start with this version: earlier figures are for
|
||||
all views together.
|
||||
|
||||
- New exposure warnings (docs/exposure.md) appear for views reachable from
|
||||
the internet, e.g. "no rate-limit" or "version visible" on an external
|
||||
view: fix them from the Exposure page, or acknowledge them. A change
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- "update PTR" when editing a record (`jarl record update ID --ptr`) did
|
||||
nothing unless the name or address changed too, and the web UI didn't
|
||||
even send it alone: a reverse zone created after the record never got
|
||||
its PTR. Alone, it now adds the PTR record if it is missing (or enables
|
||||
it). For records of linked zones, the reverse zones of every linked view
|
||||
count, not only the record's own view.
|
||||
|
||||
- Option values in the web UI looked different from the editor: a long
|
||||
line wrapped back to the left edge, losing its indentation, and the
|
||||
column changed width when Edit opened. Values now keep their layout
|
||||
|
||||
@@ -200,8 +200,8 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.4.0 is the current release, and 0.4.1 is in testing
|
||||
(0.4.1-rc.5): it adds Prometheus metrics, address ranges in ACLs and
|
||||
Version 0.4.0 is the current release, and 0.5.0 is in testing
|
||||
(0.5.0-rc.1): it adds Prometheus metrics, address ranges in ACLs and
|
||||
other address match lists, safer changeset handling, internet exposure
|
||||
checks with response rate limiting, TLS certificates Jarl obtains and
|
||||
renews itself, linked zones for split DNS, DNS over TLS and HTTPS, and statistics
|
||||
|
||||
+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)
|
||||
}
|
||||
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 {
|
||||
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
|
||||
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
||||
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
|
||||
validated by parsing.
|
||||
|
||||
|
||||
+69
-13
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -642,11 +643,42 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
|
||||
if !ok {
|
||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||
if rz == nil {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
|
||||
return nil, nil
|
||||
var ops []model.Op
|
||||
found := false
|
||||
for _, vid := range ptrViews(x.st, z) {
|
||||
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) {
|
||||
if r.Name == ptrName && r.Type == "PTR" {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||
if rz == nil {
|
||||
return nil
|
||||
var ops []model.Op
|
||||
for _, vid := range ptrViews(x.st, z) {
|
||||
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
|
||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||
if rz == nil {
|
||||
return nil
|
||||
var ops []model.Op
|
||||
for _, vid := range ptrViews(x.st, z) {
|
||||
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 []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
||||
return ops
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
if in.Disabled != nil {
|
||||
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)...)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,9 @@ type RecordInput struct {
|
||||
// Disabled keeps the record in Jarl but out of the zone.
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
// PTR also creates (or deletes, disables or enables) the matching PTR
|
||||
// record in the reverse zone, for A and AAAA records.
|
||||
// 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"`
|
||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||
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" },
|
||||
];
|
||||
|
||||
// The top bar: the pages used most, then a menu for the server's
|
||||
// configuration and security. ["key", "#/href", "Label", "admin"?]
|
||||
const navItems = [
|
||||
["dashboard", "#/", "Dashboard"],
|
||||
["stats", "#/stats", "Statistics"],
|
||||
["zones", "#/zones", "Zones"],
|
||||
["blocking", "#/blocking", "Blocking"],
|
||||
["views", "#/views", "Views"],
|
||||
["exposure", "#/exposure", "Exposure"],
|
||||
["certificates", "#/certificates", "Certificates", "admin"],
|
||||
["encdns", "#/encrypted-dns", "Encrypted DNS", "admin"],
|
||||
["acls", "#/acls", "ACLs"],
|
||||
["keys", "#/keys", "TSIG keys"],
|
||||
["options", "#/options", "Server options"],
|
||||
["blocking", "#/blocking", "Blocking"],
|
||||
["changes", "#/changes", "Changes"],
|
||||
["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");
|
||||
@@ -168,12 +172,16 @@ function layout() {
|
||||
logo(30),
|
||||
wordmark({ rule: false, height: 19, cls: "brand-name" })),
|
||||
navEl,
|
||||
h("div", { class: "spacer" }),
|
||||
modeEl = h("span"),
|
||||
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
||||
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { class: "small", onclick: logout }, "Log out"));
|
||||
// The right-hand group stays together, on the right, also when it
|
||||
// has to wrap below the navigation.
|
||||
h("div", { class: "header-right" },
|
||||
modeEl = h("span"),
|
||||
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
|
||||
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)));
|
||||
modeHolder = modeEl;
|
||||
renderNav();
|
||||
@@ -196,9 +204,33 @@ function footer(b) {
|
||||
}
|
||||
|
||||
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
|
||||
// changes) and the small review/live switch in the header.
|
||||
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 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 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 save = async () => {
|
||||
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);
|
||||
if (eComment.value !== (r.comment || "")) body.comment = eComment.value;
|
||||
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 {
|
||||
const res = await change("PATCH", "/records/" + r.id, body);
|
||||
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; }
|
||||
.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); }
|
||||
|
||||
/* 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
|
||||
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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user