Certificate export, missing PTR list, simpler "Used for"
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
57012fece8
commit
b4bb99b482
16 files changed
+352
-12
No files matched your search
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user