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:
1 parent
295c549479
commit
113a6913a2
10 files changed
+161
-12
No files matched your search
@@ -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
|
||||
|
||||
+27
-3
@@ -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 "-"
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-1
@@ -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
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."],
|
||||
]),
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in new issue
Block a user