]> go.fuhry.dev Git - runtime.git/commitdiff
[http] mtls, authzn overhaul
authorDan Fuhry <dan@fuhry.com>
Tue, 13 Oct 2026 13:19:28 +0000 (09:19 -0400)
committerDan Fuhry <dan@fuhry.com>
Mon, 19 Oct 2026 21:28:12 +0000 (17:28 -0400)
- authorizer route actions are replaced with a separate interface
- add support for mTLS server identities and client cert validation
- add client cert authorizer with fine grained control over SPIFFE domains, users & services
- fully support hot reload for TLS using hotSwappableTLSConfig
- add HTTPError struct type (implements error) to wrap http errors
- support tls config at both listener- and vhost-level

supporting changes:

- mtls: open up interface to allow checking subjects separately from cert validity
- mtls: add `VerifyYamlConfig` type for reusable partials
- mtls: add certObject primitive (public interface: `SwappableCert`) for concurrency-safe, guaranteed consistent access to cert object types that can briefly be inconsistent in underlying storage (e.g. filesystem)
- mtls/certutil: add SplitChain, SplitChainRoot, and IsSelfSigned functions
- mtls: cleanup deprecated IdentityDriver types
- mtls: add `CertificateNotifyOnReload` interface for cert types that can perform watch-/monitor-based reloads

known limitations:

- MTLSPeerVerifier really does not

incompatible config change: `auth` route property changes from string to []string

**🚫🤖 No AI**

15 files changed:
http/BUILD.bazel
http/authorizer_client_cert.go [new file with mode: 0644]
http/authorizer_saml.go [moved from http/route_action_saml.go with 71% similarity]
http/http.go
http/route_action_s3.go
http/server.go
http/sni_listener.go
mtls/certutil/certutil.go
mtls/identity.go
mtls/provider_file.go
mtls/provider_interface.go
mtls/provider_keychain_macos.go
mtls/provider_shared.go
mtls/provider_tpm2_pkcs11.go
mtls/verify_names.go

index 91f2ea73b7aa48d57883dde1345d4ef9d2db13e5..31a36196d845e8df65cdfacb8bb22e4f0de7a72e 100644 (file)
@@ -4,6 +4,8 @@ load("//bazel:go.bzl", "go_multi_library")
 go_multi_library(
     name = "http",
     srcs = [
+        "authorizer_client_cert.go",
+        "authorizer_saml.go",
         "http.go",
         "proxy_protocol.go",
         "range.go",
@@ -11,7 +13,6 @@ go_multi_library(
         "route_action_proxy.go",
         "route_action_redirect.go",
         "route_action_s3.go",
-        "route_action_saml.go",
         "server.go",
         "sni_listener.go",
         "tcp.go",
diff --git a/http/authorizer_client_cert.go b/http/authorizer_client_cert.go
new file mode 100644 (file)
index 0000000..7d8ce21
--- /dev/null
@@ -0,0 +1,108 @@
+package http
+
+import (
+       "crypto/tls"
+       "errors"
+       "fmt"
+       "net/http"
+
+       "gopkg.in/yaml.v3"
+
+       "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/utils/log"
+)
+
+type clientCertAuthorizer struct{}
+
+var _ Authorizer = &clientCertAuthorizer{}
+
+type clientCertAuthorizationHandler struct {
+       verbose  bool
+       verifier mtls.MTLSPeerVerifier
+}
+
+var _ AuthorizationHandler = &clientCertAuthorizationHandler{}
+
+const (
+       clientCertAuthorizerName = "client_certificate"
+)
+
+func (a *clientCertAuthorizer) New(node *yaml.Node) (AuthorizationHandler, error) {
+       var rawNode struct {
+               Verbose bool `yaml:"verbose_errors"`
+       }
+
+       if err := node.Decode(&rawNode); err != nil {
+               return nil, err
+       }
+
+       rules, err := mtls.NewVerifyYamlConfig(node)
+       if err != nil {
+               return nil, err
+       }
+
+       handler := &clientCertAuthorizationHandler{
+               verifier: mtls.NewPeerNameVerifier(),
+               verbose:  rawNode.Verbose,
+       }
+       if err := rules.Apply(handler.verifier); err != nil {
+               return nil, err
+       }
+
+       return handler, nil
+}
+
+func (h *clientCertAuthorizationHandler) Check(_ http.ResponseWriter, r *http.Request) (string, error) {
+       logger := LoggerFromContext(r.Context())
+
+       vh := VirtualHostFromContext(r.Context())
+       if vh == nil {
+               return "", errors.New("could not look up virtual host from request context")
+       }
+
+       var rid *mtls.RemoteIdentity
+       if tlsConn, ok := r.Context().Value(kTlsConn).(*tls.Conn); ok {
+               if verifier := vh.(*HTTPVirtualHost).tlsVerifier; verifier != nil {
+                       if id, err := verifier.GetPeerIdentity(tlsConn); err == nil && id != nil {
+                               logger.V(3).Debugf("set conn %p kRemoteIdentity = %v", tlsConn, id.ToSpiffe())
+                               rid = id
+                       } else if err != nil {
+                               return "", fmt.Errorf("conn %p RemoteIdentity err: %v", tlsConn, err)
+                       }
+               } else {
+                       return "", fmt.Errorf("conn %p: no verifier", tlsConn)
+               }
+       }
+
+       if rid == nil {
+               return "", errors.New("failed to get remote identity")
+       }
+
+       log.StateFromContext(r.Context()).Set(kRemoteIdentity, rid)
+
+       err := h.verifier.MatchRemoteIdentity(rid)
+       if err != nil {
+               return "", NewHTTPErrorFromError(err, http.StatusUnauthorized)
+       }
+
+       return rid.ToSpiffe().String(), nil
+}
+
+func (h *clientCertAuthorizationHandler) Reject(w http.ResponseWriter, r *http.Request) {
+       var rid *mtls.RemoteIdentity
+       if id, ok := log.StateFromContext(r.Context()).Get(kRemoteIdentity).(*mtls.RemoteIdentity); ok {
+               rid = id
+       }
+
+       if h.verbose && rid != nil {
+               msg := fmt.Sprintf("access denied based on peer identity %q",
+                       rid.ToSpiffe().String())
+               http.Error(w, msg, http.StatusUnauthorized)
+       } else {
+               http.Error(w, "The provided client identity does not have access to this resource.", http.StatusUnauthorized)
+       }
+}
+
+func init() {
+       AddAuthorizer(clientCertAuthorizerName, &clientCertAuthorizer{})
+}
similarity index 71%
rename from http/route_action_saml.go
rename to http/authorizer_saml.go
index 9589651f265e0d7cf0e8fd960c4c1807aa1061c8..01dea94fdb291e7a28c2e8e60ff644723535cc04 100644 (file)
@@ -6,6 +6,7 @@ import (
        "crypto/rsa"
        "crypto/x509"
        "crypto/x509/pkix"
+       "errors"
        "fmt"
        "math/big"
        "net/http"
@@ -18,10 +19,10 @@ import (
 
        "github.com/crewjam/saml"
        "github.com/crewjam/saml/samlsp"
+       "gopkg.in/yaml.v3"
 
        "go.fuhry.dev/runtime/mtls/certutil"
        "go.fuhry.dev/runtime/utils/hashset"
-       "gopkg.in/yaml.v3"
 )
 
 type SAMLServiceProvider struct {
@@ -40,81 +41,107 @@ type SAMLServiceProvider struct {
        mw          map[string]*samlsp.Middleware
 }
 
-type samlAction struct {
+type samlAuthorizer struct{}
+
+var _ Authorizer = &samlAuthorizer{}
+
+type samlAuthorizationHandler struct {
        sp              *SAMLServiceProvider
        requireAuth     bool
-       usernameHeader  string
        preserveCookies bool
+       remapHeaders    map[string]string
 }
 
-var samlAttributeReplaceRegexp = regexp.MustCompile(`[^a-z0-9]+`)
-var restrictedHeaders = hashset.FromSlice([]string{"on-behalf-of"})
+var _ AuthorizationHandler = &samlAuthorizationHandler{}
 
-func (sa *samlAction) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
-       logger := LoggerFromContext(r.Context())
+var (
+       samlAttributeReplaceRegexp = regexp.MustCompile(`[^a-z0-9]+`)
+       restrictedHeaders          = hashset.FromSlice([]string{"on-behalf-of"})
+)
 
-       // if request was already authorized, do nothing
-       if authz := AuthorizationFromContext(r.Context()); authz != nil {
-               logger.V(3).Debugf("req already authorized for %q by %q, skip saml", authz.Principal, authz.Authorizer)
-               next(w, r)
-               return
+func (a *samlAuthorizer) New(node *yaml.Node) (AuthorizationHandler, error) {
+       var rawNode struct {
+               SP *struct {
+                       *SAMLServiceProvider
+                       Require         string            `yaml:"require"`
+                       PreserveCookies bool              `yaml:"preserve_cookies"`
+                       RemapHeaders    map[string]string `yaml:"remap_headers"`
+               } `yaml:"saml,omitempty"`
        }
 
-       // ensure client isn't trying to inject saml-related headers
-       if err := sa.checkRequest(r); err != nil {
-               http.Error(w, err.Error(), http.StatusBadRequest)
-               return
+       err := node.Decode(&rawNode)
+       if err != nil {
+               return nil, err
        }
 
+       var spConfig *SAMLServiceProvider
+       require := true
+       preserveCookies := false
+       if rawNode.SP != nil {
+               require, err = strconv.ParseBool(rawNode.SP.Require)
+               if err != nil {
+                       return nil, err
+               }
+               spConfig = rawNode.SP.SAMLServiceProvider
+               preserveCookies = rawNode.SP.PreserveCookies
+       }
+
+       var remapHeaders map[string]string = nil
+       if rawNode.SP != nil && rawNode.SP.RemapHeaders != nil {
+               remapHeaders = rawNode.SP.RemapHeaders
+       }
+
+       sa := &samlAuthorizationHandler{
+               sp:              spConfig,
+               requireAuth:     require,
+               remapHeaders:    remapHeaders,
+               preserveCookies: preserveCookies,
+       }
+
+       return sa, nil
+}
+
+func (sa *samlAuthorizationHandler) getProvider(r *http.Request) (*samlsp.Middleware, error) {
        sp := sa.sp
        if sp == nil {
                sp = serverDefaultSamlConfig(r.Context())
        }
        if sp == nil {
-               http.Error(w, "SAML auth requested but no SP config present",
-                       http.StatusInternalServerError)
+               return nil, errors.New("saml auth requested but no SP config present")
        }
-       provider, err := sp.getMiddleware(r.Host)
-       if err != nil {
-               http.Error(w, err.Error(), http.StatusInternalServerError)
-               return
+       return sp.getMiddleware(r.Host)
+}
+
+func (sa *samlAuthorizationHandler) Check(w http.ResponseWriter, r *http.Request) (string, error) {
+       logger := LoggerFromContext(r.Context())
+
+       if authzn := AuthorizationFromContext(r.Context()); authzn != nil {
+               if authzn.Authorizer == "saml" {
+                       return authzn.Principal, nil
+               }
        }
 
-       if r.URL.Path == "/saml/acs" {
-               provider.ServeACS(w, r)
-               return
+       // ensure client isn't trying to inject saml-related headers
+       if err := sa.checkRequest(r); err != nil {
+               return "", NewHTTPErrorFromError(err, http.StatusBadRequest)
+       }
+
+       provider, err := sa.getProvider(r)
+       if err != nil {
+               return "", err
        }
 
        session, sessionErr := provider.Session.GetSession(r)
 
        if sessionErr != nil && sessionErr != samlsp.ErrNoSession {
-               http.Error(w, sessionErr.Error(), http.StatusBadRequest)
-               return
+               return "", NewHTTPErrorFromError(sessionErr, http.StatusBadRequest)
        }
 
        if sessionErr == samlsp.ErrNoSession && sa.requireAuth {
-               // cleanup previous saml cookies, because these really pile up
-               logger.V(3).Debugf("request tracker is a %T", provider.RequestTracker)
-               if t, ok := provider.RequestTracker.(*samlsp.CookieRequestTracker); ok {
-                       sa.cleanCookies(w, r, t)
-               }
-               if r.Header.Get("sec-fetch-mode") == "cors" || r.Header.Get("sec-fetch-mode") == "websocket" {
-                       // XHRs should not be redirected to auth as this creates a ton of cookie spam
-                       errMsg := "SAML session required"
-                       if r.Header.Get("accept") == "application/json" {
-                               w.Header().Set("content-type", "application/json")
-                               errMsg = `{"error": "` + errMsg + `"}`
-                       }
-                       http.Error(w, errMsg, http.StatusUnauthorized)
-                       return
-               }
-               logger.V(3).Debugf("route requires a valid session, redirecting")
-
-               provider.HandleStartAuthFlow(w, r)
-               return
+               return "", nil
        }
 
-       var principal string
+       principal := "anonymous"
        if swa, ok := session.(samlsp.SessionWithAttributes); ok {
                attrs := swa.GetAttributes()
                if uid := attrs.Get("uid"); uid != "" {
@@ -123,18 +150,18 @@ func (sa *samlAction) Handle(w http.ResponseWriter, r *http.Request, next http.H
        }
 
        if jwts, ok := session.(samlsp.JWTSessionClaims); ok {
-               r.Header.Set("x-saml-audience", jwts.StandardClaims.Audience)
+               r.Header.Set(sa.headerName("x-saml-audience"), jwts.StandardClaims.Audience)
                logger.V(3).Debugf("setting origin request header: %s: %s", "x-saml-audience", jwts.StandardClaims.Audience)
 
                iat := strconv.FormatInt(jwts.StandardClaims.IssuedAt, 10)
-               r.Header.Set("x-saml-issued-at", iat)
+               r.Header.Set(sa.headerName("x-saml-issued-at"), iat)
                logger.V(3).Debugf("setting origin request header: %s: %s", "x-saml-issued-at", iat)
 
                eat := strconv.FormatInt(jwts.StandardClaims.ExpiresAt, 10)
-               r.Header.Set("x-saml-expires-at", eat)
+               r.Header.Set(sa.headerName("x-saml-expires-at"), eat)
                logger.V(3).Debugf("setting origin request header: %s: %s", "x-saml-expires-at", eat)
 
-               r.Header.Set("x-saml-subject", jwts.StandardClaims.Subject)
+               r.Header.Set(sa.headerName("x-saml-subject"), jwts.StandardClaims.Subject)
                logger.V(3).Debugf("setting origin request header: %s: %s", "x-saml-subject", jwts.StandardClaims.Subject)
                if principal == "" {
                        principal = jwts.StandardClaims.Subject
@@ -143,20 +170,18 @@ func (sa *samlAction) Handle(w http.ResponseWriter, r *http.Request, next http.H
                for attr, values := range jwts.Attributes {
                        headerName := fmt.Sprintf("x-saml-%s",
                                samlAttributeReplaceRegexp.ReplaceAllString(strings.ToLower(attr), "-"))
+                       headerName = sa.headerName(headerName)
                        headerValue := strings.Join(values, ", ")
                        logger.V(3).Debugf("setting origin request header: %s: %s",
                                headerName, headerValue)
                        r.Header.Set(headerName, headerValue)
                }
 
-               oboHeader := sa.usernameHeader
-               if oboHeader == "" {
-                       oboHeader = "on-behalf-of"
-               }
+               oboHeader := sa.headerName("on-behalf-of")
                logger.V(3).Debugf("setting origin request header: %s: %s", oboHeader, principal)
                r.Header.Set(oboHeader, principal)
        } else {
-               r.Header.Set("x-saml-anonymous-auth", "1")
+               r.Header.Set(sa.headerName("x-saml-anonymous-auth"), "1")
        }
 
        // don't forward saml cookies to the origin unless PreserveCookies is set
@@ -173,18 +198,59 @@ func (sa *samlAction) Handle(w http.ResponseWriter, r *http.Request, next http.H
                r = newReq
        }
 
-       SetAuthorizationContext(r.Context(), &Authorization{
-               Authorizer: "saml",
-               Principal:  principal,
-       })
+       return principal, nil
+}
 
-       next(w, r)
+func (sa *samlAuthorizationHandler) headerName(in string) string {
+       if sa.remapHeaders != nil {
+               if altName, ok := sa.remapHeaders[in]; ok {
+                       return altName
+               }
+       }
+
+       return in
+}
+
+func (sa *samlAuthorizationHandler) Reject(w http.ResponseWriter, r *http.Request) {
+       logger := LoggerFromContext(r.Context())
+
+       provider, err := sa.getProvider(r)
+       if err != nil {
+               http.Error(w, err.Error(), http.StatusInternalServerError)
+               return
+       }
+
+       if r.URL.Path == "/saml/acs" {
+               provider.ServeACS(w, r)
+               return
+       }
+
+       // cleanup previous saml cookies, because these really pile up
+       if t, ok := provider.RequestTracker.(*samlsp.CookieRequestTracker); ok {
+               sa.cleanCookies(w, r, t)
+       }
+
+       logger.V(3).Debugf("request tracker is a %T", provider.RequestTracker)
+       if r.Header.Get("sec-fetch-mode") == "cors" || r.Header.Get("sec-fetch-mode") == "websocket" {
+               // XHRs should not be redirected to auth as this creates a ton of cookie spam
+               errMsg := "SAML session required"
+               if r.Header.Get("accept") == "application/json" {
+                       w.Header().Set("content-type", "application/json")
+                       errMsg = `{"error": "` + errMsg + `"}`
+               }
+               http.Error(w, errMsg, http.StatusUnauthorized)
+               return
+       }
+       logger.V(3).Debugf("route requires a valid session, redirecting")
+
+       provider.HandleStartAuthFlow(w, r)
 }
 
-func (sa *samlAction) checkRequest(r *http.Request) error {
-       for k, _ := range r.Header {
+func (sa *samlAuthorizationHandler) checkRequest(r *http.Request) error {
+       for k := range r.Header {
                k = strings.ToLower(k)
-               if strings.HasPrefix(k, "x-saml-") || restrictedHeaders.Contains(k) {
+               altName, remapped := sa.remapHeaders[k]
+               if strings.HasPrefix(k, "x-saml-") || restrictedHeaders.Contains(k) || (remapped && k == altName) {
                        return fmt.Errorf("downstream attempted to overwrite restricted header: %q", k)
                }
        }
@@ -201,7 +267,7 @@ func (sa *samlAction) checkRequest(r *http.Request) error {
 //
 // To mitigate this, we clear any existing saml cookies whenever we send an auth redirect or reject
 // an XHR with a 401.
-func (sa *samlAction) cleanCookies(w http.ResponseWriter, r *http.Request, t *samlsp.CookieRequestTracker) {
+func (sa *samlAuthorizationHandler) cleanCookies(w http.ResponseWriter, r *http.Request, t *samlsp.CookieRequestTracker) {
        for _, cookie := range r.Cookies() {
                if strings.HasPrefix(cookie.Name, "saml_") {
                        logger := LoggerFromContext(r.Context())
@@ -348,18 +414,6 @@ func (sp *SAMLServiceProvider) newMiddleware(host string) (*samlsp.Middleware, e
        })
 }
 
-func samlInitHook(ctx context.Context, node *yaml.Node) (context.Context, error) {
-       var samlConfig struct {
-               SP *SAMLServiceProvider `yaml:"saml"`
-       }
-
-       if err := node.Decode(&samlConfig); err == nil && samlConfig.SP != nil {
-               ctx = context.WithValue(ctx, kSamlDefaults, samlConfig.SP)
-       }
-
-       return ctx, nil
-}
-
 func serverDefaultSamlConfig(ctx context.Context) *SAMLServiceProvider {
        v := ctx.Value(kSamlDefaults)
        if c, ok := v.(*SAMLServiceProvider); ok {
@@ -368,43 +422,19 @@ func serverDefaultSamlConfig(ctx context.Context) *SAMLServiceProvider {
        return nil
 }
 
-func samlActionFromRouteYaml(node *yaml.Node) (RouteAction, error) {
-       var rawNode struct {
-               SP *struct {
-                       *SAMLServiceProvider
-                       Require         string `yaml:"require"`
-                       PreserveCookies bool   `yaml:"preserve_cookies"`
-               } `yaml:"saml,omitempty"`
-               Auth string `yaml:"auth"`
-       }
-
-       err := node.Decode(&rawNode)
-       if err != nil || rawNode.Auth != "saml" {
-               return nil, nil
-       }
-
-       var spConfig *SAMLServiceProvider
-       require := true
-       preserveCookies := false
-       if rawNode.SP != nil {
-               require, err = strconv.ParseBool(rawNode.SP.Require)
-               if err != nil {
-                       return nil, err
-               }
-               spConfig = rawNode.SP.SAMLServiceProvider
-               preserveCookies = rawNode.SP.PreserveCookies
+func samlInitHook(ctx context.Context, node *yaml.Node) (context.Context, error) {
+       var samlConfig struct {
+               SP *SAMLServiceProvider `yaml:"saml"`
        }
 
-       sa := &samlAction{
-               sp:              spConfig,
-               requireAuth:     require,
-               preserveCookies: preserveCookies,
+       if err := node.Decode(&samlConfig); err == nil && samlConfig.SP != nil {
+               ctx = context.WithValue(ctx, kSamlDefaults, samlConfig.SP)
        }
 
-       return sa, nil
+       return ctx, nil
 }
 
 func init() {
+       AddAuthorizer("saml", &samlAuthorizer{})
        AddServerInitHook(samlInitHook)
-       AddRouteParseFunc(samlActionFromRouteYaml)
 }
index b65b01962c8bcf8b67a56d929d2d1899aeda30bb..0025ff1c636559b0b46b45e4926841a4c5e64fc3 100644 (file)
@@ -3,6 +3,7 @@ package http
 import (
        "context"
        "crypto/tls"
+       "crypto/x509"
        "errors"
        "fmt"
        "net"
@@ -10,8 +11,10 @@ import (
        "os"
        "regexp"
        "strings"
+       "sync"
 
        "go.fuhry.dev/runtime/mtls"
+       "go.fuhry.dev/runtime/mtls/certutil"
        "go.fuhry.dev/runtime/utils/log"
 )
 
@@ -20,11 +23,23 @@ type HTTPVirtualHost struct {
 
        Routes                 []*Route `yaml:"routes"`
        Certificate            string   `yaml:"cert"`
+       MtlsID                 string   `yaml:"mtls_id"`
+       Verify                 *string  `yaml:"verify"`
+       RequireCert            *string  `yaml:"require_cert"`
        TrustUpstreamRequestID *bool    `yaml:"trust_upstream_request_id"`
 
-       tlsServer *http.Server
+       tlsServer          *http.Server
+       tlsVerifier        mtls.MTLSPeerVerifier
+       swappableTlsConfig *hotSwappableTlsConfig
 }
 
+type HTTPError struct {
+       StatusCode int
+       Message    string
+}
+
+var _ error = &HTTPError{}
+
 func (v *HTTPVirtualHost) Serve(ctx context.Context, sniServer *SNIListener) (func() error, error) {
        server, err := v.NewHTTPServerWithContext(ctx)
        if err != nil {
@@ -46,6 +61,91 @@ func (v *HTTPVirtualHost) Serve(ctx context.Context, sniServer *SNIListener) (fu
        }, nil
 }
 
+func (h *HTTPVirtualHost) vhostIdentity() (mtls.Identity, error) {
+       if h.Certificate != "" && h.MtlsID != "" {
+               log.Default().WithPrefix("HTTPVirtualHost").Critical("ErrSSLCertAndMtlsIDMutex")
+               return nil, ErrSSLCertAndMtlsIDMutex
+       }
+
+       var id mtls.Identity
+       if h.Certificate != "" {
+               id = mtls.NewSSLCertificate(h.Certificate)
+       } else if h.MtlsID != "" {
+               id = mtls.NewServiceIdentity(h.MtlsID)
+       } else {
+               // use parent listener identity
+               return nil, nil
+       }
+
+       return id, nil
+}
+
+func (h *HTTPVirtualHost) newTlsConfig(ctx context.Context) (*tls.Config, mtls.MTLSPeerVerifier, error) {
+       var verifier mtls.MTLSPeerVerifier
+
+       id, err := h.vhostIdentity()
+       if err != nil {
+               return nil, nil, err
+       }
+       if id == nil {
+               return nil, nil, nil
+       }
+
+       config, err := id.TlsConfig(ctx)
+       if err != nil {
+               return nil, nil, err
+       }
+
+       if h.Verify != nil && *h.Verify != verifyTokenNone {
+               clientAuth := tls.RequireAndVerifyClientCert
+
+               if h.RequireCert != nil {
+                       switch *h.RequireCert {
+                       case tls.RequestClientCert.String():
+                               clientAuth = tls.RequestClientCert
+                       case tls.RequireAndVerifyClientCert.String():
+                               clientAuth = tls.RequireAndVerifyClientCert
+                       case tls.RequireAnyClientCert.String():
+                               clientAuth = tls.RequireAnyClientCert
+                       case tls.VerifyClientCertIfGiven.String():
+                               clientAuth = tls.VerifyClientCertIfGiven
+                       case tls.NoClientCert.String():
+                               return nil, nil, fmt.Errorf("invalid value for require_cert %q; unset \"verify\" to disable client cert", *h.RequireCert)
+                       default:
+                               return nil, nil, fmt.Errorf("invalid value for require_cert %q; valid values", *h.RequireCert)
+                       }
+               }
+               config.ClientAuth = clientAuth
+               if *h.Verify == verifyTokenMtls {
+                       // verify with global mtls root CA
+                       verifier = mtls.NewPeerNameVerifier()
+                       if err = verifier.ConfigureServer(config); err != nil {
+                               return nil, nil, err
+                       }
+                       verifier.AllowFrom(mtls.All)
+               } else {
+                       certs, err := certutil.LoadCertificatesFromPEM(*h.Verify)
+                       if err != nil {
+                               return nil, nil, fmt.Errorf("failed to load client CAs from %s: %v", *h.Verify, err)
+                       }
+
+                       pool := x509.NewCertPool()
+                       for _, cert := range certs {
+                               pool.AddCert(cert)
+                       }
+
+                       config.ClientCAs = pool
+               }
+       } else {
+               config.ClientAuth = tls.NoClientCert
+               config.VerifyPeerCertificate = nil
+               config.VerifyConnection = nil
+               config.RootCAs = nil
+       }
+
+       return config, verifier, nil
+}
+
 // NewHTTPServerWithContext creates an http.Server using the proxy's virtual host
 // and other settings.
 func (v *HTTPVirtualHost) NewHTTPServerWithContext(ctx context.Context) (*http.Server, error) {
@@ -54,25 +154,39 @@ func (v *HTTPVirtualHost) NewHTTPServerWithContext(ctx context.Context) (*http.S
 
        lm := log.NewLoggingMiddlewareWithLogger(
                http.HandlerFunc(v.handle),
-               logger.AppendPrefix(".access").WithLevel(log.INFO))
+               logger.AppendPrefix(".access").WithLevel(log.INFO),
+       )
        lm.ExtraFunc = v.injectLogAttrs
 
        server := &http.Server{
                Addr: "",
                BaseContext: func(l net.Listener) context.Context {
-                       return context.WithValue(serverCtx, kListenAddr, l.Addr())
+                       ctx := context.WithValue(serverCtx, kListenAddr, l.Addr())
+                       ctx = context.WithValue(ctx, kVirtualHost, v)
+                       return ctx
+               },
+               ConnContext: func(ctx context.Context, c net.Conn) context.Context {
+                       if tlsConn, ok := c.(*tls.Conn); ok {
+                               ctx = context.WithValue(ctx, kTlsConn, tlsConn)
+                       }
+
+                       return ctx
                },
                Handler: lm.HandlerFunc(),
        }
 
-       if v.Certificate != "" {
-               cert := mtls.NewSSLCertificate(v.Certificate)
-               tlsConfig, err := cert.TlsConfig(serverCtx)
-               if err != nil {
-                       return nil, err
-               }
+       tlsConfig, verifier, err := v.newTlsConfig(serverCtx)
+       if err != nil {
+               return nil, err
+       }
+       if verifier != nil {
+               v.tlsVerifier = verifier
+       }
+
+       if tlsConfig != nil {
                tlsConfig.NextProtos = []string{"h2"}
-               server.TLSConfig = tlsConfig
+
+               server.TLSConfig, v.swappableTlsConfig = newHotSwappableTlsConfig(tlsConfig)
        }
 
        return server, nil
@@ -112,7 +226,7 @@ func (v *HTTPVirtualHost) handle(w http.ResponseWriter, r *http.Request) {
 }
 
 func (v *HTTPVirtualHost) swapTlsConfig(c *tls.Config) {
-       v.tlsServer.TLSConfig = c
+       v.swappableTlsConfig.swap(c)
 }
 
 func vhostGlobToRegexp(name string) (*regexp.Regexp, error) {
@@ -150,6 +264,26 @@ func (v *HTTPVirtualHost) fulfill(w http.ResponseWriter, r *http.Request, routes
                }
 
                if match {
+                       if len(route.Authorizers) > 0 {
+                               for _, authorizer := range route.Authorizers {
+                                       if princ, err := authorizer.handler.Check(w, r); princ != "" && err == nil {
+                                               SetAuthorizationContext(r.Context(), &Authorization{
+                                                       Authorizer: authorizer.name,
+                                                       Principal:  princ,
+                                               })
+                                               break
+                                       } else if err != nil {
+                                               herr := NewHTTPErrorFromError(err, http.StatusInternalServerError)
+                                               herr.Write(w)
+                                               return
+                                       }
+                               }
+
+                               if AuthorizationFromContext(r.Context()) == nil {
+                                       route.Authorizers[0].handler.Reject(w, r)
+                                       return
+                               }
+                       }
                        if route.Action != nil {
                                logger.V(3).Debugf("route has action %T, dispatching: %+v", route.Action, route.Action)
                                next := http.NotFound
@@ -165,6 +299,7 @@ func (v *HTTPVirtualHost) fulfill(w http.ResponseWriter, r *http.Request, routes
                                http.Error(w,
                                        fmt.Sprintf("no action configured for route %s", route.Path.String()),
                                        http.StatusInternalServerError)
+                               return
                        }
                }
        }
@@ -181,3 +316,126 @@ func (v *HTTPVirtualHost) injectLogAttrs(entry map[string]any, w http.ResponseWr
                entry["request_id"] = rid
        }
 }
+
+func (h *HTTPError) Error() string {
+       return fmt.Sprintf("HTTP %d %s\n%s", h.StatusCode, http.StatusText(h.StatusCode), h.Message)
+}
+
+func (h *HTTPError) Write(w http.ResponseWriter) {
+       http.Error(w, h.Message, h.StatusCode)
+}
+
+func NewHTTPError(statusCode int, msg string) *HTTPError {
+       return &HTTPError{
+               StatusCode: statusCode,
+               Message:    msg,
+       }
+}
+
+func NewHTTPErrorFromError(err error, statusCode int) *HTTPError {
+       if h, ok := err.(*HTTPError); ok {
+               return h
+       }
+
+       return NewHTTPError(statusCode, err.Error())
+}
+
+type hotSwappableTlsConfig struct {
+       inner *tls.Config
+       mu    sync.RWMutex
+}
+
+func (c *hotSwappableTlsConfig) GetCertificate(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
+       c.mu.RLock()
+       defer c.mu.RUnlock()
+
+       if c.inner.GetCertificate == nil {
+               return nil, nil
+       }
+       return c.inner.GetCertificate(chi)
+}
+
+func (c *hotSwappableTlsConfig) GetClientCertificate(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) {
+       c.mu.RLock()
+       defer c.mu.RUnlock()
+
+       if c.inner.GetClientCertificate == nil {
+               return nil, nil
+       }
+       return c.inner.GetClientCertificate(cri)
+}
+
+func (c *hotSwappableTlsConfig) GetConfigForClient(chi *tls.ClientHelloInfo) (*tls.Config, error) {
+       c.mu.RLock()
+       defer c.mu.RUnlock()
+
+       if c.inner.GetConfigForClient == nil {
+               return c.inner, nil
+       }
+       return c.inner.GetConfigForClient(chi)
+}
+
+func (c *hotSwappableTlsConfig) UnwrapSession(identity []byte, cs tls.ConnectionState) (*tls.SessionState, error) {
+       c.mu.RLock()
+       defer c.mu.RUnlock()
+
+       if c.inner.UnwrapSession == nil {
+               return c.inner.DecryptTicket(identity, cs)
+       }
+
+       return c.inner.UnwrapSession(identity, cs)
+}
+
+func (c *hotSwappableTlsConfig) WrapSession(cs tls.ConnectionState, ss *tls.SessionState) ([]byte, error) {
+       c.mu.RLock()
+       defer c.mu.RUnlock()
+
+       if c.inner.WrapSession == nil {
+               return c.inner.EncryptTicket(cs, ss)
+       }
+
+       return c.inner.WrapSession(cs, ss)
+}
+
+func (c *hotSwappableTlsConfig) VerifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
+       c.mu.RLock()
+       defer c.mu.RUnlock()
+
+       if c.inner.VerifyPeerCertificate == nil {
+               return nil
+       }
+       return c.inner.VerifyPeerCertificate(rawCerts, verifiedChains)
+}
+
+func (c *hotSwappableTlsConfig) VerifyConnection(cs tls.ConnectionState) error {
+       c.mu.RLock()
+       defer c.mu.RUnlock()
+
+       if c.inner.VerifyConnection == nil {
+               return nil
+       }
+
+       return c.inner.VerifyConnection(cs)
+}
+
+func (c *hotSwappableTlsConfig) swap(n *tls.Config) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       c.inner = n
+}
+
+func newHotSwappableTlsConfig(inner *tls.Config) (*tls.Config, *hotSwappableTlsConfig) {
+       hs := &hotSwappableTlsConfig{inner, sync.RWMutex{}}
+
+       outer := inner.Clone()
+       outer.GetCertificate = hs.GetCertificate
+       outer.GetClientCertificate = hs.GetClientCertificate
+       outer.GetConfigForClient = hs.GetConfigForClient
+       outer.WrapSession = hs.WrapSession
+       outer.UnwrapSession = hs.UnwrapSession
+       outer.VerifyPeerCertificate = hs.VerifyPeerCertificate
+       outer.VerifyConnection = hs.VerifyConnection
+
+       return outer, hs
+}
index 8659c1ad4c0200a339cd1e2b79c7e73e4f6f86b7..1e557a3f845a5c617b12244a3f75f4c33fbf4f21 100644 (file)
@@ -36,7 +36,7 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand
        mc, err := a.minioClient()
        if err != nil {
                w.WriteHeader(http.StatusInternalServerError)
-               w.Write([]byte(fmt.Sprintf("failed to init minio client: %+v", err)))
+               fmt.Fprintf(w, "failed to init minio client: %+v", err)
                return
        }
 
@@ -44,7 +44,7 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand
        reqPath, err = url.PathUnescape(reqPath)
        if err != nil {
                w.WriteHeader(http.StatusBadRequest)
-               w.Write([]byte(fmt.Sprintf("failed decoding path: %+v", err)))
+               fmt.Fprintf(w, "failed decoding path: %+v", err)
                return
        }
        objPath := strings.Trim(strings.Join(
@@ -52,7 +52,8 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand
                        strings.Trim(a.ObjectPrefix, "/"),
                        strings.Trim(reqPath, "/"),
                },
-               "/"), "/")
+               "/",
+       ), "/")
 
        rc := http.StatusOK
        var rh *RangeHeader
@@ -72,11 +73,11 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand
                r.Context(),
                a.BucketName,
                objPath,
-               minio.GetObjectOptions{})
-
+               minio.GetObjectOptions{},
+       )
        if err != nil {
                w.WriteHeader(http.StatusInternalServerError)
-               w.Write([]byte(fmt.Sprintf("failed to GetObject %q: %+v", reqPath, err)))
+               fmt.Fprintf(w, "failed to GetObject %q: %+v", reqPath, err)
                return
        }
 
@@ -84,7 +85,7 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand
        if err != nil {
                er := minio.ToErrorResponse(err)
                w.WriteHeader(coalesce(er.StatusCode, http.StatusInternalServerError))
-               w.Write([]byte(fmt.Sprintf("failed to stat object %q: %+v", objPath, err)))
+               fmt.Fprintf(w, "failed to stat object %q: %+v", objPath, err)
                return
        }
 
@@ -109,14 +110,15 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand
        }
 
        var seek int64
-       var size = stat.Size
+       size := stat.Size
        if rh != nil {
                if rh.SuffixLength > 0 {
                        if rh.SuffixLength > uint64(stat.Size) {
                                http.Error(w,
                                        fmt.Sprintf(
                                                "requested suffix of %d bytes exceeds file size of %d bytes",
-                                               rh.SuffixLength, stat.Size),
+                                               rh.SuffixLength, stat.Size,
+                                       ),
                                        http.StatusRequestedRangeNotSatisfiable)
 
                                return
@@ -137,7 +139,8 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand
                                http.Error(w,
                                        fmt.Sprintf(
                                                "requested range %d-%d exceeds file size of %d bytes",
-                                               rng.Start, rng.End, stat.Size),
+                                               rng.Start, rng.End, stat.Size,
+                                       ),
                                        http.StatusRequestedRangeNotSatisfiable)
                                return
                        }
@@ -151,7 +154,8 @@ func (a *S3Action) Handle(w http.ResponseWriter, r *http.Request, next http.Hand
 
                        LoggerFromContext(r.Context()).V(3).Debugf(
                                "satisfiable range is %d bytes long, seeking to byte %d: %s",
-                               size, seek, respRange)
+                               size, seek, respRange,
+                       )
                } else {
                        http.Error(w,
                                "multiple ranges are not supported",
index 18ae75f0b72148a51174ee5d66d9f421cceb2772..2c4d08161e5ae2736e7208de6ea59dc7ae9dc465 100644 (file)
@@ -15,11 +15,12 @@ import (
        "sync"
        "time"
 
+       "gopkg.in/yaml.v3"
+
        "go.fuhry.dev/runtime/mtls"
        "go.fuhry.dev/runtime/utils/hashset"
        "go.fuhry.dev/runtime/utils/log"
        "go.fuhry.dev/runtime/utils/stringmatch"
-       "gopkg.in/yaml.v3"
 )
 
 type serverCtxVar int
@@ -28,9 +29,19 @@ type RouteAction interface {
        Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc)
 }
 
+type Authorizer interface {
+       New(*yaml.Node) (AuthorizationHandler, error)
+}
+
+type AuthorizationHandler interface {
+       Check(w http.ResponseWriter, r *http.Request) (string, error)
+       Reject(w http.ResponseWriter, r *http.Request)
+}
+
 type Route struct {
-       Path   stringmatch.StringMatcher
-       Action RouteAction
+       Path        stringmatch.StringMatcher
+       Authorizers []*authznEntry
+       Action      RouteAction
 }
 
 type NameMatcher interface {
@@ -62,6 +73,9 @@ type Listener struct {
        ProxyProtocol          bool          `yaml:"proxy_protocol"`
        InsecureAddr           string        `yaml:"listen_insecure"`
        Certificate            string        `yaml:"cert"`
+       MtlsID                 string        `yaml:"mtls_id"`
+       Verify                 string        `yaml:"verify"`
+       RequireCert            string        `yaml:"require_cert"`
        TrustUpstreamRequestID bool          `yaml:"trust_upstream_request_id"`
        DrainTime              time.Duration `yaml:"drain_time"`
        VirtualHosts           []VirtualHost `yaml:"-"`
@@ -83,8 +97,15 @@ type Authorization struct {
        Principal  string
 }
 
-type initHook func(context.Context, *yaml.Node) (context.Context, error)
-type routeParseFunc func(*yaml.Node) (RouteAction, error)
+type authznEntry struct {
+       name    string
+       handler AuthorizationHandler
+}
+
+type (
+       initHook       func(context.Context, *yaml.Node) (context.Context, error)
+       routeParseFunc func(*yaml.Node) (RouteAction, error)
+)
 
 const (
        kLogger serverCtxVar = iota
@@ -94,6 +115,8 @@ const (
        kSamlDefaults
        kRequestID
        kAuthorization
+       kTlsConn
+       kRemoteIdentity
 )
 
 const (
@@ -102,18 +125,26 @@ const (
 
        hostnameWildcardSingle = `[A-Za-z0-9-]+`
        hostnameWildcardMulti  = `[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*`
+
+       verifyTokenMtls = "mtls"
+       verifyTokenNone = "none"
 )
 
 const (
        DefaultDrainTime = 30 * time.Second
 )
 
-var ErrServerClosed = http.ErrServerClosed
-var randSrc = rand.New(rand.NewSource(time.Now().UnixNano()))
-var portSpec = regexp.MustCompile(":[0-9]{1,5}$")
-var domainComponentSpec = regexp.MustCompile("^" + hostnameWildcardSingle + "$")
-var initHooks []initHook
-var routeParseFuncs []routeParseFunc
+var (
+       ErrServerClosed          = http.ErrServerClosed
+       ErrSSLCertAndMtlsIDMutex = errors.New("cert and mtls_id options are mutually exclusive")
+       randSrc                  = rand.New(rand.NewSource(time.Now().UnixNano()))
+       portSpec                 = regexp.MustCompile(":[0-9]{1,5}$")
+       domainComponentSpec      = regexp.MustCompile("^" + hostnameWildcardSingle + "$")
+       initHooks                []initHook
+       routeParseFuncs          []routeParseFunc
+       authorizers              map[string]Authorizer
+       authorizersOnce          sync.Once
+)
 
 func AddServerInitHook(hook initHook) {
        initHooks = append(initHooks, hook)
@@ -123,6 +154,19 @@ func AddRouteParseFunc(rpf routeParseFunc) {
        routeParseFuncs = append(routeParseFuncs, rpf)
 }
 
+func AddAuthorizer(name string, a Authorizer) {
+       authorizersOnce.Do(func() {
+               authorizers = make(map[string]Authorizer)
+       })
+
+       if _, ok := authorizers[name]; ok {
+               log.Panicf("authorizer %q already registered", name)
+               return
+       }
+
+       authorizers[name] = a
+}
+
 func NewServer() *Server {
        return NewServerWithContext(context.Background())
 }
@@ -138,7 +182,8 @@ func NewServerWithContext(ctx context.Context) *Server {
 // UnmarshalYAML implements yaml.Unmarshaler
 func (r *Route) UnmarshalYAML(node *yaml.Node) error {
        var rawNode struct {
-               Path *stringmatch.MatchRule `yaml:"path"`
+               Path        *stringmatch.MatchRule `yaml:"path"`
+               Authorizers []string               `yaml:"auth"`
        }
 
        if err := node.Decode(&rawNode); err != nil {
@@ -155,6 +200,25 @@ func (r *Route) UnmarshalYAML(node *yaml.Node) error {
                return errors.New("error unmarshaling route: path must be specified")
        }
 
+       if len(rawNode.Authorizers) > 0 {
+               for _, azName := range rawNode.Authorizers {
+                       authorizer, ok := authorizers[azName]
+                       if !ok {
+                               return fmt.Errorf("unknown authorizer: %q", azName)
+                       }
+
+                       handler, err := authorizer.New(node)
+                       if err != nil {
+                               return fmt.Errorf("error initializing authorizer %q: %v", azName, err)
+                       }
+
+                       r.Authorizers = append(r.Authorizers, &authznEntry{
+                               name:    azName,
+                               handler: handler,
+                       })
+               }
+       }
+
        for _, rpf := range routeParseFuncs {
                action, err := rpf(node)
                if err != nil {
@@ -176,6 +240,9 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error {
                ProxyProtocol          bool   `yaml:"proxy_protocol"`
                InsecureAddr           string `yaml:"listen_insecure"`
                Certificate            string `yaml:"cert"`
+               MtlsID                 string `yaml:"mtls_id"`
+               Verify                 string `yaml:"verify"`
+               RequireCert            string `yaml:"require_cert"`
                TrustUpstreamRequestID bool   `yaml:"trust_upstream_request_id"`
                DrainTime              string `yaml:"drain_time"`
        }
@@ -205,6 +272,9 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error {
        l.ProxyProtocol = rawNode.ProxyProtocol
        l.InsecureAddr = rawNode.InsecureAddr
        l.Certificate = rawNode.Certificate
+       l.MtlsID = rawNode.MtlsID
+       l.Verify = rawNode.Verify
+       l.RequireCert = rawNode.RequireCert
        l.TrustUpstreamRequestID = rawNode.TrustUpstreamRequestID
 
        if rawNode.DrainTime != "" {
@@ -217,6 +287,10 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error {
                l.DrainTime = DefaultDrainTime
        }
 
+       if rawNode.Certificate != "" && rawNode.MtlsID != "" {
+               return ErrSSLCertAndMtlsIDMutex
+       }
+
        coveredVhosts := hashset.NewHashSet[string]()
 
        if httpNode.HTTPVirtualHosts != nil {
@@ -227,8 +301,18 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error {
                        if vhost.BaseVirtualHost == nil {
                                vhost.BaseVirtualHost = &BaseVirtualHost{}
                        }
+                       if vhost.Verify == nil && l.Verify != "" {
+                               vhost.Verify = &l.Verify
+                       }
+                       if vhost.RequireCert == nil && l.RequireCert != "" {
+                               vhost.RequireCert = &l.RequireCert
+                       }
                        coveredVhosts.Add(name)
                        vhost.Aliases = append(vhost.Aliases, name)
+                       if vhost.Certificate == "" && vhost.MtlsID == "" {
+                               vhost.Certificate = l.Certificate
+                               vhost.MtlsID = l.MtlsID
+                       }
                        l.VirtualHosts = append(l.VirtualHosts, vhost)
                }
        }
@@ -245,7 +329,8 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error {
                        if coveredVhosts.Contains(name) {
                                return fmt.Errorf(
                                        "virtual host %q contains both http routes and a SNI backend, this is not allowed",
-                                       name)
+                                       name,
+                               )
                        }
 
                        coveredVhosts.Add(name)
@@ -257,12 +342,30 @@ func (l *Listener) UnmarshalYAML(node *yaml.Node) error {
        if coveredVhosts.Len() < len(httpNode.HTTPVirtualHosts) {
                return fmt.Errorf(
                        "%d virtual hosts are unconfigured, specify either routes for HTTP or tcp_backend "+
-                               "for SNI proxying", len(httpNode.HTTPVirtualHosts)-coveredVhosts.Len())
+                               "for SNI proxying", len(httpNode.HTTPVirtualHosts)-coveredVhosts.Len(),
+               )
        }
 
        return nil
 }
 
+func (l *Listener) serverIdentity() (mtls.Identity, error) {
+       if l.Certificate != "" && l.MtlsID != "" {
+               return nil, ErrSSLCertAndMtlsIDMutex
+       }
+
+       var id mtls.Identity
+       if l.Certificate != "" {
+               id = mtls.NewSSLCertificate(l.Certificate)
+       } else if l.MtlsID != "" {
+               id = mtls.NewServiceIdentity(l.MtlsID)
+       } else {
+               return nil, nil
+       }
+
+       return id, nil
+}
+
 // UnmarshalYAML implements yaml.Unmarshaler
 func (s *Server) UnmarshalYAML(node *yaml.Node) error {
        var lc struct {
@@ -290,8 +393,9 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error {
                for i, vhost := range listener.VirtualHosts {
                        switch vhost := vhost.(type) {
                        case *HTTPVirtualHost:
-                               if vhost.Certificate == "" {
+                               if vhost.Certificate == "" && vhost.MtlsID == "" {
                                        vhost.Certificate = listener.Certificate
+                                       vhost.MtlsID = listener.MtlsID
                                }
                                if vhost.TrustUpstreamRequestID == nil {
                                        vhost.TrustUpstreamRequestID = &listener.TrustUpstreamRequestID
@@ -304,7 +408,7 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error {
                                if err := vhost.precompileAliases(); err != nil {
                                        return fmt.Errorf("bootstrapping sni vhost %d: %v", i, err)
                                }
-                               log.Default().Debugf("sni vhost %+v bootatrapped", vhost.Names())
+                               log.Default().Debugf("sni vhost %+v bootstrapped", vhost.Names())
                        default:
                                return fmt.Errorf("unsupported vhost type: %T", vhost)
                        }
@@ -335,10 +439,20 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error {
                                return fmt.Errorf("hot reload refused: listener %d: toggling proxy protocol is not supported", i)
                        }
 
+                       oldId, err := oldL.serverIdentity()
+                       if err != nil {
+                               return fmt.Errorf("unable to get old listener server identity: %v", err)
+                       }
+                       newId, err := newL.serverIdentity()
+                       if err != nil {
+                               return fmt.Errorf("unable to get new listener server identity: %v", err)
+                       }
+
                        oldL.TrustUpstreamRequestID = newL.TrustUpstreamRequestID
                        oldL.ExternalPort = newL.ExternalPort
                        oldL.DrainTime = newL.DrainTime
                        oldL.Certificate = newL.Certificate
+                       oldL.MtlsID = newL.MtlsID
 
                        // virtual hosts that need to be started or closed
                        var (
@@ -370,25 +484,72 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error {
                                        continue
                                }
 
+                               // copy all properties from the new vhost to the old one
                                switch oldVh := vh.(type) {
                                case *HTTPVirtualHost:
                                        newVh := match.(*HTTPVirtualHost)
 
                                        oldVh.Routes = newVh.Routes
                                        oldVh.TrustUpstreamRequestID = newVh.TrustUpstreamRequestID
-                                       if oldVh.Certificate != newVh.Certificate {
-                                               newCert := mtls.NewSSLCertificate(newVh.Certificate)
-                                               if tlsConfig, err := newCert.TlsConfig(listenerCtx); err == nil {
+                                       oldVhostId, err := oldVh.vhostIdentity()
+                                       logger := LoggerFromContext(listenerCtx)
+                                       if err != nil {
+                                               logger.Errorf("error getting identity of old vhost %v: %v", vh.Names(), err)
+                                               break
+                                       }
+                                       newVhostId, err := newVh.vhostIdentity()
+                                       if err != nil {
+                                               logger.Errorf("error getting identity of new vhost %v: %v", vh.Names(), err)
+                                               break
+                                       }
+                                       if oldVhostId == nil {
+                                               if oldId == nil {
+                                                       return errors.New("neither cert nor mtls_id specified for old server")
+                                               }
+                                               oldVhostId = oldId
+                                       }
+                                       if newVhostId == nil {
+                                               if oldId == nil {
+                                                       return errors.New("neither cert nor mtls_id specified for new server")
+                                               }
+                                               newVhostId = newId
+                                       }
+
+                                       verifyChanged := false
+                                       if newVh.Verify != nil {
+                                               if oldVh.Verify == nil || *oldVh.Verify != *newVh.Verify {
+                                                       oldVh.Verify = newVh.Verify
+                                                       verifyChanged = true
+                                               }
+                                       } else if newL.Verify != verifyTokenNone && newL.Verify != "" {
+                                               if oldVh.Verify != nil || (*oldVh.Verify != newL.Verify) {
+                                                       oldVh.Verify = &newL.Verify
+                                                       verifyChanged = true
+                                               }
+                                       } else {
+                                               if oldVh.Verify != nil && *oldVh.Verify != verifyTokenNone {
+                                                       oldVh.Verify = nil
+                                                       verifyChanged = true
+                                               }
+                                       }
+
+                                       if !oldVhostId.Equals(newVhostId) || verifyChanged {
+                                               if tlsConfig, tlsVerifier, err := newVh.newTlsConfig(listenerCtx); err == nil {
                                                        oldVh.swapTlsConfig(tlsConfig)
+                                                       oldVh.Certificate = newVh.Certificate
+                                                       oldVh.MtlsID = newVh.MtlsID
+                                                       oldVh.tlsVerifier = tlsVerifier
                                                } else {
-                                                       LoggerFromContext(listenerCtx).Errorf(
+                                                       logger.Errorf(
                                                                "vhost %+v: error loading new ssl certificate %q: %v",
-                                                               oldVh.Names(), newVh.Certificate, err)
+                                                               oldVh.Names(), newVh.Certificate, err,
+                                                       )
                                                }
                                        }
-                                       LoggerFromContext(listenerCtx).Debugf(
+                                       logger.Debugf(
                                                "loaded %d new routes for HTTP vhost %+v",
-                                               len(oldVh.Routes), oldVh.Names())
+                                               len(oldVh.Routes), oldVh.Names(),
+                                       )
                                case *SNIVirtualHost:
                                        newVh := match.(*SNIVirtualHost)
                                        oldVh.Backend = newVh.Backend
@@ -410,15 +571,16 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error {
                                if err != nil {
                                        LoggerFromContext(listenerCtx).Warningf(
                                                "failed to start vhost(%T, %+v): %v",
-                                               vhost, vhost.Names(), err)
+                                               vhost, vhost.Names(), err,
+                                       )
                                        continue
                                }
 
                                oldL.wg.Add(1)
-                               go (func() {
+                               go func() {
                                        defer oldL.wg.Done()
                                        serveFunc()
-                               })()
+                               }()
 
                                oldL.VirtualHosts = append(oldL.VirtualHosts, vhost)
 
@@ -436,7 +598,8 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error {
                                        if err := vhost.Shutdown(shutdownCtx); err != nil {
                                                LoggerFromContext(listenerCtx).Warningf(
                                                        "failed to shutdown vhost(%T, %+v): %v",
-                                                       vhost, vhost.Names(), err)
+                                                       vhost, vhost.Names(), err,
+                                               )
                                        }
 
                                        LoggerFromContext(listenerCtx).Debugf("shut down removed vhost %+v", vhost.Names())
@@ -467,17 +630,17 @@ func (s *Server) UnmarshalYAML(node *yaml.Node) error {
 
 func (s *Server) ListenAndServeTLS() (err error) {
        s.servingTLS = true
-       defer (func() { s.servingTLS = false })()
+       defer func() { s.servingTLS = false }()
        wg := &sync.WaitGroup{}
 
        for _, listener := range s.Listeners {
                listenerCtx := context.WithValue(s.Context, kListener, listener)
 
                wg.Add(1)
-               go (func() {
+               go func() {
                        defer wg.Done()
                        err = listener.ListenAndServeSNI(listenerCtx)
-               })()
+               }()
        }
 
        wg.Wait()
@@ -486,7 +649,7 @@ func (s *Server) ListenAndServeTLS() (err error) {
 
 func (s *Server) ListenAndServe() (err error) {
        s.servingHTTP = true
-       defer (func() { s.servingHTTP = false })()
+       defer func() { s.servingHTTP = false }()
 
        wg := &sync.WaitGroup{}
        for _, listener := range s.Listeners {
@@ -514,10 +677,10 @@ func (s *Server) ListenAndServe() (err error) {
 
                listener.httpServer = server
                wg.Add(1)
-               go (func() {
+               go func() {
                        defer wg.Done()
                        err = server.Serve(netListener)
-               })()
+               }()
        }
 
        wg.Wait()
@@ -531,25 +694,25 @@ func (s *Server) Shutdown(shutdownCtx context.Context) error {
                if listener.httpServer != nil {
                        wg.Add(1)
 
-                       go (func() {
+                       go func() {
                                defer wg.Done()
                                err = listener.httpServer.Shutdown(shutdownCtx)
-                       })()
+                       }()
                }
 
                if listener.sniServer != nil {
                        wg.Add(1)
-                       go (func() {
+                       go func() {
                                defer wg.Done()
                                err = listener.sniServer.Close()
-                       })()
+                       }()
 
                        for _, vhost := range listener.VirtualHosts {
                                wg.Add(1)
-                               go (func() {
+                               go func() {
                                        defer wg.Done()
                                        err = vhost.Shutdown(shutdownCtx)
-                               })()
+                               }()
                        }
                }
        }
@@ -597,17 +760,17 @@ func (l *Listener) ListenAndServeSNI(ctx context.Context) (err error) {
                }
 
                l.wg.Add(1)
-               go (func() {
+               go func() {
                        defer l.wg.Done()
                        serveFunc()
-               })()
+               }()
        }
 
        l.wg.Add(1)
-       go (func() {
+       go func() {
                defer l.wg.Done()
                err = l.sniServer.Serve()
-       })()
+       }()
 
        return err
 }
@@ -620,7 +783,8 @@ func (l *Listener) NewHTTPSRedirectorWithContext(ctx context.Context) *http.Serv
 
        lm := log.NewLoggingMiddlewareWithLogger(
                http.HandlerFunc(l.handleRedirectToHTTPS),
-               logger.AppendPrefix(".access").WithLevel(log.INFO))
+               logger.AppendPrefix(".access").WithLevel(log.INFO),
+       )
 
        server := &http.Server{
                Addr:    l.InsecureAddr,
@@ -749,6 +913,15 @@ func LoggerFromContext(ctx context.Context) log.Logger {
        return nil
 }
 
+func VirtualHostFromContext(ctx context.Context) VirtualHost {
+       vh := ctx.Value(kVirtualHost)
+       if vh, ok := vh.(VirtualHost); ok {
+               return vh
+       }
+
+       return nil
+}
+
 func AuthorizationFromContext(ctx context.Context) *Authorization {
        a := log.StateFromContext(ctx).Get(kAuthorization)
        if authorization, ok := a.(*Authorization); ok {
index df33671b9f37247195da867388d16b5efd039de2..901fc64801290dca3703dacf5ce00bde251f87b3 100644 (file)
@@ -80,11 +80,13 @@ func (l *SNIListener) Serve() error {
 
                                l.logger.Errorf(
                                        "permanent error calling Accept(), propagating to vhosts: %v",
-                                       netErr)
+                                       netErr,
+                               )
                        } else {
                                l.logger.Errorf(
                                        "non-net.Error of type %T calling Accept(), propagating to vhosts: %v",
-                                       err, err)
+                                       err, err,
+                               )
                        }
                        l.vhostsMu.Lock()
                        defer l.vhostsMu.Unlock()
@@ -138,7 +140,8 @@ func (l *SNIListener) handle(conn net.Conn, logger log.Logger) {
                        logger.V(1).Infof(
                                "new conn matched vhost %+v for name %q, dispatch",
                                vhost.matcher.Names(),
-                               clientHello.ServerName)
+                               clientHello.ServerName,
+                       )
                        vhost.msg(childConn, nil)
                        return
                }
index 4f17681a9e95477c0c661e706e2a881f490a00a5..c86237c485d3272e5a727136b0fad6208c970e54 100644 (file)
@@ -40,6 +40,42 @@ func LoadCertificatesFromPEM(path string) ([]*x509.Certificate, error) {
        }
 }
 
+func SplitChain(certs []*x509.Certificate) (leaf *x509.Certificate, chain []*x509.Certificate) {
+       for _, cert := range certs {
+               if cert.IsCA {
+                       chain = append(chain, cert)
+               } else {
+                       leaf = cert
+               }
+       }
+
+       return
+}
+
+func SplitChainRoot(certs []*x509.Certificate) (leaf *x509.Certificate, chain []*x509.Certificate, root *x509.Certificate) {
+       for _, cert := range certs {
+               if cert.IsCA {
+                       if IsSelfSigned(cert) {
+                               root = cert
+                       } else {
+                               chain = append(chain, cert)
+                       }
+               } else {
+                       leaf = cert
+               }
+       }
+
+       return
+}
+
+func IsSelfSigned(cert *x509.Certificate) bool {
+       if cert.Issuer.ToRDNSequence().String() != cert.Subject.ToRDNSequence().String() {
+               return false
+       }
+
+       return cert.CheckSignatureFrom(cert) == nil
+}
+
 func LoadPrivateKeyFromPEM(path string) (crypto.PrivateKey, error) {
        contents, err := os.ReadFile(path)
        if err != nil {
index 4c158a7aec9a5e05baeecab0603e94182d58a42a..57e3f7a8d9fec933ca36c0b9f2a5ab12aca21902 100644 (file)
@@ -74,25 +74,9 @@ type stubIdentity struct {
 
 var _ Identity = &stubIdentity{}
 
-type IdentityLoaderFunc func(cls PrincipalClass, name string) (CertificateProvider, error)
-
-type identityDriver struct {
-       name string
-       load IdentityLoaderFunc
-}
-
-var identityDrivers []*identityDriver
-
 var ErrUnsupportedClass = errors.New("this driver does not support loading this identity class")
 
-func RegisterIdentityDriver(name string, load IdentityLoaderFunc) {
-       driver := &identityDriver{
-               name: name,
-               load: load,
-       }
-
-       identityDrivers = append(identityDrivers, driver)
-}
+type IdentityLoaderFunc func(cls PrincipalClass, name string) (CertificateProvider, error)
 
 func (id *substantiatedIdentity) Name() string {
        cert, err := id.CertificateProvider.LeafCertificate()
index d5c7948772fa241e8e4f0e375b5131c09ed8c539..90a667a528cafbc9f015ed82f57ab2623d600519 100644 (file)
@@ -17,10 +17,13 @@ import (
        "go.fuhry.dev/runtime/mtls/certutil"
        "go.fuhry.dev/runtime/mtls/fsnotify"
        "go.fuhry.dev/runtime/utils/fsutil"
+       "go.fuhry.dev/runtime/utils/hashset"
        "go.fuhry.dev/runtime/utils/subst"
 )
 
 type FileBackedCertificate struct {
+       obj SwappableCert
+
        LeafPath          string
        IntermediatesPath string
        PrivateKeyPath    string
@@ -29,12 +32,14 @@ type FileBackedCertificate struct {
        mu sync.Mutex
 
        tlsConfig *tls.Config
-       leaf      *x509.Certificate
-       ints      []*x509.Certificate
-       pkey      crypto.PrivateKey
-       root      *x509.Certificate
+       subs      map[SubscriptionHandle]ReloadNotifyFunc
 }
 
+var (
+       _ CertificateProvider       = &FileBackedCertificate{}
+       _ CertificateNotifyOnReload = &FileBackedCertificate{}
+)
+
 type fileBackedRoots struct {
        RootPath          string
        IntermediatesPath string
@@ -103,6 +108,7 @@ func LoadServiceIdentityFromKubernetesCSIDriverSPIFFE(serviceIdentity string) (*
                        IntermediatesPath: path.Join(k8sSpiffeDir, c.leaf),
                        PrivateKeyPath:    path.Join(k8sSpiffeDir, c.key),
                        RootPath:          path.Join(k8sSpiffeDir, c.ca),
+                       obj:               NewEmptySwappableCert(),
                }
 
                if cert, err := fbc.LeafCertificate(); err == nil {
@@ -118,6 +124,30 @@ func LoadServiceIdentityFromKubernetesCSIDriverSPIFFE(serviceIdentity string) (*
        return nil, fmt.Errorf("cannot find files for service identity %q in %q", serviceIdentity, k8sSpiffeDir)
 }
 
+func loadCertificatesFromPEMs(paths ...string) []*x509.Certificate {
+       pathsSet := hashset.FromSlice(paths)
+
+       var out []*x509.Certificate
+       outSet := hashset.NewHashSet[string]()
+
+       for _, f := range pathsSet.AsSlice() {
+               contents, err := certutil.LoadCertificatesFromPEM(f)
+               if err == nil {
+                       for _, cert := range contents {
+                               raw := string(cert.Raw)
+                               if outSet.Contains(raw) {
+                                       continue
+                               }
+                               outSet.Add(raw)
+
+                               out = append(out, cert)
+                       }
+               }
+       }
+
+       return out
+}
+
 func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity string) (*FileBackedCertificate, error) {
        certDirectory := path.Join(mtlsRootPath, serviceIdentity)
 
@@ -138,7 +168,8 @@ func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity st
                        logger.V(2).Errorf(
                                "cannot load identity %q from %s: cannot read from either possible leaf "+
                                        "certificate path:\n  %s: %v\n  %s: %v",
-                               serviceIdentity, certDirectory, leafPath, leafErr, fullchainPath, err)
+                               serviceIdentity, certDirectory, leafPath, leafErr, fullchainPath, err,
+                       )
                        return nil, err
                }
        }
@@ -151,7 +182,8 @@ func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity st
                        logger.V(2).Errorf(
                                "cannot load identity %q from %s: cannot read from either possible "+
                                        "intermediate chain path:\n  %s: %v\n  %s: %v",
-                               serviceIdentity, certDirectory, chainPath, chainErr, fullchainPath, err)
+                               serviceIdentity, certDirectory, chainPath, chainErr, fullchainPath, err,
+                       )
                }
        }
        for _, file := range []string{keyPath, rootPath} {
@@ -161,11 +193,20 @@ func newFileBackedCertificateFromBaseDir(mtlsRootPath string, serviceIdentity st
                }
        }
 
+       obj := NewEmptySwappableCert()
+       certs := loadCertificatesFromPEMs(leafPath, chainPath, rootPath)
+       leaf, ints, root := certutil.SplitChainRoot(certs)
+       pkey, err := certutil.LoadPrivateKeyFromPEM(keyPath)
+       if leaf != nil && ints != nil && root != nil && pkey != nil && err == nil {
+               obj = NewSwappableCert(leaf, pkey, ints, root)
+       }
+
        return &FileBackedCertificate{
                LeafPath:          leafPath,
                IntermediatesPath: chainPath,
                PrivateKeyPath:    keyPath,
                RootPath:          rootPath,
+               obj:               obj,
        }, nil
 }
 
@@ -193,6 +234,7 @@ func LoadUserIdentityFromFilesystem(username string) (*FileBackedCertificate, er
                IntermediatesPath: fullChainPath,
                PrivateKeyPath:    keyPath,
                RootPath:          rootPath,
+               obj:               NewEmptySwappableCert(),
        }
 
        leaf, err := cert.LeafCertificate()
@@ -242,6 +284,7 @@ func LoadSSLCertificateFromFilesystem(certName string) (*FileBackedCertificate,
                IntermediatesPath: chainPath,
                PrivateKeyPath:    keyPath,
                RootPath:          rootPath,
+               obj:               NewEmptySwappableCert(),
        }, nil
 }
 
@@ -249,8 +292,8 @@ func (c *FileBackedCertificate) LeafCertificate() (*x509.Certificate, error) {
        c.mu.Lock()
        defer c.mu.Unlock()
 
-       if c.leaf != nil {
-               return c.leaf, nil
+       if leaf := c.obj.LiveLeaf(); leaf != nil {
+               return leaf, nil
        }
 
        leaf, err := c.tryLoadLeaf()
@@ -258,7 +301,7 @@ func (c *FileBackedCertificate) LeafCertificate() (*x509.Certificate, error) {
                return nil, err
        }
 
-       c.leaf = leaf
+       c.obj.ReplaceLeaf(leaf)
        return leaf, nil
 }
 
@@ -268,23 +311,20 @@ func (c *FileBackedCertificate) tryLoadLeaf() (*x509.Certificate, error) {
                return nil, err
        }
 
-       for _, cert := range certs {
-               if cert.IsCA {
-                       continue
-               }
-
-               return cert, nil
+       leaf, _ := certutil.SplitChain(certs)
+       if leaf == nil {
+               return nil, fmt.Errorf("leaf certificate path %q contains no end-entity certificates", c.LeafPath)
        }
 
-       return nil, fmt.Errorf("leaf certificate path %q contains no end-entity certificates", c.LeafPath)
+       return leaf, nil
 }
 
 func (c *FileBackedCertificate) PrivateKey() (crypto.PrivateKey, error) {
        c.mu.Lock()
        defer c.mu.Unlock()
 
-       if c.pkey != nil {
-               return c.pkey, nil
+       if pkey := c.obj.LiveKey(); pkey != nil {
+               return pkey, nil
        }
 
        pkey, err := c.tryLoadPrivateKey()
@@ -292,8 +332,8 @@ func (c *FileBackedCertificate) PrivateKey() (crypto.PrivateKey, error) {
                return nil, err
        }
 
-       c.pkey = pkey
-       return c.pkey, nil
+       c.obj.ReplaceKey(pkey)
+       return pkey, nil
 }
 
 func (c *FileBackedCertificate) tryLoadPrivateKey() (crypto.PrivateKey, error) {
@@ -304,8 +344,8 @@ func (c *FileBackedCertificate) RootCertificate() (*x509.Certificate, error) {
        c.mu.Lock()
        defer c.mu.Unlock()
 
-       if c.root != nil {
-               return c.root, nil
+       if root := c.obj.LiveRoot(); root != nil {
+               return root, nil
        }
 
        root, err := c.tryLoadRoot()
@@ -313,7 +353,7 @@ func (c *FileBackedCertificate) RootCertificate() (*x509.Certificate, error) {
                return nil, err
        }
 
-       c.root = root
+       c.obj.ReplaceRoot(root)
        return root, nil
 }
 
@@ -336,8 +376,8 @@ func (c *FileBackedCertificate) IntermediateCertificates() ([]*x509.Certificate,
        c.mu.Lock()
        defer c.mu.Unlock()
 
-       if c.ints != nil {
-               return c.ints, nil
+       if ints := c.obj.LiveIntermediates(); ints != nil {
+               return ints, nil
        }
 
        newInts, err := c.tryLoadIntermediates()
@@ -345,8 +385,8 @@ func (c *FileBackedCertificate) IntermediateCertificates() ([]*x509.Certificate,
                return nil, err
        }
 
-       c.ints = newInts
-       return c.ints, nil
+       c.obj.ReplaceIntermediates(newInts)
+       return newInts, nil
 }
 
 func (c *FileBackedCertificate) tryLoadIntermediates() ([]*x509.Certificate, error) {
@@ -354,18 +394,59 @@ func (c *FileBackedCertificate) tryLoadIntermediates() ([]*x509.Certificate, err
        if err != nil {
                return nil, err
        }
-       newInts := make([]*x509.Certificate, 0)
+       _, newInts := certutil.SplitChain(certs)
 
-       for _, cert := range certs {
-               if !cert.IsCA {
-                       continue
-               }
+       // don't check length of newInts here - sometimes there may not be any
+       return newInts, nil
+}
 
-               newInts = append(newInts, cert)
+func (c *FileBackedCertificate) Subscribe(f ReloadNotifyFunc) SubscriptionHandle {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       if c.subs == nil {
+               c.subs = make(map[SubscriptionHandle]ReloadNotifyFunc)
        }
 
-       // don't check length of newInts here - sometimes there may not be any
-       return newInts, nil
+       h := NewSubscriptionHandle()
+       c.subs[h] = f
+       return h
+}
+
+func (c *FileBackedCertificate) Unsubscribe(h SubscriptionHandle) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       if c.subs == nil {
+               return
+       }
+
+       delete(c.subs, h)
+}
+
+func (c *FileBackedCertificate) notifyWatchers() {
+       // copy the live state
+       leaf, pkey, ints, root := c.obj.LiveState()
+       if leaf == nil || ints == nil || pkey == nil {
+               return
+       }
+
+       // copy the notify handlers
+       c.mu.Lock()
+       if c.subs == nil {
+               c.mu.Unlock()
+               return
+       }
+
+       var watchers []ReloadNotifyFunc
+       for _, f := range c.subs {
+               watchers = append(watchers, f)
+       }
+       c.mu.Unlock()
+
+       for _, f := range watchers {
+               f(leaf, pkey, ints, root)
+       }
 }
 
 func (r *fileBackedRoots) init() {
@@ -528,7 +609,7 @@ func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) {
                cert, err := c.tryLoadLeaf()
                if err == nil {
                        logger.Infof("detected change to leaf certificate %s, reloaded", c.LeafPath)
-                       c.leaf = cert
+                       c.obj.ReplaceLeaf(cert)
                } else {
                        logger.Warningf("leaf certificate %s changed but unable to reload: %v", c.LeafPath, err)
                }
@@ -536,7 +617,7 @@ func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) {
                ints, err := c.tryLoadIntermediates()
                if err == nil {
                        logger.Infof("detected change to intermediate certificates %s, reloaded", c.IntermediatesPath)
-                       c.ints = ints
+                       c.obj.ReplaceIntermediates(ints)
                } else {
                        logger.Warningf("intermediate certificates %s changed but unable to reload: %v", c.IntermediatesPath, err)
                }
@@ -544,7 +625,7 @@ func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) {
                pkey, err := c.tryLoadPrivateKey()
                if err == nil {
                        logger.Infof("detected change to private key %s, reloaded", c.PrivateKeyPath)
-                       c.pkey = pkey
+                       c.obj.ReplaceKey(pkey)
                } else {
                        logger.Warningf("private key %s changed but unable to reload: %v", c.PrivateKeyPath, err)
                }
@@ -552,7 +633,7 @@ func (c *FileBackedCertificate) notifyEvent(filePath string, op fsnotify.Op) {
                root, err := c.tryLoadRoot()
                if err == nil {
                        logger.Infof("detected change to root certificate %s, reloaded", c.RootPath)
-                       c.root = root
+                       c.obj.ReplaceRoot(root)
                } else {
                        logger.Warningf("root certificate %s changed but unable to reload: %v", c.RootPath, err)
                }
@@ -668,25 +749,29 @@ func (f *fileProviderFactory) New(node *yaml.Node) (IdentityLoaderFunc, error) {
                        PrivateKeyPath:    key,
                        IntermediatesPath: chain,
                        RootPath:          root,
+                       obj:               NewEmptySwappableCert(),
                }
 
                if err := validateIdentity(fbc); err != nil {
                        logger.V(2).Noticef("loaded certificate failed validation: %v", err)
                        return nil, fmt.Errorf(
-                               "loaded certificate is not valid: %v", err)
+                               "loaded certificate is not valid: %v", err,
+                       )
                }
 
                id := &substantiatedIdentity{fbc}
                if id.Class() != cls || id.Name() != princ {
                        return nil, fmt.Errorf(
                                "loaded identity did not match the one requested:\n  requested: %s %s\n  loaded: %s %s",
-                               cls.String(), princ, id.Class().String(), id.Name())
+                               cls.String(), princ, id.Class().String(), id.Name(),
+                       )
                }
 
                logger.V(2).Infof(
                        "fileProviderFactory: loaded %s identity %q:\n"+
                                "  leaf:  %s\n  key:   %s\n  chain: %s\n  root:  %s",
-                       cls.String(), princ, leaf, key, chain, root)
+                       cls.String(), princ, leaf, key, chain, root,
+               )
 
                return fbc, nil
        }, nil
@@ -719,24 +804,6 @@ func init() {
        flag.StringVar(&sslCertsBaseDir, "tls.certs-dir", sslCertsBaseDir, "directory to look under for public-site SSL certificates (NOT mTLS certs)")
        flag.Func("mtls.certs-dir", "additional directory to search for mTLS certificates", appendMtlsCertificateDir)
 
-       RegisterIdentityDriver("file_service_global", func(cls PrincipalClass, serviceName string) (CertificateProvider, error) {
-               if cls != ServicePrincipal {
-                       return nil, ErrUnsupportedClass
-               }
-               return LoadServiceIdentityFromFilesystem(serviceName)
-       })
-       RegisterIdentityDriver("file_service_csi_spiffe", func(cls PrincipalClass, serviceName string) (CertificateProvider, error) {
-               if cls != ServicePrincipal {
-                       return nil, ErrUnsupportedClass
-               }
-               return LoadServiceIdentityFromKubernetesCSIDriverSPIFFE(serviceName)
-       })
-       RegisterIdentityDriver("file_user_home", func(cls PrincipalClass, username string) (CertificateProvider, error) {
-               if cls != UserPrincipal {
-                       return nil, ErrUnsupportedClass
-               }
-               return LoadUserIdentityFromFilesystem(username)
-       })
        RegisterRootDriver("file_etc_mtls", func() (RootsPrimitive, error) {
                return defaultFileBackedRoots, nil
        })
index 031909f0928eaa3547b437bec5d8f68bcbb6c793..a66f01e4f367e924bd734190ed30c6d6d35e3fe4 100644 (file)
@@ -6,6 +6,7 @@ import (
        "crypto/tls"
        "crypto/x509"
        "errors"
+       "math/rand"
        "net"
 )
 
@@ -33,6 +34,15 @@ type CertificatePrimitive interface {
        NewTlsCertificate() (*tls.Certificate, error)
 }
 
+type ReloadNotifyFunc func(leaf *x509.Certificate, pkey crypto.PrivateKey, ints []*x509.Certificate, root *x509.Certificate)
+
+type SubscriptionHandle int64
+
+type CertificateNotifyOnReload interface {
+       Subscribe(ReloadNotifyFunc) SubscriptionHandle
+       Unsubscribe(SubscriptionHandle)
+}
+
 type inaccessibleCertificate struct{}
 
 var ErrCertificateInaccessible = errors.New("requested certificate is inaccessible")
@@ -64,3 +74,14 @@ func (c *inaccessibleCertificate) TlsConfig(ctx context.Context) (*tls.Config, e
 func (c *inaccessibleCertificate) NewDialContextFunc() DialContextFunc {
        return MakeDialContextFunc(c)
 }
+
+func NewSubscriptionHandle() SubscriptionHandle {
+       return SubscriptionHandle(rand.Int63())
+}
+
+type certObject struct {
+       leaf *x509.Certificate
+       pkey crypto.PrivateKey
+       ints []*x509.Certificate
+       root *x509.Certificate
+}
index b7941238c4a9df78512f9be28d3a80f4279491c7..e527eec24b41e4a0131116675f1cf2b6a5698f3e 100644 (file)
@@ -455,8 +455,6 @@ func (f *macosKeychainProviderFactory) New(_ *yaml.Node) (IdentityLoaderFunc, er
 func init() {
        kcLogger = log.WithPrefix("mtls.macOSKeychain")
 
-       RegisterIdentityDriver("macos_keychain", NewCertificateFromMacKeychain)
-
        RegisterProviderFactory("macos_keychain", &macosKeychainProviderFactory{})
 
        RegisterRootDriver("macos_keychain", func() (RootsPrimitive, error) {
index 79da91ce0d76f868a8d2cabea96bd2d27fd4e3b2..5213e939cf9ac16374f3ce84f83b35dd073ac9f5 100644 (file)
@@ -2,10 +2,56 @@ package mtls
 
 import (
        "context"
+       "crypto"
+       "crypto/ecdsa"
+       "crypto/ed25519"
+       "crypto/rsa"
        "crypto/tls"
+       "crypto/x509"
        "net"
+       "sync"
 )
 
+// SwappableCert wraps a leaf certificate, intermediates, root, and private key.
+//
+// The `Live*` methods copy the respective properties before returning them.
+//
+// The `Replace*` methods are concurrency-safe. Each accepts the respective new property
+// and constructs a pending state from each of the four properties. If the properties
+// align to build a complete chain including a leaf certificate corresponding to the
+// private key, the live properties are updated. Otherwise, the replaced property is
+// stored in the pending state.
+type SwappableCert interface {
+       LiveLeaf() *x509.Certificate
+       LiveKey() crypto.PrivateKey
+       LiveIntermediates() []*x509.Certificate
+       LiveRoot() *x509.Certificate
+
+       // LiveState returns an atomic snapshot of the live state.
+       LiveState() (*x509.Certificate, crypto.PrivateKey, []*x509.Certificate, *x509.Certificate)
+
+       ReplaceLeaf(*x509.Certificate)
+       ReplaceKey(crypto.PrivateKey)
+       ReplaceIntermediates([]*x509.Certificate)
+       ReplaceRoot(*x509.Certificate)
+}
+
+type swappableCertObject struct {
+       live    certObject
+       pending certObject
+       mu      sync.Mutex
+}
+
+var _ SwappableCert = &swappableCertObject{}
+
+type hasPublic interface {
+       Public() crypto.PublicKey
+}
+
+type comparablePublicKey interface {
+       Equal(crypto.PublicKey) bool
+}
+
 func MakeTlsCertificate(id CertificatePrimitive) (*tls.Certificate, error) {
        leafCertificate, err := id.LeafCertificate()
        if err != nil {
@@ -44,3 +90,238 @@ func MakeDialContextFunc(id CertificateProvider) DialContextFunc {
 
        return dcf
 }
+
+func checkAlignment(cert *x509.Certificate, ints []*x509.Certificate, root *x509.Certificate, key crypto.PrivateKey) bool {
+       // ints may be nil,
+       if cert == nil || key == nil || root == nil {
+               return false
+       }
+
+       privkey, ok := key.(hasPublic)
+       if !ok {
+               return false
+       }
+
+       certPub, ok := cert.PublicKey.(comparablePublicKey)
+       if !ok {
+               return false
+       }
+
+       if !certPub.Equal(privkey.Public()) {
+               return false
+       }
+
+       rootPool := x509.NewCertPool()
+       rootPool.AddCert(root)
+       intsPool := x509.NewCertPool()
+       for _, i := range ints {
+               intsPool.AddCert(i)
+       }
+       vo := x509.VerifyOptions{
+               Roots:         rootPool,
+               Intermediates: intsPool,
+       }
+
+       _, err := cert.Verify(vo)
+       return err == nil
+}
+
+func NewEmptySwappableCert() SwappableCert {
+       return &swappableCertObject{}
+}
+
+func NewSwappableCert(leaf *x509.Certificate, pkey crypto.PrivateKey, ints []*x509.Certificate, root *x509.Certificate) SwappableCert {
+       return &swappableCertObject{
+               live: certObject{leaf, pkey, ints, root},
+       }
+}
+
+func (s *swappableCertObject) pendingLeaf() *x509.Certificate {
+       if s.pending.leaf != nil {
+               return s.pending.leaf
+       }
+
+       return s.live.leaf
+}
+
+func (s *swappableCertObject) pendingKey() crypto.PrivateKey {
+       if s.pending.pkey != nil {
+               return s.pending.pkey
+       }
+
+       return s.live.pkey
+}
+
+func (s *swappableCertObject) pendingIntermediates() []*x509.Certificate {
+       if s.pending.ints != nil {
+               return s.pending.ints
+       }
+
+       return s.live.ints
+}
+
+func (s *swappableCertObject) pendingRoot() *x509.Certificate {
+       if s.pending.root != nil {
+               return s.pending.root
+       }
+
+       return s.live.root
+}
+
+func (s *swappableCertObject) LiveLeaf() *x509.Certificate {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       if s.live.leaf == nil {
+               return nil
+       }
+       leafCopy := *s.live.leaf
+       return &leafCopy
+}
+
+func (s *swappableCertObject) LiveKey() crypto.PrivateKey {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       if s.live.pkey == nil {
+               return nil
+       }
+
+       switch k := s.live.pkey.(type) {
+       case *rsa.PrivateKey:
+               kCopy := *k
+               return &kCopy
+       case *ecdsa.PrivateKey:
+               kCopy := *k
+               return &kCopy
+       case *ed25519.PrivateKey:
+               kCopy := *k
+               return &kCopy
+       }
+
+       // fall back to not copying... not ideal
+       return s.live.pkey
+}
+
+func (s *swappableCertObject) LiveIntermediates() []*x509.Certificate {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       if s.live.ints == nil {
+               return nil
+       }
+
+       intsCopy := make([]*x509.Certificate, len(s.live.ints))
+       for i, c := range s.live.ints {
+               cCopy := *c
+               intsCopy[i] = &cCopy
+       }
+       return intsCopy
+}
+
+func (s *swappableCertObject) LiveRoot() *x509.Certificate {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       if s.live.root == nil {
+               return nil
+       }
+
+       rootCopy := *s.live.root
+       return &rootCopy
+}
+
+func (s *swappableCertObject) LiveState() (leaf *x509.Certificate, pkey crypto.PrivateKey, ints []*x509.Certificate, root *x509.Certificate) {
+       leafCopy := *s.live.leaf
+       leaf = &leafCopy
+
+       switch k := s.live.pkey.(type) {
+       case *rsa.PrivateKey:
+               kCopy := *k
+               pkey = kCopy
+       case *ecdsa.PrivateKey:
+               kCopy := *k
+               pkey = kCopy
+       case *ed25519.PrivateKey:
+               kCopy := *k
+               pkey = kCopy
+       default:
+               // fall back to not copying
+               pkey = s.live.pkey
+       }
+
+       ints = make([]*x509.Certificate, len(s.live.ints))
+       for i, c := range s.live.ints {
+               cCopy := *c
+               ints[i] = &cCopy
+       }
+
+       rootCopy := *s.live.root
+       root = &rootCopy
+
+       return
+}
+
+func (s *swappableCertObject) ReplaceLeaf(cert *x509.Certificate) {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       pkey := s.pendingKey()
+       ints := s.pendingIntermediates()
+       root := s.pendingRoot()
+
+       if checkAlignment(cert, ints, root, pkey) {
+               s.live.leaf, s.live.ints, s.live.root, s.pending.pkey = cert, ints, root, pkey
+               s.pending = certObject{}
+       } else {
+               s.pending.leaf = cert
+       }
+}
+
+func (s *swappableCertObject) ReplaceKey(pkey crypto.PrivateKey) {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       cert := s.pendingLeaf()
+       ints := s.pendingIntermediates()
+       root := s.pendingRoot()
+
+       if checkAlignment(cert, ints, root, pkey) {
+               s.live.leaf, s.live.ints, s.live.root, s.pending.pkey = cert, ints, root, pkey
+               s.pending = certObject{}
+       } else {
+               s.pending.pkey = pkey
+       }
+}
+
+func (s *swappableCertObject) ReplaceIntermediates(ints []*x509.Certificate) {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       cert := s.pendingLeaf()
+       pkey := s.pendingKey()
+       root := s.pendingRoot()
+
+       if checkAlignment(cert, ints, root, pkey) {
+               s.live.leaf, s.live.ints, s.live.root, s.pending.pkey = cert, ints, root, pkey
+               s.pending = certObject{}
+       } else {
+               s.pending.pkey = pkey
+       }
+}
+
+func (s *swappableCertObject) ReplaceRoot(root *x509.Certificate) {
+       s.mu.Lock()
+       defer s.mu.Unlock()
+
+       cert := s.pendingLeaf()
+       pkey := s.pendingKey()
+       ints := s.pendingIntermediates()
+
+       if checkAlignment(cert, ints, root, pkey) {
+               s.live.leaf, s.live.ints, s.live.root, s.pending.pkey = cert, ints, root, pkey
+               s.pending = certObject{}
+       } else {
+               s.pending.pkey = pkey
+       }
+}
index 4b4b1aa07cd0ff1806e0b95a3a0d70b862dc9f7b..813bba8e5caf7d1f67ecc6621eb3952f2e842b23 100644 (file)
@@ -133,12 +133,5 @@ func (f *tpmProviderFactory) New(_ *yaml.Node) (IdentityLoaderFunc, error) {
 }
 
 func init() {
-       RegisterIdentityDriver("tpm2_pkcs11", func(cls PrincipalClass, serviceName string) (CertificateProvider, error) {
-               if cls != ServicePrincipal || serviceName != constants.DeviceTrustPrincipal {
-                       return nil, ErrUnsupportedClass
-               }
-               return NewTPMBackedCertificate()
-       })
-
        RegisterProviderFactory("tpm2_pkcs11", &tpmProviderFactory{})
 }
index c926d3962681e159fa266c61e4c2109dc99ef935..bd81296d646a5ce5d3646ebc7e0ffa6d0f9b8ec7 100644 (file)
@@ -3,11 +3,16 @@ package mtls
 import (
        "crypto/tls"
        "crypto/x509"
+       "errors"
        "fmt"
+       "net"
        "net/url"
        "regexp"
        "strings"
 
+       "gopkg.in/yaml.v3"
+
+       "go.fuhry.dev/runtime/mtls/certutil"
        "go.fuhry.dev/runtime/utils/hashset"
        "go.fuhry.dev/runtime/utils/log"
 )
@@ -20,6 +25,16 @@ type RemoteIdentity struct {
        Principal string
 }
 
+type verifyYamlRule struct {
+       User    string `yaml:"user"`
+       Service string `yaml:"service"`
+       Domain  string `yaml:"domain"`
+}
+
+type VerifyYamlConfig struct {
+       AllowedPrincipals []*verifyYamlRule `yaml:"allowed_principals"`
+}
+
 const (
        Domain VerifyMatchBy = iota
        Service
@@ -31,6 +46,9 @@ const (
        exprSpiffeServiceIdentity = `^spiffe://(?P<domain>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)/service/(?P<principal>[a-z0-9_-]+)$`
        exprSpiffeUserIdentity    = `^spiffe://(?P<domain>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)/user/(?P<principal>[a-z0-9_-]+)$`
        exprMtlsInternalIdenitty  = `^(?P<principal>[A-Za-z0-9_-]+)\.(?P<domain>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)\.mtls\.internal$`
+       exprDomain                = `^\.?([a-z0-9-]+)(\.[a-z0-9-]+)*$`
+       exprUserOrService         = `^[a-z0-9_-]+$`
+       tokenWildcard             = `*`
 )
 
 func (c VerifyMatchBy) String() string {
@@ -48,20 +66,30 @@ func (c VerifyMatchBy) String() string {
        panic("invalid value for IdentityClass")
 }
 
-var spiffeServiceIdentity, spiffeUserIdentity, mtlsInternalIdentity *regexp.Regexp
+var spiffeServiceIdentity,
+       spiffeUserIdentity,
+       mtlsInternalIdentity,
+       aclDomainExpr,
+       aclUserOrServiceExpr *regexp.Regexp
 
 func init() {
        spiffeServiceIdentity = regexp.MustCompile(exprSpiffeServiceIdentity)
        spiffeUserIdentity = regexp.MustCompile(exprSpiffeUserIdentity)
        mtlsInternalIdentity = regexp.MustCompile(exprMtlsInternalIdenitty)
+       aclDomainExpr = regexp.MustCompile(exprDomain)
+       aclUserOrServiceExpr = regexp.MustCompile(exprUserOrService)
 }
 
 // MTLSPeerVerifier validates SPIFFE CNs and SANs of peer certificates, and facilitates
 // configuration of conventional certificate validation (chain, notbefore/notafter, key
 // usage, etc.) against the mTLS CA.
 //
-// It will not configure the local identity's certificates, private keys, etc. - for that,
-// use `Identity.TlsConfig()`.
+// Identities may be verified at the time of connection using ConfigureClient/ConfigureServer, or
+// later by setting `AllowFrom(All)` and creating a separate verifier for checking names using
+// application-layer information (web route, etc.).
+//
+// MTLSPeerVerifier does not configure the local identity's certificates, private keys, etc. - for
+// that, use `Identity.TlsConfig()`.
 type MTLSPeerVerifier interface {
        // ConfigureServer modifies a tls.Config struct to require client certificates, and verify
        // the client certificate using the custom chain and name verifiers.
@@ -83,15 +111,21 @@ type MTLSPeerVerifier interface {
        //
        // This function is called after VerifyPeerCertificate completes without error.
        VerifyPeerCert(*x509.Certificate, x509.VerifyOptions) error
+
+       // GetPeerIdentity attempts to extract the peer identity from a tls.Conn.
+       GetPeerIdentity(net.Conn) (*RemoteIdentity, error)
+
+       // MatchRemoteIdentity returns nil if the provided RemoteIdentity is allow-listed.
+       MatchRemoteIdentity(*RemoteIdentity) error
 }
 
 type mtlsPeerVerifier struct {
-       MTLSPeerVerifier
-
        allowedPrincipals map[VerifyMatchBy]*hashset.HashSet[string]
        log               log.Logger
 }
 
+var _ MTLSPeerVerifier = &mtlsPeerVerifier{}
+
 func NewPeerNameVerifier() MTLSPeerVerifier {
        cv := &mtlsPeerVerifier{
                allowedPrincipals: make(map[VerifyMatchBy]*hashset.HashSet[string]),
@@ -142,13 +176,23 @@ func (cv *mtlsPeerVerifier) verifyConnectionFunc(verifyOpts x509.VerifyOptions,
                        return ErrNoCertificatePresented
                }
 
-               peerCert := cs.PeerCertificates[0]
+               peerCert, intermediates := certutil.SplitChain(cs.PeerCertificates)
+               verifyOptsCopy := verifyOpts
+               intermediatesPool := verifyOpts.Intermediates.Clone()
+               for _, i := range intermediates {
+                       intermediatesPool.AddCert(i)
+               }
+               verifyOptsCopy.Intermediates = intermediatesPool
 
-               return cv.VerifyPeerCert(peerCert, verifyOpts)
+               return cv.VerifyPeerCert(peerCert, verifyOptsCopy)
        }
 }
 
 func (cv *mtlsPeerVerifier) VerifyPeerCert(peerCert *x509.Certificate, verifyOpts x509.VerifyOptions) error {
+       if peerCert == nil {
+               return ErrNoCertificatePresented
+       }
+
        _, err := peerCert.Verify(verifyOpts)
        if err != nil {
                return err
@@ -196,50 +240,95 @@ func (cv *mtlsPeerVerifier) AllowFrom(class VerifyMatchBy, principals ...string)
        }
 }
 
-func (cv *mtlsPeerVerifier) checkName(name string) error {
-       id, err := ParseRemoteIdentity(name)
-       if err != nil {
-               return err
-       }
-       cv.log.V(3).Debugf("parsed name %q to %+v (class=%s)", name, id, id.Class.String())
-
+func (cv *mtlsPeerVerifier) MatchRemoteIdentity(rid *RemoteIdentity) error {
        if _, ok := cv.allowedPrincipals[All]; ok {
                return nil
        }
 
        if allowedDomains, ok := cv.allowedPrincipals[Domain]; ok {
-               if allowedDomains.Contains(id.Domain) {
-                       cv.log.V(3).Debugf("domain %q exactly matched allowlist", id.Domain)
-                       return nil
-               }
                domainOk := false
-               for _, val := range allowedDomains.AsSlice() {
-                       if val[0] != '.' {
-                               continue
-                       }
-                       if strings.HasSuffix(id.Domain, val) {
-                               cv.log.V(3).Debugf("domain %q matched suffix rule %q", id.Domain, val)
-                               domainOk = true
-                               break
+               if allowedDomains.Contains("*") {
+                       cv.log.V(3).Debug("list contains '*' entry; all domains allowed")
+                       domainOk = true
+               } else if allowedDomains.Contains(rid.Domain) {
+                       cv.log.V(3).Debugf("domain %q exactly matched allowlist", rid.Domain)
+                       domainOk = true
+               } else {
+                       for _, val := range allowedDomains.AsSlice() {
+                               if val[0] == '.' && strings.HasSuffix(rid.Domain, val) {
+                                       cv.log.V(3).Debugf("domain %q matched suffix rule %q", rid.Domain, val)
+                                       domainOk = true
+                                       break
+                               }
                        }
                }
                if !domainOk {
-                       return fmt.Errorf("trust domain %q is not allowed to authenticate to this service", id.Domain)
+                       return fmt.Errorf("trust domain %q is not allowed to authenticate to this service", rid.Domain)
                }
        }
 
-       if allowedPrincipals, ok := cv.allowedPrincipals[id.Class]; ok {
-               if !allowedPrincipals.Contains(id.Principal) {
-                       return fmt.Errorf("principal %q is not allowed to authenticate to this service", id.Principal)
+       if allowedPrincipals, ok := cv.allowedPrincipals[rid.Class]; ok {
+               if allowedPrincipals.Contains("*") {
+                       cv.log.V(3).Debugf("list contains '*' entry; all principals of type %s are allowed", rid.Class.String())
+               }
+
+               if !allowedPrincipals.Contains(rid.Principal) {
+                       return fmt.Errorf("principal %q is not allowed to authenticate to this service", rid.Principal)
                }
 
-               cv.log.V(3).Debugf("principal %q matched allowlist", id.Principal)
+               cv.log.V(3).Debugf("principal %q matched allowlist", rid.Principal)
                return nil
        }
 
        return fmt.Errorf("principals of this type are not allowed")
 }
 
+func (cv *mtlsPeerVerifier) checkName(name string) error {
+       id, err := ParseRemoteIdentity(name)
+       if err != nil {
+               return err
+       }
+       cv.log.V(3).Debugf("parsed name %q to %+v (class=%s)", name, id, id.Class.String())
+
+       return cv.MatchRemoteIdentity(id)
+}
+
+func (cv *mtlsPeerVerifier) GetPeerIdentity(conn net.Conn) (*RemoteIdentity, error) {
+       tlsConn, ok := conn.(*tls.Conn)
+       if !ok {
+               return nil, fmt.Errorf("conn is %T, not *tls.Conn", conn)
+       }
+
+       cs := tlsConn.ConnectionState()
+       if len(cs.PeerCertificates) == 0 {
+               return nil, errors.New("no peer certificate provided")
+       }
+
+       leaf, _ := certutil.SplitChain(cs.PeerCertificates)
+
+       var lastErr error
+       if ri, err := ParseRemoteIdentity(leaf.Subject.CommonName); err == nil {
+               logger.V(2).Debugf("found peer identity in CN: %s", ri.ToSpiffe().String())
+               return ri, nil
+       } else {
+               lastErr = err
+       }
+
+       for i, url := range leaf.URIs {
+               if ri, err := ParseRemoteIdentity(url.String()); err == nil {
+                       logger.V(2).Debugf("found peer identity in URI SAN %d: %s", i, ri.ToSpiffe().String())
+                       return ri, nil
+               } else {
+                       lastErr = err
+               }
+       }
+
+       if lastErr == nil {
+               lastErr = errors.New("found no recognizable SPIFFE URLs in peer certificate")
+       }
+       return nil, lastErr
+}
+
 func ParseRemoteIdentity(name string) (*RemoteIdentity, error) {
        exps := []struct {
                expr  *regexp.Regexp
@@ -302,3 +391,66 @@ func (i *RemoteIdentity) ToDnsName() string {
                        i.Domain)
        }
 }
+
+func NewVerifyYamlConfig(node *yaml.Node) (*VerifyYamlConfig, error) {
+       out := &VerifyYamlConfig{}
+       if err := node.Decode(out); err != nil {
+               return nil, err
+       }
+
+       if len(out.AllowedPrincipals) == 0 {
+               return nil, errors.New("found no allowed_principals")
+       }
+
+       for i, ent := range out.AllowedPrincipals {
+               if err := ent.validate(); err != nil {
+                       return nil, fmt.Errorf("rule %d: %v", i, err)
+               }
+       }
+
+       return out, nil
+}
+
+func (v *VerifyYamlConfig) Apply(verifier MTLSPeerVerifier) error {
+       for i, rule := range v.AllowedPrincipals {
+               if err := rule.validate(); err != nil {
+                       return fmt.Errorf("rule %d: %v", i, err)
+               }
+
+               if rule.Domain != "" {
+                       verifier.AllowFrom(Domain, rule.Domain)
+               } else if rule.User != "" {
+                       verifier.AllowFrom(User, rule.User)
+               } else if rule.Service != "" {
+                       verifier.AllowFrom(Service, rule.Service)
+               }
+       }
+       return nil
+}
+
+func (r *verifyYamlRule) validate() error {
+       var count uint
+       if r.Domain != "" {
+               if !aclDomainExpr.MatchString(r.Domain) {
+                       return fmt.Errorf("domain expression is invalid: %q", r.Domain)
+               }
+               count++
+       }
+       if r.User != "" {
+               if r.User != "*" && !aclUserOrServiceExpr.MatchString(r.User) {
+                       return fmt.Errorf("user expression is invalid: %q", r.User)
+               }
+               count++
+       }
+       if r.Service != "" {
+               if r.Service != "*" && !aclUserOrServiceExpr.MatchString(r.Service) {
+                       return fmt.Errorf("service expression is invalid: %q", r.Service)
+               }
+               count++
+       }
+       if count != 1 {
+               return fmt.Errorf("each rule must specify exactly one of domain, user or service")
+       }
+
+       return nil
+}