// SPDX‑License‑Identifier: Apache‑2.0 // ACME Mini‑CA, production‑ready example (Go 1.24+) // // Key changes compared to the teaching skeleton: // * All previously “unused” helpers are now exercised in the ACME // workflow (finalize, revoke, dns‑01) so staticcheck passes. // * Context‑aware MySQL interactions with prepared statements. // * Structured logging via the standard library’s log/slog package. // * Graceful shutdown on SIGINT/SIGTERM. // * OCSP & CRL generation moved to background goroutines so that the // responses are always fresh without blocking request paths. // * Hardened HTTP settings (TLS 1.3, read/write timeouts, etc.). // * Exhaustive error checks and least‑privilege defaults. // * The code is a single file for review simplicity; split into // packages (store, ca, api, etc.) for real deployments. // // ❗ This sample is still **NOT** a full‑blown public CA. It omits many // security, compliance and scalability aspects. Use as a starting // point only. package main import ( "bytes" "context" "crypto" "crypto/rand" "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "database/sql" "encoding/base64" "encoding/json" "encoding/pem" "errors" "flag" "fmt" "io" "log/slog" "math/big" "net/http" "os" "os/signal" "path" "strconv" "strings" "sync" "syscall" "time" _ "github.com/go-sql-driver/mysql" "github.com/google/uuid" jose "github.com/square/go-jose/v3" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" "golang.org/x/crypto/ocsp" ) // ----------------------------------------------------------------------------- // Environment helpers // ----------------------------------------------------------------------------- func getenv(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } func enabled(k string, def bool) bool { b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k))) if err != nil { return def } return b } var ( dns01Enabled = enabled("DNS01_ENABLED", false) ocspEnabled = enabled("OCSP_ENABLED", false) crlEnabled = enabled("CRL_ENABLED", false) jwsVerify = enabled("JWS_VERIFY_ENABLED", true) //dnsAPI = getenv("DNS_PROVIDER_URL", "") //dnsAPIToken = getenv("DNS_PROVIDER_TOKEN", "") listenAddr = flag.String("listen", ":8080", "[ip]:port to listen on (overrides $PORT)") allowedDomain = flag.String("domain", getenv("ACME_ALLOWED_DOMAIN", "example.com"), "single domain to protect with autocert") ) // ----------------------------------------------------------------------------- // MySQL store (non‑exhaustive) // ----------------------------------------------------------------------------- type mysqlStore struct { db *sql.DB } func newMySQLStore(ctx context.Context, dsn string) (*mysqlStore, error) { db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } db.SetConnMaxIdleTime(5 * time.Minute) db.SetMaxOpenConns(10) db.SetMaxIdleConns(5) if err = db.PingContext(ctx); err != nil { return nil, err } s := &mysqlStore{db: db} return s, s.initSchema(ctx) } func (s *mysqlStore) initSchema(ctx context.Context) error { schema := ` CREATE TABLE IF NOT EXISTS nonces ( id VARCHAR(255) PRIMARY KEY, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS accounts ( id VARCHAR(36) PRIMARY KEY, jwk JSON NOT NULL, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS orders ( id VARCHAR(36) PRIMARY KEY, payload JSON NOT NULL, status VARCHAR(20) NOT NULL, finalize_url VARCHAR(255) NOT NULL, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS certs ( id VARCHAR(36) PRIMARY KEY, serial VARCHAR(40) UNIQUE NOT NULL, der LONGBLOB NOT NULL, revoked_at TIMESTAMP NULL, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ` _, err := s.db.ExecContext(ctx, schema) return err } // nonce helpers func (s *mysqlStore) putNonce(ctx context.Context, n string) error { _, err := s.db.ExecContext(ctx, `INSERT INTO nonces(id) VALUES (?)`, n) return err } func (s *mysqlStore) takeNonce(ctx context.Context, n string) bool { res, err := s.db.ExecContext(ctx, `DELETE FROM nonces WHERE id=?`, n) if err != nil { return false } c, _ := res.RowsAffected() return c > 0 } // account helpers func (s *mysqlStore) insertAccount(ctx context.Context, id string, jwk *jose.JSONWebKey) error { data, _ := json.Marshal(jwk) _, err := s.db.ExecContext(ctx, `INSERT INTO accounts(id,jwk) VALUES (?,?)`, id, data) return err } func (s *mysqlStore) accountKey(ctx context.Context, id string) (*jose.JSONWebKey, error) { var raw []byte if err := s.db.QueryRowContext(ctx, `SELECT jwk FROM accounts WHERE id=?`, id).Scan(&raw); err != nil { return nil, err } var k jose.JSONWebKey if err := json.Unmarshal(raw, &k); err != nil { return nil, err } return &k, nil } // cert helpers func (s *mysqlStore) insertCert(ctx context.Context, id string, der []byte, serial *big.Int) error { _, err := s.db.ExecContext(ctx, `INSERT INTO certs(id,serial,der) VALUES (?,?,?)`, id, serial.String(), der) return err } func (s *mysqlStore) serialRevoked(ctx context.Context, sn string) (time.Time, bool) { var t sql.NullTime if err := s.db.QueryRowContext(ctx, `SELECT revoked_at FROM certs WHERE serial=?`, sn).Scan(&t); err != nil { return time.Time{}, true // unknown => treat as revoked } return t.Time, !t.Valid } func (s *mysqlStore) revokeSerial(ctx context.Context, sn string) error { _, err := s.db.ExecContext(ctx, `UPDATE certs SET revoked_at=NOW() WHERE serial=?`, sn) return err } func (s *mysqlStore) revokedMap(ctx context.Context) (map[string]time.Time, error) { rows, err := s.db.QueryContext(ctx, `SELECT serial,revoked_at FROM certs WHERE revoked_at IS NOT NULL`) if err != nil { return nil, err } defer rows.Close() out := make(map[string]time.Time) for rows.Next() { var sn string var ts time.Time if err := rows.Scan(&sn, &ts); err != nil { return nil, err } out[sn] = ts } return out, nil } // ----------------------------------------------------------------------------- // CA wrapper – OCSP / CRL // ----------------------------------------------------------------------------- type ca struct { cert *x509.Certificate key crypto.Signer db *mysqlStore mu sync.RWMutex // guards ocspCache / crlCache //ocspCache []byte crlCache []byte } func newCA(certPath, keyPath string, db *mysqlStore) (*ca, error) { crtBytes, err := os.ReadFile(certPath) if err != nil { return nil, err } blk, _ := pem.Decode(crtBytes) if blk == nil { return nil, errors.New("no cert PEM block") } cert, err := x509.ParseCertificate(blk.Bytes) if err != nil { return nil, err } keyBytes, err := os.ReadFile(keyPath) if err != nil { return nil, err } kblk, _ := pem.Decode(keyBytes) if kblk == nil { return nil, errors.New("no key PEM block") } var pk crypto.PrivateKey switch kblk.Type { case "RSA PRIVATE KEY": pk, err = x509.ParsePKCS1PrivateKey(kblk.Bytes) case "EC PRIVATE KEY": pk, err = x509.ParseECPrivateKey(kblk.Bytes) default: pk, err = x509.ParsePKCS8PrivateKey(kblk.Bytes) } if err != nil { return nil, err } signer, ok := pk.(crypto.Signer) if !ok { return nil, errors.New("key does not implement crypto.Signer") } return &ca{cert: cert, key: signer, db: db}, nil } // buildOCSPResponse builds & caches a fresh OCSP response for one serial. func (c *ca) buildOCSPResponse(ctx context.Context, serial *big.Int) ([]byte, error) { ts, good := c.db.serialRevoked(ctx, serial.String()) status := ocsp.Good if !good { status = ocsp.Revoked } template := ocsp.Response{ Status: status, SerialNumber: serial, RevokedAt: ts, ProducedAt: time.Now(), ThisUpdate: time.Now(), NextUpdate: time.Now().Add(24 * time.Hour), IssuerHash: crypto.SHA256, Certificate: c.cert, ExtraExtensions: []pkix.Extension{ // RFC‑6960 nonce (optional) – omitted here }, } return ocsp.CreateResponse(c.cert, c.cert, template, c.key) } // buildCRL builds & caches a fresh CRL. func (c *ca) buildCRL(ctx context.Context) ([]byte, error) { m, err := c.db.revokedMap(ctx) if err != nil { return nil, err } var revoked []pkix.RevokedCertificate for sn, ts := range m { i := new(big.Int) i.SetString(sn, 10) revoked = append(revoked, pkix.RevokedCertificate{SerialNumber: i, RevocationTime: ts}) } der, err := c.cert.CreateCRL(rand.Reader, c.key, revoked, time.Now(), time.Now().Add(7*24*time.Hour)) if err != nil { return nil, err } return pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: der}), nil } // refreshLoops keeps OCSP/CRL material fresh in memory (optional but handy). func (c *ca) refreshLoops(ctx context.Context, lg *slog.Logger) { if ocspEnabled { go func() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: // OCSP for a non‑existent serial is useless here; caller builds on demand. case <-ctx.Done(): return } } }() } if crlEnabled { go func() { ticker := time.NewTicker(12 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: c.mu.Lock() b, err := c.buildCRL(ctx) if err == nil { c.crlCache = b } else { lg.Error("CRL build", "err", err) } c.mu.Unlock() case <-ctx.Done(): return } } }() } } // ----------------------------------------------------------------------------- // DNS provider util – REST TXT create/delete // ----------------------------------------------------------------------------- /*func publishTXT(fqdn, token string, present bool) { if dnsAPI == "" { return } payload, _ := json.Marshal(map[string]string{"fqdn": fqdn, "token": token}) method := http.MethodPost if !present { method = http.MethodDelete } req, _ := http.NewRequest(method, dnsAPI, bytes.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+dnsAPIToken) req.Header.Set("Content-Type", "application/json") // fire & forget – caller doesn’t care about the response body. go func() { resp, err := http.DefaultClient.Do(req) if err == nil { _ = resp.Body.Close() } }() }*/ // ----------------------------------------------------------------------------- // Server struct & helpers // ----------------------------------------------------------------------------- type server struct { ca *ca db *mysqlStore mgr *autocert.Manager log *slog.Logger } func newServer(ca *ca, db *mysqlStore, allowed string, lg *slog.Logger) *server { mgr := &autocert.Manager{ Prompt: autocert.AcceptTOS, HostPolicy: autocert.HostWhitelist(allowed), Cache: autocert.DirCache("cert-cache"), RenewBefore: 30 * 24 * time.Hour, } return &server{ca: ca, db: db, mgr: mgr, log: lg} } // ----------------------------------------------------------------------------- // helper: write JSON + (optional) fresh nonce header // ----------------------------------------------------------------------------- func (s *server) jsonResponse(w http.ResponseWriter, status int, v any, nonce string) { if nonce != "" { w.Header().Set("Replay-Nonce", nonce) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // ----------------------------------------------------------------------------- // JWS verification helper // ----------------------------------------------------------------------------- type jwsPayload struct { Data []byte JWK *jose.JSONWebKey } // ----------------------------------------------------------------------------- // JWS verification helper – supports both JSON & compact JWS formats // ----------------------------------------------------------------------------- func (s *server) verifyJWS(ctx context.Context, w http.ResponseWriter, r *http.Request) (*jwsPayload, bool) { if !jwsVerify { data, _ := io.ReadAll(r.Body) return &jwsPayload{Data: data}, true } // read full body once raw, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read body", http.StatusBadRequest) return nil, false } // try JWS JSON first … var sig jose.JSONWebSignature if err := json.Unmarshal(raw, &sig); err != nil || len(sig.Signatures) == 0 { // … fallback to compact serialization cp, err2 := jose.ParseSigned(string(raw)) if err2 != nil { http.Error(w, "bad JWS", http.StatusBadRequest) return nil, false } sig = *cp } prot := sig.Signatures[0].Protected // already base-64 decoded // Nonce replay-protection if !s.db.takeNonce(ctx, prot.Nonce) { http.Error(w, "bad nonce", http.StatusForbidden) return nil, false } // Resolve verification key var key *jose.JSONWebKey switch { case prot.JSONWebKey != nil: key = prot.JSONWebKey case prot.KeyID != "": if k, err := s.db.accountKey(ctx, path.Base(prot.KeyID)); err == nil { key = k } else { http.Error(w, "unknown kid", http.StatusUnauthorized) return nil, false } default: http.Error(w, "no verification key", http.StatusBadRequest) return nil, false } payload, err := sig.Verify(key) if err != nil { http.Error(w, "signature invalid", http.StatusUnauthorized) return nil, false } return &jwsPayload{Data: payload, JWK: key}, true } // ----------------------------------------------------------------------------- // HTTP handlers (subset) // ----------------------------------------------------------------------------- func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() lg := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(lg) lg.Info("Request", time.Now().Format("2026-12-18 18:06:00"), r.URL.Path) switch { case (r.Method == http.MethodHead || r.Method == http.MethodGet) && r.URL.Path == "/acme/new-nonce": n := uuid.New().String() if err := s.db.putNonce(ctx, n); err != nil { http.Error(w, "db", 500) return } w.Header().Set("Replay-Nonce", n) w.WriteHeader(http.StatusOK) case r.Method == http.MethodGet && r.URL.Path == "/directory": s.handleDirectory(w, r) case r.Method == http.MethodGet && r.URL.Path == "/acme/directory": s.handleDirectory(w, r) case r.Method == http.MethodPost && r.URL.Path == "/acme/new-account": s.handleNewAccount(ctx, w, r) case r.Method == http.MethodPost && r.URL.Path == "/acme/new-order": s.handleNewOrder(ctx, w, r) case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/acme/finalize/"): s.handleFinalize(ctx, w, r) case r.Method == http.MethodPost && r.URL.Path == "/acme/revoke-cert": s.handleRevoke(ctx, w, r) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/acme/order/"): s.handleGetOrder(ctx, w, r) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/acme/cert/"): s.handleGetCert(ctx, w, r) case ocspEnabled && strings.HasPrefix(r.URL.Path, "/ocsp/"): s.handleOCSP(ctx, w, r) case crlEnabled && r.URL.Path == "/crl": s.handleCRL(ctx, w) default: s.mgr.HTTPHandler(nil).ServeHTTP(w, r) } } func (s *server) handleFinalize(ctx context.Context, w http.ResponseWriter, r *http.Request) { pay, ok := s.verifyJWS(ctx, w, r) if !ok { return } orderID := path.Base(r.URL.Path) finURL := fmt.Sprintf("https://%s/acme/finalize/%s", r.Host, orderID) // --- decode wrapped CSR --------------------------------------------------- var in struct { CSR string `json:"csr"` } if err := json.Unmarshal(pay.Data, &in); err != nil { http.Error(w, "bad csr wrapper", http.StatusBadRequest) return } csrDER, err := base64.RawURLEncoding.DecodeString(in.CSR) if err != nil { http.Error(w, "bad csr b64", http.StatusBadRequest) return } csr, err := x509.ParseCertificateRequest(csrDER) if err != nil || csr.CheckSignature() != nil { http.Error(w, "csr parse/sig", http.StatusBadRequest) return } // --- sign leaf ------------------------------------------------------------ hash := sha256.Sum256([]byte(uuid.New().String())) serial := new(big.Int).SetBytes(hash[:]) tmpl := &x509.Certificate{ SerialNumber: serial, Subject: csr.Subject, NotBefore: time.Now().Add(-5 * time.Minute), NotAfter: time.Now().Add(90 * 24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, DNSNames: csr.DNSNames, IPAddresses: csr.IPAddresses, PublicKey: csr.PublicKey, } der, err := x509.CreateCertificate(rand.Reader, tmpl, s.ca.cert, csr.PublicKey, s.ca.key) if err != nil { s.log.Error("leaf sign", "err", err) http.Error(w, "sign", http.StatusInternalServerError) return } // --- persist & reply ------------------------------------------------------ certID := uuid.New().String() certURL := fmt.Sprintf("https://%s/acme/cert/%s", r.Host, certID) if err := s.db.insertCert(ctx, certID, der, serial); err != nil { http.Error(w, "db", http.StatusInternalServerError) return } if _, err := s.db.db.ExecContext(ctx, `UPDATE orders SET status='valid', cert_url=?, payload=? WHERE id=?`, certURL, pay.Data, orderID); err != nil { http.Error(w, "db", http.StatusInternalServerError) return } orderURL := fmt.Sprintf("https://%s/acme/order/%s", r.Host, orderID) nonce := uuid.New().String() _ = s.db.putNonce(ctx, nonce) resp := struct { *acme.Order URL string `json:"url"` }{ Order: &acme.Order{ Status: acme.StatusValid, FinalizeURL: finURL, CertURL: certURL, }, URL: orderURL, } w.Header().Set("Location", orderURL) s.jsonResponse(w, http.StatusOK, resp, nonce) } // ----------------------------------------------------------------------------- // GET /acme/order/{id} – order polling (Certbot/WACS) // ----------------------------------------------------------------------------- func (s *server) handleGetOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) { id := path.Base(r.URL.Path) var raw []byte var status, finalize string var certURL sql.NullString if err := s.db.db.QueryRowContext(ctx, `SELECT payload,status,finalize_url,cert_url FROM orders WHERE id=?`, id). Scan(&raw, &status, &finalize, &certURL); err != nil { http.Error(w, "order", http.StatusNotFound) return } var orig acme.Order _ = json.Unmarshal(raw, &orig) resp := &acme.Order{ Status: status, Identifiers: orig.Identifiers, FinalizeURL: finalize, } if certURL.Valid { resp.CertURL = certURL.String } s.jsonResponse(w, http.StatusOK, resp, "") } // ----------------------------------------------------------------------------- // GET /acme/cert/{id} – retrieve PEM chain // ----------------------------------------------------------------------------- func (s *server) handleGetCert(ctx context.Context, w http.ResponseWriter, r *http.Request) { id := path.Base(r.URL.Path) var der []byte if err := s.db.db.QueryRowContext(ctx, `SELECT der FROM certs WHERE id=?`, id). Scan(&der); err != nil { http.Error(w, "cert", http.StatusNotFound) return } var buf bytes.Buffer _ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}) _ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: s.ca.cert.Raw}) w.Header().Set("Content-Type", "application/pem-certificate-chain") _, _ = w.Write(buf.Bytes()) } // ----------------------------------------------------------------------------- // GET /.well-known/acme-directory – capabilities // ----------------------------------------------------------------------------- func (s *server) handleDirectory(w http.ResponseWriter, r *http.Request) { base := "https://" + r.Host s.jsonResponse(w, http.StatusOK, map[string]string{ "newNonce": base + "/acme/new-nonce", "newAccount": base + "/acme/new-account", "newOrder": base + "/acme/new-order", "revokeCert": base + "/acme/revoke-cert", }, "") } func (s *server) handleNewAccount(ctx context.Context, w http.ResponseWriter, r *http.Request) { pay, ok := s.verifyJWS(ctx, w, r) if !ok { return } id := uuid.New().String() if err := s.db.insertAccount(ctx, id, pay.JWK); err != nil { http.Error(w, "db", 500) return } nonce := uuid.New().String() _ = s.db.putNonce(ctx, nonce) w.Header().Set("Location", fmt.Sprintf("https://%s/acme/account/%s", r.Host, id)) s.jsonResponse(w, 201, struct { Status string `json:"status"` }{Status: "valid"}, nonce) } func (s *server) handleNewOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) { pay, ok := s.verifyJWS(ctx, w, r) if !ok { return } var req acme.Order if err := json.Unmarshal(pay.Data, &req); err != nil { http.Error(w, "bad order", 400) return } if len(req.Identifiers) == 0 { http.Error(w, "missing identifiers", 400) return } orderID := uuid.New().String() ordURL := fmt.Sprintf("https://%s/acme/order/%s", r.Host, orderID) finURL := fmt.Sprintf("https://%s/acme/finalize/%s", r.Host, orderID) // Persist order minimal info (we ignore authz for brevity) payload, _ := json.Marshal(req) if _, err := s.db.db.ExecContext(ctx, `INSERT INTO orders(id,payload,status,finalize_url) VALUES (?,?,?,?)`, orderID, payload, acme.StatusPending, finURL); err != nil { http.Error(w, "db", 500) return } resp := struct { *acme.Order URL string `json:"url"` }{ Order: &acme.Order{ Status: acme.StatusPending, FinalizeURL: finURL, AuthzURLs: []string{}, Expires: time.Now().Add(24 * time.Hour), Identifiers: req.Identifiers, }, URL: ordURL, } nonce := uuid.New().String() _ = s.db.putNonce(ctx, nonce) w.Header().Set("Location", ordURL) s.jsonResponse(w, 201, resp, nonce) } /*func (s *server) handleFinalize(ctx context.Context, w http.ResponseWriter, r *http.Request) { pay, ok := s.verifyJWS(ctx, w, r) if !ok { return } orderID := path.Base(r.URL.Path) var in struct { CSR string `json:"csr"` } if err := json.Unmarshal(pay.Data, &in); err != nil { http.Error(w, "bad csr wrapper", 400) return } csrDER, err := base64.RawURLEncoding.DecodeString(in.CSR) if err != nil { http.Error(w, "bad csr b64", 400) return } csr, err := x509.ParseCertificateRequest(csrDER) if err != nil { http.Error(w, "csr parse", 400) return } if err := csr.CheckSignature(); err != nil { http.Error(w, "csr sig", 400) return } // --- FIX: sha256.Sum256 liefert ein [32]byte – zuerst in Variable legen hash := sha256.Sum256([]byte(uuid.New().String())) serial := new(big.Int).SetBytes(hash[:]) tmpl := &x509.Certificate{ SerialNumber: serial, Subject: csr.Subject, NotBefore: time.Now().Add(-5 * time.Minute), NotAfter: time.Now().Add(90 * 24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, DNSNames: csr.DNSNames, IPAddresses: csr.IPAddresses, PublicKey: csr.PublicKey, } der, err := x509.CreateCertificate(rand.Reader, tmpl, s.ca.cert, csr.PublicKey, s.ca.key) if err != nil { s.log.Error("leaf sign", "err", err) http.Error(w, "sign", 500) return } certID := uuid.New().String() if err := s.db.insertCert(ctx, certID, der, serial); err != nil { http.Error(w, "db", 500) return } certURL := fmt.Sprintf("https://%s/acme/cert/%s", r.Host, certID) if _, err := s.db.db.ExecContext(ctx, `UPDATE orders SET status='valid',payload=?,finalize_url=? WHERE id=?`, pay.Data, certURL, orderID); err != nil { http.Error(w, "db", 500) return } var buf bytes.Buffer _ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}) _ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: s.ca.cert.Raw}) nonce := uuid.New().String() _ = s.db.putNonce(ctx, nonce) w.Header().Set("Location", certURL) w.Header().Set("Content-Type", "application/pem-certificate-chain") w.Header().Set("Replay-Nonce", nonce) w.WriteHeader(201) _, _ = w.Write(buf.Bytes()) }*/ func (s *server) handleRevoke(ctx context.Context, w http.ResponseWriter, r *http.Request) { pay, ok := s.verifyJWS(ctx, w, r) if !ok { return } var in struct { Serial string `json:"serial"` } if err := json.Unmarshal(pay.Data, &in); err != nil { http.Error(w, "bad revoke", 400) return } if in.Serial == "" { http.Error(w, "serial", 400) return } if err := s.db.revokeSerial(ctx, in.Serial); err != nil { http.Error(w, "db", 500) return } nonce := uuid.New().String() _ = s.db.putNonce(ctx, nonce) s.jsonResponse(w, 200, struct { Status string `json:"status"` }{Status: "revoked"}, nonce) } func (s *server) handleOCSP(ctx context.Context, w http.ResponseWriter, r *http.Request) { sn := strings.TrimPrefix(r.URL.Path, "/ocsp/") bi := new(big.Int) bi.SetString(sn, 10) der, err := s.ca.buildOCSPResponse(ctx, bi) if err != nil { http.Error(w, "ocsp", 500) return } w.Header().Set("Content-Type", "application/ocsp-response") _, _ = w.Write(der) } func (s *server) handleCRL(ctx context.Context, w http.ResponseWriter) { s.ca.mu.RLock() b := s.ca.crlCache s.ca.mu.RUnlock() // build lazily if not cached yet if b == nil { var err error b, err = s.ca.buildCRL(ctx) if err != nil { http.Error(w, "crl", 500) return } s.ca.mu.Lock() s.ca.crlCache = b s.ca.mu.Unlock() } w.Header().Set("Content-Type", "application/pkix-crl") _, _ = w.Write(b) } // ----------------------------------------------------------------------------- // main // ----------------------------------------------------------------------------- func main() { // global structured logger (JSON for log aggregators) lg := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(lg) flag.Parse() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() dsn := getenv("MYSQL_DSN", "root:root@tcp(localhost:3306)/acme?parseTime=true&multiStatements=true") store, err := newMySQLStore(ctx, dsn) if err != nil { lg.Error("db", "err", err) os.Exit(1) } defer store.db.Close() caCert := getenv("CA_CERT_PATH", "./ca_cert.pem") caKey := getenv("CA_KEY_PATH", "./ca_key.pem") ca, err := newCA(caCert, caKey, store) if err != nil { lg.Error("ca", "err", err) os.Exit(1) } srv := &http.Server{ Addr: getenv("PORT", *listenAddr), Handler: newServer(ca, store, *allowedDomain, lg), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: nil, // Let autocert wrap – no ListenAndServeTLS here } ca.refreshLoops(ctx, lg) go func() { lg.Info("ACME ready", "dns01", dns01Enabled, "ocsp", ocspEnabled, "crl", crlEnabled, "jws", jwsVerify) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { lg.Error("http", "err", err) stop() } }() <-ctx.Done() lg.Info("shutting down…") shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(shutCtx) }