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") } }