From b4bb99b482fb97311285b08084a36f7a9d46ed43 Mon Sep 17 00:00:00 2001 From: adam Date: Sun, 18 Oct 2026 16:07:26 +0100 Subject: [PATCH] Certificate export, missing PTR list, simpler "Used for" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Certificates can be exported for use outside Jarl: Export… on the Certificates page (download or copy), jarl cert export, and GET /certificates/{name}/pem?part=fullchain|cert|key|bundle. Administrators only; key exports are audited. - A reverse zone's missing-PTR banner lists the addresses and names (ptr_missing_list in the zone API). - "Used for" shows DoT/DoH for named, and "not used by Jarl" instead of "nothing yet". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +++ cmd/jarl/certs.go | 90 ++++++++++++++++++- docs/api.md | 1 + docs/certificates.md | 27 +++++- docs/cli.md | 2 + docs/reverse.md | 6 +- internal/api/types.go | 14 +++ internal/certs/manager.go | 25 ++++++ internal/model/certificate.go | 3 +- internal/server/certs_test.go | 36 ++++++++ internal/server/dsat_test.go | 13 +++ internal/server/h_certs.go | 46 ++++++++++ internal/server/present.go | 6 +- internal/server/routes.go | 1 + .../web/static/assets/pages/certificates.js | 73 ++++++++++++++- internal/web/static/assets/pages/zone.js | 7 +- 16 files changed, 352 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5700f10..9e81617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to Jarl are documented here. The format follows ### Added +- Certificates can be exported for use outside Jarl: **Export…** on the + Certificates page (download, or copy to the clipboard), + `jarl cert export NAME [--dir DIR | --part PART]` + and `GET /certificates/{name}/pem?part=fullchain|cert|key|bundle`. + Administrators only; every export of the private key is audited + (`certificate.export_key`). +- A reverse zone's "addresses have no PTR record" banner lists them, with + the name each would get, instead of only counting them + (`ptr_missing_list` in the zone API). - A zone's page links to the same zone in the other views ("also in external"), in each view's colour, keeping the tab you are on. It works for zones that aren't linked too, and the zone API's new @@ -18,6 +27,11 @@ All notable changes to Jarl are documented here. The format follows ### Changed +- The Certificates page's "Used for" column says DoT and DoH for named, + with the addresses in the tooltip, instead of listing every address. A + certificate with no use says "not used by Jarl" rather than "nothing + yet": it may well be used elsewhere. + - Deleting a linked zone's local records (an override) removes that name and type from that view only. It used to bring the shared records back into the view, so a delete looked undone. The other views keep their diff --git a/cmd/jarl/certs.go b/cmd/jarl/certs.go index 06a525e..c589e2d 100644 --- a/cmd/jarl/certs.go +++ b/cmd/jarl/certs.go @@ -99,9 +99,10 @@ used too (--cert-file and --key-file): Jarl rereads it when it changes.`, jarl cert create own --cert-file /etc/ssl/ns1/fullchain.pem --key-file /etc/ssl/ns1/privkey.pem --use web jarl cert list jarl cert issue ns1 # renew now - jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun)`, + jarl cert update ns1 --challenge porkbun # pin the route (auto, jarl or porkbun) + jarl cert export ns1 --dir /etc/ssl/ns1 # fullchain.pem and privkey.pem, for another program`, } - cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certDeleteCmd(), certAccountCmd()) + cmd.AddCommand(certListCmd(), certShowCmd(), certCreateCmd(), certUpdateCmd(), certIssueCmd(), certCheckCmd(), certExportCmd(), certDeleteCmd(), certAccountCmd()) return cmd } @@ -493,6 +494,91 @@ is published and the CA isn't asked.`, return cmd } +func certExportCmd() *cobra.Command { + var dir, part string + var noKey, force bool + cmd := &cobra.Command{ + Use: "export NAME", + Short: "Save a certificate (and its key) to use in another program", + Long: `Saves a certificate's current files, to use it outside Jarl (a mail +server, a reverse proxy): fullchain.pem and privkey.pem in --dir (the +key readable only by you), or one part on standard output with --part: + + fullchain the certificate and its chain + cert the certificate alone + key the private key + bundle the key, then the chain (one file, as HAProxy wants) + +Administrators only; every export of the key is audited. Jarl renews the +certificate, but not your copy: export it again after a renewal (the +'renews' date in 'jarl cert show'), or point the program at the files Jarl +writes for named (use dns) instead.`, + Example: ` jarl cert export ns1 --dir /etc/ssl/ns1 + jarl cert export ns1 --part bundle > /etc/haproxy/ns1.pem + jarl cert export ns1 --no-key --dir .`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + get := func(p string) (string, error) { + var out string + err := c.Get(ctx(), certPath(args[0])+"/pem", url.Values{"part": {p}}, &out) + return out, err + } + if part != "" { + out, err := get(part) + if err != nil { + return err + } + _, err = os.Stdout.WriteString(out) + return err + } + files := []struct{ part, name string }{{"fullchain", "fullchain.pem"}, {"key", "privkey.pem"}} + if noKey { + files = files[:1] + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + for _, f := range files { + path := dir + string(os.PathSeparator) + f.name + if _, err := os.Stat(path); err == nil && !force { + return fmt.Errorf("%s exists (--force replaces it)", path) + } + } + for _, f := range files { + out, err := get(f.part) + if err != nil { + return err + } + path := dir + string(os.PathSeparator) + f.name + mode := os.FileMode(0o644) + if f.part == "key" { + mode = 0o600 + } + if err := os.WriteFile(path, []byte(out), mode); err != nil { + return err + } + if err := os.Chmod(path, mode); err != nil { + return err + } + if !flagQuiet { + fmt.Println("wrote", path) + } + } + return nil + }, + } + cmd.Flags().StringVar(&dir, "dir", ".", "directory for fullchain.pem and privkey.pem") + cmd.Flags().StringVar(&part, "part", "", "write one part to standard output: fullchain, cert, key or bundle") + cmd.Flags().BoolVar(&noKey, "no-key", false, "only fullchain.pem") + cmd.Flags().BoolVar(&force, "force", false, "replace existing files") + _ = cmd.RegisterFlagCompletionFunc("part", cobra.FixedCompletions([]string{"fullchain", "cert", "key", "bundle"}, cobra.ShellCompDirectiveNoFileComp)) + return cmd +} + func certDeleteCmd() *cobra.Command { var yes bool cmd := &cobra.Command{ diff --git a/docs/api.md b/docs/api.md index bd6b3a7..9ce2354 100644 --- a/docs/api.md +++ b/docs/api.md @@ -604,6 +604,7 @@ Administrators only. See [certificates.md](certificates.md#api). | GET, PATCH, DELETE | `/certificates/{name}` | | | POST | `/certificates/{name}/issue` | 202: being obtained (poll until `issuing_since` is gone); a file certificate is reread (200) | | POST | `/certificates/{name}/check` | the routes of a certificate's names | +| GET | `/certificates/{name}/pem?part=PART` | the certificate as PEM: `fullchain` (default), `cert`, `key`, `bundle`; key exports audited ([certificates.md](certificates.md#using-a-certificate-outside-jarl)) | `GET /status` includes `certificates` for administrators: `total`, `problems` (failing, expiring, expired), `next` and `next_after`, diff --git a/docs/certificates.md b/docs/certificates.md index 6c217ed..0680cf8 100644 --- a/docs/certificates.md +++ b/docs/certificates.md @@ -216,11 +216,35 @@ and the last good one stays in use. jarld runs as user `jarl`, which must be able to read both files (e.g. `chgrp jarl` and `chmod 640`, or have your renewal hook copy them somewhere it can read). +## Using a certificate outside Jarl + +A certificate needn't be used by Jarl at all ("not used by Jarl" on the +Certificates page): Jarl obtains and renews it, and you export it for +another program, such as a mail server or a reverse proxy. **Export…** on +the Certificates page downloads each file or copies it to the clipboard +(copying needs the web UI over HTTPS, or from localhost). Or: + +```sh +jarl cert export mail --dir /etc/ssl/mail # fullchain.pem, and privkey.pem (mode 600) +jarl cert export mail --part bundle > /etc/haproxy/mail.pem +``` + +The parts are `fullchain` (the certificate and its chain), `cert` (the +certificate alone), `key` and `bundle` (the key, then the chain, in one +file, as HAProxy wants). Only administrators can export, and every export +of the key is in the audit log (`certificate.export_key`). + +Jarl renews the certificate, not your copy: export it again after each +renewal (`jarl cert show` says when that is), for example from a daily +cron job. A program on this server can instead read the files Jarl writes +for named (use `dns`), which Jarl keeps current. + ## Security notes - Certificates Jarl obtains, their keys and the ACME account key are kept in Jarl's database, so they are in its backups (like TSIG secrets). The - API never returns a key. + API returns a key only to an administrator who exports it, and records + that in the audit log. - Every publicly trusted certificate is published in Certificate Transparency logs, with all its names. A name like `ns1.adbraun.lol` used for both the web UI and DNS reveals less than separate names. Avoid @@ -240,6 +264,7 @@ your renewal hook copy them somewhere it can read). | GET, PATCH, DELETE | `/certificates/{name}` | `domains` and `uses` replace the whole list | | POST | `/certificates/{name}/issue` | obtain now: 202, then poll `GET /certificates/{name}` until `issuing_since` is gone; a file certificate is reread (200) | | POST | `/certificates/{name}/check` | the routes for an existing certificate | +| GET | `/certificates/{name}/pem?part=fullchain` | the certificate as PEM, to save: `fullchain` (default), `cert`, `key` or `bundle` (key and chain). Exports of the key are audited | ## Testing with Pebble diff --git a/docs/cli.md b/docs/cli.md index 20936ad..d31254b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -446,6 +446,8 @@ jarl cert show ns1 jarl cert issue ns1 # renew now (--no-wait to return at once) jarl cert update ns1 --challenge porkbun # auto, jarl or porkbun jarl cert update ns1 --use dns # --use and --domain replace the list (--use none) +jarl cert export mail --dir /etc/ssl/mail # fullchain.pem and privkey.pem, for another program +jarl cert export mail --part bundle # one part to stdout: fullchain, cert, key, bundle jarl cert delete ns1 jarl cert account # the ACME account (set in jarl.yaml) ``` diff --git a/docs/reverse.md b/docs/reverse.md index 2b85a99..26665a2 100644 --- a/docs/reverse.md +++ b/docs/reverse.md @@ -132,8 +132,10 @@ view's A and AAAA records use. An address with several names gets one PTR: 3. the shortest name, then the first alphabetically. Jarl says which name it chose, and which it didn't, so you can change it. -A reverse zone's page shows how many addresses still lack a PTR, with a -button to add them. `jarl zone show` shows it as `Missing PTRs`. +A reverse zone's page shows the addresses that still lack a PTR record and +the name each would get (the first 25), with a button to add them. `jarl +zone show` shows how many as `Missing PTRs`; the API lists them in +`ptr_missing_list`. ## Keeping PTR records in step diff --git a/internal/api/types.go b/internal/api/types.go index 6f747fd..43432bb 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -45,6 +45,9 @@ type Zone struct { // (POST /zones/{zone}/fill-ptr adds them). Prefix string `json:"prefix,omitempty"` PTRMissing int `json:"ptr_missing,omitempty"` + // PTRMissingList are the first 25 of them: the PTR records adding + // them would create. + PTRMissingList []PTRFill `json:"ptr_missing_list,omitempty"` // Pending is set when listing with a changeset: add, change or delete. Pending string `json:"pending,omitempty"` } @@ -76,6 +79,17 @@ type View struct { Limited bool `json:"limited,omitempty"` } +// PTRFill is a PTR record a reverse zone lacks: Name (the PTR owner) for +// Address would give Target. Others are its other names; From the view +// whose record names it, when the zone's own view has none. +type PTRFill struct { + Address string `json:"address"` + Name string `json:"name"` + Target string `json:"target"` + Others []string `json:"others,omitempty"` + From string `json:"from,omitempty"` +} + // ViewColor sets the colour the web UI shows a view in. type ViewColor struct { Color string `json:"color"` diff --git a/internal/certs/manager.go b/internal/certs/manager.go index 133d3e6..b16b791 100644 --- a/internal/certs/manager.go +++ b/internal/certs/manager.go @@ -295,6 +295,31 @@ func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, err return c, err } +// PEM returns a certificate's current chain and private key, for +// exporting it: from Jarl's store, or from the files of one you renew +// yourself. +func (m *Manager) PEM(ctx context.Context, name string) (*model.Certificate, []byte, []byte, error) { + c, err := m.Get(ctx, name) + if err != nil { + return nil, nil, nil, err + } + if c.Source == model.CertSourceFile { + cert, err := os.ReadFile(c.CertFile) + if err != nil { + return nil, nil, nil, fileErr(err) + } + key, err := os.ReadFile(c.KeyFile) + if err != nil { + return nil, nil, nil, fileErr(err) + } + return c, cert, key, nil + } + if c.CertPEM == "" { + return nil, nil, nil, &InputError{Msg: "certificate " + name + " hasn't been obtained yet, so there is nothing to export"} + } + return c, []byte(c.CertPEM), []byte(c.KeyPEM), nil +} + // Create adds a certificate. A file-source certificate is read at once; // one Jarl obtains is left for Issue (or the background loop). func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) { diff --git a/internal/model/certificate.go b/internal/model/certificate.go index ac29be6..94f8b06 100644 --- a/internal/model/certificate.go +++ b/internal/model/certificate.go @@ -39,7 +39,8 @@ type Certificate struct { Comment string `json:"comment"` // The current certificate: acme source only (a file source's is read - // from its files). The key never leaves the server through the API. + // from its files). The key leaves the server only through the + // administrators' export (GET /certificates/{name}/pem), audited. CertPEM string `json:"-"` KeyPEM string `json:"-"` diff --git a/internal/server/certs_test.go b/internal/server/certs_test.go index 6731adf..a8645cc 100644 --- a/internal/server/certs_test.go +++ b/internal/server/certs_test.go @@ -165,3 +165,39 @@ func TestHTTPSAndPlainOnOnePort(t *testing.T) { t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String()) } } + +// A certificate's files can be exported for use elsewhere; the key only +// by an administrator, audited. +func TestCertificateExport(t *testing.T) { + env := newAPIEnv(t) + withCerts(env) + certFile, keyFile := selfSigned(t, t.TempDir(), "mail.example.com") + env.do("POST", "/certificates", env.token, map[string]any{"name": "mail", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201) + chain, _ := os.ReadFile(certFile) + key, _ := os.ReadFile(keyFile) + for part, want := range map[string]string{"": string(chain), "fullchain": string(chain), "cert": string(chain), "key": string(key), "bundle": string(key) + "\n" + string(chain)} { + b, status := env.req("GET", "/certificates/mail/pem?part="+part, env.token, nil) + if status != 200 || string(b) != want { + t.Errorf("part %q: %d %q", part, status, b) + } + } + if _, status := env.req("GET", "/certificates/mail/pem?part=nope", env.token, nil); status != 400 { + t.Errorf("bad part: %d", status) + } + if _, status := env.req("GET", "/certificates/none/pem", env.token, nil); status != 404 { + t.Errorf("no such certificate: %d", status) + } + // Not for a user who isn't an administrator. + hash, _ := auth.HashPassword("pw-pw-pw-pw") + u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}} + env.st.CreateUser(context.Background(), u) + tok, pid, sh := auth.NewToken() + env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh}) + if _, status := env.req("GET", "/certificates/mail/pem?part=key", tok, nil); status != 403 { + t.Errorf("non-admin: %d", status) + } + audit, _ := env.req("GET", "/audit?action=certificate.export_key", env.token, nil) + if n := strings.Count(string(audit), "certificate.export_key"); n != 2 { + t.Errorf("audited key exports: %d (%s)", n, audit) + } +} diff --git a/internal/server/dsat_test.go b/internal/server/dsat_test.go index 33a5a97..4f5dea2 100644 --- a/internal/server/dsat_test.go +++ b/internal/server/dsat_test.go @@ -80,3 +80,16 @@ func TestOneViewToken(t *testing.T) { // The admin still has to say which. e.do("GET", "/zones/adbraun.lol", e.token, nil, 400) } + +// A reverse zone lists the PTR records it lacks, not just how many. +func TestPTRMissingList(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/zones", e.token, map[string]any{"name": "adbraun.lol", "nameservers": []string{"ns1.example.net."}, + "records": []map[string]any{{"name": "nas", "type": "A", "data": "10.60.60.5"}}}, 201) + e.do("POST", "/zones", e.token, map[string]any{"name": "10.60.60.0/24", "nameservers": []string{"ns1.example.net."}}, 201) + z := e.do("GET", "/zones/60.60.10.in-addr.arpa", e.token, nil, 200) + list, _ := z["ptr_missing_list"].([]any) + if z["ptr_missing"] != float64(1) || len(list) != 1 || list[0].(map[string]any)["address"] != "10.60.60.5" || list[0].(map[string]any)["target"] != "nas.adbraun.lol." { + t.Errorf("ptr_missing %v, list %v", z["ptr_missing"], z["ptr_missing_list"]) + } +} diff --git a/internal/server/h_certs.go b/internal/server/h_certs.go index 7716737..0e9e4d3 100644 --- a/internal/server/h_certs.go +++ b/internal/server/h_certs.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/pem" "errors" "net/http" "time" @@ -116,6 +117,51 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip return nil } +// certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle, +// the certificate as PEM for use elsewhere: the chain (default), the +// certificate alone, the private key, or the key followed by the chain +// (one file, as HAProxy wants). Anything with the key is audited. +func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + if err := s.certsReady(p); err != nil { + return err + } + part := r.URL.Query().Get("part") + if part == "" { + part = "fullchain" + } + c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name")) + if err != nil { + return certErr(err) + } + var out []byte + file := c.Name + "-" + part + ".pem" + switch part { + case "fullchain": + out = chain + case "cert": + if b, _ := pem.Decode(chain); b != nil { + out = pem.EncodeToMemory(b) + } + case "key": + out = key + case "bundle": + out = append(append(append([]byte{}, key...), '\n'), chain...) + default: + return badRequest("part must be fullchain, cert, key or bundle") + } + if len(out) == 0 { + return badRequest("certificate %s has no %s", c.Name, part) + } + if part == "key" || part == "bundle" { + s.eng.Audit(r.Context(), p, "certificate.export_key", "certificate", c.ID, c.Name, "success", "exported the private key ("+part+")") + } + w.Header().Set("Content-Type", "application/x-pem-file") + w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`) + w.Header().Set("Cache-Control", "no-store") + _, err = w.Write(out) + return err +} + // certCreate: POST /certificates. With "issue": true (the default for // certificates Jarl obtains) it is obtained before the answer. func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { diff --git a/internal/server/present.go b/internal/server/present.go index 0b7f0f9..b7285a0 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -41,7 +41,11 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re if pf, ok := dnsname.PrefixFromReverse(z.Name); ok { out.Prefix = pf.String() if z.Type == model.ZonePrimary { - out.PTRMissing = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID))) + fills := engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID)) + out.PTRMissing = len(fills) + for _, f := range fills[:min(len(fills), 25)] { + out.PTRMissingList = append(out.PTRMissingList, api.PTRFill(f)) + } } } for _, o := range st.LinkedWith(z) { diff --git a/internal/server/routes.go b/internal/server/routes.go index 684c7ab..c2e4876 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -174,6 +174,7 @@ func (s *Server) routes() { m.HandleFunc("POST "+p+"/certificates", s.authed(s.certCreate)) m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck)) m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet)) + m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM)) m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate)) m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete)) m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue)) diff --git a/internal/web/static/assets/pages/certificates.js b/internal/web/static/assets/pages/certificates.js index c8ef383..6adfd66 100644 --- a/internal/web/static/assets/pages/certificates.js +++ b/internal/web/static/assets/pages/certificates.js @@ -32,10 +32,9 @@ export async function render(el) { { label: "Name", render: (c) => h("a", { href: "#", onclick: (e) => { e.preventDefault(); details(c); } }, h("strong", null, c.name)) }, { label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) }, { label: "Used for", render: (c) => [ - c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ", - c.uses.includes("dns") ? [badge("named", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))), - !(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null, - !c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] }, + c.uses.includes("web") ? [badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : ""), " "] : null, + c.uses.includes("dns") ? namedUse(c) : null, + !c.uses.length ? h("span", { class: "muted", title: "Export it to use it in another program" }, "not used by Jarl") : null] }, { label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) }, { label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") }, { label: "Status", render: (c) => { @@ -45,6 +44,7 @@ export async function render(el) { { label: "", render: (c) => h("span", null, h("button", { class: "small", disabled: !!c.issuing_since, title: c.source === "acme" ? "Obtain a new certificate now" : "Reread the files now", onclick: () => issue(c) }, c.source === "acme" ? "Renew" : "Reread"), " ", h("button", { class: "small", onclick: () => editDialog(c, a) }, "Edit"), " ", + c.not_after ? [h("button", { class: "small", title: "Download it to use in another program", onclick: () => exportDialog(c) }, "Export…"), " "] : null, h("button", { class: "small danger", onclick: () => del(c) }, "Delete")), tdClass: "actions-cell" }, ], r.certificates, { empty: "No certificates yet." }))); @@ -64,6 +64,15 @@ export async function render(el) { } } +// namedUse is what named uses a certificate for: DoT and DoH, with the +// addresses in the tooltip (the details list them). +function namedUse(c) { + const uses = c.named_use || []; + if (!uses.length) return h("span", { class: "muted", title: "Encrypted DNS sets up named's DNS over TLS and HTTPS" }, "named: not set up"); + const kinds = [["DNS over TLS", "DoT"], ["DNS over HTTPS", "DoH"]].filter(([long]) => uses.some((u) => u.startsWith(long))); + return h("span", { title: uses.join("\n") }, kinds.map(([, short]) => [badge(short, ""), " "])); +} + function details(c) { const rows = [ ["Source", c.source === "acme" ? "Jarl obtains and renews it (" + c.key_type + " key)" : "your files: " + c.cert_file + ", " + c.key_file], @@ -85,6 +94,62 @@ function details(c) { copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true }); } +// exportDialog downloads a certificate's files to use elsewhere. +function exportDialog(c) { + const parts = [ + ["fullchain", "fullchain.pem", "the certificate and its chain: what most programs want"], + ["cert", "cert.pem", "the certificate alone"], + ["key", "privkey.pem", "the private key"], + ["bundle", "bundle.pem", "the key, then the chain, in one file (HAProxy)"], + ]; + modal("Export certificate " + c.name, h("div", null, + c.source === "acme" + ? h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). Jarl renews the certificate, but not your copy: export it again after each renewal", + c.next_attempt ? " (next: " + fmtTime(c.next_attempt) + ")" : "", ", or have the program read the files Jarl writes for named (use it for named).") + : h("p", null, "For a program outside Jarl (a mail server, a reverse proxy). You renew this one yourself, so a program on this server can read your files directly: ", h("code", null, c.cert_file), "."), + h("table", { class: "small" }, parts.map(([part, file, what]) => h("tr", null, + h("td", null, h("button", { class: "small" + (part === "fullchain" ? " primary" : ""), title: "Download " + file, onclick: () => download(c, part, file) }, file), " ", + h("button", { class: "small", title: "Copy " + file + " to the clipboard", onclick: () => copyPEM(c, part, file) }, "Copy")), + h("td", { class: part === "key" || part === "bundle" ? "" : "muted" }, what, part === "key" || part === "bundle" ? h("span", { class: "muted" }, " (audited)") : null)))), + h("p", { class: "small muted" }, "CLI: ", h("code", null, "jarl cert export " + c.name + " --dir DIR"))), [{ label: "Close", value: null }]); +} + +// fetchPEM gets one part of a certificate, after asking when it holds the +// private key (verb: "Download" or "Copy"). null when not confirmed. +async function fetchPEM(c, part, verb) { + if ((part === "key" || part === "bundle") && !await confirmDialog(verb + " the private key", + "Whoever has the key of " + c.name + " can pose as " + (c.names || c.domains || []).join(", ") + " until the certificate expires. Keep it as safe as the server keeps it" + + (verb === "Copy" ? ", and clear it from the clipboard when you're done (clipboard managers keep a history)" : "") + ". This is recorded in the audit log.", verb)) return null; + const resp = await fetch("/api/v1/certificates/" + encodeURIComponent(c.name) + "/pem?part=" + part, { credentials: "same-origin" }); + if (!resp.ok) { + let msg = "HTTP " + resp.status; + try { msg = (await resp.json()).error.message; } catch { /* not JSON */ } + throw new Error(msg); + } + return resp.text(); +} + +async function copyPEM(c, part, file) { + try { + const text = await fetchPEM(c, part, "Copy"); + if (text === null) return; + if (!navigator.clipboard) throw new Error("the browser allows copying only over HTTPS (or from localhost); download it instead"); + await navigator.clipboard.writeText(text); + toast("Copied " + file); + } catch (e) { toast("Copy failed: " + e.message, "err", 8000); } +} + +async function download(c, part, file) { + try { + const text = await fetchPEM(c, part, "Download"); + if (text === null) return; + const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "application/x-pem-file" })), download: c.name + "-" + file }); + document.body.append(a); + a.click(); + a.remove(); + } catch (e) { toast(e.message, "err", 8000); } +} + async function issue(c) { try { const r = await api("POST", "/certificates/" + encodeURIComponent(c.name) + "/issue", { body: {} }); diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index 1e3e3db..77c4fa4 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -67,7 +67,12 @@ export async function render(el, params, query) { h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" }, plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ", - h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them"))) : null; + h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them")), + h("ul", { class: "small ptr-missing" }, (zone.ptr_missing_list || []).map((f) => h("li", null, + h("span", { class: "mono" }, f.address), " → ", h("span", { class: "mono" }, display(f.target)), + f.from ? h("span", { class: "muted" }, " (from view " + f.from + ")") : null, + f.others && f.others.length ? h("span", { class: "muted" }, " (also " + f.others.map(display).join(", ") + ")") : null)), + zone.ptr_missing > (zone.ptr_missing_list || []).length ? h("li", { class: "muted" }, "and " + (zone.ptr_missing - zone.ptr_missing_list.length) + " more") : null)) : null; const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))); // The header and tabs show at once. Below them, switching tabs keeps the // previous tab's content (faded) until the new one is loaded, rather