Skip to content

Commit eebf8f5

Browse files
committed
Add mutual tls auth support to the router. The verification is via client
side certificates and its use can be mandated to be either required or optional. In addition, an env variable ROUTER_MUTUAL_TLS_AUTH_CN provides more fine-grain control on access (via regular expressions) based on certificate common names.
1 parent dd68e3f commit eebf8f5

File tree

4 files changed

+185
-2
lines changed

4 files changed

+185
-2
lines changed

contrib/completions/bash/oc

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contrib/completions/zsh/oc

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

images/router/haproxy/conf/haproxy-config.template

+60
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ frontend fe_sni
225225
{{- if isTrue (env "ROUTER_STRICT_SNI") }} strict-sni {{ end }}
226226
{{- ""}} crt {{firstMatch ".+" .DefaultCertificate "/var/lib/haproxy/conf/default_pub_keys.pem"}}
227227
{{- ""}} crt-list /var/lib/haproxy/conf/cert_config.map accept-proxy
228+
{{- with (env "ROUTER_MUTUAL_TLS_AUTH_CA") }} ca-file {{.}} {{ end }}
229+
{{- with (env "ROUTER_MUTUAL_TLS_AUTH_CRL") }} crl-file {{.}} {{ end }}
230+
{{- with (env "ROUTER_MUTUAL_TLS_AUTH") }} verify {{.}} {{ end }}
228231
{{- if isTrue (env "ROUTER_ENABLE_HTTP2") }} alpn h2,http/1.1{{ end }}
229232
mode http
230233

@@ -235,6 +238,37 @@ frontend fe_sni
235238
# before matching, or any requests containing uppercase characters will never match.
236239
http-request set-header Host %[req.hdr(Host),lower]
237240

241+
{{ if ne (env "ROUTER_MUTUAL_TLS_AUTH" "none") "none" }}
242+
{{- with (env "ROUTER_MUTUAL_TLS_AUTH_FILTER") }}
243+
# If a mutual TLS auth subject filter environment variable is set, we deny
244+
# requests if the DN field in the client certificate doesn't match that value.
245+
# Please note that this match is a regular expression match.
246+
# Example: For DN set to: /CN=header.test/ST=CA/C=US/O=Security/OU=OpenShift3,
247+
# A. ROUTER_MUTUAL_TLS_AUTH_FILTER="header.test" OR
248+
# ROUTER_MUTUAL_TLS_AUTH_FILTER="head" OR
249+
# ROUTER_MUTUAL_TLS_AUTH_FILTER="^/CN=header.test/ST=CA/C=US/O=Security/OU=OpenShift3$" /* exact match example */
250+
# the filter would match the DN field (substring or exact match)
251+
# and the request will be passed on to the backend.
252+
# B. ROUTER_MUTUAL_TLS_AUTH_FILTER="legacy-web-client", the request
253+
# will be rejected.
254+
acl cert_cn_matches ssl_c_s_dn -m reg {{.}}
255+
http-request deny unless cert_cn_matches
256+
{{- end }}
257+
258+
# Add X-SSL* headers to pass client certificate information to the backend.
259+
http-request set-header X-SSL %[ssl_fc]
260+
http-request set-header X-SSL-Client-Verify %[ssl_c_verify]
261+
http-request set-header X-SSL-Client-Serial %{+Q}[ssl_c_serial,hex]
262+
http-request set-header X-SSL-Client-Version %{+Q}[ssl_c_version]
263+
http-request set-header X-SSL-Client-SHA1 %{+Q}[ssl_c_sha1,hex]
264+
http-request set-header X-SSL-Client-DN %{+Q}[ssl_c_s_dn]
265+
http-request set-header X-SSL-Client-CN %{+Q}[ssl_c_s_dn(cn)]
266+
http-request set-header X-SSL-Issuer %{+Q}[ssl_c_i_dn]
267+
http-request set-header X-SSL-Client-NotBefore %{+Q}[ssl_c_notbefore]
268+
http-request set-header X-SSL-Client-NotAfter %{+Q}[ssl_c_notafter]
269+
http-request set-header X-SSL-Client-DER %{+Q}[ssl_c_der,base64]
270+
{{- end }}
271+
238272
# map to backend
239273
# Search from most specific to general path (host case).
240274
# Note: If no match, haproxy uses the default_backend, no other
@@ -261,6 +295,9 @@ backend be_no_sni
261295
frontend fe_no_sni
262296
# terminate ssl on edge
263297
bind 127.0.0.1:{{env "ROUTER_SERVICE_NO_SNI_PORT" "10443"}} ssl no-sslv3 crt {{firstMatch ".+" .DefaultCertificate "/var/lib/haproxy/conf/default_pub_keys.pem"}} accept-proxy
298+
{{- with (env "ROUTER_MUTUAL_TLS_AUTH_CA") }} ca-file {{.}} {{ end }}
299+
{{- with (env "ROUTER_MUTUAL_TLS_AUTH_CRL") }} crl-file {{.}} {{ end }}
300+
{{- with (env "ROUTER_MUTUAL_TLS_AUTH") }} verify {{.}} {{ end }}
264301
mode http
265302

266303
# Strip off Proxy headers to prevent HTTpoxy (https://httpoxy.org/)
@@ -270,6 +307,29 @@ frontend fe_no_sni
270307
# before matching, or any requests containing uppercase characters will never match.
271308
http-request set-header Host %[req.hdr(Host),lower]
272309

310+
{{ if ne (env "ROUTER_MUTUAL_TLS_AUTH" "none") "none" }}
311+
{{- with (env "ROUTER_MUTUAL_TLS_AUTH_FILTER") }}
312+
# If a mutual TLS auth subject filter environment variable is set, we deny
313+
# requests if the DN field in the client certificate doesn't match that value.
314+
# Please note that this match is a regular expression match.
315+
# See the config section 'frontend fe_sni' for examples.
316+
acl cert_cn_matches ssl_c_s_dn -m reg {{.}}
317+
http-request deny unless cert_cn_matches
318+
{{- end }}
319+
320+
# Add X-SSL* headers to pass client certificate information to the backend.
321+
http-request set-header X-SSL %[ssl_fc]
322+
http-request set-header X-SSL-Client-Verify %[ssl_c_verify]
323+
http-request set-header X-SSL-Client-Serial %{+Q}[ssl_c_serial,hex]
324+
http-request set-header X-SSL-Client-Version %{+Q}[ssl_c_version]
325+
http-request set-header X-SSL-Client-SHA1 %{+Q}[ssl_c_sha1,hex]
326+
http-request set-header X-SSL-Client-DN %{+Q}[ssl_c_s_dn]
327+
http-request set-header X-SSL-Client-CN %{+Q}[ssl_c_s_dn(cn)]
328+
http-request set-header X-SSL-Issuer %{+Q}[ssl_c_i_dn]
329+
http-request set-header X-SSL-Client-NotBefore %{+Q}[ssl_c_notbefore]
330+
http-request set-header X-SSL-Client-NotAfter %{+Q}[ssl_c_notafter]
331+
http-request set-header X-SSL-Client-DER %{+Q}[ssl_c_der,base64]
332+
{{- end }}
273333

274334
# map to backend
275335
# Search from most specific to general path (host case).

pkg/oc/admin/router/router.go

+109-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1919
"k8s.io/apimachinery/pkg/runtime"
2020
"k8s.io/apimachinery/pkg/util/intstr"
21+
"k8s.io/apimachinery/pkg/util/sets"
2122
"k8s.io/apimachinery/pkg/util/validation"
2223
"k8s.io/client-go/dynamic"
2324
"k8s.io/kubernetes/pkg/api/legacyscheme"
@@ -84,6 +85,11 @@ var (
8485
privkeyName = "router.pem"
8586
privkeyPath = secretsPath + "/" + privkeyName
8687

88+
defaultMutualTLSAuth = "none"
89+
clientCertConfigDir = "/etc/pki/tls/client-certs"
90+
clientCertConfigCA = "ca.pem"
91+
clientCertConfigCRL = "crl.pem"
92+
8793
defaultCertificatePath = path.Join(defaultCertificateDir, "tls.crt")
8894
)
8995

@@ -232,6 +238,23 @@ type RouterConfig struct {
232238
Threads int32
233239

234240
Local bool
241+
242+
// MutualTLSAuth controls access to the router using a mutually agreed
243+
// upon TLS authentication mechanism (example client certificates).
244+
// One of: required | optional | none - the default is none.
245+
MutualTLSAuth string
246+
247+
// MutualTLSAuthCA contains the CA certificates that will be used
248+
// to verify a client's certificate.
249+
MutualTLSAuthCA string
250+
251+
// MutualTLSAuthCRL contains the certificate revocation list used to
252+
// verify a client's certificate.
253+
MutualTLSAuthCRL string
254+
255+
// MutualTLSAuthFilter contains the value to filter requests based on
256+
// a client certificate subject field substring match.
257+
MutualTLSAuthFilter string
235258
}
236259

237260
const (
@@ -260,6 +283,8 @@ func NewCmdRouter(f kcmdutil.Factory, parentName, name string, out, errout io.Wr
260283
StatsPort: defaultStatsPort,
261284
HostNetwork: true,
262285
HostPorts: true,
286+
287+
MutualTLSAuth: defaultMutualTLSAuth,
263288
}
264289

265290
cmd := &cobra.Command{
@@ -313,15 +338,25 @@ func NewCmdRouter(f kcmdutil.Factory, parentName, name string, out, errout io.Wr
313338
cmd.Flags().BoolVar(&cfg.Local, "local", cfg.Local, "If true, do not contact the apiserver")
314339
cmd.Flags().Int32Var(&cfg.Threads, "threads", cfg.Threads, "Specifies the number of threads for the haproxy router.")
315340

341+
cmd.Flags().StringVar(&cfg.MutualTLSAuth, "mutual-tls-auth", cfg.MutualTLSAuth, "Controls access to the router using mutually agreed upon TLS configuration (example client certificates). You can choose one of 'required', 'optional', or 'none'. The default is none.")
342+
cmd.Flags().StringVar(&cfg.MutualTLSAuthCA, "mutual-tls-auth-ca", cfg.MutualTLSAuthCA, "Optional path to a file containing one or more CA certificates used for mutual TLS authentication. The CA certificate[s] are used by the router to verify a client's certificate.")
343+
cmd.Flags().StringVar(&cfg.MutualTLSAuthCRL, "mutual-tls-auth-crl", cfg.MutualTLSAuthCRL, "Optional path to a file containing the certificate revocation list used for mutual TLS authentication. The certificate revocation list is used by the router to verify a client's certificate.")
344+
cmd.Flags().StringVar(&cfg.MutualTLSAuthFilter, "mutual-tls-auth-filter", cfg.MutualTLSAuthFilter, "Optional regular expression to filter the client certificates. If the client certificate subject field does _not_ match this regular expression, requests will be rejected by the router.")
345+
316346
cfg.Action.BindForOutput(cmd.Flags())
317347
cmd.Flags().String("output-version", "", "The preferred API versions of the output objects")
318348

319349
return cmd
320350
}
321351

352+
// generateMutualTLSSecretName generates a mutual TLS auth secret name.
353+
func generateMutualTLSSecretName(prefix string) string {
354+
return fmt.Sprintf("%s-mutual-tls-auth", prefix)
355+
}
356+
322357
// generateSecretsConfig generates any Secret and Volume objects, such
323358
// as SSH private keys, that are necessary for the router container.
324-
func generateSecretsConfig(cfg *RouterConfig, namespace string, defaultCert []byte, certName string) ([]*kapi.Secret, []kapi.Volume, []kapi.VolumeMount, error) {
359+
func generateSecretsConfig(cfg *RouterConfig, namespace, certName string, defaultCert, mtlsAuthCA, mtlsAuthCRL []byte) ([]*kapi.Secret, []kapi.Volume, []kapi.VolumeMount, error) {
325360
var secrets []*kapi.Secret
326361
var volumes []kapi.Volume
327362
var mounts []kapi.VolumeMount
@@ -428,6 +463,42 @@ func generateSecretsConfig(cfg *RouterConfig, namespace string, defaultCert []by
428463
}
429464
mounts = append(mounts, mount)
430465

466+
mtlsSecretData := map[string][]byte{}
467+
if len(mtlsAuthCA) > 0 {
468+
mtlsSecretData[clientCertConfigCA] = mtlsAuthCA
469+
}
470+
if len(mtlsAuthCRL) > 0 {
471+
mtlsSecretData[clientCertConfigCRL] = mtlsAuthCRL
472+
}
473+
474+
if len(mtlsSecretData) > 0 {
475+
secretName := generateMutualTLSSecretName(cfg.Name)
476+
secret := &kapi.Secret{
477+
ObjectMeta: metav1.ObjectMeta{
478+
Name: secretName,
479+
},
480+
Data: mtlsSecretData,
481+
}
482+
secrets = append(secrets, secret)
483+
484+
volume := kapi.Volume{
485+
Name: "mutual-tls-config",
486+
VolumeSource: kapi.VolumeSource{
487+
Secret: &kapi.SecretVolumeSource{
488+
SecretName: secretName,
489+
},
490+
},
491+
}
492+
volumes = append(volumes, volume)
493+
494+
mount := kapi.VolumeMount{
495+
Name: volume.Name,
496+
ReadOnly: true,
497+
MountPath: clientCertConfigDir,
498+
}
499+
mounts = append(mounts, mount)
500+
}
501+
431502
return secrets, volumes, mounts, nil
432503
}
433504

@@ -596,6 +667,14 @@ func RunCmdRouter(f kcmdutil.Factory, cmd *cobra.Command, out, errout io.Writer,
596667
if err != nil {
597668
return fmt.Errorf("error getting client: %v", err)
598669
}
670+
671+
if len(cfg.MutualTLSAuthCA) > 0 || len(cfg.MutualTLSAuthCRL) > 0 {
672+
secretName := generateMutualTLSSecretName(cfg.Name)
673+
if _, err := kClient.Core().Secrets(namespace).Get(secretName, metav1.GetOptions{}); err == nil {
674+
return fmt.Errorf("router could not be created: mutual tls secret %q already exists", secretName)
675+
}
676+
}
677+
599678
service, err := kClient.Core().Services(namespace).Get(name, metav1.GetOptions{})
600679
if err != nil {
601680
if !generate {
@@ -648,6 +727,20 @@ func RunCmdRouter(f kcmdutil.Factory, cmd *cobra.Command, out, errout io.Writer,
648727
return fmt.Errorf("router could not be created; error reading default certificate file: %v", err)
649728
}
650729

730+
mtlsAuthOptions := []string{"required", "optional", "none"}
731+
allowedMutualTLSAuthOptions := sets.NewString(mtlsAuthOptions...)
732+
if !allowedMutualTLSAuthOptions.Has(cfg.MutualTLSAuth) {
733+
return fmt.Errorf("invalid mutual tls auth option %v, expected one of %v", cfg.MutualTLSAuth, mtlsAuthOptions)
734+
}
735+
mtlsAuthCA, err := fileutil.LoadData(cfg.MutualTLSAuthCA)
736+
if err != nil {
737+
return fmt.Errorf("reading ca certificates for mutual tls auth: %v", err)
738+
}
739+
mtlsAuthCRL, err := fileutil.LoadData(cfg.MutualTLSAuthCRL)
740+
if err != nil {
741+
return fmt.Errorf("reading certificate revocation list for mutual tls auth: %v", err)
742+
}
743+
651744
if len(cfg.StatsPassword) == 0 {
652745
cfg.StatsPassword = generateStatsPassword()
653746
if !cfg.Action.ShouldPrint() {
@@ -707,6 +800,20 @@ func RunCmdRouter(f kcmdutil.Factory, cmd *cobra.Command, out, errout io.Writer,
707800
env["ROUTER_METRICS_TLS_CERT_FILE"] = "/etc/pki/tls/metrics/tls.crt"
708801
env["ROUTER_METRICS_TLS_KEY_FILE"] = "/etc/pki/tls/metrics/tls.key"
709802
}
803+
mtlsAuth := strings.TrimSpace(cfg.MutualTLSAuth)
804+
if len(mtlsAuth) > 0 && mtlsAuth != defaultMutualTLSAuth {
805+
env["ROUTER_MUTUAL_TLS_AUTH"] = cfg.MutualTLSAuth
806+
if len(mtlsAuthCA) > 0 {
807+
env["ROUTER_MUTUAL_TLS_AUTH_CA"] = path.Join(clientCertConfigDir, clientCertConfigCA)
808+
}
809+
if len(mtlsAuthCRL) > 0 {
810+
env["ROUTER_MUTUAL_TLS_AUTH_CRL"] = path.Join(clientCertConfigDir, clientCertConfigCRL)
811+
}
812+
if len(cfg.MutualTLSAuthFilter) > 0 {
813+
env["ROUTER_MUTUAL_TLS_AUTH_FILTER"] = strings.Replace(cfg.MutualTLSAuthFilter, " ", "\\ ", -1)
814+
}
815+
}
816+
710817
env.Add(secretEnv)
711818
if len(defaultCert) > 0 {
712819
if cfg.SecretsAsEnv {
@@ -717,7 +824,7 @@ func RunCmdRouter(f kcmdutil.Factory, cmd *cobra.Command, out, errout io.Writer,
717824
}
718825
env.Add(app.Environment{"DEFAULT_CERTIFICATE_DIR": defaultCertificateDir})
719826
var certName = fmt.Sprintf("%s-certs", cfg.Name)
720-
secrets, volumes, mounts, err := generateSecretsConfig(cfg, namespace, defaultCert, certName)
827+
secrets, volumes, mounts, err := generateSecretsConfig(cfg, namespace, certName, defaultCert, mtlsAuthCA, mtlsAuthCRL)
721828
if err != nil {
722829
return fmt.Errorf("router could not be created: %v", err)
723830
}

0 commit comments

Comments
 (0)