package certs import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "io" "log/slog" "math/big" "net" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/miekg/dns" "jarl/internal/auth" "jarl/internal/engine" "jarl/internal/model" "jarl/internal/porkbun" "jarl/internal/render" "jarl/internal/store" ) // fakeDNS answers like the internet would: zones with their public name // servers, CNAME records, and TXT records (Porkbun's, from the fake API). type fakeDNS struct { mu sync.Mutex zones map[string][]string // zone -> NS cnames map[string]string txt func(name string) []string addr string } func newFakeDNS(t *testing.T) *fakeDNS { f := &fakeDNS{zones: map[string][]string{}, cnames: map[string]string{}, txt: func(string) []string { return nil }} pc, err := net.ListenPacket("udp", "127.0.0.1:0") if err != nil { t.Fatal(err) } srv := &dns.Server{PacketConn: pc, Handler: f} go srv.ActivateAndServe() t.Cleanup(func() { srv.Shutdown() }) f.addr = pc.LocalAddr().String() return f } func (f *fakeDNS) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { f.mu.Lock() defer f.mu.Unlock() m := new(dns.Msg) m.SetReply(r) q := r.Question[0] name := strings.ToLower(q.Name) zone := "" for z := range f.zones { if dns.IsSubDomain(z, name) && len(z) > len(zone) { zone = z } } if zone == "" { m.Rcode = dns.RcodeNameError w.WriteMsg(m) return } soa := &dns.SOA{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 60}, Ns: f.zones[zone][0], Mbox: "h." + zone, Serial: 1, Refresh: 1, Retry: 1, Expire: 1, Minttl: 60} switch { case q.Qtype == dns.TypeSOA && name == zone: m.Answer = append(m.Answer, soa) case q.Qtype == dns.TypeNS && name == zone: for _, ns := range f.zones[zone] { m.Answer = append(m.Answer, &dns.NS{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 60}, Ns: ns}) } case f.cnames[name] != "": m.Answer = append(m.Answer, &dns.CNAME{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: f.cnames[name]}) case q.Qtype == dns.TypeTXT && len(f.txt(name)) > 0: for _, v := range f.txt(name) { m.Answer = append(m.Answer, &dns.TXT{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, Txt: []string{v}}) } default: m.Ns = append(m.Ns, soa) } w.WriteMsg(m) } type env struct { t *testing.T m *Manager st *store.Store eng *engine.Engine dns *fakeDNS pb *porkbun.Fake dir string } // newEnv sets up Jarl with zone example.com (name server // ns1.example.com, which the internet also uses), a fake internet and a // fake Porkbun account with pb.test. func newEnv(t *testing.T, withPorkbun bool) *env { dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "jarl.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) eng := engine.New(st, nil, engine.Config{ Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}, StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true, }, quiet) e := &env{t: t, st: st, eng: eng, dns: newFakeDNS(t), dir: dir} e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}), engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com")})) e.dns.mu.Lock() e.dns.zones["example.com."] = []string{"ns1.example.net."} e.dns.zones["pb.test."] = []string{"curitiba.ns.porkbun.com.", "fortaleza.ns.porkbun.com."} e.dns.zones["other.test."] = []string{"a.ns.cloudflare.com."} e.dns.mu.Unlock() cfg := Config{Resolvers: []string{e.dns.addr}, Dir: filepath.Join(dir, "tls"), Directory: "https://acme.invalid/dir"} if withPorkbun { e.pb = porkbun.NewFake("pk1_test", "sk1_test", "pb.test") ts := httptest.NewServer(e.pb) t.Cleanup(ts.Close) cfg.Porkbun = &porkbun.Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_test", Secret: "sk1_test"} e.dns.mu.Lock() e.dns.txt = func(name string) []string { return e.pb.TXT(name) } e.dns.mu.Unlock() } e.m = New(cfg, st, eng, quiet) // lego's own lookups (CNAME following) use the fake internet too. dns01.AddRecursiveNameservers([]string{e.dns.addr})(nil) return e } func (e *env) submit(reqs ...engine.Request) { e.t.Helper() if _, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{}); err != nil { e.t.Fatal(err) } } func (e *env) state() *model.State { st, err := e.st.LoadState(context.Background()) if err != nil { e.t.Fatal(err) } return st } func TestRoute(t *testing.T) { e := newEnv(t, true) ctx := context.Background() e.dns.mu.Lock() e.dns.cnames["_acme-challenge.www.other.test."] = "www-other.acme.example.com." e.dns.mu.Unlock() cases := []struct { challenge, domain string route string // "" for an error target string errHas string }{ {"auto", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""}, {"auto", "*.example.com", "jarl", "_acme-challenge.example.com.", ""}, {"auto", "ns1.pb.test", "porkbun", "_acme-challenge.ns1.pb.test.", ""}, {"auto", "www.other.test", "jarl", "www-other.acme.example.com.", ""}, {"auto", "mail.other.test", "", "", "Point _acme-challenge.mail.other.test with a CNAME"}, {"porkbun", "www.example.com", "", "", "not Porkbun"}, {"jarl", "ns1.pb.test", "", "", "no primary zone in Jarl is pb.test"}, {"jarl", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""}, } for _, c := range cases { r := e.m.route(ctx, e.state(), c.challenge, c.domain, "") if r.Route != c.route || c.target != "" && r.Target != c.target || c.errHas != "" && !strings.Contains(r.Error, c.errHas) { t.Errorf("%s %s: route %q target %q error %q", c.challenge, c.domain, r.Route, r.Target, r.Error) } } // The move from Porkbun to Jarl: while the internet still asks // Porkbun, auto goes there even though Jarl has the zone; once the // name servers change, it comes to Jarl. e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("pb.test")})) if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "porkbun" { t.Errorf("before the move: %+v", r) } if r := e.m.route(ctx, e.state(), "jarl", "ns1.pb.test", ""); !strings.Contains(r.Error, "not by the name servers in Jarl's zone (ns1.example.net)") { t.Errorf("jarl before the move: %+v", r) } e.dns.mu.Lock() e.dns.zones["pb.test."] = []string{"ns1.example.net."} e.dns.mu.Unlock() if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "jarl" { t.Errorf("after the move: %+v", r) } if r := e.m.route(ctx, e.state(), "porkbun", "ns1.pb.test", ""); !strings.Contains(r.Error, "not Porkbun") { t.Errorf("porkbun after the move: %+v", r) } // Without a Porkbun key, a Porkbun domain says what is missing. e2 := newEnv(t, false) if r := e2.m.route(ctx, e2.state(), "auto", "ns1.pb.test", ""); !strings.Contains(r.Error, "no porkbun.api_key") { t.Errorf("without a key: %+v", r) } } func TestProvider(t *testing.T) { e := newEnv(t, true) ctx := context.Background() c := &model.Certificate{Name: "t", Challenge: "auto"} p := &provider{m: e.m, ctx: ctx, cert: c, cleanups: map[string]func() error{}} // Porkbun: the record goes in through the API and is removed again. info := dns01.GetChallengeInfo("ns1.pb.test", "key-auth-1") if err := p.Present("ns1.pb.test", "tok", "key-auth-1"); err != nil { t.Fatal(err) } if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 1 || got[0] != info.Value { t.Fatalf("Porkbun TXT = %v, want %s", got, info.Value) } if err := p.CleanUp("ns1.pb.test", "tok", "key-auth-1"); err != nil { t.Fatal(err) } if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 0 { t.Fatalf("Porkbun TXT after cleanup = %v", got) } // Jarl: the record goes into the zone, in a changeset by the system. txt := func() []string { var out []string st := e.state() for _, r := range st.Records { if r.Type == "TXT" && r.Name == "_acme-challenge.www.example.com." { out = append(out, r.Data) } } return out } info = dns01.GetChallengeInfo("www.example.com", "key-auth-2") if err := p.Present("www.example.com", "tok", "key-auth-2"); err != nil { t.Fatal(err) } if got := txt(); len(got) != 1 || !strings.Contains(got[0], info.Value) { t.Fatalf("Jarl TXT = %v, want %s", got, info.Value) } if err := p.CleanUp("www.example.com", "tok", "key-auth-2"); err != nil { t.Fatal(err) } if got := txt(); len(got) != 0 { t.Fatalf("Jarl TXT after cleanup = %v", got) } if len(p.routes) != 2 || p.routes[0].Route != "porkbun" || p.routes[1].Route != "jarl" { t.Errorf("routes = %+v", p.routes) } // A name nobody can answer for fails before anything is published. if err := p.Present("mail.other.test", "tok", "k"); err == nil || !strings.Contains(err.Error(), "CNAME") { t.Errorf("other.test: %v", err) } } // makeCert returns a certificate for names (signed by a CA unless // selfSigned) and its key, as PEM; withCA appends the CA certificate. func makeCert(t *testing.T, names []string, notBefore, notAfter time.Time, withCA bool) (certPEM, keyPEM []byte) { t.Helper() caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA", Organization: []string{"Jarl"}}, NotBefore: notBefore, NotAfter: notAfter.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} caDER, _ := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey) caCert, _ := x509.ParseCertificate(caDER) key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) leaf := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names, NotBefore: notBefore, NotAfter: notAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} der, err := x509.CreateCertificate(rand.Reader, leaf, caCert, &key.PublicKey, caKey) if err != nil { t.Fatal(err) } certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) if withCA { certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...) } kd, _ := x509.MarshalPKCS8PrivateKey(key) return certPEM, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}) } func TestInspect(t *testing.T) { now := time.Now() cert, key := makeCert(t, []string{"ns1.example.com", "*.example.com"}, now, now.Add(90*24*time.Hour), true) in, pair, err := Inspect(cert, key) if err != nil || pair == nil { t.Fatal(err) } if strings.Join(in.Names, " ") != "ns1.example.com *.example.com" || in.Issuer != "Jarl Test CA" || in.KeyType != "ec256" || len(in.Warnings) != 0 { t.Errorf("info = %+v", in) } leafOnly, _ := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false) _, otherKey := makeCert(t, []string{"b.example.com"}, now, now.Add(time.Hour), false) if _, _, err := Inspect(cert, otherKey); err == nil || !strings.Contains(err.Error(), "doesn't belong") { t.Errorf("mismatched key: %v", err) } leafCert, leafKey := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false) if in, _, err := Inspect(leafCert, leafKey); err != nil || len(in.Warnings) != 1 || !strings.Contains(in.Warnings[0], "full chain") { t.Errorf("leaf only: %+v %v", in, err) } _ = leafOnly if !Covers([]string{"*.example.com"}, []string{"www.example.com"}) || Covers([]string{"*.example.com"}, []string{"a.b.example.com"}) || Covers([]string{"example.com"}, []string{"www.example.com"}) { t.Error("Covers") } } func TestDueAndStatus(t *testing.T) { m := &Manager{now: time.Now} now := time.Now().UTC().Truncate(time.Second) nb, na := now.Add(-80*24*time.Hour), now.Add(10*24*time.Hour) c := &model.Certificate{Source: model.CertSourceACME, CreatedAt: now.Add(-time.Hour)} if got := m.Due(c); !got.Equal(c.CreatedAt) { t.Errorf("never obtained: due %v", got) } if s := m.StatusOf(c); s.State != "pending" { t.Errorf("pending: %+v", s) } c.CertPEM, c.NotBefore, c.NotAfter = "x", &nb, &na if got, want := m.Due(c), twoThirds(nb, na); !got.Equal(want) { t.Errorf("due %v, want two thirds %v", got, want) } ari := now.Add(time.Hour) c.RenewAt = &ari if got := m.Due(c); !got.Equal(ari) { t.Errorf("due %v, want the CA's %v", got, ari) } // 10 days left of 90 is less than a sixth: expiring. if s := m.StatusOf(c); s.State != "expiring" { t.Errorf("status %+v", s) } // After three failures it waits four hours from the last attempt. last := now.Add(-time.Hour) c.Failures, c.LastAttempt, c.LastError = 3, &last, "boom" if got := m.Due(c); !got.Equal(last.Add(4 * time.Hour)) { t.Errorf("backoff: due %v", got) } if s := m.StatusOf(c); s.State != "failing" || !strings.Contains(s.Message, "boom") { t.Errorf("failing: %+v", s) } c.Failures = 30 if got := m.Due(c); !got.Equal(last.Add(24 * time.Hour)) { t.Errorf("backoff is at most a day: due %v", got) } old := now.Add(-time.Hour) c.NotAfter = &old if s := m.StatusOf(c); s.State != "expired" { t.Errorf("expired: %+v", s) } } func TestInput(t *testing.T) { e := newEnv(t, false) ctx := context.Background() admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}} bad := []Input{ {Name: model.Ptr("Bad Name"), Domains: &[]string{"a.example.com"}}, {Name: model.Ptr("x"), Domains: &[]string{"localhost"}}, {Name: model.Ptr("x"), Domains: &[]string{"a.*.example.com"}}, {Name: model.Ptr("x"), Domains: &[]string{}}, {Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"mail"}}, {Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Challenge: model.Ptr("http")}, {Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, KeyType: model.Ptr("rsa1024")}, {Name: model.Ptr("x"), Source: model.Ptr("file"), CertFile: model.Ptr("rel.pem"), KeyFile: model.Ptr("/k.pem")}, {Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, CertFile: model.Ptr("/c.pem")}, } for _, in := range bad { if _, err := e.m.Create(ctx, in, admin); err == nil { t.Errorf("accepted %+v", in) } } c, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"WWW.Example.com.", "www.example.com", "*.example.com"}, Uses: &[]string{"web", "dns", "web"}}, admin) if err != nil { t.Fatal(err) } if strings.Join(c.Domains, " ") != "www.example.com *.example.com" || strings.Join(c.Uses, ",") != "dns,web" || c.Challenge != "auto" || c.KeyType != "ec256" { t.Errorf("normalized: %+v", c) } // Only one certificate serves jarld's HTTPS. if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web2"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"web"}}, admin); err == nil || !strings.Contains(err.Error(), "already used for jarld's HTTPS") { t.Errorf("second web certificate: %v", err) } if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"a.example.com"}}, admin); err != ErrConflict { t.Errorf("duplicate name: %v", err) } if _, err := e.m.Update(ctx, "web", Input{Name: model.Ptr("renamed")}, admin); err == nil { t.Error("rename accepted") } } func TestFileSource(t *testing.T) { e := newEnv(t, false) ctx := context.Background() admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}} now := time.Now() certFile, keyFile := filepath.Join(e.dir, "fullchain.pem"), filepath.Join(e.dir, "privkey.pem") cert, key := makeCert(t, []string{"ns1.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true) os.WriteFile(certFile, cert, 0o600) os.WriteFile(keyFile, key, 0o600) c, err := e.m.Create(ctx, Input{Name: model.Ptr("own"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"web", "dns"}}, admin) if err != nil { t.Fatal(err) } c, _ = e.m.Get(ctx, "own") if strings.Join(c.Domains, " ") != "ns1.example.com" || c.NotAfter == nil || c.CertPEM != "" { t.Errorf("read: %+v", c) } if e.m.WebCertificate() == nil || e.m.WebName() != "own" { t.Error("not served for HTTPS") } chain := filepath.Join(e.m.FilesDir("own"), ChainFile) if b, err := os.ReadFile(chain); err != nil || string(b) != string(cert) { t.Fatalf("files for named: %v", err) } if fi, err := os.Stat(filepath.Join(e.m.FilesDir("own"), KeyFile)); err != nil || fi.Mode().Perm() != 0o640 { t.Errorf("key file: %v %v", fi, err) } // Renewed by its owner: the loop notices and puts the new one in place. cert2, key2 := makeCert(t, []string{"ns1.example.com", "dns.example.com"}, now, now.Add(90*24*time.Hour), true) os.WriteFile(certFile, cert2, 0o600) os.WriteFile(keyFile, key2, 0o600) future := now.Add(time.Minute) os.Chtimes(certFile, future, future) e.m.Tick(ctx) c, _ = e.m.Get(ctx, "own") if strings.Join(c.Domains, " ") != "ns1.example.com dns.example.com" { t.Errorf("not reread: %v", c.Domains) } if b, _ := os.ReadFile(chain); string(b) != string(cert2) { t.Error("files for named not updated") } if got := e.m.WebCertificate(); got == nil || len(got.Leaf.DNSNames) != 2 { t.Error("HTTPS still serves the old certificate") } // A broken renewal keeps the last good certificate in place. os.WriteFile(keyFile, key, 0o600) os.Chtimes(keyFile, future.Add(time.Minute), future.Add(time.Minute)) e.m.Tick(ctx) c, _ = e.m.Get(ctx, "own") if !strings.Contains(c.LastError, "doesn't belong") || e.m.StatusOf(c).State != "failing" { t.Errorf("mismatch not reported: %+v", c) } if b, _ := os.ReadFile(chain); string(b) != string(cert2) { t.Error("files for named replaced by a broken pair") } // Without the dns use the files go; deleting stops HTTPS. if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil { // The pair is broken now; put the good key back first. t.Log(err) } os.WriteFile(keyFile, key2, 0o600) if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil { t.Fatal(err) } if _, err := os.Stat(e.m.FilesDir("own")); !os.IsNotExist(err) { t.Errorf("files for named left behind: %v", err) } if err := e.m.Delete(ctx, "own", admin); err != nil { t.Fatal(err) } if e.m.WebCertificate() != nil { t.Error("still serving a deleted certificate") } } func TestCleanLegoError(t *testing.T) { in := errors.New("error: one or more domains had a problem:\n[www.example.org] [www.example.org] acme: error presenting token: example.org is served by x, which Jarl can't write to\n[a.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: Incorrect TXT record \"abc\" found at _acme-challenge.a.example.com\n") want := `www.example.org: example.org is served by x, which Jarl can't write to; a.example.com: the CA refused (unauthorized): Incorrect TXT record "abc" found at _acme-challenge.a.example.com` if got := cleanLegoError(in).Error(); got != want { t.Errorf("got %s\nwant %s", got, want) } if got := cleanLegoError(errors.New("acme: error: 429 :: urn:ietf:params:acme:error:rateLimited :: too many")).Error(); got != "the CA refused (rateLimited): too many" { t.Errorf("rate limit: %s", got) } }