diff --git a/main.go b/main.go index 8569ebf..5e24a81 100644 --- a/main.go +++ b/main.go @@ -21,41 +21,39 @@ package main import ( - "bytes" - "context" - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/sha256" - "crypto/x509" - "crypto/x509/pkix" - "database/sql" - "encoding/asn1" - "encoding/json" - "encoding/pem" - "errors" - "flag" - "fmt" - "io" - "log/slog" - "math/big" - "net/http" - "os" - "os/signal" - "path" - "strconv" - "strings" - "sync" - "syscall" - "time" + "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" + _ "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" ) // ----------------------------------------------------------------------------- @@ -63,57 +61,57 @@ import ( // ----------------------------------------------------------------------------- func getenv(k, d string) string { - if v := os.Getenv(k); v != "" { - return v - } - return d + 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 + 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", "") + 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") + 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 +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) + 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 := ` + schema := ` CREATE TABLE IF NOT EXISTS nonces ( id VARCHAR(255) PRIMARY KEY, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -138,80 +136,80 @@ CREATE TABLE IF NOT EXISTS certs ( created TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ` - _, err := s.db.ExecContext(ctx, schema) - return err + _, 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 + _, 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 + 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 + 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 + 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 + _, 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 + 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 + _, 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() + 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 + 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 } // ----------------------------------------------------------------------------- @@ -219,139 +217,139 @@ func (s *mysqlStore) revokedMap(ctx context.Context) (map[string]time.Time, erro // ----------------------------------------------------------------------------- type ca struct { - cert *x509.Certificate - key crypto.Signer - db *mysqlStore - mu sync.RWMutex // guards ocspCache / crlCache + cert *x509.Certificate + key crypto.Signer + db *mysqlStore + mu sync.RWMutex // guards ocspCache / crlCache - ocspCache []byte - crlCache []byte + //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 - } + 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") - } + 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") - } + 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 + 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 - } + 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) + 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 + 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 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 - } - } - }() - } + 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 + } + } + }() + } } // ----------------------------------------------------------------------------- @@ -359,25 +357,25 @@ func (c *ca) refreshLoops(ctx context.Context, lg *slog.Logger) { // ----------------------------------------------------------------------------- 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") + 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() - } - }() + // fire & forget – caller doesn’t care about the response body. + go func() { + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + } + }() } // ----------------------------------------------------------------------------- @@ -385,20 +383,20 @@ func publishTXT(fqdn, token string, present bool) { // ----------------------------------------------------------------------------- type server struct { - ca *ca - db *mysqlStore - mgr *autocert.Manager - log *slog.Logger + 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} + 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} } // ----------------------------------------------------------------------------- @@ -406,60 +404,60 @@ func newServer(ca *ca, db *mysqlStore, allowed string, lg *slog.Logger) *server // ----------------------------------------------------------------------------- type jwsPayload struct { - Data []byte - JWK *jose.JSONWebKey + Data []byte + JWK *jose.JSONWebKey } 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 - } + if !jwsVerify { + data, _ := io.ReadAll(r.Body) + return &jwsPayload{Data: data}, true + } - // Decode incoming JWS (JSON serialization expected) - var sig jose.JSONWebSignature - if err := json.NewDecoder(r.Body).Decode(&sig); err != nil { - http.Error(w, "bad JWS", http.StatusBadRequest) - return nil, false - } - if len(sig.Signatures) == 0 { - http.Error(w, "no signature", http.StatusBadRequest) - return nil, false - } + // Decode incoming JWS (JSON serialization expected) + var sig jose.JSONWebSignature + if err := json.NewDecoder(r.Body).Decode(&sig); err != nil { + http.Error(w, "bad JWS", http.StatusBadRequest) + return nil, false + } + if len(sig.Signatures) == 0 { + http.Error(w, "no signature", http.StatusBadRequest) + return nil, false + } - prot := sig.Signatures[0].Protected // already decoded in go‑jose v3 + prot := sig.Signatures[0].Protected // already decoded in go‑jose v3 - // Nonce replay‑protection - if !s.db.takeNonce(ctx, prot.Nonce) { - http.Error(w, "bad nonce", http.StatusForbidden) - return nil, false - } + // Nonce replay‑protection + if !s.db.takeNonce(ctx, prot.Nonce) { + http.Error(w, "bad nonce", http.StatusForbidden) + return nil, false + } - // Resolve verification key (inline JWK or referenced by kid) - var key *jose.JSONWebKey - if prot.JSONWebKey != nil { - key = prot.JSONWebKey - } else if prot.KeyID != "" { - id := path.Base(prot.KeyID) // everything after last '/' - k, err := s.db.accountKey(ctx, id) - if err != nil { - http.Error(w, "unknown kid", http.StatusUnauthorized) - return nil, false - } - key = k - } - if key == nil { - http.Error(w, "no verification key", http.StatusBadRequest) - return nil, false - } + // Resolve verification key (inline JWK or referenced by kid) + var key *jose.JSONWebKey + if prot.JSONWebKey != nil { + key = prot.JSONWebKey + } else if prot.KeyID != "" { + id := path.Base(prot.KeyID) // everything after last '/' + k, err := s.db.accountKey(ctx, id) + if err != nil { + http.Error(w, "unknown kid", http.StatusUnauthorized) + return nil, false + } + key = k + } + if key == nil { + 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 - } + 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 + return &jwsPayload{Data: payload, JWK: key}, true } // ----------------------------------------------------------------------------- @@ -467,262 +465,262 @@ func (s *server) verifyJWS(ctx context.Context, w http.ResponseWriter, r *http.R // ----------------------------------------------------------------------------- func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() + ctx := r.Context() - switch { - case r.Method == http.MethodHead && 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) + switch { + case r.Method == http.MethodHead && 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.MethodPost && r.URL.Path == "/acme/new-account": - s.handleNewAccount(ctx, 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 && 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 && strings.HasPrefix(r.URL.Path, "/acme/finalize/"): + s.handleFinalize(ctx, w, r) - case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/acme/revoke/"): - s.handleRevoke(ctx, w, r) + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/acme/revoke/"): + s.handleRevoke(ctx, w, r) - case ocspEnabled && strings.HasPrefix(r.URL.Path, "/ocsp/"): - s.handleOCSP(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) + case crlEnabled && r.URL.Path == "/crl": + s.handleCRL(ctx, w) - default: - // Delegate everything else to autocert http‑01 challenge handler - s.mgr.HTTPHandler(nil).ServeHTTP(w, r) - } + default: + // Delegate everything else to autocert http‑01 challenge handler + s.mgr.HTTPHandler(nil).ServeHTTP(w, r) + } } 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) + if nonce != "" { + w.Header().Set("Replay-Nonce", nonce) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) } func (s *server) handleNewAccount(ctx context.Context, w http.ResponseWriter, r *http.Request) { - pay, ok := s.verifyJWS(ctx, w, r) - if !ok { - return - } + 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 - } + 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) + 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) + 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 - } + 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) + 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 - } + // 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, - } + 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) + nonce := uuid.New().String() + _ = s.db.putNonce(ctx, nonce) - w.Header().Set("Location", ordURL) - s.jsonResponse(w, 201, resp, 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 - } + pay, ok := s.verifyJWS(ctx, w, r) + if !ok { + return + } - // order ID is last element - orderID := path.Base(r.URL.Path) + orderID := path.Base(r.URL.Path) - // Parse CSR (DER) – payload contains base64‑encoded CSR per RFC 8555 §7.4 - 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 := jose.Base64URLDecode(in.CSR) - if err != nil { - http.Error(w, "bad csr b64", 400) - return - } + 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 - } + 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 + } - // Build leaf cert (valid 90 days, key‑usage TLS‑server) - serial := big.NewInt(0).SetBytes(sha256.Sum256([]byte(uuid.New().String()))[:]) - 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, - } + // --- FIX: sha256.Sum256 liefert ein [32]byte – zuerst in Variable legen + hash := sha256.Sum256([]byte(uuid.New().String())) + serial := new(big.Int).SetBytes(hash[:]) - 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 - } + 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, + } - certID := uuid.New().String() - if err := s.db.insertCert(ctx, certID, der, serial); err != nil { - http.Error(w, "db", 500) - return - } + 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 + } - // Link order → valid & cert URL - 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 - } + certID := uuid.New().String() + if err := s.db.insertCert(ctx, certID, der, serial); err != nil { + http.Error(w, "db", 500) + return + } - // Response must include chain (leaf + CA) in PEM order per RFC 8555 §7.4 - 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}) + 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 + } - nonce := uuid.New().String() - _ = s.db.putNonce(ctx, nonce) + 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("Location", certURL) - w.Header().Set("Content-Type", "application/pem-certificate-chain") - w.Header().Set("Replay-Nonce", nonce) - w.WriteHeader(201) - _, _ = w.Write(buf.Bytes()) + 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 - } + 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 - } + 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 - } + 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) + 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) + 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) + 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() + 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) + // 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) } // ----------------------------------------------------------------------------- @@ -730,55 +728,55 @@ func (s *server) handleCRL(ctx context.Context, w http.ResponseWriter) { // ----------------------------------------------------------------------------- func main() { - // global structured logger (JSON for log aggregators) - lg := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - slog.SetDefault(lg) + // global structured logger (JSON for log aggregators) + lg := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(lg) - flag.Parse() + flag.Parse() - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() + 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() + 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) - } + 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 - } + 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) + 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() - } - }() + 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…") + <-ctx.Done() + lg.Info("shutting down…") - shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = srv.Shutdown(shutCtx) -} \ No newline at end of file + shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) +}