Skip to content

Commit 23b4a0c

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 b666026 commit 23b4a0c

16 files changed

Lines changed: 1111 additions & 1004 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
@@ -505,35 +505,35 @@ if p('router.client_ca_certs')
505505
params['client_ca_certs'] = client_ca_certs
506506
end
507507

508-
if_p('router.mtls_domains') do |mtls_domains|
509-
if !mtls_domains.is_a?(Array)
510-
raise 'router.mtls_domains must be provided as an array'
508+
if_p('router.domains') do |domains|
509+
if !domains.is_a?(Array)
510+
raise 'router.domains must be provided as an array'
511511
end
512512

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

519-
if !domain_config.key?('domain') || domain_config['domain'].nil? || domain_config['domain'].strip.empty?
520-
raise 'Each entry in router.mtls_domains must have a "domain" key'
519+
if !domain_config.key?('name') || domain_config['name'].nil? || domain_config['name'].strip.empty?
520+
raise 'Each entry in router.domains must have a "name" key'
521521
end
522522

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

527527
processed_entry = {
528-
'domain' => domain_config['domain'],
528+
'domain' => domain_config['name'],
529529
'ca_certs' => domain_config['ca_certs']
530530
}
531531

532532
if domain_config.key?('forwarded_client_cert') && !domain_config['forwarded_client_cert'].nil?
533533
valid_modes = ['always_forward', 'forward', 'sanitize_set']
534534
mode = domain_config['forwarded_client_cert']
535535
unless valid_modes.include?(mode)
536-
raise "Invalid forwarded_client_cert mode '#{mode}' for domain '#{domain_config['domain']}'. Must be one of: #{valid_modes.join(', ')}"
536+
raise "Invalid forwarded_client_cert mode '#{mode}' for domain '#{domain_config['name']}'. Must be one of: #{valid_modes.join(', ')}"
537537
end
538538
processed_entry['forwarded_client_cert'] = mode
539539
end
@@ -542,15 +542,15 @@ if_p('router.mtls_domains') do |mtls_domains|
542542
valid_formats = ['raw', 'envoy']
543543
format = domain_config['xfcc_format']
544544
unless valid_formats.include?(format)
545-
raise "Invalid xfcc_format '#{format}' for domain '#{domain_config['domain']}'. Must be one of: #{valid_formats.join(', ')}"
545+
raise "Invalid xfcc_format '#{format}' for domain '#{domain_config['name']}'. Must be one of: #{valid_formats.join(', ')}"
546546
end
547547
processed_entry['xfcc_format'] = format
548548
end
549549

550550
processed_domains << processed_entry
551551
end
552552

553-
params['mtls_domains'] = processed_domains
553+
params['domains'] = processed_domains
554554
end
555555

556556
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
@@ -410,9 +410,10 @@ type Config struct {
410410
ClientCACerts string `yaml:"client_ca_certs,omitempty"`
411411
ClientCAPool *x509.CertPool `yaml:"-"`
412412

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

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

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

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

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

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

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

972973
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)