package server import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "io" "log/slog" "math/big" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "jarl/internal/auth" "jarl/internal/certs" "jarl/internal/model" ) // selfSigned writes a self-signed certificate for names into dir. func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile string) { t.Helper() key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(30 * 24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { t.Fatal(err) } kd, _ := x509.MarshalPKCS8PrivateKey(key) certFile, keyFile = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem") os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600) os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600) return } func withCerts(env *apiEnv) *certs.Manager { quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet) env.srv.cfg.Certs = m return m } func TestCertificatesAPI(t *testing.T) { env := newAPIEnv(t) m := withCerts(env) certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com") // A user who isn't an administrator sees nothing. hash, _ := auth.HashPassword("pw-pw-pw-pw") u := &model.User{Username: "ops", PasswordHash: hash, Grants: []model.Grant{{Resource: "*", Access: "write"}}} env.st.CreateUser(context.Background(), u) tok, pid, sh := auth.NewToken() env.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: "t", PublicID: pid, SecretHash: sh}) env.do("GET", "/certificates", tok, nil, 403) out := env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "source": "file", "cert_file": certFile, "key_file": keyFile, "uses": []string{"web"}}, 201) c := out["certificate"].(map[string]any) if c["serving"] != true || c["status"].(map[string]any)["state"] != "ok" || c["domains"].([]any)[0] != "jarl.example.com" { t.Errorf("created: %v", c) } if _, has := c["key_pem"]; has { t.Error("the key left the server") } if m.WebCertificate() == nil { t.Error("not serving") } env.do("POST", "/certificates", env.token, map[string]any{"name": "own", "domains": []string{"a.example.com"}}, 409) env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "domains": []string{"a.example.com"}, "uses": []string{"web"}}, 400) env.do("POST", "/certificates", env.token, map[string]any{"name": "x", "source": "file", "cert_file": "/nonexistent.pem", "key_file": keyFile}, 400) list := env.do("GET", "/certificates", env.token, nil, 200) if n := len(list["certificates"].([]any)); n != 1 || list["account"].(map[string]any)["directory"] != "https://acme.invalid/dir" { t.Errorf("list: %v", list) } env.do("PATCH", "/certificates/own", env.token, map[string]any{"uses": []string{"dns"}}, 200) if m.WebCertificate() != nil { t.Error("still serving after the web use was taken off") } got := env.do("GET", "/certificates/own", env.token, nil, 200) if files, ok := got["files"].(map[string]any); !ok || !strings.HasSuffix(files["key"].(string), "/own/privkey.pem") { t.Errorf("files: %v", got["files"]) } env.do("POST", "/certificates/own/issue", env.token, map[string]any{}, 200) env.do("DELETE", "/certificates/own", env.token, nil, 200) env.do("GET", "/certificates/own", env.token, nil, 404) } func TestHTTPSAndPlainOnOnePort(t *testing.T) { env := newAPIEnv(t) certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.example.com") s := env.srv cr, err := newCertReloader(certFile, keyFile, s.log) if err != nil { t.Fatal(err) } s.certs = cr ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } cfg := &tls.Config{GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}} hs := &http.Server{Handler: s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.TLS != nil { io.WriteString(w, "tls "+r.Proto) } else { io.WriteString(w, "plain") } }))} go hs.Serve(newSniffListener(ln, cfg)) t.Cleanup(func() { hs.Close() }) addr := ln.Addr().String() get := func(c *http.Client, url string) string { t.Helper() resp, err := c.Get(url) if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } tlsClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, ForceAttemptHTTP2: true}} if got := get(tlsClient, "https://"+addr+"/"); got != "tls HTTP/2.0" { t.Errorf("HTTPS: %q", got) } // Loopback may still use plain HTTP (tools on the server itself). if got := get(http.DefaultClient, "http://"+addr+"/"); got != "plain" { t.Errorf("plain from loopback: %q", got) } // From elsewhere: browsers are redirected, API calls refused. h := s.requireHTTPS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "served") })) req := httptest.NewRequest("GET", "http://dns.example.com:8053/zones?x=1", nil) req.RemoteAddr = "192.0.2.10:5555" rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != "https://dns.example.com:8053/zones?x=1" { t.Errorf("GET from elsewhere: %d %s", rec.Code, rec.Header().Get("Location")) } req = httptest.NewRequest("POST", "http://dns.example.com:8053/api/v1/records", strings.NewReader("{}")) req.RemoteAddr = "192.0.2.10:5555" req.Header.Set("Authorization", "Bearer x") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "https_required") || !strings.Contains(rec.Body.String(), "rotate the token") { t.Errorf("POST from elsewhere: %d %s", rec.Code, rec.Body.String()) } // Without any certificate, plain HTTP is served as before. s.certs = nil rec = httptest.NewRecorder() req = httptest.NewRequest("GET", "http://dns.example.com:8053/", nil) req.RemoteAddr = "192.0.2.10:5555" h.ServeHTTP(rec, req) if rec.Body.String() != "served" { t.Errorf("no certificate: %d %s", rec.Code, rec.Body.String()) } }