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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user