TLS certificates: Jarl obtains and renews them over ACME
Jarl is now an ACME client (lego's core, no DNS providers). It answers DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while Porkbun serves the domain; "auto" decides from the domain's public name servers at every renewal, and a CNAME on _acme-challenge is followed. Certificates are used for jarld's HTTPS (served without a restart; each port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted proxies still served) and/or written for named's DNS over TLS/HTTPS, with rndc reconfig on renewal. Renewal follows the CA's ARI window or two thirds of the lifetime, with back-off. Certificates you renew yourself can be used too. Certificates page and dashboard card, jarl cert, /api/v1/certificates, Prometheus gauges, docs/certificates.md, and an e2e section against Pebble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88965096bb
commit
88f63ffb0a
43 files changed
+4584
-45
No files matched your search
@@ -0,0 +1,167 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user