The web UI's certificate from Let's Encrypt, through Jarl
tls.acme names the certificate and jarl: the Jarl server and a token that may only write the challenge record. Steward gets the certificate with DNS-01 (lego), writing the record through Jarl's API into every view that has the zone, renews it with a third of its lifetime left and swaps it in without a restart. A check runs before every order: Jarl, the zone, the token's permission, a test record, the zone's name servers, public resolvers, clean-up; each step says what to fix. Staging by default. With a certificate, each listener serves HTTPS and plain HTTP on one port (redirecting plain HTTP, except from the server itself). steward cert / cert check / cert renew, Server -> Certificate in the web UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md and docs/jarl.md (the Jarl user and the token's grant). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d0ee2c8844
commit
7b3f0f764c
32 files changed
+2544
-27
No files matched your search
@@ -0,0 +1,266 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user