Skip to content

Commit 46b4007

Browse files
committed
Implement RFC domain-scoped mTLS app-to-app routing in GoRouter
Replace MtlsAllowedSources model with AccessScope/AccessRules selectors, add per-connection TLS state tracking via ConnContext, implement two-layer RFC authorization handler (SNI/Host 421 check + scope/rules enforcement), emit mTLS fields in RTR access logs, and rename router.mtls_domains to router.domains in BOSH config.
1 parent 08527d9 commit 46b4007

15 files changed

Lines changed: 1108 additions & 1006 deletions

File tree

jobs/gorouter/spec

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,25 +200,25 @@ properties:
200200
router.only_trust_client_ca_certs:
201201
description: "When router.only_trust_client_ca_certs is true, router.client_ca_certs are the only trusted CA certs for client requests. When router.only_trust_client_ca_certs is false, router.client_ca_certs are trusted in addition to router.ca_certs and the CA certificates installed on the filesystem. This will have no affect if the `router.client_cert_validation` property is set to none."
202202
default: false
203-
router.mtls_domains:
203+
router.domains:
204204
description: |
205205
Array of domains requiring mutual TLS authentication. Each domain can have its own CA certificate pool, forwarded_client_cert mode, and xfcc_format.
206206
For non-wildcard domains, the domain must match the request host exactly.
207-
For wildcard domains (e.g., *.apps.mtls.internal), the wildcard must be the leftmost label and matches any single label.
207+
For wildcard domains (e.g., *.apps.identity), the wildcard must be the leftmost label and matches any single label.
208208

209209
xfcc_format controls the format of the X-Forwarded-Client-Cert header:
210210
- "raw" (default): Full base64-encoded certificate (~1.5KB)
211211
- "envoy": Compact Hash=<sha256>;Subject="<DN>" format (~300 bytes)
212212
default: []
213213
example:
214-
- domain: "*.apps.mtls.internal"
214+
- name: "*.apps.identity"
215215
ca_certs: |
216216
-----BEGIN CERTIFICATE-----
217-
<CA certificate for apps.mtls.internal domain>
217+
<CA certificate for apps.identity domain>
218218
-----END CERTIFICATE-----
219219
forwarded_client_cert: sanitize_set
220220
xfcc_format: envoy
221-
- domain: "secure.example.com"
221+
- name: "secure.example.com"
222222
ca_certs: |
223223
-----BEGIN CERTIFICATE-----
224224
<CA certificate for secure.example.com>

jobs/gorouter/templates/gorouter.yml.erb

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -503,35 +503,35 @@ if p('router.client_ca_certs')
503503
params['client_ca_certs'] = client_ca_certs
504504
end
505505

506-
if_p('router.mtls_domains') do |mtls_domains|
507-
if !mtls_domains.is_a?(Array)
508-
raise 'router.mtls_domains must be provided as an array'
506+
if_p('router.domains') do |domains|
507+
if !domains.is_a?(Array)
508+
raise 'router.domains must be provided as an array'
509509
end
510510

511511
processed_domains = []
512-
mtls_domains.each do |domain_config|
512+
domains.each do |domain_config|
513513
if !domain_config.is_a?(Hash)
514-
raise 'Each entry in router.mtls_domains must be a hash'
514+
raise 'Each entry in router.domains must be a hash'
515515
end
516516

517-
if !domain_config.key?('domain') || domain_config['domain'].nil? || domain_config['domain'].strip.empty?
518-
raise 'Each entry in router.mtls_domains must have a "domain" key'
517+
if !domain_config.key?('name') || domain_config['name'].nil? || domain_config['name'].strip.empty?
518+
raise 'Each entry in router.domains must have a "name" key'
519519
end
520520

521521
if !domain_config.key?('ca_certs') || domain_config['ca_certs'].nil? || domain_config['ca_certs'].strip.empty?
522-
raise 'Each entry in router.mtls_domains must have a "ca_certs" key with certificate content'
522+
raise 'Each entry in router.domains must have a "ca_certs" key with certificate content'
523523
end
524524

525525
processed_entry = {
526-
'domain' => domain_config['domain'],
526+
'domain' => domain_config['name'],
527527
'ca_certs' => domain_config['ca_certs']
528528
}
529529

530530
if domain_config.key?('forwarded_client_cert') && !domain_config['forwarded_client_cert'].nil?
531531
valid_modes = ['always_forward', 'forward', 'sanitize_set']
532532
mode = domain_config['forwarded_client_cert']
533533
unless valid_modes.include?(mode)
534-
raise "Invalid forwarded_client_cert mode '#{mode}' for domain '#{domain_config['domain']}'. Must be one of: #{valid_modes.join(', ')}"
534+
raise "Invalid forwarded_client_cert mode '#{mode}' for domain '#{domain_config['name']}'. Must be one of: #{valid_modes.join(', ')}"
535535
end
536536
processed_entry['forwarded_client_cert'] = mode
537537
end
@@ -540,15 +540,15 @@ if_p('router.mtls_domains') do |mtls_domains|
540540
valid_formats = ['raw', 'envoy']
541541
format = domain_config['xfcc_format']
542542
unless valid_formats.include?(format)
543-
raise "Invalid xfcc_format '#{format}' for domain '#{domain_config['domain']}'. Must be one of: #{valid_formats.join(', ')}"
543+
raise "Invalid xfcc_format '#{format}' for domain '#{domain_config['name']}'. Must be one of: #{valid_formats.join(', ')}"
544544
end
545545
processed_entry['xfcc_format'] = format
546546
end
547547

548548
processed_domains << processed_entry
549549
end
550550

551-
params['mtls_domains'] = processed_domains
551+
params['domains'] = processed_domains
552552
end
553553

554554
if_p('router.http_rewrite') do |r|

src/code.cloudfoundry.org/gorouter/accesslog/schema/access_log_record.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ type AccessLogRecord struct {
127127
GorouterTime float64
128128

129129
LocalAddress string
130+
131+
// mTLS authorization fields (populated for mTLS domains only).
132+
// MtlsAuth is "allowed" or "denied"; empty for non-mTLS requests.
133+
MtlsAuth string
134+
// MtlsRule identifies the rule that matched or caused denial.
135+
MtlsRule string
136+
// MtlsDeniedReason is a human-readable denial explanation (empty on allow).
137+
MtlsDeniedReason string
138+
// CallerApp/Space/Org are the CF identity fields from the client certificate.
139+
CallerApp string
140+
CallerSpace string
141+
CallerOrg string
142+
// TlsSNI is the SNI used during TLS (logged on 421 rejections).
143+
TlsSNI string
130144
}
131145

132146
func (r *AccessLogRecord) formatStartedAt() string {
@@ -316,6 +330,43 @@ func (r *AccessLogRecord) makeRecord(performTruncate bool) []byte {
316330
b.WriteString(`x_cf_routererror:`)
317331
b.WriteDashOrStringValue(r.RouterError)
318332

333+
// mTLS identity and authorization fields (only emitted when present)
334+
if r.TlsSNI != "" {
335+
// #nosec G104
336+
b.WriteString(` tls_sni:`)
337+
b.WriteDashOrStringValue(r.TlsSNI)
338+
}
339+
if r.CallerApp != "" {
340+
// #nosec G104
341+
b.WriteString(` caller_app:`)
342+
b.WriteDashOrStringValue(r.CallerApp)
343+
}
344+
if r.CallerSpace != "" {
345+
// #nosec G104
346+
b.WriteString(` caller_space:`)
347+
b.WriteDashOrStringValue(r.CallerSpace)
348+
}
349+
if r.CallerOrg != "" {
350+
// #nosec G104
351+
b.WriteString(` caller_org:`)
352+
b.WriteDashOrStringValue(r.CallerOrg)
353+
}
354+
if r.MtlsAuth != "" {
355+
// #nosec G104
356+
b.WriteString(` mtls_auth:`)
357+
b.WriteDashOrStringValue(r.MtlsAuth)
358+
}
359+
if r.MtlsRule != "" {
360+
// #nosec G104
361+
b.WriteString(` mtls_rule:`)
362+
b.WriteDashOrStringValue(r.MtlsRule)
363+
}
364+
if r.MtlsDeniedReason != "" {
365+
// #nosec G104
366+
b.WriteString(` mtls_denied_reason:`)
367+
b.WriteDashOrStringValue(r.MtlsDeniedReason)
368+
}
369+
319370
r.addExtraHeaders(b, performTruncate)
320371

321372
return b.Bytes()

src/code.cloudfoundry.org/gorouter/config/config.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,10 @@ type Config struct {
409409
ClientCACerts string `yaml:"client_ca_certs,omitempty"`
410410
ClientCAPool *x509.CertPool `yaml:"-"`
411411

412-
// MtlsDomains configures domains that require client certificates (mTLS)
413-
// Routes on these domains will require valid instance identity certificates
414-
MtlsDomains []MtlsDomainConfig `yaml:"mtls_domains,omitempty"`
412+
// Domains configures domains that require client certificates (mTLS).
413+
// Corresponds to router.domains in the BOSH manifest (RFC: router.domains).
414+
// Routes on these domains will require valid instance identity certificates.
415+
Domains []MtlsDomainConfig `yaml:"domains,omitempty"`
415416
// Computed: map of domain -> config for fast lookup
416417
mtlsDomainMap map[string]*MtlsDomainConfig `yaml:"-"`
417418

@@ -930,16 +931,16 @@ func (c *Config) processMtlsDomains() error {
930931
// Initialize mTLS domain map
931932
c.mtlsDomainMap = make(map[string]*MtlsDomainConfig)
932933

933-
for i := range c.MtlsDomains {
934-
domain := &c.MtlsDomains[i]
934+
for i := range c.Domains {
935+
domain := &c.Domains[i]
935936
domain.RequireClientCert = true
936937

937938
// Validate forwarded_client_cert mode
938939
if domain.ForwardedClientCert == "" {
939940
domain.ForwardedClientCert = SANITIZE_SET // Default to most secure
940941
}
941942
if !slices.Contains(AllowedForwardedClientCertModes, domain.ForwardedClientCert) {
942-
return fmt.Errorf("mtls_domains[%d].forwarded_client_cert must be one of %v",
943+
return fmt.Errorf("domains[%d].forwarded_client_cert must be one of %v",
943944
i, AllowedForwardedClientCertModes)
944945
}
945946

@@ -948,24 +949,24 @@ func (c *Config) processMtlsDomains() error {
948949
domain.XFCCFormat = XFCC_FORMAT_RAW // Default to raw for backwards compatibility
949950
}
950951
if !slices.Contains(AllowedXFCCFormats, domain.XFCCFormat) {
951-
return fmt.Errorf("mtls_domains[%d].xfcc_format must be one of %v",
952+
return fmt.Errorf("domains[%d].xfcc_format must be one of %v",
952953
i, AllowedXFCCFormats)
953954
}
954955

955956
// Build CA pool for this domain
956957
if domain.CACerts != "" {
957958
pool := x509.NewCertPool()
958959
if !pool.AppendCertsFromPEM([]byte(domain.CACerts)) {
959-
return fmt.Errorf("mtls_domains[%d].ca_certs contains invalid certificates", i)
960+
return fmt.Errorf("domains[%d].ca_certs contains invalid certificates", i)
960961
}
961962
domain.CAPool = pool
962963
} else {
963-
return fmt.Errorf("mtls_domains[%d].ca_certs is required", i)
964+
return fmt.Errorf("domains[%d].ca_certs is required", i)
964965
}
965966

966967
// Validate domain is not empty
967968
if domain.Domain == "" {
968-
return fmt.Errorf("mtls_domains[%d].domain is required", i)
969+
return fmt.Errorf("domains[%d].domain is required", i)
969970
}
970971

971972
c.mtlsDomainMap[domain.Domain] = domain

src/code.cloudfoundry.org/gorouter/handlers/access_log.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ func (a *accessLog) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http
8383

8484
alr.LocalAddress = reqInfo.LocalAddress
8585

86+
// mTLS authorization fields
87+
alr.MtlsAuth = reqInfo.MtlsAuth
88+
alr.MtlsRule = reqInfo.MtlsRule
89+
alr.MtlsDeniedReason = reqInfo.MtlsDeniedReason
90+
alr.CallerApp = reqInfo.CallerApp
91+
alr.CallerSpace = reqInfo.CallerSpace
92+
alr.CallerOrg = reqInfo.CallerOrg
93+
alr.TlsSNI = reqInfo.TlsSNI
94+
8695
a.accessLogger.Log(*alr)
8796
}
8897

src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ var _ = Describe("Clientcert mTLS Domain XFCC Format", func() {
236236
cfg, err := config.DefaultConfig()
237237
Expect(err).NotTo(HaveOccurred())
238238

239-
cfg.MtlsDomains = []config.MtlsDomainConfig{{
239+
cfg.Domains = []config.MtlsDomainConfig{{
240240
Domain: "*.apps.mtls.internal",
241241
CACerts: string(certChain.CACertPEM),
242242
ForwardedClientCert: config.SANITIZE_SET,
@@ -319,7 +319,7 @@ var _ = Describe("Clientcert mTLS Domain XFCC Format", func() {
319319
cfg, err := config.DefaultConfig()
320320
Expect(err).NotTo(HaveOccurred())
321321

322-
cfg.MtlsDomains = []config.MtlsDomainConfig{{
322+
cfg.Domains = []config.MtlsDomainConfig{{
323323
Domain: "*.apps.mtls.internal",
324324
CACerts: string(certChain.CACertPEM),
325325
ForwardedClientCert: config.SANITIZE_SET,
@@ -395,7 +395,7 @@ var _ = Describe("Clientcert mTLS Domain XFCC Format", func() {
395395
cfg, err := config.DefaultConfig()
396396
Expect(err).NotTo(HaveOccurred())
397397

398-
cfg.MtlsDomains = []config.MtlsDomainConfig{{
398+
cfg.Domains = []config.MtlsDomainConfig{{
399399
Domain: "*.apps.mtls.internal",
400400
CACerts: string(certChain.CACertPEM),
401401
ForwardedClientCert: config.SANITIZE_SET,
@@ -405,7 +405,7 @@ var _ = Describe("Clientcert mTLS Domain XFCC Format", func() {
405405
Expect(err).NotTo(HaveOccurred())
406406

407407
// After Process(), XFCCFormat should be set to "raw"
408-
Expect(cfg.MtlsDomains[0].XFCCFormat).To(Equal(config.XFCC_FORMAT_RAW))
408+
Expect(cfg.Domains[0].XFCCFormat).To(Equal(config.XFCC_FORMAT_RAW))
409409
})
410410
})
411411
})

0 commit comments

Comments
 (0)