- 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>
204 lines
8.3 KiB
Go
204 lines
8.3 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"io"
|
|
"log/slog"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"jarl/internal/auth"
|
|
"jarl/internal/certs"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// selfSigned writes a self-signed certificate for names into dir.
|
|
func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile string) {
|
|
t.Helper()
|
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
|
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(30 * 24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
|
certFile, keyFile = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
|
os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
|
|
os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
|
|
return
|
|
}
|
|
|
|
func withCerts(env *apiEnv) *certs.Manager {
|
|
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
|
|
env.srv.cfg.Certs = m
|
|
return m
|
|
}
|
|
|
|
func TestCertificatesAPI(t *testing.T) {
|
|
env := newAPIEnv(t)
|
|
m := withCerts(env)
|
|
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
|
|
|
// A user who isn't an administrator sees nothing.
|
|
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})
|
|
env.do("GET", "/certificates", tok, nil, 403)
|
|
|
|
out := env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "source": "file", "cert_file": certFile, "key_file": keyFile, "uses": []string{"web"}}, 201)
|
|
c := out["certificate"].(map[string]any)
|
|
if c["serving"] != true || c["status"].(map[string]any)["state"] != "ok" || c["domains"].([]any)[0] != "jarl.example.com" {
|
|
t.Errorf("created: %v", c)
|
|
}
|
|
if _, has := c["key_pem"]; has {
|
|
t.Error("the key left the server")
|
|
}
|
|
if m.WebCertificate() == nil {
|
|
t.Error("not serving")
|
|
}
|
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "domains": []string{"a.example.com"}}, 409)
|
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "domains": []string{"a.example.com"}, "uses": []string{"web"}}, 400)
|
|
env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "source": "file", "cert_file": "/nonexistent.pem", "key_file": keyFile}, 400)
|
|
list := env.do("GET", "/certificates", env.token, nil, 200)
|
|
if n := len(list["certificates"].([]any)); n != 1 || list["account"].(map[string]any)["directory"] != "https://acme.invalid/dir" {
|
|
t.Errorf("list: %v", list)
|
|
}
|
|
env.do("PATCH", "/certificates/own", env.token, map[string]any{"uses": []string{"dns"}}, 200)
|
|
if m.WebCertificate() != nil {
|
|
t.Error("still serving after the web use was taken off")
|
|
}
|
|
got := env.do("GET", "/certificates/own", env.token, nil, 200)
|
|
if files, ok := got["files"].(map[string]any); !ok || !strings.HasSuffix(files["key"].(string), "/own/privkey.pem") {
|
|
t.Errorf("files: %v", got["files"])
|
|
}
|
|
env.do("POST", "/certificates/own/issue", env.token, map[string]any{}, 200)
|
|
env.do("DELETE", "/certificates/own", env.token, nil, 200)
|
|
env.do("GET", "/certificates/own", env.token, nil, 404)
|
|
}
|
|
|
|
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
|
env := newAPIEnv(t)
|
|
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com")
|
|
s := env.srv
|
|
cr, err := newCertReloader(certFile, keyFile, s.log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.certs = cr
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &tls.Config{GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
|
hs := &http.Server{Handler: s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.TLS != nil {
|
|
io.WriteString(w, "tls "+r.Proto)
|
|
} else {
|
|
io.WriteString(w, "plain")
|
|
}
|
|
}))}
|
|
go hs.Serve(newSniffListener(ln, cfg))
|
|
t.Cleanup(func() { hs.Close() })
|
|
addr := ln.Addr().String()
|
|
|
|
get := func(c *http.Client, url string) string {
|
|
t.Helper()
|
|
resp, err := c.Get(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return string(b)
|
|
}
|
|
tlsClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, ForceAttemptHTTP2: true}}
|
|
if got := get(tlsClient, "https://"+addr+"/"); got != "tls HTTP/2.0" {
|
|
t.Errorf("HTTPS: %q", got)
|
|
}
|
|
// Loopback may still use plain HTTP (tools on the server itself).
|
|
if got := get(http.DefaultClient, "http://"+addr+"/"); got != "plain" {
|
|
t.Errorf("plain from loopback: %q", got)
|
|
}
|
|
|
|
// From elsewhere: browsers are redirected, API calls refused.
|
|
h := s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "served") }))
|
|
req := httptest.NewRequest("GET", "http://dns.example.com:8053/zones?x=1", nil)
|
|
req.RemoteAddr = "192.0.2.10:5555"
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != "https://dns.example.com:8053/zones?x=1" {
|
|
t.Errorf("GET from elsewhere: %d %s", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
req = httptest.NewRequest("POST", "http://dns.example.com:8053/api/v1/records", strings.NewReader("{}"))
|
|
req.RemoteAddr = "192.0.2.10:5555"
|
|
req.Header.Set("Authorization", "Bearer x")
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "https_required") || !strings.Contains(rec.Body.String(), "rotate the token") {
|
|
t.Errorf("POST from elsewhere: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
// Without any certificate, plain HTTP is served as before.
|
|
s.certs = nil
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest("GET", "http://dns.example.com:8053/", nil)
|
|
req.RemoteAddr = "192.0.2.10:5555"
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Body.String() != "served" {
|
|
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)
|
|
}
|
|
}
|