feat: implement multi CA serving
This commit is contained in:
452
main.go
452
main.go
@@ -11,6 +11,10 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
@@ -21,180 +25,320 @@ import (
|
||||
"ocspcrl/internal/ocsp_source"
|
||||
)
|
||||
|
||||
type loadCrlFunction func() error
|
||||
|
||||
func loadCrlFromFile(path string) (*x509.RevocationList, error) {
|
||||
crlContent, openCrlError := os.ReadFile(path)
|
||||
if openCrlError != nil {
|
||||
return nil, openCrlError
|
||||
}
|
||||
|
||||
// if the file contains a pem block, decode it
|
||||
// otherwise, assume it is a DER encoded CRL
|
||||
crlBlock := &pem.Block{}
|
||||
if bytes.Contains(crlContent, []byte("BEGIN")) {
|
||||
block, rest := pem.Decode(crlContent)
|
||||
if len(rest) > 0 {
|
||||
return nil, fmt.Errorf("failed to decode crl")
|
||||
}
|
||||
crlBlock = block
|
||||
} else {
|
||||
crlBlock = &pem.Block{Type: "X509 CRL", Bytes: crlContent}
|
||||
}
|
||||
|
||||
crl, parseCrlError := x509.ParseRevocationList(crlBlock.Bytes)
|
||||
if parseCrlError != nil {
|
||||
return nil, parseCrlError
|
||||
}
|
||||
return crl, nil
|
||||
}
|
||||
|
||||
func reloadCrlWorker(signal chan os.Signal, loadCrlFunc loadCrlFunction) {
|
||||
defer log.Println("reload crl worker stopped")
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-signal:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
loadCrlError := loadCrlFunc()
|
||||
if loadCrlError != nil {
|
||||
log.Printf("failed to reload crl: %v", loadCrlError)
|
||||
} else {
|
||||
log.Println("reloaded crl")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type responder struct {
|
||||
certificatePath string
|
||||
keyPath string
|
||||
}
|
||||
|
||||
type crlSourceFile struct {
|
||||
path string
|
||||
}
|
||||
const (
|
||||
crlFileName = "crl.pem"
|
||||
keyFileName = "key.pem"
|
||||
responderFileName = "responder.crt"
|
||||
caFileName = "ca.crt"
|
||||
)
|
||||
|
||||
type configuration struct {
|
||||
responder *responder
|
||||
caCrtPath string
|
||||
crlSourceType string
|
||||
crlSourceFile *crlSourceFile
|
||||
casDirectory string
|
||||
applicationListenAddress string
|
||||
metricsListenAddress string
|
||||
}
|
||||
|
||||
func parseConfiguration(args []string) *configuration {
|
||||
config := &configuration{}
|
||||
app := kingpin.New("ocspcrl", "OCSP responder / CRL server (multi-CA)")
|
||||
app.HelpFlag.Short('h')
|
||||
app.Flag("cas-directory", "Path to a directory containing one subdirectory per CA. Each subdirectory must contain ca.crt, responder.crt, key.pem and crl.pem. The subdirectory name is used as the route prefix.").
|
||||
Envar("CAS_DIRECTORY").Required().ExistingDirVar(&config.casDirectory)
|
||||
app.Flag("web.listen-address", "Address for application endpoint").
|
||||
Envar("WEB_LISTEN_ADDRESS").Default(":8080").StringVar(&config.applicationListenAddress)
|
||||
app.Flag("metrics.listen-address", "Address for metrics endpoint").
|
||||
Envar("METRICS_LISTEN_ADDRESS").Default("[::1]:8081").StringVar(&config.metricsListenAddress)
|
||||
kingpin.MustParse(app.Parse(args))
|
||||
return config
|
||||
}
|
||||
|
||||
func decodeCrlBytes(content []byte) ([]byte, error) {
|
||||
if !bytes.Contains(content, []byte("BEGIN")) {
|
||||
return content, nil
|
||||
}
|
||||
block, rest := pem.Decode(content)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("crl pem block could not be decoded")
|
||||
}
|
||||
if len(bytes.TrimSpace(rest)) > 0 {
|
||||
return nil, fmt.Errorf("crl file contains trailing data")
|
||||
}
|
||||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
func loadCrlFromFile(path string) (*x509.RevocationList, error) {
|
||||
content, readError := os.ReadFile(path)
|
||||
if readError != nil {
|
||||
return nil, readError
|
||||
}
|
||||
derBytes, decodeError := decodeCrlBytes(content)
|
||||
if decodeError != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, decodeError)
|
||||
}
|
||||
return x509.ParseRevocationList(derBytes)
|
||||
}
|
||||
|
||||
func loadCertificateFromFile(path string) (*x509.Certificate, error) {
|
||||
content, readError := os.ReadFile(path)
|
||||
if readError != nil {
|
||||
return nil, readError
|
||||
}
|
||||
block, rest := pem.Decode(content)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("%s: certificate pem block could not be decoded", path)
|
||||
}
|
||||
if len(bytes.TrimSpace(rest)) > 0 {
|
||||
return nil, fmt.Errorf("%s: certificate file contains trailing data", path)
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
type caInstance struct {
|
||||
name string
|
||||
crlPath string
|
||||
caCertificate *x509.Certificate
|
||||
source *ocsp_source.CrlSource
|
||||
|
||||
crlMutex sync.RWMutex
|
||||
crl *x509.RevocationList
|
||||
}
|
||||
|
||||
func (c *caInstance) reloadCrl() error {
|
||||
crl, loadError := loadCrlFromFile(c.crlPath)
|
||||
if loadError != nil {
|
||||
return loadError
|
||||
}
|
||||
c.crlMutex.Lock()
|
||||
c.crl = crl
|
||||
c.crlMutex.Unlock()
|
||||
metrics.CrlEntries.WithLabelValues(c.name).Set(float64(len(crl.RevokedCertificateEntries)))
|
||||
c.source.UseCrl(*crl)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *caInstance) currentCrl() *x509.RevocationList {
|
||||
c.crlMutex.RLock()
|
||||
defer c.crlMutex.RUnlock()
|
||||
return c.crl
|
||||
}
|
||||
|
||||
type caFiles struct {
|
||||
key string
|
||||
responder string
|
||||
ca string
|
||||
crl string
|
||||
}
|
||||
|
||||
func newCaFiles(directory string) caFiles {
|
||||
return caFiles{
|
||||
key: filepath.Join(directory, keyFileName),
|
||||
responder: filepath.Join(directory, responderFileName),
|
||||
ca: filepath.Join(directory, caFileName),
|
||||
crl: filepath.Join(directory, crlFileName),
|
||||
}
|
||||
}
|
||||
|
||||
func (f caFiles) ensureExist(caName string) error {
|
||||
for _, path := range []string{f.key, f.responder, f.ca, f.crl} {
|
||||
if _, statError := os.Stat(path); statError != nil {
|
||||
return fmt.Errorf("ca %q: %w", caName, statError)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyResponderIssuedByCa(responder tls.Certificate, ca *x509.Certificate) error {
|
||||
if responder.Leaf == nil {
|
||||
return fmt.Errorf("responder leaf certificate could not be parsed")
|
||||
}
|
||||
if !bytes.Equal(ca.RawSubject, responder.Leaf.RawIssuer) {
|
||||
return fmt.Errorf("responder certificate issuer does not match ca certificate subject; %+q != %+q",
|
||||
ca.Subject.String(), responder.Leaf.Issuer.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadCa(name, directory string) (*caInstance, error) {
|
||||
files := newCaFiles(directory)
|
||||
if existsError := files.ensureExist(name); existsError != nil {
|
||||
return nil, existsError
|
||||
}
|
||||
|
||||
responderKeyPair, loadResponderError := tls.LoadX509KeyPair(files.responder, files.key)
|
||||
if loadResponderError != nil {
|
||||
return nil, fmt.Errorf("ca %q: failed to load responder key pair: %w", name, loadResponderError)
|
||||
}
|
||||
|
||||
caCertificate, loadCaError := loadCertificateFromFile(files.ca)
|
||||
if loadCaError != nil {
|
||||
return nil, fmt.Errorf("ca %q: failed to load ca certificate: %w", name, loadCaError)
|
||||
}
|
||||
|
||||
if verifyError := verifyResponderIssuedByCa(responderKeyPair, caCertificate); verifyError != nil {
|
||||
return nil, fmt.Errorf("ca %q: %w", name, verifyError)
|
||||
}
|
||||
|
||||
instance := &caInstance{
|
||||
name: name,
|
||||
crlPath: files.crl,
|
||||
caCertificate: caCertificate,
|
||||
source: ocsp_source.NewCrlSource(caCertificate, responderKeyPair),
|
||||
}
|
||||
|
||||
if reloadError := instance.reloadCrl(); reloadError != nil {
|
||||
return nil, fmt.Errorf("ca %q: failed to load crl: %w", name, reloadError)
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func listCaSubdirectories(rootDir string) ([]string, error) {
|
||||
entries, readDirError := os.ReadDir(rootDir)
|
||||
if readDirError != nil {
|
||||
return nil, fmt.Errorf("failed to read cas directory: %w", readDirError)
|
||||
}
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func discoverCas(rootDir string) ([]*caInstance, error) {
|
||||
names, listError := listCaSubdirectories(rootDir)
|
||||
if listError != nil {
|
||||
return nil, listError
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("no ca subdirectories found in %s", rootDir)
|
||||
}
|
||||
|
||||
cas := make([]*caInstance, 0, len(names))
|
||||
for _, name := range names {
|
||||
instance, loadError := loadCa(name, filepath.Join(rootDir, name))
|
||||
if loadError != nil {
|
||||
return nil, loadError
|
||||
}
|
||||
cas = append(cas, instance)
|
||||
}
|
||||
return cas, nil
|
||||
}
|
||||
|
||||
func writeBinary(w http.ResponseWriter, contentType string, body []byte) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
func writePem(w http.ResponseWriter, contentType, blockType string, body []byte) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
pem.Encode(w, &pem.Block{Type: blockType, Bytes: body})
|
||||
}
|
||||
|
||||
func registerOcspRoutes(router *http.ServeMux, prefix string, ca *caInstance) {
|
||||
responder := cfocsp.NewResponder(ca.source, nil)
|
||||
router.Handle(prefix+"/ocsp", responder)
|
||||
router.Handle(prefix+"/ocsp/", http.StripPrefix(prefix+"/ocsp/", responder))
|
||||
}
|
||||
|
||||
func registerCrlRoutes(router *http.ServeMux, prefix string, ca *caInstance) {
|
||||
router.HandleFunc(prefix+"/crl", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeBinary(w, "application/pkix-cert", ca.currentCrl().Raw)
|
||||
})
|
||||
router.HandleFunc(prefix+"/crl.pem", func(w http.ResponseWriter, r *http.Request) {
|
||||
writePem(w, "application/pkix-crl", "X509 CRL", ca.currentCrl().Raw)
|
||||
})
|
||||
}
|
||||
|
||||
func registerCaCertificateRoutes(router *http.ServeMux, prefix string, ca *caInstance) {
|
||||
router.HandleFunc(prefix+"/ca", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeBinary(w, "application/pkix-cert", ca.caCertificate.Raw)
|
||||
})
|
||||
router.HandleFunc(prefix+"/ca.pem", func(w http.ResponseWriter, r *http.Request) {
|
||||
writePem(w, "application/x-x509-ca-cert", "CERTIFICATE", ca.caCertificate.Raw)
|
||||
})
|
||||
}
|
||||
|
||||
func registerCaRoutes(router *http.ServeMux, ca *caInstance) {
|
||||
prefix := "/" + ca.name
|
||||
registerOcspRoutes(router, prefix, ca)
|
||||
registerCrlRoutes(router, prefix, ca)
|
||||
registerCaCertificateRoutes(router, prefix, ca)
|
||||
}
|
||||
|
||||
func buildApplicationRouter(cas []*caInstance) *http.ServeMux {
|
||||
router := http.NewServeMux()
|
||||
for _, ca := range cas {
|
||||
registerCaRoutes(router, ca)
|
||||
log.Printf("registered ca %q with routes under /%s/", ca.name, ca.name)
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
func reloadAllCrls(cas []*caInstance) {
|
||||
for _, ca := range cas {
|
||||
if reloadError := ca.reloadCrl(); reloadError != nil {
|
||||
log.Printf("failed to reload crl for ca %q: %v", ca.name, reloadError)
|
||||
} else {
|
||||
log.Printf("reloaded crl for ca %q", ca.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runReloadWorker(signalChan <-chan os.Signal, cas []*caInstance) {
|
||||
defer log.Println("reload crl worker stopped")
|
||||
for {
|
||||
_, ok := <-signalChan
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reloadAllCrls(cas)
|
||||
}
|
||||
}
|
||||
|
||||
func startServer(server *http.Server, label string) <-chan struct{} {
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
log.Printf("starting %s server on %+q", label, server.Addr)
|
||||
if listenError := server.ListenAndServe(); !errors.Is(listenError, http.ErrServerClosed) {
|
||||
log.Printf("%s server error: %v", label, listenError)
|
||||
}
|
||||
close(closed)
|
||||
}()
|
||||
return closed
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.Lshortfile)
|
||||
config := &configuration{
|
||||
responder: &responder{},
|
||||
crlSourceFile: &crlSourceFile{},
|
||||
}
|
||||
app := kingpin.New("ocspcrl", "OCSP responder / CRL server")
|
||||
app.HelpFlag.Short('h')
|
||||
app.Flag("responder.certificate-path", "Path to the responder certificate").Envar("RESPONDER_CERTIFICATE_PATH").Required().ExistingFileVar(&config.responder.certificatePath)
|
||||
app.Flag("responder.key-path", "Path to the responder key").Envar("RESPONDER_KEY_PATH").Required().ExistingFileVar(&config.responder.keyPath)
|
||||
app.Flag("ca-crt-path", "Path to the CA certificate").Envar("CA_CRT_PATH").Required().ExistingFileVar(&config.caCrtPath)
|
||||
app.Flag("crl-source-type", "Type of CRL source").Envar("CRL_SOURCE").Default("file").EnumVar(&config.crlSourceType, "file")
|
||||
app.Flag("source.file.path", "Path to the CRL file").Envar("SOURCE_FILE_PATH").ExistingFileVar(&config.crlSourceFile.path)
|
||||
app.Flag("web.listen-address", "Address for application endpoint").Envar("WEB_LISTEN_ADDRESS").Default(":8080").StringVar(&config.applicationListenAddress)
|
||||
app.Flag("metrics.listen-address", "Address for metrics endpoint").Envar("METRICS_LISTEN_ADDRESS").Default("[::1]:8081").StringVar(&config.metricsListenAddress)
|
||||
kingpin.MustParse(app.Parse(os.Args[1:]))
|
||||
config := parseConfiguration(os.Args[1:])
|
||||
|
||||
responderKeyPair, loadResponderKeyPairError := tls.LoadX509KeyPair(config.responder.certificatePath, config.responder.keyPath)
|
||||
if loadResponderKeyPairError != nil {
|
||||
log.Fatalf("failed to load responder key pair: %v", loadResponderKeyPairError)
|
||||
cas, discoverError := discoverCas(config.casDirectory)
|
||||
if discoverError != nil {
|
||||
log.Fatalf("failed to load cas: %v", discoverError)
|
||||
}
|
||||
|
||||
caCrtContent, openCaCrtError := os.ReadFile(config.caCrtPath)
|
||||
if openCaCrtError != nil {
|
||||
log.Fatalf("failed to open ca certificate: %v", openCaCrtError)
|
||||
}
|
||||
block, rest := pem.Decode(caCrtContent)
|
||||
if len(rest) > 0 {
|
||||
log.Fatalln("failed to decode ca certificate")
|
||||
}
|
||||
caCertificate, loadCaCertificateError := x509.ParseCertificate(block.Bytes)
|
||||
if loadCaCertificateError != nil {
|
||||
log.Fatalf("failed to parse ca certificate: %v", loadCaCertificateError)
|
||||
}
|
||||
applicationRouter := buildApplicationRouter(cas)
|
||||
|
||||
if !bytes.Equal(caCertificate.RawSubject, responderKeyPair.Leaf.RawIssuer) {
|
||||
log.Fatalf("responder certificate issuer does not match ca certificate subject; %+q != %+q", caCertificate.Subject.String(), responderKeyPair.Leaf.Issuer.String())
|
||||
}
|
||||
terminationChan := make(chan os.Signal, 1)
|
||||
signal.Notify(terminationChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
source := ocsp_source.NewCrlSource(caCertificate, responderKeyPair)
|
||||
|
||||
crl := &x509.RevocationList{}
|
||||
|
||||
loadCrl := func() error {
|
||||
curlCandidate, loadCrlError := loadCrlFromFile(config.crlSourceFile.path)
|
||||
if loadCrlError != nil {
|
||||
return loadCrlError
|
||||
}
|
||||
metrics.CrlEntries.Set(float64(len(curlCandidate.RevokedCertificateEntries)))
|
||||
source.UseCrl(*curlCandidate)
|
||||
crl = curlCandidate
|
||||
return nil
|
||||
}
|
||||
|
||||
signalChan := make(chan os.Signal, 1)
|
||||
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// initial load of the CRL
|
||||
if loadCrlError := loadCrl(); loadCrlError != nil {
|
||||
log.Fatalf("failed to load crl: %v", loadCrlError)
|
||||
}
|
||||
|
||||
// on HUP reload the CRL
|
||||
hupChan := make(chan os.Signal, 1)
|
||||
signal.Notify(hupChan, syscall.SIGHUP)
|
||||
go reloadCrlWorker(hupChan, loadCrl)
|
||||
|
||||
responder := cfocsp.NewResponder(source, nil)
|
||||
|
||||
applicationRouter := http.NewServeMux()
|
||||
applicationRouter.Handle("/ocsp", responder)
|
||||
applicationRouter.Handle("/ocsp/", http.StripPrefix("/ocsp/", responder))
|
||||
applicationRouter.HandleFunc("/crl", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pkix-crl")
|
||||
w.Write(crl.Raw)
|
||||
})
|
||||
applicationRouter.HandleFunc("/crl.pem", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pkix-crl")
|
||||
pem.Encode(w, &pem.Block{Type: "X509 CRL", Bytes: crl.Raw})
|
||||
})
|
||||
applicationRouter.HandleFunc("/ca", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pkix-cert")
|
||||
w.Write(caCertificate.Raw)
|
||||
})
|
||||
applicationRouter.HandleFunc("/ca.pem", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: caCertificate.Raw})
|
||||
})
|
||||
go runReloadWorker(hupChan, cas)
|
||||
|
||||
applicationServer := &http.Server{Addr: config.applicationListenAddress, Handler: metrics.Middleware(applicationRouter)}
|
||||
metricsServer := &http.Server{Addr: config.metricsListenAddress, Handler: promhttp.Handler()}
|
||||
|
||||
applicationServerClosed := make(chan any)
|
||||
metricsServerClosed := make(chan any)
|
||||
go func() {
|
||||
log.Printf("starting application server on %+q", config.applicationListenAddress)
|
||||
if listenError := applicationServer.ListenAndServe(); !errors.Is(listenError, http.ErrServerClosed) {
|
||||
log.Printf("application error: %v", listenError)
|
||||
}
|
||||
close(applicationServerClosed)
|
||||
}()
|
||||
go func() {
|
||||
log.Printf("starting metrics server on %+q", config.metricsListenAddress)
|
||||
if listenError := metricsServer.ListenAndServe(); !errors.Is(listenError, http.ErrServerClosed) {
|
||||
log.Printf("metrics server error: %v", listenError)
|
||||
}
|
||||
close(metricsServerClosed)
|
||||
}()
|
||||
applicationServerClosed := startServer(applicationServer, "application")
|
||||
metricsServerClosed := startServer(metricsServer, "metrics")
|
||||
|
||||
<-signalChan
|
||||
<-terminationChan
|
||||
close(hupChan)
|
||||
applicationServer.Shutdown(nil)
|
||||
metricsServer.Shutdown(nil)
|
||||
|
||||
Reference in New Issue
Block a user