959 lines
27 KiB
Go
959 lines
27 KiB
Go
// 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)
|
||
}
|