Give the DS command for a parent zone in Jarl, and allow port 443

The DNSSEC tab and 'jarl zone dnssec status' give the jarl record add
command (and an Add to parent button) when the parent zone is in Jarl.
jarl.service grants CAP_NET_BIND_SERVICE so the web UI can listen on 443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-09 22:42:16 +00:00
1 parent 295c549479
commit 113a6913a2
10 files changed
+160 -11

No files matched your search

+10
View File
@@ -22,9 +22,19 @@ All notable changes to Jarl are documented here. The format follows
Validation checks the zone as it is served: the last NS record can't be Validation checks the zone as it is served: the last NS record can't be
disabled, enabling a record is checked like adding it, and a disabled disabled, enabling a record is checked like adding it, and a disabled
record that a CNAME now blocks gets a warning. Database schema 5. record that a CNAME now blocks gets a warning. Database schema 5.
- DNSSEC for a zone whose parent zone is in Jarl: the DNSSEC tab gives the
`jarl record add PARENT NAME DS ...` command to copy and an "Add to
PARENT" button (staged like any change), links to the parent zone, and
says when the parent has the DS record. `jarl zone dnssec status` prints
the command too, and turning signing off gives the matching `jarl record
delete`.
### Changed ### Changed
- The web UI and API can listen on ports below 1024, such as 443:
`jarl.service` now gives jarld the `CAP_NET_BIND_SERVICE` capability
(and only that). With a unit that doesn't, jarld's error says what's
missing. See docs/configuration.md, "HTTPS on port 443".
- Records table: names are no longer greyed out when they repeat the row - Records table: names are no longer greyed out when they repeat the row
above (it made those records look inactive). Records of the same name above (it made those records look inactive). Records of the same name
are separated by a faint line instead, and a full line marks where the are separated by a faint line instead, and a full line marks where the
+27 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"slices"
"strings" "strings"
"time" "time"
@@ -110,7 +111,7 @@ A zone whose DS never reached the parent can be wiped at once instead
} }
return nil return nil
case st.Policy != "insecure": case st.Policy != "insecure":
if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record at your registrar.", displayName(st.Zone)), yes) { if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record from the parent zone.", displayName(st.Zone)), yes) {
return fmt.Errorf("canceled") return fmt.Errorf("canceled")
} }
next = "insecure" next = "insecure"
@@ -133,7 +134,9 @@ A zone whose DS never reached the parent can be wiped at once instead
if err := report(resp); err != nil || !resp.Applied || flagQuiet { if err := report(resp); err != nil || !resp.Applied || flagQuiet {
return err return err
} }
if next == "insecure" { if next == "insecure" && st.ParentZone != "" {
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else if next == "insecure" {
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view)) fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else { } else {
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').") fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
@@ -344,7 +347,7 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
if len(ds) > 0 && r.Policy != "insecure" { if len(ds) > 0 && r.Policy != "insecure" {
where := "at your registrar" where := "at your registrar"
if r.ParentZone != "" { if r.ParentZone != "" {
where = "in the parent zone " + displayName(r.ParentZone) + " (in Jarl: add it as a DS record there)" where = "in the parent zone " + displayName(r.ParentZone) + ", which is in Jarl: add it as a DS record there"
} }
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where) fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil { if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
@@ -353,9 +356,30 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
for _, k := range ds { for _, k := range ds {
fmt.Fprintf(w, " %s\n", strings.ReplaceAll(k.DS.Record, "\t", " ")) fmt.Fprintf(w, " %s\n", strings.ReplaceAll(k.DS.Record, "\t", " "))
fmt.Fprintf(w, " key tag %d, algorithm %d (%s), digest type %d (SHA-256), digest %s\n", k.DS.KeyTag, k.DS.Algorithm, k.AlgorithmName, k.DS.DigestType, k.DS.Digest) fmt.Fprintf(w, " key tag %d, algorithm %d (%s), digest type %d (SHA-256), digest %s\n", k.DS.KeyTag, k.DS.Algorithm, k.AlgorithmName, k.DS.DigestType, k.DS.Digest)
if r.ParentZone != "" {
data := fmt.Sprintf("%d %d %d %s", k.DS.KeyTag, k.DS.Algorithm, k.DS.DigestType, k.DS.Digest)
if slices.ContainsFunc(r.ParentDS, func(d string) bool { return strings.EqualFold(d, data) }) {
fmt.Fprintf(w, " %s has it.\n", displayName(r.ParentZone))
} else {
fmt.Fprintf(w, " %s\n", parentDSCommand(r, "add", data))
} }
} }
} }
}
}
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
// record in its parent zone in Jarl.
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
parts := []string{"jarl record", verb, displayName(r.ParentZone), strings.TrimSuffix(r.Zone, "."+r.ParentZone), "DS"}
if data != "" {
parts = append(parts, data)
}
if r.ParentView != "" {
parts = append(parts, "--view", r.ParentView)
}
return strings.Join(parts, " ")
}
func dash(s string) string { func dash(s string) string {
if s == "" { if s == "" {
+4 -2
View File
@@ -40,8 +40,10 @@ ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
PrivateDevices=yes PrivateDevices=yes
NoNewPrivileges=yes NoNewPrivileges=yes
CapabilityBoundingSet= # The only privilege jarld gets: listening on ports below 1024 (443 for the
AmbientCapabilities= # web UI). Nothing else, and NoNewPrivileges still applies.
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ProtectKernelLogs=yes ProtectKernelLogs=yes
+27 -1
View File
@@ -18,7 +18,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md).
| Key | Default | Meaning | | Key | Default | Meaning |
|-----|---------|---------| |-----|---------|---------|
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. | | `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. Ports below 1024 (`"192.168.10.2:443"`) work because `jarl.service` gives jarld the `CAP_NET_BIND_SERVICE` capability, and only that ([HTTPS on port 443](#example-https-on-port-443)). |
| `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. | | `socket` | `/run/jarl/jarl.sock` | A local unix socket for the API. Requests on it from root or jarld's own user need no token and have administrator rights; anyone else must send a token ([permissions.md](permissions.md#the-local-socket)). The `jarl` CLI uses it when no server is configured. At most 107 bytes long; `""` turns it off. If it can't be created, jarld logs a warning and serves the network addresses only. |
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. | | `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. |
| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. | | `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` (or `Forwarded: proto=`) headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy, and for knowing that a proxied request came over HTTPS (the session cookie's Secure flag, `acme.httpreq`). From other addresses these headers are ignored. |
@@ -149,6 +149,32 @@ audit:
retention_days: 730 retention_days: 730
``` ```
## Example: HTTPS on port 443
```yaml
listen:
- "127.0.0.1:8053" # the CLI on the server keeps using this (or the socket)
- "192.168.10.2:443"
tls:
cert_file: /etc/jarl/tls/fullchain.pem
key_file: /etc/jarl/tls/privkey.pem
```
jarld runs as the unprivileged `jarl` user, and binding a port below 1024
needs the `CAP_NET_BIND_SERVICE` capability, which `jarl.service` grants
(`AmbientCapabilities=CAP_NET_BIND_SERVICE`; nothing else). The unit in
Jarl 0.2.0 and earlier doesn't, and jarld then fails with "bind:
permission denied". Upgrading the package installs the new unit; until
then, a drop-in does the same:
```sh
sudo systemctl edit jarl
# [Service]
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# AmbientCapabilities=CAP_NET_BIND_SERVICE
sudo systemctl restart jarl
```
## Example: behind nginx ## Example: behind nginx
```yaml ```yaml
+12 -2
View File
@@ -122,8 +122,18 @@ can make validating resolvers reject cached answers. Then:
[DNSViz](https://dnsviz.net/) gives an independent picture. [DNSViz](https://dnsviz.net/) gives an independent picture.
If the parent zone is itself a primary zone in Jarl (`home.adbraun.lol` If the parent zone is itself a primary zone in Jarl (`home.adbraun.lol`
inside `adbraun.lol`), the DNSSEC tab says so: add the DS as a record in inside `adbraun.lol`), the DS goes in as a record in that zone, and Jarl
that zone. gives you both ways to add it: an **Add to adbraun.lol** button on the
DNSSEC tab, and the command, which `jarl zone dnssec status` prints too:
```sh
jarl record add adbraun.lol home DS 51126 13 2 FFBECE3D...
```
Once the parent zone has it, the tab and `status` say so. When turning
signing off, the steps give the matching `jarl record delete adbraun.lol
home DS`. The zone must be delegated first (NS records for `home` in
`adbraun.lol`), or validation refuses the DS.
## Putting your own name servers on the internet ## Putting your own name servers on the internet
+5
View File
@@ -374,6 +374,11 @@ type DNSSECResponse struct {
// ParentZone is set when the parent zone is a primary zone in Jarl // ParentZone is set when the parent zone is a primary zone in Jarl
// (in the same view): the DS record then goes there, as a record. // (in the same view): the DS record then goes there, as a record.
ParentZone string `json:"parent_zone,omitempty"` ParentZone string `json:"parent_zone,omitempty"`
ParentZoneID int64 `json:"parent_zone_id,omitempty"`
ParentView string `json:"parent_view,omitempty"`
// ParentDS is the DS records the parent zone in Jarl has for the
// zone (record data, e.g. "51126 13 2 FFBE...").
ParentDS []string `json:"parent_ds,omitempty"`
// Registrar names the registrar Jarl can publish the DS at // Registrar names the registrar Jarl can publish the DS at
// ("porkbun"), when one is configured in jarl.yaml. // ("porkbun"), when one is configured in jarl.yaml.
Registrar string `json:"registrar,omitempty"` Registrar string `json:"registrar,omitempty"`
+6 -1
View File
@@ -37,7 +37,12 @@ func (s *Server) zoneDNSSEC(w http.ResponseWriter, r *http.Request, p *auth.Prin
} }
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)} resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
if pz := parentInJarl(st, z); pz != nil { if pz := parentInJarl(st, z); pz != nil {
resp.ParentZone = pz.Name resp.ParentZone, resp.ParentZoneID, resp.ParentView = pz.Name, pz.ID, st.ViewName(pz)
for _, rec := range st.ServedRecords(pz.ID) {
if rec.Name == z.Name && rec.Type == "DS" {
resp.ParentDS = append(resp.ParentDS, rec.Data)
}
}
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
return nil return nil
+15
View File
@@ -12,8 +12,10 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"jarl/internal/api" "jarl/internal/api"
@@ -116,6 +118,16 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
} }
// Handler returns the server's HTTP handler with all middleware. // Handler returns the server's HTTP handler with all middleware.
// lowPort reports whether addr ("host:port") has a port below 1024.
func lowPort(addr string) bool {
_, port, err := net.SplitHostPort(addr)
if err != nil {
return false
}
n, err := strconv.Atoi(port)
return err == nil && n > 0 && n < 1024
}
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
return s.recoverer(s.securityHeaders(s.logRequests(s.mux))) return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
} }
@@ -142,6 +154,9 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
for _, l := range listeners { for _, l := range listeners {
l.Close() l.Close()
} }
if errors.Is(err, syscall.EACCES) && lowPort(addr) {
return fmt.Errorf("listening on %s: %w (ports below 1024 need the CAP_NET_BIND_SERVICE capability; Jarl's jarl.service grants it, so check that the installed unit is current and no drop-in removes it: systemctl cat jarl)", addr, err)
}
return fmt.Errorf("listening on %s: %w", addr, err) return fmt.Errorf("listening on %s: %w", addr, err)
} }
listeners = append(listeners, ln) listeners = append(listeners, ln)
+35 -2
View File
@@ -71,10 +71,34 @@ function stateBadge(st) {
return badge(st, { omnipresent: "ok", rumoured: "accent", unretentive: "warn" }[st] || ""); return badge(st, { omnipresent: "ok", rumoured: "accent", unretentive: "warn" }[st] || "");
} }
// parentName is the zone's name relative to its parent zone in Jarl
// ("sectest" for sectest.adbraun.lol in adbraun.lol).
function parentName(zone, s) {
return zone.name.slice(0, -(s.parent_zone.length + 1));
}
// jarlCmd is the jarl command for a record change in the parent zone.
function jarlCmd(zone, s, verb, data) {
return ["jarl record", verb, display(s.parent_zone), parentName(zone, s), "DS", data, s.parent_view ? "--view " + s.parent_view : ""].filter(Boolean).join(" ");
}
const dsData = (k) => [k.ds.key_tag, k.ds.algorithm, k.ds.digest_type, k.ds.digest].join(" ");
// inParent reports whether the parent zone in Jarl has the key's DS record.
const inParent = (s, k) => (s.parent_ds || []).some((d) => d.toUpperCase() === dsData(k).toUpperCase());
async function addToParent(zone, s, k) {
try {
const r = await change("POST", "/zones/" + s.parent_zone_id + "/records", { name: parentName(zone, s), type: "DS", data: dsData(k) });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
function dsPanel(zone, s, keys) { function dsPanel(zone, s, keys) {
const ph = s.phase; const ph = s.phase;
const where = s.parent_zone const where = s.parent_zone
? ["The parent zone ", h("a", { href: "#/zones" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, display(zone.name)), ")."] ? ["The parent zone ", h("a", { href: "#/zones/" + s.parent_zone_id + "/records" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, parentName(zone, s)), "), with the button or the jarl command below."]
: "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields."; : "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields.";
return h("div", { class: "panel" }, return h("div", { class: "panel" },
h("h2", null, "DS record for the parent zone"), h("h2", null, "DS record for the parent zone"),
@@ -90,6 +114,13 @@ function dsPanel(zone, s, keys) {
keys.map((k) => h("div", { class: "ds-record" }, keys.map((k) => h("div", { class: "ds-record" },
h("label", { class: "small muted" }, "Key " + k.tag + " — DS record"), h("label", { class: "small muted" }, "Key " + k.tag + " — DS record"),
copyBox(k.ds.record.replace(/\t/g, " ")), copyBox(k.ds.record.replace(/\t/g, " ")),
s.parent_zone ? [
h("label", { class: "small muted" }, "jarl command"),
copyBox(jarlCmd(zone, s, "add", dsData(k))),
inParent(s, k)
? h("p", { class: "small" }, badge("in " + display(s.parent_zone), "ok"), " ", display(s.parent_zone) + " has this DS record.")
: h("p", null, h("button", { class: ph.can_publish_ds ? "primary" : "", disabled: !ph.can_publish_ds, title: ph.can_publish_ds ? "" : "Not yet: see above", onclick: () => addToParent(zone, s, k) }, "Add to " + display(s.parent_zone))),
] : null,
h("dl", { class: "kv small" }, h("dl", { class: "kv small" },
h("dt", null, "Key tag"), h("dd", null, copyBox(String(k.ds.key_tag))), h("dt", null, "Key tag"), h("dd", null, copyBox(String(k.ds.key_tag))),
h("dt", null, "Algorithm"), h("dd", null, copyBox(String(k.ds.algorithm)), h("span", { class: "muted" }, " " + (k.algorithm_name || ""))), h("dt", null, "Algorithm"), h("dd", null, copyBox(String(k.ds.algorithm)), h("span", { class: "muted" }, " " + (k.algorithm_name || ""))),
@@ -142,7 +173,9 @@ async function disableDialog(zone, s) {
h("p", null, "Signing is turned off in steps, so the zone is never unsigned while the parent zone still has its DS record (validating resolvers would then reject the whole domain):"), h("p", null, "Signing is turned off in steps, so the zone is never unsigned while the parent zone still has its DS record (validating resolvers would then reject the whole domain):"),
steps([ steps([
[h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."], [h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."],
[h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."], s.parent_zone
? [h("strong", null, "You: "), "remove the DS record from ", display(s.parent_zone), " (", h("code", null, jarlCmd(zone, s, "delete", "")), ", or on its Records page), then click Check DS at parent so named knows it's gone."]
: [h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."],
[h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."], [h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."],
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."], [h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
]), ]),
+19
View File
@@ -384,6 +384,25 @@ contains "removed .*Kadbraun.test"
run "dnssec status: clean" 0 $J zone dnssec status adbraun.test --view external run "dnssec status: clean" 0 $J zone dnssec status adbraun.test --view external
contains "Status:.*DNSSEC is off.$" contains "Status:.*DNSSEC is off.$"
# DNSSEC for a zone whose parent is in Jarl: the DS goes in as a record.
run "child zone" 0 $J zone create sub.adbraun.test --view external --ns ns1.adbraun.test.
run "delegate the child" 0 $J record add adbraun.test sub NS ns1.adbraun.test. --view external
run "dnssec enable the child" 0 $J zone dnssec enable sub.adbraun.test --view external
for i in $(seq 50); do $J zone dnssec status sub.adbraun.test --view external 2>/dev/null | grep -q "signed serial" && break; sleep 0.2; done
run "child status gives the jarl command" 0 $J zone dnssec status sub.adbraun.test --view external
contains "in the parent zone adbraun.test, which is in Jarl"
contains "jarl record add adbraun.test sub DS [0-9]* 13 2 [0-9A-F]* --view external"
DSCMD=$(echo "$OUT" | grep -o "jarl record add adbraun.test sub DS .*")
run "the printed command adds the DS" 0 $J ${DSCMD#jarl }
run "the parent's zone file has the DS" 0 $J zone file adbraun.test --view external
contains "^sub.*IN.DS.$(echo "$DSCMD" | sed 's/.* DS \(.*\) --view.*/\1/')"
run "child status: parent has it" 0 $J zone dnssec status sub.adbraun.test --view external
contains "adbraun.test has it."
run "remove the child's DS" 0 $J record delete adbraun.test sub DS --view external --yes
run "wipe the child" 0 $J zone dnssec wipe sub.adbraun.test --view external --yes --force --resolver 127.0.0.1
run "delete the child" 0 $J zone delete sub.adbraun.test --view external --yes
run "remove the delegation" 0 $J record delete adbraun.test sub NS --view external --yes
run "status at the end" 0 $J status run "status at the end" 0 $J status
contains "Drift:.*none" contains "Drift:.*none"