package certs import ( "context" "encoding/json" "net" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/miekg/dns" "steward/internal/config" "steward/internal/jarl" ) // fakeJarl is Jarl's API as far as Steward uses it, and the DNS server // that serves what it writes (both the "public resolver" and the zone's // name server). type fakeJarl struct { mu sync.Mutex txt map[string][]string // name -> values canWrite bool token string served bool // the DNS server serves what is written zones []jarl.Zone writes int dryRuns int nsHasIPv6 bool } func (f *fakeJarl) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer "+f.token { w.WriteHeader(401) w.Write([]byte(`{"error":{"code":"unauthenticated","message":"invalid API token: unknown token"}}`)) return } switch r.URL.Path { case "/api/v1/auth/whoami": w.Write([]byte(`{"user":{"username":"steward"},"build":{"label":"v0.6.1"}}`)) case "/api/v1/zones": json.NewEncoder(w).Encode(f.zones) case "/api/v1/apply": var b struct { Ops []struct { Op string `json:"op"` Data map[string]any `json:"data"` } `json:"ops"` DryRun bool `json:"dry_run"` } json.NewDecoder(r.Body).Decode(&b) if !f.canWrite { w.WriteHeader(403) w.Write([]byte(`{"error":{"code":"forbidden","message":"permission denied: this needs records:write"}}`)) return } f.mu.Lock() defer f.mu.Unlock() if b.DryRun { f.dryRuns++ w.Write([]byte(`{}`)) return } f.writes++ for _, op := range b.Ops { name, v := op.Data["name"].(string), op.Data["data"].(string) var keep []string for _, x := range f.txt[name] { if x != v { keep = append(keep, x) } } if op.Op == "record.create" { keep = append(keep, v) } f.txt[name] = keep } w.Write([]byte(`{}`)) default: w.WriteHeader(404) } } func (f *fakeJarl) dns(w dns.ResponseWriter, r *dns.Msg) { m := new(dns.Msg) m.SetReply(r) q := r.Question[0] hdr := dns.RR_Header{Name: q.Name, Class: dns.ClassINET, Ttl: 60} switch q.Qtype { case dns.TypeNS: if q.Name == "adbraun.test." { hdr.Rrtype = dns.TypeNS m.Answer = append(m.Answer, &dns.NS{Hdr: hdr, Ns: "ns1.adbraun.test."}) } else { m.Rcode = dns.RcodeNameError } case dns.TypeA: hdr.Rrtype = dns.TypeA m.Answer = append(m.Answer, &dns.A{Hdr: hdr, A: net.ParseIP("127.0.0.1")}) case dns.TypeAAAA: // An IPv6 address nothing answers on (the check must not fail // because of it). if f.nsHasIPv6 { hdr.Rrtype = dns.TypeAAAA m.Answer = append(m.Answer, &dns.AAAA{Hdr: hdr, AAAA: net.ParseIP("100::1")}) } case dns.TypeCNAME: if q.Name == "_acme-challenge.dhcp.other.test." { hdr.Rrtype = dns.TypeCNAME m.Answer = append(m.Answer, &dns.CNAME{Hdr: hdr, Target: "dhcp.acme.adbraun.test."}) } case dns.TypeTXT: f.mu.Lock() if f.served { for _, v := range f.txt[q.Name] { hdr.Rrtype = dns.TypeTXT m.Answer = append(m.Answer, &dns.TXT{Hdr: hdr, Txt: []string{v}}) } } f.mu.Unlock() } w.WriteMsg(m) } func setup(t *testing.T, f *fakeJarl, edit func(a *config.ACME)) (*Manager, string) { t.Helper() pc, err := net.ListenPacket("udp", "127.0.0.1:0") if err != nil { t.Fatal(err) } srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(f.dns)} go srv.ActivateAndServe() t.Cleanup(func() { srv.Shutdown() }) hs := httptest.NewServer(f) t.Cleanup(hs.Close) addr := pc.LocalAddr().String() _, port, _ := net.SplitHostPort(addr) oldPort, oldWait, oldPoll := nsPort, propagationWait, pollEvery nsPort, propagationWait, pollEvery = port, 2*time.Second, 100*time.Millisecond t.Cleanup(func() { nsPort, propagationWait, pollEvery = oldPort, oldWait, oldPoll }) a := config.ACME{Domain: "dhcp.adbraun.test", Resolvers: []string{addr}, CheckPropagation: true} if edit != nil { edit(&a) } jc, _ := jarl.New(hs.URL, "steward_good", "", false) m := &Manager{cfg: Config{ACME: a}, jarl: jc, now: time.Now} return m, addr } func steps(c *Check) string { var out []string for _, s := range c.Steps { out = append(out, s.Name+"="+s.Status) } return strings.Join(out, " ") } func newFake() *fakeJarl { return &fakeJarl{txt: map[string][]string{}, canWrite: true, token: "steward_good", served: true, zones: []jarl.Zone{{Name: "adbraun.test.", View: "internal", LinkGroup: 7}, {Name: "adbraun.test.", View: "external", LinkGroup: 7}, {Name: "other.test.", View: "internal"}}} } func TestPreflightOK(t *testing.T) { f := newFake() f.nsHasIPv6 = true m, _ := setup(t, f, nil) c := m.Preflight(context.Background()) want := "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=ok Public resolvers=ok Clean up=ok" if !c.OK || steps(c) != want { t.Fatalf("got %s\n%+v", steps(c), c.Steps) } if !strings.Contains(c.Steps[1].Detail, "views internal, external") { t.Errorf("zone step: %s", c.Steps[1].Detail) } // Linked zones are written once; the test record is gone afterwards. if f.dryRuns != 1 || f.writes != 2 || len(f.txt["_acme-challenge.dhcp.adbraun.test."]) != 0 { t.Errorf("dry runs %d writes %d records %v", f.dryRuns, f.writes, f.txt) } } func TestPreflightFailures(t *testing.T) { cases := []struct { name string edit func(f *fakeJarl, a *config.ACME) want string text string }{ {"bad token", func(f *fakeJarl, a *config.ACME) { f.token = "other" }, "Jarl=fail", "check jarl.token"}, {"no zone", func(f *fakeJarl, a *config.ACME) { a.Domain = "dhcp.example.net" }, "Jarl=ok Zone=fail", "no primary zone the token can see"}, {"no permission", func(f *fakeJarl, a *config.ACME) { f.canWrite = false }, "Jarl=ok Zone=ok Permission=fail", "records:write zones=adbraun.test names=_acme-challenge.dhcp.adbraun.test types=TXT"}, {"not served", func(f *fakeJarl, a *config.ACME) { f.served = false }, "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=fail Clean up=ok", "still don't serve it"}, {"not delegated", func(f *fakeJarl, a *config.ACME) { f.zones[0].Name, f.zones[1].Name = "test.", "test." }, "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=ok Clean up=ok", "know no name servers for test"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := newFake() var am *config.ACME m, _ := setup(t, f, func(a *config.ACME) { am = a }) tc.edit(f, am) m.cfg.ACME = *am c := m.Preflight(context.Background()) if c.OK || steps(c) != tc.want { t.Fatalf("got %s, want %s\n%+v", steps(c), tc.want, c.Steps) } all := "" for _, s := range c.Steps { all += s.Detail + "\n" } if !strings.Contains(all, tc.text) { t.Errorf("details lack %q:\n%s", tc.text, all) } }) } } func TestResolversOnlyWarnWhenNameServersServe(t *testing.T) { f := newFake() m, _ := setup(t, f, nil) // The "public resolver" is another server that never sees the record. pc, _ := net.ListenPacket("udp", "127.0.0.1:0") srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { mm := new(dns.Msg) mm.SetReply(r) if r.Question[0].Qtype == dns.TypeTXT { mm.Rcode = dns.RcodeNameError } else { f.dns(w, r) return } w.WriteMsg(mm) })} go srv.ActivateAndServe() defer srv.Shutdown() m.cfg.ACME.Resolvers = []string{pc.LocalAddr().String()} c := m.Preflight(context.Background()) if !c.OK || !strings.Contains(steps(c), "Public resolvers=warn") { t.Fatalf("got %s %+v", steps(c), c.Steps) } } func TestPreflightFollowsCNAME(t *testing.T) { f := newFake() m, _ := setup(t, f, func(a *config.ACME) { a.Domain = "dhcp.other.test" }) c := m.Preflight(context.Background()) if !c.OK || !strings.Contains(c.Steps[2].Detail, "dhcp.acme.adbraun.test") { t.Fatalf("got %s %+v", steps(c), c.Steps) } } func TestZonesFor(t *testing.T) { zs := []jarl.Zone{{Name: "lol.", View: "a"}, {Name: "adbraun.lol.", View: "internal", LinkGroup: 3}, {Name: "adbraun.lol.", View: "external", LinkGroup: 3}, {Name: "adbraun.lol.", View: "guest"}, {Name: "notadbraun.lol.", View: "internal"}} got := jarl.ZonesFor("_acme-challenge.dhcp.adbraun.lol.", zs, "") if len(got) != 2 || got[0].View != "internal" || got[1].View != "guest" { t.Errorf("got %+v", got) } if n := jarl.ChallengeName("*.Dhcp.adbraun.lol."); n != "_acme-challenge.dhcp.adbraun.lol." { t.Errorf("challenge name %q", n) } }