jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
6.2 KiB
Go
174 lines
6.2 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"jarl/internal/auth"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// certUser creates a service user with grants and returns a token.
|
|
func certUser(t *testing.T, e *apiEnv, name string, grants ...string) (string, *model.User) {
|
|
t.Helper()
|
|
gs := []model.Grant{}
|
|
for _, s := range grants {
|
|
g, err := auth.ParseGrant(s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gs = append(gs, g)
|
|
}
|
|
u := &model.User{Username: name, Grants: gs}
|
|
if err := e.st.CreateUser(context.Background(), u); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, pid, sh := auth.NewToken()
|
|
if err := e.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: name + "-host", PublicID: pid, SecretHash: sh}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tok, u
|
|
}
|
|
|
|
// obtained gives a certificate a current certificate, as if the CA had
|
|
// signed it.
|
|
func obtained(t *testing.T, e *apiEnv, name string, domains ...string) {
|
|
t.Helper()
|
|
certFile, keyFile := selfSigned(t, t.TempDir(), domains...)
|
|
cb, _ := os.ReadFile(certFile)
|
|
kb, _ := os.ReadFile(keyFile)
|
|
ctx := context.Background()
|
|
for i := 0; i < 50 && e.srv.cfg.Certs.Issuing(name) != (time.Time{}); i++ {
|
|
time.Sleep(20 * time.Millisecond) // the failing attempt (no CA) ends
|
|
}
|
|
c, err := e.st.CertificateByName(ctx, name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
na, nb := time.Now().Add(30*24*time.Hour), time.Now()
|
|
c.CertPEM, c.KeyPEM, c.NotAfter, c.NotBefore, c.Serial, c.Failures, c.LastError = string(cb), string(kb), &na, &nb, "s-"+name, 0, ""
|
|
if err := e.st.UpdateCertificate(ctx, c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestCertGet(t *testing.T) {
|
|
e := newAPIEnv(t)
|
|
withCerts(e)
|
|
mx, mxUser := certUser(t, e, "mx", "certificates:write names=*.adbraun.lol")
|
|
other, _ := certUser(t, e, "other", "certificates:write names=*.adbraun.lol")
|
|
wide, _ := certUser(t, e, "wide", "*:write")
|
|
|
|
req := func(tok, name string, domains ...string) (map[string]any, int) {
|
|
b, status := e.req("POST", "/certificates/"+name+"/request", tok, map[string]any{"domains": domains})
|
|
var out map[string]any
|
|
_ = jsonUnmarshal(b, &out)
|
|
return out, status
|
|
}
|
|
// "*" doesn't cover certificates.
|
|
if _, st := req(wide, "mail", "mail.adbraun.lol"); st != 403 {
|
|
t.Errorf("*:write: %d", st)
|
|
}
|
|
// Names outside the grant.
|
|
if out, st := req(mx, "mail", "mail.example.org"); st != 403 || !strings.Contains(errMsg(out), "mail.example.org") {
|
|
t.Errorf("other names: %d %v", st, out)
|
|
}
|
|
// A new certificate, owned by mx.
|
|
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 && st != 202 || out["created"] != true {
|
|
t.Fatalf("create: %d %v", st, out)
|
|
}
|
|
c, _ := e.st.CertificateByName(context.Background(), "mail")
|
|
if c.OwnerID != mxUser.ID || len(c.Uses) != 0 {
|
|
t.Errorf("owner %d uses %v", c.OwnerID, c.Uses)
|
|
}
|
|
// Another user can't have the name, or see it.
|
|
if out, st := req(other, "mail", "mail.adbraun.lol"); st != 409 || !strings.Contains(errMsg(out), "taken") {
|
|
t.Errorf("taken: %d %v", st, out)
|
|
}
|
|
e.do("GET", "/certificates/mail", other, nil, 404)
|
|
if l := e.do("GET", "/certificates", other, nil, 200)["certificates"].([]any); len(l) != 0 {
|
|
t.Errorf("other's list: %v", l)
|
|
}
|
|
// Nothing to download until it is obtained.
|
|
e.do("GET", "/certificates/mail/pem", mx, nil, 400)
|
|
obtained(t, e, "mail", "mail.adbraun.lol")
|
|
|
|
// The same names: the same certificate.
|
|
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 || out["created"] != false || out["changed"] != false {
|
|
t.Errorf("again: %d %v", st, out)
|
|
}
|
|
if c2, _ := e.st.CertificateByName(context.Background(), "mail"); c2.Serial != "s-mail" {
|
|
t.Errorf("replaced: %v", c2.Serial)
|
|
}
|
|
// Download: recorded, with the token.
|
|
if b, st := e.req("GET", "/certificates/mail/pem?part=key", mx, nil); st != 200 || !strings.Contains(string(b), "PRIVATE KEY") {
|
|
t.Errorf("key: %d", st)
|
|
}
|
|
got := e.do("GET", "/certificates/mail", e.token, nil, 200)
|
|
pulls, _ := got["pulled_by"].([]any)
|
|
if got["owner"] != "mx" || len(pulls) != 1 || !strings.Contains(pulls[0].(map[string]any)["who"].(string), "mx-host") || pulls[0].(map[string]any)["serial"] != "s-mail" {
|
|
t.Errorf("owner %v pulled_by %v", got["owner"], got["pulled_by"])
|
|
}
|
|
// A name added: obtained again.
|
|
if out, st := req(mx, "mail", "mail.adbraun.lol", "smtp.adbraun.lol"); out["changed"] != true || st != 200 && st != 202 {
|
|
t.Errorf("SAN added: %d %v", st, out)
|
|
}
|
|
// The owner's own list.
|
|
if l := e.do("GET", "/certificates", mx, nil, 200)["certificates"].([]any); len(l) != 1 {
|
|
t.Errorf("mx's list: %v", l)
|
|
}
|
|
// Jarl's own certificates are the administrators'.
|
|
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.adbraun.lol")
|
|
e.do("POST", "/certificates", e.token, map[string]any{"name": "web", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
|
|
if _, st := req(mx, "web", "jarl.adbraun.lol"); st != 409 {
|
|
t.Errorf("Jarl's own: %d", st)
|
|
}
|
|
e.do("GET", "/certificates/web/pem", mx, nil, 404)
|
|
// The limit: one more for mx, then no more.
|
|
limit := 2
|
|
mxUser.CertLimit = &limit
|
|
if err := e.st.UpdateUser(context.Background(), mxUser); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, st := req(mx, "www", "www.adbraun.lol"); st != 200 && st != 202 {
|
|
t.Errorf("second: %d %v", st, out)
|
|
}
|
|
if out, st := req(mx, "third", "x.adbraun.lol"); st != 400 || !strings.Contains(errMsg(out), "limit") {
|
|
t.Errorf("over the limit: %d %v", st, out)
|
|
}
|
|
// The owner may delete theirs.
|
|
e.do("DELETE", "/certificates/www", mx, nil, 200)
|
|
}
|
|
|
|
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
|
|
|
// errMsg is an error answer's message.
|
|
func errMsg(out map[string]any) string {
|
|
e, _ := out["error"].(map[string]any)
|
|
m, _ := e["message"].(string)
|
|
return m
|
|
}
|
|
|
|
// A private key goes back only over HTTPS or to this server itself.
|
|
func TestKeyTransport(t *testing.T) {
|
|
e := newAPIEnv(t)
|
|
for addr, want := range map[string]bool{"127.0.0.1:5000": true, "[::1]:5000": true, "192.0.2.7:5000": false} {
|
|
r, _ := http.NewRequest("GET", "/x", nil)
|
|
r.RemoteAddr = addr
|
|
if ok, why := e.srv.keyTransport(r); ok != want {
|
|
t.Errorf("%s: %v (%s)", addr, ok, why)
|
|
}
|
|
}
|
|
r, _ := http.NewRequest("GET", "/x", nil)
|
|
r.RemoteAddr, r.TLS = "192.0.2.7:5000", &tls.ConnectionState{}
|
|
if ok, _ := e.srv.keyTransport(r); !ok {
|
|
t.Error("HTTPS refused")
|
|
}
|
|
}
|