diff --git a/CHANGELOG.md b/CHANGELOG.md index b8592fe..10426d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 disabled, enabling a record is checked like adding it, and a disabled record that a CNAME now blocks gets a warning. Database schema 5. +- DNSSEC for a zone whose parent zone is in Jarl: the DNSSEC tab gives the + `jarl record add PARENT NAME DS ...` command to copy and an "Add to + PARENT" button (staged like any change), links to the parent zone, and + says when the parent has the DS record. `jarl zone dnssec status` prints + the command too, and turning signing off gives the matching `jarl record + delete`. ### Changed +- The web UI and API can listen on ports below 1024, such as 443: + `jarl.service` now gives jarld the `CAP_NET_BIND_SERVICE` capability + (and only that). With a unit that doesn't, jarld's error says what's + missing. See docs/configuration.md, "HTTPS on port 443". - Records table: names are no longer greyed out when they repeat the row above (it made those records look inactive). Records of the same name are separated by a faint line instead, and a full line marks where the diff --git a/cmd/jarl/dnssec.go b/cmd/jarl/dnssec.go index 2ec7385..d090b9c 100644 --- a/cmd/jarl/dnssec.go +++ b/cmd/jarl/dnssec.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "slices" "strings" "time" @@ -110,7 +111,7 @@ A zone whose DS never reached the parent can be wiped at once instead } return nil 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") } 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 { 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)) } else { 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" { where := "at your registrar" 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) if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil { @@ -353,10 +356,31 @@ func printDNSSEC(w io.Writer, r *api.DNSSECResponse) { for _, k := range ds { 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) + 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 { if s == "" { return "-" diff --git a/deploy/systemd/jarl.service b/deploy/systemd/jarl.service index fdc6d54..c09f398 100644 --- a/deploy/systemd/jarl.service +++ b/deploy/systemd/jarl.service @@ -40,8 +40,10 @@ ProtectHome=yes PrivateTmp=yes PrivateDevices=yes NoNewPrivileges=yes -CapabilityBoundingSet= -AmbientCapabilities= +# The only privilege jarld gets: listening on ports below 1024 (443 for the +# web UI). Nothing else, and NoNewPrivileges still applies. +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_BIND_SERVICE ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes diff --git a/docs/configuration.md b/docs/configuration.md index 334a637..f3c9ab6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,7 +18,7 @@ The CLI has its own small configuration file, described in [cli.md](cli.md). | 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. | | `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. | @@ -149,6 +149,32 @@ audit: 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 ```yaml diff --git a/docs/dnssec.md b/docs/dnssec.md index b17cab2..b67ec9e 100644 --- a/docs/dnssec.md +++ b/docs/dnssec.md @@ -122,8 +122,18 @@ can make validating resolvers reject cached answers. Then: [DNSViz](https://dnsviz.net/) gives an independent picture. 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 -that zone. +inside `adbraun.lol`), the DS goes in as a record in that zone, and Jarl +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 diff --git a/internal/api/types.go b/internal/api/types.go index 65d08fc..82a3aad 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -373,7 +373,12 @@ type DNSSECResponse struct { *dnssec.Status // 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. - 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 // ("porkbun"), when one is configured in jarl.yaml. Registrar string `json:"registrar,omitempty"` diff --git a/internal/server/h_dnssec.go b/internal/server/h_dnssec.go index 85c20cb..5c15b46 100644 --- a/internal/server/h_dnssec.go +++ b/internal/server/h_dnssec.go @@ -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)} 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) return nil diff --git a/internal/server/server.go b/internal/server/server.go index 3f66c77..e42007f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,8 +12,10 @@ import ( "net/http" "net/netip" "os" + "strconv" "strings" "sync" + "syscall" "time" "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. +// 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 { 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 { 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) } listeners = append(listeners, ln) diff --git a/internal/web/static/assets/pages/dnssec.js b/internal/web/static/assets/pages/dnssec.js index df73270..ea3d87c 100644 --- a/internal/web/static/assets/pages/dnssec.js +++ b/internal/web/static/assets/pages/dnssec.js @@ -71,10 +71,34 @@ function stateBadge(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) { const ph = s.phase; 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."; return h("div", { class: "panel" }, 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" }, h("label", { class: "small muted" }, "Key " + k.tag + " — DS record"), 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("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 || ""))), @@ -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):"), 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, "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, "Then: "), "this page lets you finish, which switches DNSSEC off."], ]), diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 2ec8314..5746461 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -384,6 +384,25 @@ contains "removed .*Kadbraun.test" run "dnssec status: clean" 0 $J zone dnssec status adbraun.test --view external 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 contains "Drift:.*none"