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
@@ -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
|
||||
|
||||
+88
-2
@@ -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{
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+26
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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