diff --git a/.gitignore b/.gitignore index 98de01f6a..93937ecf0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ tags src/golang.org/x/tools/ src/github.com/kisielk/ src/golang.org/x/sync/ + +# Devbox local development environment +devbox.json +devbox.lock +.devbox/ diff --git a/jobs/gorouter/spec b/jobs/gorouter/spec index 8e7a93cf2..0f5bb7138 100644 --- a/jobs/gorouter/spec +++ b/jobs/gorouter/spec @@ -200,6 +200,31 @@ properties: router.only_trust_client_ca_certs: 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." default: false + router.domains: + description: | + Array of domains requiring mutual TLS authentication. Each domain can have its own CA certificate pool, forwarded_client_cert mode, and xfcc_format. + For non-wildcard domains, the domain must match the request host exactly. + For wildcard domains (e.g., *.apps.identity), the wildcard must be the leftmost label and matches any single label. + + xfcc_format controls the format of the X-Forwarded-Client-Cert header: + - "raw" (default): Full base64-encoded certificate (~1.5KB) + - "envoy": Compact Hash=;Subject="" format (~300 bytes) + default: [] + example: + - name: "*.apps.identity" + ca_certs: | + -----BEGIN CERTIFICATE----- + + -----END CERTIFICATE----- + forwarded_client_cert: sanitize_set + xfcc_format: envoy + - name: "secure.example.com" + ca_certs: | + -----BEGIN CERTIFICATE----- + + -----END CERTIFICATE----- + forwarded_client_cert: forward + xfcc_format: raw router.backends.max_attempts: description: | Maximum number of attempts on failing requests against backend routes. diff --git a/jobs/gorouter/templates/gorouter.yml.erb b/jobs/gorouter/templates/gorouter.yml.erb index 47d9c30b0..400c34cc2 100644 --- a/jobs/gorouter/templates/gorouter.yml.erb +++ b/jobs/gorouter/templates/gorouter.yml.erb @@ -505,6 +505,54 @@ if p('router.client_ca_certs') params['client_ca_certs'] = client_ca_certs end +if_p('router.domains') do |domains| + if !domains.is_a?(Array) + raise 'router.domains must be provided as an array' + end + + processed_domains = [] + domains.each do |domain_config| + if !domain_config.is_a?(Hash) + raise 'Each entry in router.domains must be a hash' + end + + if !domain_config.key?('name') || domain_config['name'].nil? || domain_config['name'].strip.empty? + raise 'Each entry in router.domains must have a "name" key' + end + + if !domain_config.key?('ca_certs') || domain_config['ca_certs'].nil? || domain_config['ca_certs'].strip.empty? + raise 'Each entry in router.domains must have a "ca_certs" key with certificate content' + end + + processed_entry = { + 'domain' => domain_config['name'], + 'ca_certs' => domain_config['ca_certs'] + } + + if domain_config.key?('forwarded_client_cert') && !domain_config['forwarded_client_cert'].nil? + valid_modes = ['always_forward', 'forward', 'sanitize_set'] + mode = domain_config['forwarded_client_cert'] + unless valid_modes.include?(mode) + raise "Invalid forwarded_client_cert mode '#{mode}' for domain '#{domain_config['name']}'. Must be one of: #{valid_modes.join(', ')}" + end + processed_entry['forwarded_client_cert'] = mode + end + + if domain_config.key?('xfcc_format') && !domain_config['xfcc_format'].nil? + valid_formats = ['raw', 'envoy'] + format = domain_config['xfcc_format'] + unless valid_formats.include?(format) + raise "Invalid xfcc_format '#{format}' for domain '#{domain_config['name']}'. Must be one of: #{valid_formats.join(', ')}" + end + processed_entry['xfcc_format'] = format + end + + processed_domains << processed_entry + end + + params['domains'] = processed_domains +end + if_p('router.http_rewrite') do |r| params['http_rewrite'] = r end diff --git a/src/code.cloudfoundry.org/go.mod b/src/code.cloudfoundry.org/go.mod index e2caad819..1a8fa6e5a 100644 --- a/src/code.cloudfoundry.org/go.mod +++ b/src/code.cloudfoundry.org/go.mod @@ -5,17 +5,17 @@ go 1.25.0 replace github.com/cactus/go-statsd-client => github.com/cactus/go-statsd-client v2.0.2-0.20150911070441-6fa055a7b594+incompatible require ( - code.cloudfoundry.org/cfhttp/v2 v2.74.0 - code.cloudfoundry.org/clock v1.66.0 - code.cloudfoundry.org/debugserver v0.92.0 - code.cloudfoundry.org/diego-logging-client v0.101.0 - code.cloudfoundry.org/eventhub v0.69.0 + code.cloudfoundry.org/cfhttp/v2 v2.73.0 + code.cloudfoundry.org/clock v1.65.0 + code.cloudfoundry.org/debugserver v0.91.0 + code.cloudfoundry.org/diego-logging-client v0.100.0 + code.cloudfoundry.org/eventhub v0.68.0 code.cloudfoundry.org/go-loggregator/v9 v9.2.1 code.cloudfoundry.org/go-metric-registry v0.0.0-20260409052016-6e68d03a192f - code.cloudfoundry.org/lager/v3 v3.66.0 - code.cloudfoundry.org/localip v0.68.0 + code.cloudfoundry.org/lager/v3 v3.65.0 + code.cloudfoundry.org/localip v0.67.0 code.cloudfoundry.org/locket v0.0.0-20251117222557-be612341b29d - code.cloudfoundry.org/tlsconfig v0.51.0 + code.cloudfoundry.org/tlsconfig v0.50.0 github.com/armon/go-proxyproto v0.1.0 github.com/cactus/go-statsd-client v3.2.1+incompatible github.com/cloudfoundry-community/go-uaa v0.3.6 @@ -28,14 +28,14 @@ require ( github.com/jinzhu/gorm v1.9.16 github.com/kisielk/errcheck v1.10.0 github.com/lib/pq v1.12.3 - github.com/nats-io/nats-server/v2 v2.12.7 - github.com/nats-io/nats.go v1.51.0 + github.com/nats-io/nats-server/v2 v2.12.6 + github.com/nats-io/nats.go v1.50.0 github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/openzipkin/zipkin-go v0.4.3 github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 - github.com/tedsuo/ifrit v0.0.0-20260418191334-846868129986 + github.com/tedsuo/ifrit v0.0.0-20230516164442-7862c310ad26 github.com/tedsuo/rata v1.0.0 github.com/urfave/cli v1.22.17 github.com/urfave/negroni/v3 v3.1.1 @@ -54,7 +54,7 @@ require ( require ( code.cloudfoundry.org/bbs v0.0.0-20260323203855-1402bd61fc46 // indirect - code.cloudfoundry.org/durationjson v0.69.0 // indirect + code.cloudfoundry.org/durationjson v0.68.0 // indirect code.cloudfoundry.org/go-diodes v0.0.0-20260209061029-a81ffbc46978 // indirect code.cloudfoundry.org/inigo v0.0.0-20210615140442-4bdc4f6e44d5 // indirect filippo.io/edwards25519 v1.2.0 // indirect @@ -74,10 +74,10 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect - github.com/honeycombio/libhoney-go v1.27.1 // indirect + github.com/honeycombio/libhoney-go v1.26.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/klauspost/compress v1.18.5 // indirect @@ -103,6 +103,6 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect gopkg.in/alexcesaro/statsd.v2 v2.0.0 // indirect ) diff --git a/src/code.cloudfoundry.org/go.sum b/src/code.cloudfoundry.org/go.sum index ebc2130ef..8e98cb3da 100644 --- a/src/code.cloudfoundry.org/go.sum +++ b/src/code.cloudfoundry.org/go.sum @@ -594,18 +594,18 @@ cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= code.cloudfoundry.org/bbs v0.0.0-20260323203855-1402bd61fc46 h1:LdeIKJdg/mlH4nKyGyBMFg6IzuqeL+39zpBbAx5Lrcs= code.cloudfoundry.org/bbs v0.0.0-20260323203855-1402bd61fc46/go.mod h1:XKlGVVXFi5EcHHMPzw3xgONK9PeEZuUbIC43XNwxD10= -code.cloudfoundry.org/cfhttp/v2 v2.74.0 h1:U8OIpXEi0tjpx6upeQzC4IVse7lfPFf5APqdiql0B04= -code.cloudfoundry.org/cfhttp/v2 v2.74.0/go.mod h1:isgxk/v6y29WDjwnLcuIqF1kVT2BjJ4lZcJSj6jpSM4= -code.cloudfoundry.org/clock v1.66.0 h1:hX7B+4EREnSf2T0xpq2gQGV5LI5LYRqwB331M9OhqW4= -code.cloudfoundry.org/clock v1.66.0/go.mod h1:tiZfotIRQkAjmhlD4rtSNU8mV15Kpvgdir4Vgejnv4k= -code.cloudfoundry.org/debugserver v0.92.0 h1:8vOe2PiaxaFswszaZ0jZd2dwb3c9jp4wQ7ZyHYR1zFo= -code.cloudfoundry.org/debugserver v0.92.0/go.mod h1:qnW5PYSM6GePGU69MED+5Dk25Pcy/sxaDwg6RTuVgGc= -code.cloudfoundry.org/diego-logging-client v0.101.0 h1:h/RqVy9EbM0/XEa+80VuuU4QfnSfFuti++cWPx0W19E= -code.cloudfoundry.org/diego-logging-client v0.101.0/go.mod h1:Z2jLQZRUfMji6tDWsvU/ZF1+5qGteswFotxwHEpIhhI= -code.cloudfoundry.org/durationjson v0.69.0 h1:kHakzF7A+ykfZqhP9+e52RVKPkwt77wxvIf83BERMIM= -code.cloudfoundry.org/durationjson v0.69.0/go.mod h1:E/V7DjnjNr3dykRKIVxiQFySo1um00Dtn8BPzHwdY28= -code.cloudfoundry.org/eventhub v0.69.0 h1:RWpHkFnwMGyK0BITLRlOURO3CiZqEwrBpc8bihEv9GQ= -code.cloudfoundry.org/eventhub v0.69.0/go.mod h1:BwZEwSb7jFYZvj1md8nk7wswTmjooIMMPoz9uZ2IwFg= +code.cloudfoundry.org/cfhttp/v2 v2.73.0 h1:yJ6/98S6Hk7+O1pSqYS7VdPwvSu8rN7ZvARjCPLaDzY= +code.cloudfoundry.org/cfhttp/v2 v2.73.0/go.mod h1:acl6VWNCkPN8L92/nBL/JlLTKsFPnW4F0fuGbfsoAkM= +code.cloudfoundry.org/clock v1.65.0 h1:r3QNfdjq8sxzAnuTf24FAh7A+7KVbCOJ4ZSMBMPd/1M= +code.cloudfoundry.org/clock v1.65.0/go.mod h1:vHjVDAJB13nKjOzbIBtrCoVGt8q/91o7d2h+6WysXas= +code.cloudfoundry.org/debugserver v0.91.0 h1:GS/EPXyMIy9PiS3hUuAhEsQUwI1DqhBVuZ1rLAdp8aw= +code.cloudfoundry.org/debugserver v0.91.0/go.mod h1:QFDX6EWyYl/N4+UhyG+7Jr8ex6G2qp0uc3AaX4GZHis= +code.cloudfoundry.org/diego-logging-client v0.100.0 h1:Xhc0finEO6nV5ix0+zMI1ZLyiG6Ug1vOi8sVW03VGH8= +code.cloudfoundry.org/diego-logging-client v0.100.0/go.mod h1:xE3jDwFN2ixn4IKkaVMGaOSEX8pXGvaMDSCHgg1Qepg= +code.cloudfoundry.org/durationjson v0.68.0 h1:6Ay0kK5XpxuZ5cWyzFX52oiIO6Atv0nG87a0xY8JBUQ= +code.cloudfoundry.org/durationjson v0.68.0/go.mod h1:GNKEsRSGjaZd4ED8d/17Kr+ttpZne22hAkaWQPUbxtE= +code.cloudfoundry.org/eventhub v0.68.0 h1:o47FFA/ffB57qMEGHCH9JsaT0p+f6J7V321fp8FaQHE= +code.cloudfoundry.org/eventhub v0.68.0/go.mod h1:tCx1f+4W2vVJa3v2oP/YRz679g31crtFxbU6erN551M= code.cloudfoundry.org/go-diodes v0.0.0-20260209061029-a81ffbc46978 h1:uZ6UIz7zl39FMy5GybKzI83zD35c4fvkU8sQEZDH/x8= code.cloudfoundry.org/go-diodes v0.0.0-20260209061029-a81ffbc46978/go.mod h1:ZZMgJNANhsfqeXF//d5qDK0dNnQ4jTBsib4WR0xbWJQ= code.cloudfoundry.org/go-loggregator/v9 v9.2.1 h1:S6Lgg5UJbhh2bt2TGQxs6R00CF8PrUA3GFPYDxy56Fk= @@ -614,14 +614,14 @@ code.cloudfoundry.org/go-metric-registry v0.0.0-20260409052016-6e68d03a192f h1:z code.cloudfoundry.org/go-metric-registry v0.0.0-20260409052016-6e68d03a192f/go.mod h1:0IMHt7ZlRV53qzyPdjWnh37L0Itkvrlen7czr+Qbm84= code.cloudfoundry.org/inigo v0.0.0-20210615140442-4bdc4f6e44d5 h1:XVhLtnvbIlLQh7L0KADVFjd2dfgXVcOpqPLpMtg/IZA= code.cloudfoundry.org/inigo v0.0.0-20210615140442-4bdc4f6e44d5/go.mod h1:1ZB1JCh2FAp+SqX79ve6dc8YREvbsziULEOncAilX4Q= -code.cloudfoundry.org/lager/v3 v3.66.0 h1:OM9Zy+KCTUpanOfIlL4ac87po7VlBZEq+Mdf32VBi/g= -code.cloudfoundry.org/lager/v3 v3.66.0/go.mod h1:d+HET0t/G9vf8+sxAzhauv313xbfv8molPzY4uNb7vo= -code.cloudfoundry.org/localip v0.68.0 h1:PvXGssaG2ENu/Ux6Fm69qWmn6mTecATrhkG37BHN2Rs= -code.cloudfoundry.org/localip v0.68.0/go.mod h1:Gek6kZZfONg7T27yO1/xh/n90bsQA8WDPE2gKwhYYNg= +code.cloudfoundry.org/lager/v3 v3.65.0 h1:Z/euENq42rULCPl65R4tdNDWav9AJ6OoZkKfjIkQ3JM= +code.cloudfoundry.org/lager/v3 v3.65.0/go.mod h1:reJ2m/UwSmkU/eJkrgHf4ZEhAMnBuXGiGzLmuDR3D5s= +code.cloudfoundry.org/localip v0.67.0 h1:LwTdyXZLy4UA+6JkYHKjbI77JUU2KDllFhkgKhkVGAY= +code.cloudfoundry.org/localip v0.67.0/go.mod h1:mfTYuX8W6lSPXwaBrPc72BubNlBMmpowBwDWkYrca2M= code.cloudfoundry.org/locket v0.0.0-20251117222557-be612341b29d h1:UQBC4hxKpaSc0lNcVafX71I8NLBncxDoWdSX2JTtRBA= code.cloudfoundry.org/locket v0.0.0-20251117222557-be612341b29d/go.mod h1:AwHLRkdXtttLXNB8RHgLfErJ2kKafH62AR2OClhy6xI= -code.cloudfoundry.org/tlsconfig v0.51.0 h1:thK329gjMwbx+Cj0ZG/9c6jiWTeMAUOekPD3Qwx9g6w= -code.cloudfoundry.org/tlsconfig v0.51.0/go.mod h1:K7hANtU0m+X/IGEF6IA83KPEE6gKcL/efpltoKVfw+M= +code.cloudfoundry.org/tlsconfig v0.50.0 h1:HzbNSzYcM+c8V1ql1pYaXZGGUGsk6XgLjWSVidHvEIc= +code.cloudfoundry.org/tlsconfig v0.50.0/go.mod h1:esGzvjLioIRanToEWKLyNMo04xjiH3tX52irs0jjs98= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= @@ -896,8 +896,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4Zs github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/honeycombio/libhoney-go v1.27.1 h1:79FR19fVpaeDMqTDfpXtMxd90vzsxhZnIOSysMrUSQQ= -github.com/honeycombio/libhoney-go v1.27.1/go.mod h1:qLZO8Q3ep/hISEoVC7m8N9ZOvn2eqaGdoJg9XXXasqM= +github.com/honeycombio/libhoney-go v1.26.0 h1:fdwS7c/5h6ifJqQZ178nm4UEZha04GTbwJMZ7xkShhk= +github.com/honeycombio/libhoney-go v1.26.0/go.mod h1:cR+t7pq9heP00+1/+TNWCrAfjSA74xKWI8YGOANlzYY= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -906,8 +906,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= @@ -967,10 +967,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= -github.com/nats-io/nats-server/v2 v2.12.7 h1:prQ9cPiWHcnwfT81Wi5lU9LL8TLY+7pxDru6fQYLCQQ= -github.com/nats-io/nats-server/v2 v2.12.7/go.mod h1:dOnmkprKMluTmTF7/QHZioxlau3sKHUM/LBPy9AiBPw= -github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= -github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nats-server/v2 v2.12.6 h1:Egbx9Vl7Ch8wTtpXPGqbehkZ+IncKqShUxvrt1+Enc8= +github.com/nats-io/nats-server/v2 v2.12.6/go.mod h1:4HPlrvtmSO3yd7KcElDNMx9kv5EBJBnJJzQPptXlheo= +github.com/nats-io/nats.go v1.50.0 h1:5zAeQrTvyrKrWLJ0fu02W3br8ym57qf7csDzgLOpcds= +github.com/nats-io/nats.go v1.50.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= @@ -1078,8 +1078,9 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tedsuo/ifrit v0.0.0-20260418191334-846868129986 h1:etGVMUNp4ZYI0EoO7MxUKTG187RK8tbwIijDcXtSeL4= -github.com/tedsuo/ifrit v0.0.0-20260418191334-846868129986/go.mod h1:b0WkuWMdITecmKiTvZnmIffiXD+P1TUysIxv8Mm4m/s= +github.com/tedsuo/ifrit v0.0.0-20230330192023-5cba443a66c4/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/tedsuo/ifrit v0.0.0-20230516164442-7862c310ad26 h1:mWCRvpoEMVlslxEvvptKgIUb35va9yj9Oq5wGw/er5I= +github.com/tedsuo/ifrit v0.0.0-20230516164442-7862c310ad26/go.mod h1:0uD3VMXkZ7Bw0ojGCwDzebBBzPBXtzEZeXai+56BLX4= github.com/tedsuo/rata v1.0.0 h1:Sf9aZrYy6ElSTncjnGkyC2yuVvz5YJetBIUKJ4CmeKE= github.com/tedsuo/rata v1.0.0/go.mod h1:X47ELzhOoLbfFIY0Cql9P6yo3Cdwf2CMX3FVZxRzJPc= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -1748,8 +1749,8 @@ google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOl google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= diff --git a/src/code.cloudfoundry.org/gorouter/accesslog/schema/access_log_record.go b/src/code.cloudfoundry.org/gorouter/accesslog/schema/access_log_record.go index 126381b39..b2f4bb2da 100644 --- a/src/code.cloudfoundry.org/gorouter/accesslog/schema/access_log_record.go +++ b/src/code.cloudfoundry.org/gorouter/accesslog/schema/access_log_record.go @@ -127,6 +127,20 @@ type AccessLogRecord struct { GorouterTime float64 LocalAddress string + + // Identity-aware routing authorization fields. + // AuthOutcome is "allowed" or "denied"; empty if no authorization was performed. + AuthOutcome string + // AuthRule identifies the rule that matched or caused denial. + AuthRule string + // AuthDeniedReason is a human-readable denial explanation (empty on allow). + AuthDeniedReason string + // CallerApp/Space/Org are the CF identity fields from the client certificate. + CallerApp string + CallerSpace string + CallerOrg string + // TlsSNI is the SNI used during TLS (logged on 421 rejections). + TlsSNI string } func (r *AccessLogRecord) formatStartedAt() string { @@ -316,6 +330,43 @@ func (r *AccessLogRecord) makeRecord(performTruncate bool) []byte { b.WriteString(`x_cf_routererror:`) b.WriteDashOrStringValue(r.RouterError) + // mTLS identity and authorization fields (only emitted when present) + if r.TlsSNI != "" { + // #nosec G104 + b.WriteString(` tls_sni:`) + b.WriteDashOrStringValue(r.TlsSNI) + } + if r.CallerApp != "" { + // #nosec G104 + b.WriteString(` caller_app:`) + b.WriteDashOrStringValue(r.CallerApp) + } + if r.CallerSpace != "" { + // #nosec G104 + b.WriteString(` caller_space:`) + b.WriteDashOrStringValue(r.CallerSpace) + } + if r.CallerOrg != "" { + // #nosec G104 + b.WriteString(` caller_org:`) + b.WriteDashOrStringValue(r.CallerOrg) + } + if r.AuthOutcome != "" { + // #nosec G104 + b.WriteString(` auth:`) + b.WriteDashOrStringValue(r.AuthOutcome) + } + if r.AuthRule != "" { + // #nosec G104 + b.WriteString(` auth_rule:`) + b.WriteDashOrStringValue(r.AuthRule) + } + if r.AuthDeniedReason != "" { + // #nosec G104 + b.WriteString(` auth_denied_reason:`) + b.WriteDashOrStringValue(r.AuthDeniedReason) + } + r.addExtraHeaders(b, performTruncate) return b.Bytes() diff --git a/src/code.cloudfoundry.org/gorouter/common/component_test.go b/src/code.cloudfoundry.org/gorouter/common/component_test.go index e36c0e168..3e4c7bf2a 100644 --- a/src/code.cloudfoundry.org/gorouter/common/component_test.go +++ b/src/code.cloudfoundry.org/gorouter/common/component_test.go @@ -167,8 +167,9 @@ var _ = Describe("Component", func() { var natsRunner *test_util.NATSRunner BeforeEach(func() { - natsPort := test_util.NextAvailPort() + natsPort := test_util.ReservePort() natsRunner = test_util.NewNATSRunner(int(natsPort)) + test_util.ReleasePort(natsPort) natsRunner.Start() mbusClient = natsRunner.MessageBus mbusClient.Opts.SkipSubjectValidation = true diff --git a/src/code.cloudfoundry.org/gorouter/config/config.go b/src/code.cloudfoundry.org/gorouter/config/config.go index 8be9f587e..d721edbfb 100644 --- a/src/code.cloudfoundry.org/gorouter/config/config.go +++ b/src/code.cloudfoundry.org/gorouter/config/config.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "log/slog" + "net" "net/url" "os" "runtime" @@ -36,6 +37,10 @@ const ( REDACT_QUERY_PARMS_NONE string = "none" REDACT_QUERY_PARMS_ALL string = "all" REDACT_QUERY_PARMS_HASH string = "hash" + + // XFCC format constants for mTLS domains + XFCC_FORMAT_RAW string = "raw" // Full base64-encoded certificate + XFCC_FORMAT_ENVOY string = "envoy" // Hash=;Subject="" format ) var ( @@ -45,6 +50,7 @@ var ( AllowedShardingModes = []string{SHARD_ALL, SHARD_SEGMENTS, SHARD_SHARED_AND_SEGMENTS} AllowedForwardedClientCertModes = []string{ALWAYS_FORWARD, FORWARD, SANITIZE_SET} AllowedQueryParmRedactionModes = []string{REDACT_QUERY_PARMS_NONE, REDACT_QUERY_PARMS_ALL, REDACT_QUERY_PARMS_HASH} + AllowedXFCCFormats = []string{XFCC_FORMAT_RAW, XFCC_FORMAT_ENVOY} ) type StringSet map[string]struct{} @@ -368,6 +374,17 @@ func InitClientCertMetadataRules(rules []VerifyClientCertificateMetadataRule, ce return nil } +// MtlsDomainConfig defines TLS settings for a specific domain that requires mutual TLS +type MtlsDomainConfig struct { + Domain string `yaml:"domain"` + CAPool *x509.CertPool `yaml:"-"` + CACerts string `yaml:"ca_certs"` + ForwardedClientCert string `yaml:"forwarded_client_cert"` + XFCCFormat string `yaml:"xfcc_format"` // "raw" (default) or "envoy" + // Computed fields + RequireClientCert bool `yaml:"-"` // Always true for mTLS domains +} + type Config struct { Status StatusConfig `yaml:"status,omitempty"` Nats NatsConfig `yaml:"nats,omitempty"` @@ -394,6 +411,13 @@ type Config struct { ClientCACerts string `yaml:"client_ca_certs,omitempty"` ClientCAPool *x509.CertPool `yaml:"-"` + // Domains configures domains that require client certificates (mTLS). + // Corresponds to router.domains in the BOSH manifest (RFC: router.domains). + // Routes on these domains will require valid instance identity certificates. + Domains []MtlsDomainConfig `yaml:"domains,omitempty"` + // Computed: map of domain -> config for fast lookup + mtlsDomainMap map[string]*MtlsDomainConfig `yaml:"-"` + SkipSSLValidation bool `yaml:"skip_ssl_validation,omitempty"` ForwardedClientCert string `yaml:"forwarded_client_cert,omitempty"` ForceForwardedProtoHttps bool `yaml:"force_forwarded_proto_https,omitempty"` @@ -802,6 +826,9 @@ func (c *Config) Process() error { if err := c.buildClientCertPool(); err != nil { return err } + if err := c.processMtlsDomains(); err != nil { + return err + } return nil } @@ -902,6 +929,54 @@ func (c *Config) buildClientCertPool() error { return nil } +func (c *Config) processMtlsDomains() error { + // Initialize mTLS domain map + c.mtlsDomainMap = make(map[string]*MtlsDomainConfig) + + for i := range c.Domains { + domain := &c.Domains[i] + domain.RequireClientCert = true + + // Validate forwarded_client_cert mode + if domain.ForwardedClientCert == "" { + domain.ForwardedClientCert = SANITIZE_SET // Default to most secure + } + if !slices.Contains(AllowedForwardedClientCertModes, domain.ForwardedClientCert) { + return fmt.Errorf("domains[%d].forwarded_client_cert must be one of %v", + i, AllowedForwardedClientCertModes) + } + + // Validate xfcc_format + if domain.XFCCFormat == "" { + domain.XFCCFormat = XFCC_FORMAT_RAW // Default to raw for backwards compatibility + } + if !slices.Contains(AllowedXFCCFormats, domain.XFCCFormat) { + return fmt.Errorf("domains[%d].xfcc_format must be one of %v", + i, AllowedXFCCFormats) + } + + // Build CA pool for this domain + if domain.CACerts != "" { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(domain.CACerts)) { + return fmt.Errorf("domains[%d].ca_certs contains invalid certificates", i) + } + domain.CAPool = pool + } else { + return fmt.Errorf("domains[%d].ca_certs is required", i) + } + + // Validate domain is not empty + if domain.Domain == "" { + return fmt.Errorf("domains[%d].domain is required", i) + } + + c.mtlsDomainMap[domain.Domain] = domain + } + + return nil +} + func convertCipherStringToInt(cipherStrs []string, cipherMap map[string]uint16) ([]uint16, error) { ciphers := []uint16{} for _, cipher := range cipherStrs { @@ -937,6 +1012,36 @@ func (c *Config) RoutingApiEnabled() bool { return (c.RoutingApi.Uri != "") && (c.RoutingApi.Port != 0) } +// GetMtlsDomainConfig returns the mTLS domain configuration for a given host. +// It checks for exact matches first, then wildcard matches (e.g., *.apps.mtls.internal). +// Returns nil if the host is not an mTLS domain. +func (c *Config) GetMtlsDomainConfig(host string) *MtlsDomainConfig { + // Strip port if present (e.g., "app.example.com:443" → "app.example.com") + // This ensures consistent matching regardless of whether clients include explicit ports + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + + // Check exact match first + if cfg, ok := c.mtlsDomainMap[host]; ok { + return cfg + } + // Check wildcard match (e.g., *.apps.mtls.internal) + parts := strings.SplitN(host, ".", 2) + if len(parts) == 2 { + wildcardDomain := "*." + parts[1] + if cfg, ok := c.mtlsDomainMap[wildcardDomain]; ok { + return cfg + } + } + return nil +} + +// IsMtlsDomain returns true if the given host is configured as an mTLS domain +func (c *Config) IsMtlsDomain(host string) bool { + return c.GetMtlsDomainConfig(host) != nil +} + func (c *Config) Initialize(configYAML []byte) error { return yaml.Unmarshal(configYAML, &c) } diff --git a/src/code.cloudfoundry.org/gorouter/config/config_test.go b/src/code.cloudfoundry.org/gorouter/config/config_test.go index d6b2ff33e..3c852f2c1 100644 --- a/src/code.cloudfoundry.org/gorouter/config/config_test.go +++ b/src/code.cloudfoundry.org/gorouter/config/config_test.go @@ -2043,6 +2043,109 @@ drain_timeout: 60s }) }) + + Describe("GetMtlsDomainConfig", func() { + var certChain test_util.CertChain + + BeforeEach(func() { + certChain = test_util.CreateSignedCertWithRootCA(test_util.CertNames{SANs: test_util.SubjectAltNames{DNS: "test.com"}}) + cfgForSnippet.Domains = []MtlsDomainConfig{ + { + Domain: "*.apps.identity", + XFCCFormat: "envoy", + CACerts: string(certChain.CACertPEM), + }, + { + Domain: "exact.example.com", + XFCCFormat: "raw", + CACerts: string(certChain.CACertPEM), + }, + } + err := config.Initialize(createYMLSnippet(cfgForSnippet)) + Expect(err).ToNot(HaveOccurred()) + err = config.Process() + Expect(err).ToNot(HaveOccurred()) + }) + + Context("when host includes explicit port", func() { + It("strips port and matches wildcard domain", func() { + cfg := config.GetMtlsDomainConfig("xfcc-tester.apps.identity:443") + Expect(cfg).ToNot(BeNil()) + Expect(cfg.Domain).To(Equal("*.apps.identity")) + Expect(cfg.XFCCFormat).To(Equal("envoy")) + }) + + It("strips port and matches exact domain", func() { + cfg := config.GetMtlsDomainConfig("exact.example.com:8443") + Expect(cfg).ToNot(BeNil()) + Expect(cfg.Domain).To(Equal("exact.example.com")) + Expect(cfg.XFCCFormat).To(Equal("raw")) + }) + }) + + Context("when host does not include port", func() { + It("matches wildcard domain without port", func() { + cfg := config.GetMtlsDomainConfig("xfcc-tester.apps.identity") + Expect(cfg).ToNot(BeNil()) + Expect(cfg.Domain).To(Equal("*.apps.identity")) + Expect(cfg.XFCCFormat).To(Equal("envoy")) + }) + + It("matches exact domain without port", func() { + cfg := config.GetMtlsDomainConfig("exact.example.com") + Expect(cfg).ToNot(BeNil()) + Expect(cfg.Domain).To(Equal("exact.example.com")) + Expect(cfg.XFCCFormat).To(Equal("raw")) + }) + }) + + Context("when host is not an mTLS domain", func() { + It("returns nil for non-matching host with port", func() { + cfg := config.GetMtlsDomainConfig("other.example.com:443") + Expect(cfg).To(BeNil()) + }) + + It("returns nil for non-matching host without port", func() { + cfg := config.GetMtlsDomainConfig("other.example.com") + Expect(cfg).To(BeNil()) + }) + }) + }) + + Describe("IsMtlsDomain", func() { + var certChain test_util.CertChain + + BeforeEach(func() { + certChain = test_util.CreateSignedCertWithRootCA(test_util.CertNames{SANs: test_util.SubjectAltNames{DNS: "test.com"}}) + cfgForSnippet.Domains = []MtlsDomainConfig{ + { + Domain: "*.apps.identity", + XFCCFormat: "envoy", + CACerts: string(certChain.CACertPEM), + }, + } + err := config.Initialize(createYMLSnippet(cfgForSnippet)) + Expect(err).ToNot(HaveOccurred()) + err = config.Process() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns true for mTLS domain with port", func() { + Expect(config.IsMtlsDomain("xfcc-tester.apps.identity:443")).To(BeTrue()) + }) + + It("returns true for mTLS domain without port", func() { + Expect(config.IsMtlsDomain("xfcc-tester.apps.identity")).To(BeTrue()) + }) + + It("returns false for non-mTLS domain with port", func() { + Expect(config.IsMtlsDomain("other.example.com:443")).To(BeFalse()) + }) + + It("returns false for non-mTLS domain without port", func() { + Expect(config.IsMtlsDomain("other.example.com")).To(BeFalse()) + }) + }) }) func baseConfigFixture() *Config { diff --git a/src/code.cloudfoundry.org/gorouter/handlers/access_log.go b/src/code.cloudfoundry.org/gorouter/handlers/access_log.go index 6d21296a4..7517ce311 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/access_log.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/access_log.go @@ -83,6 +83,19 @@ func (a *accessLog) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http alr.LocalAddress = reqInfo.LocalAddress + // Identity-aware routing authorization fields + if reqInfo.CallerIdentity != nil { + alr.CallerApp = reqInfo.CallerIdentity.AppGUID + alr.CallerSpace = reqInfo.CallerIdentity.SpaceGUID + alr.CallerOrg = reqInfo.CallerIdentity.OrgGUID + } + if reqInfo.AuthResult != nil { + alr.AuthOutcome = reqInfo.AuthResult.Outcome + alr.AuthRule = reqInfo.AuthResult.Rule + alr.AuthDeniedReason = reqInfo.AuthResult.DeniedReason + } + alr.TlsSNI = reqInfo.TlsSNI + a.accessLogger.Log(*alr) } diff --git a/src/code.cloudfoundry.org/gorouter/handlers/auth_error.go b/src/code.cloudfoundry.org/gorouter/handlers/auth_error.go new file mode 100644 index 000000000..121d91c3e --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/auth_error.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "fmt" + "net/http" +) + +// AuthError represents an authorization failure with structured metadata +// for logging and error responses. Used for both mTLS and future authentication +// methods like SPIFFE JWT tokens. +type AuthError struct { + // Rule is the authorization rule that failed (e.g., "domain:scope=org:post-selection") + Rule string + // Reason is a human-readable explanation of why authorization failed + Reason string + // HTTPStatus is the HTTP status code to return (typically 403 Forbidden) + HTTPStatus int +} + +func (e *AuthError) Error() string { + return fmt.Sprintf("authorization denied: %s (rule: %s)", e.Reason, e.Rule) +} + +// NewAuthError creates a new authorization error with 403 Forbidden status +func NewAuthError(rule, reason string) *AuthError { + return &AuthError{ + Rule: rule, + Reason: reason, + HTTPStatus: http.StatusForbidden, + } +} + +// NewAuthErrorWithStatus creates a new authorization error with a custom HTTP status +func NewAuthErrorWithStatus(rule, reason string, status int) *AuthError { + return &AuthError{ + Rule: rule, + Reason: reason, + HTTPStatus: status, + } +} + +// AuthResult captures the outcome of identity-aware routing authorization. +// This is populated on RequestInfo and flows into access logs. +type AuthResult struct { + // Outcome is "allowed" or "denied"; empty if no authorization was performed. + Outcome string + // Rule identifies which rule matched or caused denial, e.g. + // "route:cf:app:", "domain:scope=org", "identity_extraction". + Rule string + // DeniedReason is a human-readable explanation for denial, empty on allow. + DeniedReason string +} diff --git a/src/code.cloudfoundry.org/gorouter/handlers/clientcert.go b/src/code.cloudfoundry.org/gorouter/handlers/clientcert.go index 437387f64..71377a753 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/clientcert.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/clientcert.go @@ -1,6 +1,10 @@ package handlers import ( + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" "encoding/pem" "errors" "fmt" @@ -22,6 +26,7 @@ type clientCert struct { skipSanitization func(req *http.Request) bool forceDeleteHeader func(req *http.Request) (bool, error) forwardingMode string + config *config.Config logger *slog.Logger errorWriter errorwriter.ErrorWriter } @@ -30,6 +35,7 @@ func NewClientCert( skipSanitization func(req *http.Request) bool, forceDeleteHeader func(req *http.Request) (bool, error), forwardingMode string, + cfg *config.Config, logger *slog.Logger, ew errorwriter.ErrorWriter, ) negroni.Handler { @@ -37,6 +43,7 @@ func NewClientCert( skipSanitization: skipSanitization, forceDeleteHeader: forceDeleteHeader, forwardingMode: forwardingMode, + config: cfg, logger: logger, errorWriter: ew, } @@ -45,8 +52,22 @@ func NewClientCert( func (c *clientCert) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { logger := LoggerWithTraceInfo(c.logger, r) skip := c.skipSanitization(r) + + // Determine forwarding mode and XFCC format - use domain-specific if on mTLS domain + forwardingMode := c.forwardingMode + xfccFormat := config.XFCC_FORMAT_RAW // Default for non-mTLS domains + mtlsDomainConfig := c.config.GetMtlsDomainConfig(r.Host) + if mtlsDomainConfig != nil { + forwardingMode = mtlsDomainConfig.ForwardedClientCert + xfccFormat = mtlsDomainConfig.XFCCFormat + c.logger.Debug("using-mtls-domain-xfcc-config", + slog.String("host", r.Host), + slog.String("mode", forwardingMode), + slog.String("xfcc_format", xfccFormat)) + } + if !skip { - switch c.forwardingMode { + switch forwardingMode { case config.FORWARD: if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { r.Header.Del(xfcc) @@ -54,7 +75,11 @@ func (c *clientCert) ServeHTTP(rw http.ResponseWriter, r *http.Request, next htt case config.SANITIZE_SET: r.Header.Del(xfcc) if r.TLS != nil { - replaceXFCCHeader(r) + if xfccFormat == config.XFCC_FORMAT_ENVOY { + replaceXFCCHeaderEnvoyFormat(r) + } else { + replaceXFCCHeader(r) + } } } } @@ -95,6 +120,67 @@ func replaceXFCCHeader(r *http.Request) { } } +// replaceXFCCHeaderEnvoyFormat sets the X-Forwarded-Client-Cert header using Envoy's +// compact format: Hash=;Subject="" +// This is significantly smaller than the raw certificate format (~300 bytes vs ~1.5KB) +func replaceXFCCHeaderEnvoyFormat(r *http.Request) { + if len(r.TLS.PeerCertificates) > 0 { + cert := r.TLS.PeerCertificates[0] + r.Header.Add(xfcc, formatXFCCEnvoy(cert)) + } +} + +// formatXFCCEnvoy generates the Envoy-style XFCC header value: +// Hash=;Subject="" +func formatXFCCEnvoy(cert *x509.Certificate) string { + // Calculate SHA-256 hash of the DER-encoded certificate + hash := sha256.Sum256(cert.Raw) + hashHex := hex.EncodeToString(hash[:]) + + // Format Subject DN using standard X.509 format + subject := formatSubjectDN(cert.Subject) + + return fmt.Sprintf("Hash=%s;Subject=\"%s\"", hashHex, subject) +} + +// formatSubjectDN formats an X.509 Distinguished Name in the standard format +// e.g., "CN=instance-id,OU=app:guid,OU=space:guid,OU=organization:guid" +func formatSubjectDN(name pkix.Name) string { + var parts []string + + // Add CN first (if present) + if name.CommonName != "" { + parts = append(parts, "CN="+name.CommonName) + } + + // Add OUs (preserve order from certificate) + for _, ou := range name.OrganizationalUnit { + parts = append(parts, "OU="+ou) + } + + // Add O (Organization) + for _, o := range name.Organization { + parts = append(parts, "O="+o) + } + + // Add L (Locality) + for _, l := range name.Locality { + parts = append(parts, "L="+l) + } + + // Add ST (State/Province) + for _, st := range name.Province { + parts = append(parts, "ST="+st) + } + + // Add C (Country) + for _, c := range name.Country { + parts = append(parts, "C="+c) + } + + return strings.Join(parts, ",") +} + func sanitize(cert []byte) string { s := string(cert) r := strings.NewReplacer("-----BEGIN CERTIFICATE-----", "", diff --git a/src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go b/src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go index f8caa1bdf..306a6caf4 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/clientcert_test.go @@ -45,7 +45,8 @@ var _ = Describe("Clientcert", func() { DescribeTable("Client Cert Error Handling", func(forceDeleteHeaderFunc func(*http.Request) (bool, error), skipSanitizationFunc func(*http.Request) bool, errorCase string) { logger = test_util.NewTestLogger("") - clientCertHandler := handlers.NewClientCert(skipSanitizationFunc, forceDeleteHeaderFunc, config.SANITIZE_SET, logger.Logger, errorWriter) + cfg, _ := config.DefaultConfig() + clientCertHandler := handlers.NewClientCert(skipSanitizationFunc, forceDeleteHeaderFunc, config.SANITIZE_SET, cfg, logger.Logger, errorWriter) nextHandlerWasCalled := false nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { nextHandlerWasCalled = true }) @@ -82,7 +83,8 @@ var _ = Describe("Clientcert", func() { DescribeTable("Client Cert Result", func(forceDeleteHeaderFunc func(*http.Request) (bool, error), skipSanitizationFunc func(*http.Request) bool, forwardedClientCert string, noTLSCertStrip bool, TLSCertStrip bool, mTLSCertStrip string) { logger = test_util.NewTestLogger("test") - clientCertHandler := handlers.NewClientCert(skipSanitizationFunc, forceDeleteHeaderFunc, forwardedClientCert, logger.Logger, errorWriter) + cfg, _ := config.DefaultConfig() + clientCertHandler := handlers.NewClientCert(skipSanitizationFunc, forceDeleteHeaderFunc, forwardedClientCert, cfg, logger.Logger, errorWriter) nextReq := &http.Request{} nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { nextReq = r }) @@ -209,3 +211,201 @@ func sanitize(cert []byte) string { "\n", "") return r.Replace(s) } + +var _ = Describe("Clientcert mTLS Domain XFCC Format", func() { + var ( + dontForceDeleteHeader = func(req *http.Request) (bool, error) { return false, nil } + dontSkipSanitization = func(req *http.Request) bool { return false } + errorWriter = errorwriter.NewPlaintextErrorWriter() + logger *test_util.TestLogger + ) + + Describe("Envoy XFCC Format", func() { + It("uses Envoy format when configured for mTLS domain", func() { + logger = test_util.NewTestLogger("test") + + // Create instance identity cert with Diego format OUs + certChain := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "instance-id-123", + AppGUID: "app-guid-456", + SpaceGUID: "space-guid-789", + OrgGUID: "org-guid-abc", + }) + + // Configure mTLS domain with Envoy format + cfg, err := config.DefaultConfig() + Expect(err).NotTo(HaveOccurred()) + + cfg.Domains = []config.MtlsDomainConfig{{ + Domain: "*.apps.mtls.internal", + CACerts: string(certChain.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + XFCCFormat: config.XFCC_FORMAT_ENVOY, + }} + err = cfg.Process() + Expect(err).NotTo(HaveOccurred()) + + clientCertHandler := handlers.NewClientCert(dontSkipSanitization, dontForceDeleteHeader, config.SANITIZE_SET, cfg, logger.Logger, errorWriter) + + var capturedXFCC string + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedXFCC = r.Header.Get("X-Forwarded-Client-Cert") + }) + + n := negroni.New() + n.Use(clientCertHandler) + n.UseHandlerFunc(nextHandler) + + // Setup mTLS test server + tlsCert, err := tls.X509KeyPair(certChain.CertPEM, certChain.PrivKeyPEM) + Expect(err).ToNot(HaveOccurred()) + + certPool := x509.NewCertPool() + certPool.AddCert(certChain.CACert) + + serverTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + ClientCAs: certPool, + ClientAuth: tls.RequestClientCert, + } + + server := httptest.NewUnstartedServer(n) + server.TLS = serverTLSConfig + server.StartTLS() + defer server.Close() + + // Create client with mTLS cert + clientTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + RootCAs: certPool, + InsecureSkipVerify: true, // Test server uses 127.0.0.1 which isn't in cert SANs + } + + transport := &http.Transport{TLSClientConfig: clientTLSConfig} + client := &http.Client{Transport: transport} + + // Make request to mTLS domain + req, err := http.NewRequest("GET", server.URL, nil) + Expect(err).NotTo(HaveOccurred()) + req.Host = "myapp.apps.mtls.internal" + + _, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + + // Verify Envoy format: Hash=;Subject="" + Expect(capturedXFCC).To(HavePrefix("Hash=")) + Expect(capturedXFCC).To(ContainSubstring(";Subject=\"")) + + // Verify Subject contains OUs + Expect(capturedXFCC).To(ContainSubstring("OU=app:app-guid-456")) + Expect(capturedXFCC).To(ContainSubstring("OU=space:space-guid-789")) + Expect(capturedXFCC).To(ContainSubstring("OU=organization:org-guid-abc")) + Expect(capturedXFCC).To(ContainSubstring("CN=instance-id-123")) + + // Verify it doesn't contain base64-encoded cert (which would be much longer) + Expect(len(capturedXFCC)).To(BeNumerically("<", 500)) // Envoy format is ~300 bytes + }) + + It("uses raw format when configured for mTLS domain", func() { + logger = test_util.NewTestLogger("test") + + // Create instance identity cert + certChain := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "instance-id-123", + AppGUID: "app-guid-456", + }) + + // Configure mTLS domain with raw format (default) + cfg, err := config.DefaultConfig() + Expect(err).NotTo(HaveOccurred()) + + cfg.Domains = []config.MtlsDomainConfig{{ + Domain: "*.apps.mtls.internal", + CACerts: string(certChain.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + XFCCFormat: config.XFCC_FORMAT_RAW, + }} + err = cfg.Process() + Expect(err).NotTo(HaveOccurred()) + + clientCertHandler := handlers.NewClientCert(dontSkipSanitization, dontForceDeleteHeader, config.SANITIZE_SET, cfg, logger.Logger, errorWriter) + + var capturedXFCC string + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedXFCC = r.Header.Get("X-Forwarded-Client-Cert") + }) + + n := negroni.New() + n.Use(clientCertHandler) + n.UseHandlerFunc(nextHandler) + + // Setup mTLS test server + tlsCert, err := tls.X509KeyPair(certChain.CertPEM, certChain.PrivKeyPEM) + Expect(err).ToNot(HaveOccurred()) + + certPool := x509.NewCertPool() + certPool.AddCert(certChain.CACert) + + serverTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + ClientCAs: certPool, + ClientAuth: tls.RequestClientCert, + } + + server := httptest.NewUnstartedServer(n) + server.TLS = serverTLSConfig + server.StartTLS() + defer server.Close() + + // Create client with mTLS cert + clientTLSConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + RootCAs: certPool, + InsecureSkipVerify: true, // Test server uses 127.0.0.1 which isn't in cert SANs + } + + transport := &http.Transport{TLSClientConfig: clientTLSConfig} + client := &http.Client{Transport: transport} + + // Make request to mTLS domain + req, err := http.NewRequest("GET", server.URL, nil) + Expect(err).NotTo(HaveOccurred()) + req.Host = "myapp.apps.mtls.internal" + + _, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + + // Verify raw format: base64-encoded certificate (no "Hash=" or "Subject=") + Expect(capturedXFCC).NotTo(HavePrefix("Hash=")) + Expect(capturedXFCC).NotTo(ContainSubstring("Subject=")) + + // Raw format is base64-encoded cert, much larger + Expect(len(capturedXFCC)).To(BeNumerically(">", 1000)) + }) + + It("defaults to raw format when xfcc_format is not specified", func() { + logger = test_util.NewTestLogger("test") + + certChain := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "instance-id-123", + AppGUID: "app-guid-456", + }) + + // Configure mTLS domain without xfcc_format + cfg, err := config.DefaultConfig() + Expect(err).NotTo(HaveOccurred()) + + cfg.Domains = []config.MtlsDomainConfig{{ + Domain: "*.apps.mtls.internal", + CACerts: string(certChain.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + // XFCCFormat not set - should default to "raw" + }} + err = cfg.Process() + Expect(err).NotTo(HaveOccurred()) + + // After Process(), XFCCFormat should be set to "raw" + Expect(cfg.Domains[0].XFCCFormat).To(Equal(config.XFCC_FORMAT_RAW)) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/fakes/fake_post_selection_handler.go b/src/code.cloudfoundry.org/gorouter/handlers/fakes/fake_post_selection_handler.go new file mode 100644 index 000000000..1054a36be --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/fakes/fake_post_selection_handler.go @@ -0,0 +1,112 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package fakes + +import ( + "sync" + + "code.cloudfoundry.org/gorouter/handlers" + "code.cloudfoundry.org/gorouter/route" +) + +type FakePostSelectionHandler struct { + CheckStub func(*route.Endpoint, *handlers.RequestInfo) error + checkMutex sync.RWMutex + checkArgsForCall []struct { + arg1 *route.Endpoint + arg2 *handlers.RequestInfo + } + checkReturns struct { + result1 error + } + checkReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakePostSelectionHandler) Check(arg1 *route.Endpoint, arg2 *handlers.RequestInfo) error { + fake.checkMutex.Lock() + ret, specificReturn := fake.checkReturnsOnCall[len(fake.checkArgsForCall)] + fake.checkArgsForCall = append(fake.checkArgsForCall, struct { + arg1 *route.Endpoint + arg2 *handlers.RequestInfo + }{arg1, arg2}) + stub := fake.CheckStub + fakeReturns := fake.checkReturns + fake.recordInvocation("Check", []interface{}{arg1, arg2}) + fake.checkMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakePostSelectionHandler) CheckCallCount() int { + fake.checkMutex.RLock() + defer fake.checkMutex.RUnlock() + return len(fake.checkArgsForCall) +} + +func (fake *FakePostSelectionHandler) CheckCalls(stub func(*route.Endpoint, *handlers.RequestInfo) error) { + fake.checkMutex.Lock() + defer fake.checkMutex.Unlock() + fake.CheckStub = stub +} + +func (fake *FakePostSelectionHandler) CheckArgsForCall(i int) (*route.Endpoint, *handlers.RequestInfo) { + fake.checkMutex.RLock() + defer fake.checkMutex.RUnlock() + argsForCall := fake.checkArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakePostSelectionHandler) CheckReturns(result1 error) { + fake.checkMutex.Lock() + defer fake.checkMutex.Unlock() + fake.CheckStub = nil + fake.checkReturns = struct { + result1 error + }{result1} +} + +func (fake *FakePostSelectionHandler) CheckReturnsOnCall(i int, result1 error) { + fake.checkMutex.Lock() + defer fake.checkMutex.Unlock() + fake.CheckStub = nil + if fake.checkReturnsOnCall == nil { + fake.checkReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.checkReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakePostSelectionHandler) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakePostSelectionHandler) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ handlers.PostSelectionHandler = new(FakePostSelectionHandler) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/identity.go b/src/code.cloudfoundry.org/gorouter/handlers/identity.go new file mode 100644 index 000000000..2959e0e98 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/identity.go @@ -0,0 +1,200 @@ +package handlers + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "net/http" + "strings" + + "github.com/urfave/negroni/v3" +) + +// CallerIdentity represents the identity of the calling application extracted from mTLS +// certificate. The certificate OU field contains: +// - app: for the application GUID +// - space: for the space GUID +// - organization: for the organization GUID +type CallerIdentity struct { + AppGUID string + SpaceGUID string + OrgGUID string +} + +// identityHandler extracts the caller identity from the X-Forwarded-Client-Cert header +// on mTLS domains. The identity is stored in the RequestInfo context for use by +// authorization handlers. +type identityHandler struct{} + +// NewIdentity creates a new identity extraction handler +func NewIdentity() negroni.Handler { + return &identityHandler{} +} + +func (h *identityHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := ContextRequestInfo(r) + if err != nil { + // If RequestInfo is not available, continue without setting identity + next(w, r) + return + } + + // Extract identity from X-Forwarded-Client-Cert header + xfccHeader := r.Header.Get("X-Forwarded-Client-Cert") + if xfccHeader != "" { + identity, err := extractIdentityFromXFCC(xfccHeader) + if err == nil { + reqInfo.CallerIdentity = identity + } + // If extraction fails, continue without setting identity + // The authorization handler will deny access if identity is required + } + + next(w, r) +} + +// extractIdentityFromXFCC parses the X-Forwarded-Client-Cert header and extracts +// the application, space, and organization GUIDs from the client certificate's +// OU (Organizational Unit) field. +// +// Supported XFCC formats: +// 1. Envoy compact format: Hash=;Subject="" - parse OUs from Subject string +// 2. Envoy format with cert: Cert="" +// 3. GoRouter format: raw base64 (no PEM markers) - produced by clientcert.go sanitize() +// +// Expected OU formats: +// - "app:" +// - "space:" +// - "organization:" +func extractIdentityFromXFCC(xfcc string) (*CallerIdentity, error) { + // Try Envoy compact format first: Subject="" + // This is the most efficient format since we don't need to decode a certificate + if subjectStart := strings.Index(xfcc, "Subject=\""); subjectStart != -1 { + subjectStart += len("Subject=\"") + subjectEnd := strings.Index(xfcc[subjectStart:], "\"") + if subjectEnd == -1 { + return nil, errors.New("malformed Subject field in XFCC header") + } + subjectDN := xfcc[subjectStart : subjectStart+subjectEnd] + return extractIdentityFromSubjectDN(subjectDN) + } + + // Try Envoy format with cert: Cert="" + var certDER []byte + var err error + + if certStart := strings.Index(xfcc, "Cert=\""); certStart != -1 { + certStart += len("Cert=\"") + certEnd := strings.Index(xfcc[certStart:], "\"") + if certEnd == -1 { + return nil, errors.New("malformed Cert field in XFCC header") + } + pemData := xfcc[certStart : certStart+certEnd] + + // Decode PEM block + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return nil, errors.New("failed to decode PEM certificate") + } + certDER = block.Bytes + } else { + // GoRouter format: raw base64 without PEM markers + // The clientcert.go sanitize() function strips PEM markers and newlines + certDER, err = base64.StdEncoding.DecodeString(strings.TrimSpace(xfcc)) + if err != nil { + return nil, errors.New("failed to decode base64 certificate: " + err.Error()) + } + } + + // Parse X.509 certificate + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + return extractIdentityFromCert(cert) +} + +// extractIdentityFromSubjectDN parses a Subject DN string and extracts GUIDs +// DN format: "CN=instance-id,OU=app:guid,OU=space:guid,OU=organization:guid" +func extractIdentityFromSubjectDN(subjectDN string) (*CallerIdentity, error) { + identity := &CallerIdentity{} + + // Split DN into RDNs (Relative Distinguished Names) + // Handle both comma and slash separators + var rdns []string + if strings.Contains(subjectDN, ",") { + rdns = strings.Split(subjectDN, ",") + } else if strings.Contains(subjectDN, "/") { + // Some formats use "/" as separator + rdns = strings.Split(subjectDN, "/") + } else { + return nil, errors.New("unrecognized DN format") + } + + for _, rdn := range rdns { + rdn = strings.TrimSpace(rdn) + if rdn == "" { + continue + } + + // Parse OU fields + if strings.HasPrefix(rdn, "OU=") { + ouValue := strings.TrimPrefix(rdn, "OU=") + if strings.HasPrefix(ouValue, "app:") { + appGUID := strings.TrimPrefix(ouValue, "app:") + if appGUID != "" { + identity.AppGUID = appGUID + } + } else if strings.HasPrefix(ouValue, "space:") { + spaceGUID := strings.TrimPrefix(ouValue, "space:") + if spaceGUID != "" { + identity.SpaceGUID = spaceGUID + } + } else if strings.HasPrefix(ouValue, "organization:") { + orgGUID := strings.TrimPrefix(ouValue, "organization:") + if orgGUID != "" { + identity.OrgGUID = orgGUID + } + } + } + } + + // At minimum, require app GUID to be present + if identity.AppGUID == "" { + return nil, errors.New("no app GUID found in Subject DN") + } + + return identity, nil +} + +// extractIdentityFromCert extracts GUIDs from an X.509 certificate's OU fields +func extractIdentityFromCert(cert *x509.Certificate) (*CallerIdentity, error) { + identity := &CallerIdentity{} + for _, ou := range cert.Subject.OrganizationalUnit { + if strings.HasPrefix(ou, "app:") { + appGUID := strings.TrimPrefix(ou, "app:") + if appGUID != "" { + identity.AppGUID = appGUID + } + } else if strings.HasPrefix(ou, "space:") { + spaceGUID := strings.TrimPrefix(ou, "space:") + if spaceGUID != "" { + identity.SpaceGUID = spaceGUID + } + } else if strings.HasPrefix(ou, "organization:") { + orgGUID := strings.TrimPrefix(ou, "organization:") + if orgGUID != "" { + identity.OrgGUID = orgGUID + } + } + } + + // At minimum, require app GUID to be present + if identity.AppGUID == "" { + return nil, errors.New("no app GUID found in certificate OU") + } + + return identity, nil +} diff --git a/src/code.cloudfoundry.org/gorouter/handlers/identity_test.go b/src/code.cloudfoundry.org/gorouter/handlers/identity_test.go new file mode 100644 index 000000000..246bc14a9 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/identity_test.go @@ -0,0 +1,557 @@ +package handlers_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/urfave/negroni/v3" + + "code.cloudfoundry.org/gorouter/handlers" + "code.cloudfoundry.org/gorouter/test_util" +) + +var _ = Describe("Identity", func() { + var ( + handler negroni.Handler + nextCalled bool + nextHandler http.HandlerFunc + recorder *httptest.ResponseRecorder + request *http.Request + requestInfo *handlers.RequestInfo + ) + + BeforeEach(func() { + handler = handlers.NewIdentity() + nextCalled = false + recorder = httptest.NewRecorder() + + nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + request = r + }) + + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + Context("when RequestInfo is not in context", func() { + It("calls next handler without setting identity", func() { + handler.ServeHTTP(recorder, request, nextHandler) + + Expect(nextCalled).To(BeTrue()) + Expect(recorder.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when RequestInfo is in context", func() { + var runHandler = func() { + // Add RequestInfo to context + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.Use(handler) + n.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + request = r + // Capture RequestInfo for assertions + var err error + requestInfo, err = handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + }) + + n.ServeHTTP(recorder, request) + } + + Context("when X-Forwarded-Client-Cert header is not present", func() { + It("calls next handler without setting identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("when X-Forwarded-Client-Cert header is present", func() { + Context("with valid cert containing app GUID in OU", func() { + BeforeEach(func() { + cert := generateTestCert("app:test-app-guid-123") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts caller identity with app GUID", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("test-app-guid-123")) + }) + }) + + Context("with valid cert in GoRouter format (raw base64)", func() { + BeforeEach(func() { + cert := generateTestCert("app:gorouter-format-app-guid") + xfccHeader := buildGoRouterXFCCHeader(cert) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts caller identity with app GUID", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("gorouter-format-app-guid")) + }) + }) + + Context("with cert containing multiple OUs including app GUID", func() { + BeforeEach(func() { + cert := generateTestCertWithMultipleOUs([]string{ + "organization-unit-1", + "app:another-app-guid", + "organization-unit-2", + }) + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts the app GUID from the correct OU", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("another-app-guid")) + }) + }) + + Context("with malformed XFCC header", func() { + Context("missing Cert field", func() { + BeforeEach(func() { + request.Header.Set("X-Forwarded-Client-Cert", "Hash=123;Subject=\"CN=test\"") + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("missing closing quote in Cert field", func() { + BeforeEach(func() { + request.Header.Set("X-Forwarded-Client-Cert", "Cert=\"-----BEGIN CERTIFICATE-----\nMIIB") + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("invalid PEM data", func() { + BeforeEach(func() { + request.Header.Set("X-Forwarded-Client-Cert", "Cert=\"not-a-valid-pem-cert\"") + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("invalid certificate data", func() { + BeforeEach(func() { + invalidPEM := "-----BEGIN CERTIFICATE-----\nSW52YWxpZCBjZXJ0aWZpY2F0ZSBkYXRh\n-----END CERTIFICATE-----" + request.Header.Set("X-Forwarded-Client-Cert", buildXFCCHeader(invalidPEM)) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + }) + + Context("with cert missing app GUID in OU", func() { + Context("no OU fields", func() { + BeforeEach(func() { + cert := generateTestCertWithMultipleOUs([]string{}) + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("OU fields without app: prefix", func() { + BeforeEach(func() { + cert := generateTestCertWithMultipleOUs([]string{ + "organization-unit-1", + "organization-unit-2", + }) + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("OU with app: prefix but empty GUID", func() { + BeforeEach(func() { + cert := generateTestCert("app:") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + }) + }) + }) +}) + +func generateTestCertWithOrgAndSpace() *x509.Certificate { + return generateTestCertWithMultipleOUs([]string{ + "app:test-app-guid", + "space:test-space-guid", + "organization:test-org-guid", + }) +} + +func buildTestCertWithIdentity(appGUID, spaceGUID, orgGUID string) *x509.Certificate { + ous := []string{} + if appGUID != "" { + ous = append(ous, "app:"+appGUID) + } + if spaceGUID != "" { + ous = append(ous, "space:"+spaceGUID) + } + if orgGUID != "" { + ous = append(ous, "organization:"+orgGUID) + } + return generateTestCertWithMultipleOUs(ous) +} + +var _ = Describe("Identity with Space and Org extraction", func() { + var ( + handler negroni.Handler + nextCalled bool + recorder *httptest.ResponseRecorder + request *http.Request + requestInfo *handlers.RequestInfo + ) + + BeforeEach(func() { + handler = handlers.NewIdentity() + nextCalled = false + recorder = httptest.NewRecorder() + + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + var runHandler = func() { + // Add RequestInfo to context + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.Use(handler) + n.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + request = r + // Capture RequestInfo for assertions + var err error + requestInfo, err = handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + }) + + n.ServeHTTP(recorder, request) + } + + Context("when cert contains app, space, and org GUIDs", func() { + BeforeEach(func() { + cert := generateTestCertWithOrgAndSpace() + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts all three GUIDs", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("test-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("test-space-guid")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("test-org-guid")) + }) + }) + + Context("when cert contains only app and space GUIDs", func() { + BeforeEach(func() { + cert := buildTestCertWithIdentity("my-app", "my-space", "") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts app and space GUIDs", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("my-app")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("my-space")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("")) + }) + }) + + Context("when cert contains only app GUID", func() { + BeforeEach(func() { + cert := buildTestCertWithIdentity("my-app", "", "") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts only app GUID", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("my-app")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("")) + }) + }) + + Context("when cert contains space and org but no app GUID", func() { + BeforeEach(func() { + cert := buildTestCertWithIdentity("", "my-space", "my-org") + certPEM := encodeCertToPEM(cert) + xfccHeader := buildXFCCHeader(certPEM) + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity (app GUID required)", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) +}) + +// Helper functions for generating test certificates + +func generateTestCert(ou string) *x509.Certificate { + return generateTestCertWithMultipleOUs([]string{ou}) +} + +func generateTestCertWithMultipleOUs(ous []string) *x509.Certificate { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "test-instance", + OrganizationalUnit: ous, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + Expect(err).NotTo(HaveOccurred()) + + cert, err := x509.ParseCertificate(certDER) + Expect(err).NotTo(HaveOccurred()) + + return cert +} + +func encodeCertToPEM(cert *x509.Certificate) string { + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + return string(certPEM) +} + +func buildXFCCHeader(certPEM string) string { + // XFCC header format: Cert="" + return "Cert=\"" + certPEM + "\"" +} + +// buildGoRouterXFCCHeader produces the format that GoRouter's clientcert.go uses: +// raw base64 without PEM markers (produced by sanitize() function) +func buildGoRouterXFCCHeader(cert *x509.Certificate) string { + return base64.StdEncoding.EncodeToString(cert.Raw) +} + +var _ = Describe("Identity with Envoy Subject DN format", func() { + var ( + handler negroni.Handler + nextCalled bool + recorder *httptest.ResponseRecorder + request *http.Request + requestInfo *handlers.RequestInfo + ) + + BeforeEach(func() { + handler = handlers.NewIdentity() + nextCalled = false + recorder = httptest.NewRecorder() + + request = test_util.NewRequest("GET", "backend.apps.mtls.internal", "/", nil) + }) + + var runHandler = func() { + // Add RequestInfo to context + reqInfoHandler := handlers.NewRequestInfo() + n := negroni.New() + n.Use(reqInfoHandler) + n.Use(handler) + n.UseHandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + request = r + // Capture RequestInfo for assertions + var err error + requestInfo, err = handlers.ContextRequestInfo(r) + Expect(err).NotTo(HaveOccurred()) + }) + + n.ServeHTTP(recorder, request) + } + + Context("when XFCC header is in Envoy compact format with Subject DN", func() { + Context("with comma-separated DN format", func() { + BeforeEach(func() { + // Envoy format: Hash=;Subject="" + xfccHeader := `Hash=abc123;Subject="CN=instance-id,OU=app:envoy-app-guid,OU=space:envoy-space-guid,OU=organization:envoy-org-guid"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts all GUIDs from Subject DN", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("envoy-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("envoy-space-guid")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("envoy-org-guid")) + }) + }) + + Context("with slash-separated DN format", func() { + BeforeEach(func() { + // Some systems use slash-separated format + xfccHeader := `Hash=abc123;Subject="/CN=instance-id/OU=app:slash-app-guid/OU=space:slash-space-guid/OU=organization:slash-org-guid"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts all GUIDs from Subject DN", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("slash-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("slash-space-guid")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("slash-org-guid")) + }) + }) + + Context("with only app GUID in Subject", func() { + BeforeEach(func() { + xfccHeader := `Hash=def456;Subject="CN=instance,OU=app:only-app-guid"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("extracts app GUID", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("only-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("")) + Expect(requestInfo.CallerIdentity.OrgGUID).To(Equal("")) + }) + }) + + Context("with Subject but no app GUID", func() { + BeforeEach(func() { + xfccHeader := `Hash=ghi789;Subject="CN=instance,OU=space:some-space,OU=organization:some-org"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity (app GUID required)", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("with malformed Subject field", func() { + BeforeEach(func() { + // Missing closing quote + xfccHeader := `Hash=jkl012;Subject="CN=instance,OU=app:test-app` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("with empty Subject", func() { + BeforeEach(func() { + xfccHeader := `Hash=mno345;Subject=""` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("does not set caller identity", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).To(BeNil()) + }) + }) + + Context("with Subject containing extra whitespace", func() { + BeforeEach(func() { + xfccHeader := `Hash=pqr678;Subject="CN=instance, OU=app:whitespace-app-guid, OU=space:whitespace-space-guid"` + request.Header.Set("X-Forwarded-Client-Cert", xfccHeader) + }) + + It("trims whitespace and extracts GUIDs", func() { + runHandler() + Expect(nextCalled).To(BeTrue()) + Expect(requestInfo.CallerIdentity).NotTo(BeNil()) + Expect(requestInfo.CallerIdentity.AppGUID).To(Equal("whitespace-app-guid")) + Expect(requestInfo.CallerIdentity.SpaceGUID).To(Equal("whitespace-space-guid")) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/mtls_pre_auth.go b/src/code.cloudfoundry.org/gorouter/handlers/mtls_pre_auth.go new file mode 100644 index 000000000..37ea547e5 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/mtls_pre_auth.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "log/slog" + "net/http" + "strings" + + "code.cloudfoundry.org/gorouter/config" + logger "code.cloudfoundry.org/gorouter/logger" + "code.cloudfoundry.org/gorouter/route" +) + +// mtlsPreAuth performs pre-selection mTLS authorization checks that can be +// validated before endpoint selection (load balancing). This includes: +// - SNI/Host validation (421 Misdirected Request) +// - Route pool lookup (404 Not Found) +// - Identity extraction requirement check (403 Forbidden) +// +// Scope and route policies checking have been moved to post-selection handlers. +type mtlsPreAuth struct { + config *config.Config + logger *slog.Logger +} + +// NewMtlsPreAuth creates a new pre-selection mTLS authorization handler. +func NewMtlsPreAuth(cfg *config.Config, logger *slog.Logger) *mtlsPreAuth { + return &mtlsPreAuth{ + config: cfg, + logger: logger, + } +} + +// domainMatches checks if a hostname matches a domain pattern (supports wildcard domains). +// Examples: +// - domainMatches("mtls-backend.apps.identity", "*.apps.identity") => true +// - domainMatches("mtls-backend.apps.identity", "mtls-backend.apps.identity") => true +// - domainMatches("foo.bar.com", "*.apps.identity") => false +func domainMatches(hostname, domainPattern string) bool { + // Exact match + if hostname == domainPattern { + return true + } + // Wildcard match + if strings.HasPrefix(domainPattern, "*.") { + suffix := domainPattern[1:] // Remove the '*' + return strings.HasSuffix(hostname, suffix) + } + return false +} + +// setRouteEndpointForAccessLog sets the RouteEndpoint on reqInfo so that access +// logs are emitted to the target app even when the request is denied before the +// proxy has a chance to select an endpoint. +func setRouteEndpointForAccessLog(reqInfo *RequestInfo, pool *route.EndpointPool, logger *slog.Logger) { + if pool == nil || reqInfo.RouteEndpoint != nil { + return + } + iter := pool.Endpoints(logger, "", false, route.RoutingProperties{}) + if endpoint := iter.Next(0); endpoint != nil { + reqInfo.RouteEndpoint = endpoint + } +} + +func (h *mtlsPreAuth) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + reqInfo, err := ContextRequestInfo(r) + if err != nil { + h.logger.Error("mtls-pre-auth-failed", logger.ErrAttr(err), slog.String("reason", "request-info-missing")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + hostDomain := hostWithoutPort(r.Host) + + // ── Layer 0: Non-mTLS domain — no checks required ───────────────────────── + if !h.config.IsMtlsDomain(hostDomain) { + next(w, r) + return + } + + // ── Layer 0b: SNI / Host mismatch check (421) ────────────────────────────── + // For mTLS domains we verify that the TLS handshake actually enforced client + // certificate validation for *this* domain. Without this check an attacker + // could connect with SNI for a non-mTLS domain and then send a Host header + // pointing at an mTLS domain — bypassing certificate validation entirely. + connState := GetTLSConnectionState(r) + reqInfo.TlsSNI = connState.SNI + + if !connState.ClientCertRequired || !domainMatches(hostDomain, connState.MtlsDomain) { + h.logger.Warn("mtls-enforcement-mismatch", + slog.String("host", r.Host), + slog.String("tls_sni", connState.SNI), + slog.String("tls_mtls_domain", connState.MtlsDomain)) + w.WriteHeader(http.StatusMisdirectedRequest) // 421 + return + } + + // ── Layer 1: Route lookup ────────────────────────────────────────────────── + if reqInfo.RoutePool == nil || reqInfo.RoutePool.IsEmpty() { + h.logger.Info("mtls-pre-auth-denied", + slog.String("host", r.Host), + slog.String("reason", "no-route-pool")) + w.WriteHeader(http.StatusNotFound) + return + } + + pool := reqInfo.RoutePool + var _ *route.EndpointPool = pool // Explicit type reference to satisfy compiler + applicationId := pool.ApplicationId() + + // ── Layer 2: Route policy scope — is enforcement active? ─────────────────── + // Cloud Controller sets route_policy_scope in route options when the domain + // was created with --enforce-route-policies. An empty scope means "no + // enforcement": the route is on an mTLS domain but authorization is handled + // by the backend. + routePolicyScope := pool.RoutePolicyScope() + if routePolicyScope == "" { + // No enforcement — forward without authorization checks. + next(w, r) + return + } + + // Enforcement is active — we need caller identity for all checks below. + if reqInfo.CallerIdentity == nil { + h.logger.Info("mtls-pre-auth-denied", + slog.String("host", r.Host), + slog.String("endpoint-app", applicationId), + slog.String("reason", "identity-extraction-failed")) + setRouteEndpointForAccessLog(reqInfo, pool, h.logger) + reqInfo.AuthResult = &AuthResult{ + Outcome: "denied", + Rule: "identity_extraction", + DeniedReason: "certificate does not contain CF identity OU fields", + } + w.WriteHeader(http.StatusForbidden) + return + } + + // Pre-auth checks passed — continue to proxy (scope and route policies will be + // checked post-selection in the round tripper). + next(w, r) +} diff --git a/src/code.cloudfoundry.org/gorouter/handlers/mtls_route_policies_auth.go b/src/code.cloudfoundry.org/gorouter/handlers/mtls_route_policies_auth.go new file mode 100644 index 000000000..f9617b25d --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/mtls_route_policies_auth.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "fmt" + "log/slog" + "strings" + + "code.cloudfoundry.org/gorouter/route" +) + +// MtlsRoutePoliciesAuth performs post-selection route-level route policies authorization. +// It evaluates route policies (cf:app:, cf:space:, cf:org:, cf:any) against the +// caller's identity after endpoint selection. +// +// Route policies provide fine-grained per-route authorization beyond domain-level +// scope enforcement. This handler runs in the post-selection pipeline. +type MtlsRoutePoliciesAuth struct { + logger *slog.Logger +} + +// NewMtlsRoutePoliciesAuth creates a new post-selection route policies authorization handler. +func NewMtlsRoutePoliciesAuth(logger *slog.Logger) *MtlsRoutePoliciesAuth { + return &MtlsRoutePoliciesAuth{ + logger: logger, + } +} + +// evaluateRoutePolicies checks whether the caller identity satisfies any of the +// route policies. Policies use the source syntax from the RFC: +// +// cf:any — allow any authenticated caller +// cf:app: — allow a specific app +// cf:space: — allow all apps in a space +// cf:org: — allow all apps in an org +// +// Returns the matched source string and true on success; empty string and false +// if no policy matches. +func evaluateRoutePolicies(policies []string, identity *CallerIdentity) (string, bool) { + for _, policy := range policies { + policy = strings.TrimSpace(policy) + switch { + case policy == "cf:any": + return policy, true + case strings.HasPrefix(policy, "cf:app:"): + guid := strings.TrimPrefix(policy, "cf:app:") + if guid == identity.AppGUID { + return policy, true + } + case strings.HasPrefix(policy, "cf:space:"): + guid := strings.TrimPrefix(policy, "cf:space:") + if guid != "" && guid == identity.SpaceGUID { + return policy, true + } + case strings.HasPrefix(policy, "cf:org:"): + guid := strings.TrimPrefix(policy, "cf:org:") + if guid != "" && guid == identity.OrgGUID { + return policy, true + } + } + } + return "", false +} + +// Check performs post-selection route policies authorization. +// Returns nil if authorized, or an AuthError if no route policy matches +// the caller's identity. +func (h *MtlsRoutePoliciesAuth) Check(endpoint *route.Endpoint, reqInfo *RequestInfo) error { + // Get route policy scope from pool + if reqInfo.RoutePool == nil { + return nil // Should not happen, but be defensive + } + + routePolicyScope := reqInfo.RoutePool.RoutePolicyScope() + if routePolicyScope == "" { + return nil // No route policy enforcement configured + } + + // Route policy enforcement requires caller identity + if reqInfo.CallerIdentity == nil { + return nil // Identity check should have failed earlier in pre-auth + } + + poolHost := reqInfo.RoutePool.Host() + + // Get route policies from the selected endpoint (per-endpoint policies) + routePolicies := endpoint.RoutePolicies + if len(routePolicies) == 0 { + // Default deny: mTLS domain with enforcement enabled but no policies configured + h.logger.Info("mtls-route-policies-denied", + slog.String("route", poolHost), + slog.String("reason", "no-route-policies"), + slog.String("endpoint", endpoint.CanonicalAddr())) + + return NewAuthError( + "route:no_route_policies", + "route has no route policies configured", + ) + } + + // Evaluate route policies + identity := reqInfo.CallerIdentity + matchedPolicy, allowed := evaluateRoutePolicies(routePolicies, identity) + + if !allowed { + h.logger.Info("mtls-route-policies-denied", + slog.String("route", poolHost), + slog.String("caller-app", identity.AppGUID), + slog.String("reason", "route-policies-deny"), + slog.String("endpoint", endpoint.CanonicalAddr())) + + return NewAuthError( + "route:route_policies", + fmt.Sprintf("caller app %s not in route_policies", identity.AppGUID), + ) + } + + // Route policy matched - populate reqInfo for RTR logs + if reqInfo.AuthResult == nil { + reqInfo.AuthResult = &AuthResult{} + } + reqInfo.AuthResult.Rule = "route:" + matchedPolicy + + h.logger.Debug("mtls-route-policies-granted", + slog.String("route", poolHost), + slog.String("caller-app", identity.AppGUID), + slog.String("matched-policy", matchedPolicy), + slog.String("endpoint", endpoint.CanonicalAddr())) + + return nil +} diff --git a/src/code.cloudfoundry.org/gorouter/handlers/mtls_route_policies_auth_test.go b/src/code.cloudfoundry.org/gorouter/handlers/mtls_route_policies_auth_test.go new file mode 100644 index 000000000..4d7ce1607 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/mtls_route_policies_auth_test.go @@ -0,0 +1,443 @@ +package handlers_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "code.cloudfoundry.org/gorouter/handlers" + "code.cloudfoundry.org/gorouter/route" + "code.cloudfoundry.org/gorouter/test_util" +) + +var _ = Describe("MtlsRoutePoliciesAuth", func() { + var ( + handler *handlers.MtlsRoutePoliciesAuth + endpoint *route.Endpoint + reqInfo *handlers.RequestInfo + pool *route.EndpointPool + ) + + BeforeEach(func() { + logger := test_util.NewTestLogger("mtls-route-policies-auth") + handler = handlers.NewMtlsRoutePoliciesAuth(logger.Logger) + reqInfo = &handlers.RequestInfo{} + }) + + createPool := func(ep *route.Endpoint) *route.EndpointPool { + p := route.NewPool(&route.PoolOpts{ + Host: "backend.apps.mtls.internal", + }) + p.Put(ep) + return p + } + + Describe("Check", func() { + Context("when RoutePool is nil", func() { + It("returns nil (no enforcement)", func() { + reqInfo.RoutePool = nil + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + }) + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + }) + + Context("when RoutePolicyScope is empty", func() { + It("returns nil (no enforcement active)", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: "", // No enforcement + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + + It("skips enforcement when caller has identity", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: "", // No enforcement configured + RoutePolicies: []string{"cf:any"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "caller-space", + OrgGUID: "caller-org", + } + + // Even though caller has identity and route has policies, + // enforcement is skipped because RoutePolicyScope is empty + // (domain not configured for route policy enforcement) + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + }) + + Context("when CallerIdentity is nil", func() { + It("returns nil (identity check should have failed earlier)", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeOrg, + RoutePolicies: []string{"cf:any"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = nil + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + }) + + Context("when no route policies are configured", func() { + It("denies with AuthError (default deny)", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeOrg, + RoutePolicies: []string{}, // No sources = default deny + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("route:no_route_policies")) + Expect(authErr.Reason).To(Equal("route has no route policies configured")) + Expect(authErr.HTTPStatus).To(Equal(http.StatusForbidden)) + }) + }) + + // ── Route policy: cf:any ─────────────────────────────────────── + + Context("with route policy cf:any", func() { + It("allows any authenticated caller", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{"cf:any"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "random-caller-app", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:any")) + }) + }) + + // ── Route policy: cf:app: ──────────────────────────────── + + Context("with route policy cf:app:", func() { + It("allows caller with matching app GUID", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{"cf:app:allowed-app-123"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "allowed-app-123", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:app:allowed-app-123")) + }) + + It("denies caller with different app GUID", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{"cf:app:allowed-app-123"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "other-app-456", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("route:route_policies")) + Expect(authErr.Reason).To(ContainSubstring("caller app other-app-456 not in route_policies")) + }) + }) + + // ── Route policy: cf:space: ────────────────────────────── + + Context("with route policy cf:space:", func() { + It("allows caller from matching space", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{"cf:space:allowed-space-abc"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "allowed-space-abc", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:space:allowed-space-abc")) + }) + + It("denies caller from different space", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{"cf:space:allowed-space-abc"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "other-space-xyz", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("route:route_policies")) + }) + }) + + // ── Route policy: cf:org: ──────────────────────────────── + + Context("with route policy cf:org:", func() { + It("allows caller from matching org", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{"cf:org:allowed-org-123"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + OrgGUID: "allowed-org-123", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:org:allowed-org-123")) + }) + + It("denies caller from different org", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{"cf:org:allowed-org-123"}, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + OrgGUID: "other-org-456", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("route:route_policies")) + }) + }) + + // ── Multiple route policies ───────────────────────────────────── + + Context("with multiple route policies", func() { + It("allows caller matching first rule", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{ + "cf:app:app-1", + "cf:app:app-2", + "cf:space:space-abc", + }, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "app-1", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:app:app-1")) + }) + + It("allows caller matching second rule", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{ + "cf:app:app-1", + "cf:app:app-2", + "cf:space:space-abc", + }, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "app-2", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:app:app-2")) + }) + + It("allows caller matching third rule", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{ + "cf:app:app-1", + "cf:app:app-2", + "cf:space:space-abc", + }, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "some-other-app", + SpaceGUID: "space-abc", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:space:space-abc")) + }) + + It("denies caller matching no rules", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{ + "cf:app:app-1", + "cf:app:app-2", + "cf:space:space-abc", + }, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "unrelated-app", + SpaceGUID: "unrelated-space", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("route:route_policies")) + }) + }) + + // ── Edge cases ──────────────────────────────────────────────── + + Context("edge cases", func() { + It("handles whitespace in route policies", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{" cf:any "}, // Whitespace + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:any")) + }) + + It("skips malformed rules and evaluates valid ones", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + RoutePolicies: []string{ + "invalid-rule", + "cf:app:allowed-app", + }, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "allowed-app", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:app:allowed-app")) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/mtls_scope_auth.go b/src/code.cloudfoundry.org/gorouter/handlers/mtls_scope_auth.go new file mode 100644 index 000000000..0e71cfed3 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/mtls_scope_auth.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "fmt" + "log/slog" + + "code.cloudfoundry.org/gorouter/config" + "code.cloudfoundry.org/gorouter/route" +) + +// MtlsScopeAuth performs post-selection domain-level scope authorization. +// It checks whether the caller's org/space identity matches the SELECTED +// endpoint's org/space tags, implementing the RFC's post-selection enforcement +// model. +// +// This handler runs AFTER endpoint selection (load balancing) and enforces +// strict scope boundaries. When a route is shared across spaces with scope=space, +// intermittent 403 errors are expected as the RFC acknowledges this as the +// tradeoff for strict per-endpoint authorization. +type MtlsScopeAuth struct { + config *config.Config + logger *slog.Logger +} + +// NewMtlsScopeAuth creates a new post-selection scope authorization handler. +func NewMtlsScopeAuth(cfg *config.Config, logger *slog.Logger) *MtlsScopeAuth { + return &MtlsScopeAuth{ + config: cfg, + logger: logger, + } +} + +// Check performs post-selection scope authorization against the selected endpoint. +// Returns nil if authorized, or an AuthError if the caller's org/space +// does not match the selected endpoint's org/space tags. +func (h *MtlsScopeAuth) Check(endpoint *route.Endpoint, reqInfo *RequestInfo) error { + // Get route policy scope from pool + if reqInfo.RoutePool == nil { + return nil // Should not happen, but be defensive + } + + routePolicyScope := reqInfo.RoutePool.RoutePolicyScope() + if routePolicyScope == "" { + return nil // No scope enforcement configured + } + + // Scope enforcement requires caller identity + if reqInfo.CallerIdentity == nil { + return nil // Identity check should have failed earlier in pre-auth + } + + identity := reqInfo.CallerIdentity + poolHost := reqInfo.RoutePool.Host() + + // Perform post-selection scope check against the SELECTED endpoint's tags + switch routePolicyScope { + case route.RoutePolicyScopeOrg: + endpointOrg := endpoint.Tags["organization_id"] + if endpointOrg != identity.OrgGUID { + h.logger.Info("mtls-scope-auth-denied", + slog.String("route", poolHost), + slog.String("scope", "org"), + slog.String("caller-org", identity.OrgGUID), + slog.String("endpoint-org", endpointOrg), + slog.String("endpoint", endpoint.CanonicalAddr())) + + return NewAuthError( + "domain:scope=org:post-selection", + fmt.Sprintf("caller org %s does not match selected backend org %s", + identity.OrgGUID, endpointOrg), + ) + } + + case route.RoutePolicyScopeSpace: + endpointSpace := endpoint.Tags["space_id"] + if endpointSpace != identity.SpaceGUID { + h.logger.Info("mtls-scope-auth-denied", + slog.String("route", poolHost), + slog.String("scope", "space"), + slog.String("caller-space", identity.SpaceGUID), + slog.String("endpoint-space", endpointSpace), + slog.String("endpoint", endpoint.CanonicalAddr())) + + return NewAuthError( + "domain:scope=space:post-selection", + fmt.Sprintf("caller space %s does not match selected backend space %s", + identity.SpaceGUID, endpointSpace), + ) + } + + case route.RoutePolicyScopeAny: + // Any authenticated caller passes scope check + return nil + + default: + // Unknown scope - deny to be safe + h.logger.Warn("mtls-scope-auth-denied", + slog.String("route", poolHost), + slog.String("unknown-scope", routePolicyScope)) + + return NewAuthError( + "domain:scope=unknown:post-selection", + fmt.Sprintf("unknown route policy scope %q", routePolicyScope), + ) + } + + // Scope check passed + h.logger.Debug("mtls-scope-auth-granted", + slog.String("route", poolHost), + slog.String("scope", routePolicyScope), + slog.String("endpoint", endpoint.CanonicalAddr())) + + return nil +} diff --git a/src/code.cloudfoundry.org/gorouter/handlers/mtls_scope_auth_test.go b/src/code.cloudfoundry.org/gorouter/handlers/mtls_scope_auth_test.go new file mode 100644 index 000000000..2423dc656 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/mtls_scope_auth_test.go @@ -0,0 +1,380 @@ +package handlers_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "code.cloudfoundry.org/gorouter/config" + "code.cloudfoundry.org/gorouter/handlers" + "code.cloudfoundry.org/gorouter/route" + "code.cloudfoundry.org/gorouter/test_util" +) + +var _ = Describe("MtlsScopeAuth", func() { + var ( + handler *handlers.MtlsScopeAuth + endpoint *route.Endpoint + reqInfo *handlers.RequestInfo + pool *route.EndpointPool + cfg *config.Config + ) + + BeforeEach(func() { + logger := test_util.NewTestLogger("mtls-scope-auth") + cfg, _ = config.DefaultConfig() + handler = handlers.NewMtlsScopeAuth(cfg, logger.Logger) + reqInfo = &handlers.RequestInfo{} + }) + + createPool := func(ep *route.Endpoint) *route.EndpointPool { + p := route.NewPool(&route.PoolOpts{ + Host: "backend.apps.mtls.internal", + }) + p.Put(ep) + return p + } + + Describe("Check", func() { + Context("when RoutePool is nil", func() { + It("returns nil (no enforcement)", func() { + reqInfo.RoutePool = nil + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + }) + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + }) + + Context("when RoutePolicyScope is empty", func() { + It("returns nil (no enforcement active)", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: "", // No enforcement + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + }) + + Context("when CallerIdentity is nil", func() { + It("returns nil (identity check should have failed in pre-auth)", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeOrg, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = nil + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + }) + + // ── Scope: any ──────────────────────────────────────────────── + + Context("with scope=any", func() { + It("allows any authenticated caller", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + RoutePolicyScope: route.RoutePolicyScopeAny, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "any-caller-app", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + }) + + // ── Scope: org ──────────────────────────────────────────────── + + Context("with scope=org", func() { + It("allows caller from same org", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{"organization_id": "org-123"}, + RoutePolicyScope: route.RoutePolicyScopeOrg, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + OrgGUID: "org-123", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + + It("denies caller from different org with AuthError", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{"organization_id": "org-123"}, + RoutePolicyScope: route.RoutePolicyScopeOrg, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + OrgGUID: "org-456", // Different org + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue(), "error should be AuthError") + Expect(authErr.Rule).To(Equal("domain:scope=org:post-selection")) + Expect(authErr.Reason).To(ContainSubstring("caller org org-456 does not match selected backend org org-123")) + Expect(authErr.HTTPStatus).To(Equal(http.StatusForbidden)) + }) + + It("denies caller when endpoint has no organization_id tag", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{}, // No org tag + RoutePolicyScope: route.RoutePolicyScopeOrg, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + OrgGUID: "org-123", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("domain:scope=org:post-selection")) + Expect(authErr.Reason).To(ContainSubstring("caller org org-123 does not match selected backend org ")) + }) + + It("denies caller when caller has no org", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{"organization_id": "org-123"}, + RoutePolicyScope: route.RoutePolicyScopeOrg, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + OrgGUID: "", // No org + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("domain:scope=org:post-selection")) + }) + }) + + // ── Scope: space ────────────────────────────────────────────── + + Context("with scope=space", func() { + It("allows caller from same space", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{"space_id": "space-abc"}, + RoutePolicyScope: route.RoutePolicyScopeSpace, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "space-abc", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + + It("denies caller from different space with AuthError", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{"space_id": "space-abc"}, + RoutePolicyScope: route.RoutePolicyScopeSpace, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "space-xyz", // Different space + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("domain:scope=space:post-selection")) + Expect(authErr.Reason).To(ContainSubstring("caller space space-xyz does not match selected backend space space-abc")) + Expect(authErr.HTTPStatus).To(Equal(http.StatusForbidden)) + }) + + It("denies caller when endpoint has no space_id tag", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{}, // No space tag + RoutePolicyScope: route.RoutePolicyScopeSpace, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "space-abc", + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("domain:scope=space:post-selection")) + }) + + It("denies caller when caller has no space", func() { + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{"space_id": "space-abc"}, + RoutePolicyScope: route.RoutePolicyScopeSpace, + }) + pool = createPool(endpoint) + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "", // No space + } + + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("domain:scope=space:post-selection")) + }) + }) + + // ── Shared route scenario: intermittent 403s ───────────────── + + Context("shared route with scope=space (intermittent 403s)", func() { + It("allows request when selected endpoint matches caller's space", func() { + // Endpoint from space-abc + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-1", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{"space_id": "space-abc"}, + RoutePolicyScope: route.RoutePolicyScopeSpace, + }) + + // Pool contains endpoints from multiple spaces (shared route) + pool = route.NewPool(&route.PoolOpts{ + Host: "shared.apps.mtls.internal", + }) + pool.Put(endpoint) + + // Another endpoint from space-xyz + endpoint2 := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-2", + Host: "192.168.1.2", + Port: 8080, + Tags: map[string]string{"space_id": "space-xyz"}, + RoutePolicyScope: route.RoutePolicyScopeSpace, + }) + pool.Put(endpoint2) + + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "space-abc", + } + + // Check against endpoint from space-abc (matches caller) + err := handler.Check(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + + It("denies request when selected endpoint is from different space (intermittent 403)", func() { + // Endpoint from space-xyz (will be selected) + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-2", + Host: "192.168.1.2", + Port: 8080, + Tags: map[string]string{"space_id": "space-xyz"}, + RoutePolicyScope: route.RoutePolicyScopeSpace, + }) + + // Pool contains endpoints from multiple spaces (shared route) + pool = route.NewPool(&route.PoolOpts{ + Host: "shared.apps.mtls.internal", + }) + + // Endpoint from space-abc + endpoint1 := route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app-1", + Host: "192.168.1.1", + Port: 8080, + Tags: map[string]string{"space_id": "space-abc"}, + RoutePolicyScope: route.RoutePolicyScopeSpace, + }) + pool.Put(endpoint1) + pool.Put(endpoint) + + reqInfo.RoutePool = pool + reqInfo.CallerIdentity = &handlers.CallerIdentity{ + AppGUID: "caller-app", + SpaceGUID: "space-abc", // Caller from space-abc + } + + // Check against endpoint from space-xyz (selected, doesn't match) + err := handler.Check(endpoint, reqInfo) + Expect(err).NotTo(BeNil()) + + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("domain:scope=space:post-selection")) + Expect(authErr.Reason).To(ContainSubstring("caller space space-abc does not match selected backend space space-xyz")) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/post_selection_pipeline.go b/src/code.cloudfoundry.org/gorouter/handlers/post_selection_pipeline.go new file mode 100644 index 000000000..afb1fc20e --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/post_selection_pipeline.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "log/slog" + + "code.cloudfoundry.org/gorouter/route" +) + +// PostSelectionHandler represents a single authorization check that runs after +// endpoint selection. Each handler inspects the selected endpoint and request +// context to make an authorization decision. +// +// Handlers are composable and run in sequence. The first handler to return an +// error stops the pipeline and causes the request to be rejected. +// +//go:generate counterfeiter -o fakes/fake_post_selection_handler.go . PostSelectionHandler +type PostSelectionHandler interface { + // Check performs an authorization check against the selected endpoint. + // Returns nil if authorized, or an AuthError if denied. + Check(endpoint *route.Endpoint, reqInfo *RequestInfo) error +} + +// PostSelectionPipeline runs a sequence of post-selection authorization handlers. +// This enables composable, layered authorization checks after the load balancer +// has selected a specific backend endpoint. +type PostSelectionPipeline struct { + handlers []PostSelectionHandler + logger *slog.Logger +} + +// NewPostSelectionPipeline creates a new authorization pipeline with the given handlers. +// Handlers are executed in the order provided. +func NewPostSelectionPipeline(logger *slog.Logger, handlers ...PostSelectionHandler) *PostSelectionPipeline { + return &PostSelectionPipeline{ + handlers: handlers, + logger: logger, + } +} + +// Run executes all handlers in sequence. Returns nil if all handlers pass, +// or the first error encountered. +func (p *PostSelectionPipeline) Run(endpoint *route.Endpoint, reqInfo *RequestInfo) error { + if p == nil || len(p.handlers) == 0 { + return nil // No handlers configured, allow request + } + + for _, handler := range p.handlers { + if err := handler.Check(endpoint, reqInfo); err != nil { + // First failure stops the pipeline + return err + } + } + + return nil // All handlers passed +} diff --git a/src/code.cloudfoundry.org/gorouter/handlers/post_selection_pipeline_test.go b/src/code.cloudfoundry.org/gorouter/handlers/post_selection_pipeline_test.go new file mode 100644 index 000000000..1673fadc2 --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/handlers/post_selection_pipeline_test.go @@ -0,0 +1,281 @@ +package handlers_test + +import ( + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "code.cloudfoundry.org/gorouter/handlers" + "code.cloudfoundry.org/gorouter/handlers/fakes" + "code.cloudfoundry.org/gorouter/route" + "code.cloudfoundry.org/gorouter/test_util" +) + +var _ = Describe("PostSelectionPipeline", func() { + var ( + pipeline *handlers.PostSelectionPipeline + handler1 *fakes.FakePostSelectionHandler + handler2 *fakes.FakePostSelectionHandler + handler3 *fakes.FakePostSelectionHandler + endpoint *route.Endpoint + reqInfo *handlers.RequestInfo + authError *handlers.AuthError + genericErr error + ) + + BeforeEach(func() { + handler1 = &fakes.FakePostSelectionHandler{} + handler2 = &fakes.FakePostSelectionHandler{} + handler3 = &fakes.FakePostSelectionHandler{} + + endpoint = route.NewEndpoint(&route.EndpointOpts{ + AppId: "backend-app", + Host: "192.168.1.1", + Port: 8080, + }) + + reqInfo = &handlers.RequestInfo{} + + authError = handlers.NewAuthError("test:rule", "test reason") + genericErr = errors.New("generic error") + }) + + Describe("Run", func() { + Context("with empty pipeline", func() { + It("returns nil", func() { + logger := test_util.NewTestLogger("pipeline") + pipeline = handlers.NewPostSelectionPipeline(logger.Logger) + err := pipeline.Run(endpoint, reqInfo) + Expect(err).To(BeNil()) + }) + }) + + Context("with single handler", func() { + It("calls the handler and returns nil on success", func() { + logger := test_util.NewTestLogger("pipeline") + handler1.CheckReturns(nil) + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1) + + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(BeNil()) + Expect(handler1.CheckCallCount()).To(Equal(1)) + ep, ri := handler1.CheckArgsForCall(0) + Expect(ep).To(Equal(endpoint)) + Expect(ri).To(Equal(reqInfo)) + }) + + It("returns error when handler fails", func() { + logger := test_util.NewTestLogger("pipeline") + handler1.CheckReturns(authError) + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1) + + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(Equal(authError)) + Expect(handler1.CheckCallCount()).To(Equal(1)) + }) + }) + + Context("with multiple handlers", func() { + It("calls all handlers in order when all succeed", func() { + logger := test_util.NewTestLogger("pipeline") + handler1.CheckReturns(nil) + handler2.CheckReturns(nil) + handler3.CheckReturns(nil) + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1, handler2, handler3) + + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(BeNil()) + Expect(handler1.CheckCallCount()).To(Equal(1)) + Expect(handler2.CheckCallCount()).To(Equal(1)) + Expect(handler3.CheckCallCount()).To(Equal(1)) + + // Verify all received same endpoint and reqInfo + ep1, ri1 := handler1.CheckArgsForCall(0) + ep2, ri2 := handler2.CheckArgsForCall(0) + ep3, ri3 := handler3.CheckArgsForCall(0) + + Expect(ep1).To(Equal(endpoint)) + Expect(ep2).To(Equal(endpoint)) + Expect(ep3).To(Equal(endpoint)) + Expect(ri1).To(Equal(reqInfo)) + Expect(ri2).To(Equal(reqInfo)) + Expect(ri3).To(Equal(reqInfo)) + }) + + It("stops on first error and does not call remaining handlers", func() { + logger := test_util.NewTestLogger("pipeline") + handler1.CheckReturns(nil) + handler2.CheckReturns(authError) // Fails here + handler3.CheckReturns(nil) + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1, handler2, handler3) + + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(Equal(authError)) + Expect(handler1.CheckCallCount()).To(Equal(1)) + Expect(handler2.CheckCallCount()).To(Equal(1)) + Expect(handler3.CheckCallCount()).To(Equal(0)) // Should not be called + }) + + It("stops on first handler error", func() { + logger := test_util.NewTestLogger("pipeline") + handler1.CheckReturns(authError) // Fails immediately + handler2.CheckReturns(nil) + handler3.CheckReturns(nil) + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1, handler2, handler3) + + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(Equal(authError)) + Expect(handler1.CheckCallCount()).To(Equal(1)) + Expect(handler2.CheckCallCount()).To(Equal(0)) // Should not be called + Expect(handler3.CheckCallCount()).To(Equal(0)) // Should not be called + }) + + It("stops on third handler error", func() { + logger := test_util.NewTestLogger("pipeline") + handler1.CheckReturns(nil) + handler2.CheckReturns(nil) + handler3.CheckReturns(authError) // Fails at the end + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1, handler2, handler3) + + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(Equal(authError)) + Expect(handler1.CheckCallCount()).To(Equal(1)) + Expect(handler2.CheckCallCount()).To(Equal(1)) + Expect(handler3.CheckCallCount()).To(Equal(1)) + }) + }) + + Context("error type handling", func() { + It("returns AuthError as-is", func() { + logger := test_util.NewTestLogger("pipeline") + handler1.CheckReturns(authError) + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1) + + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(Equal(authError)) + authErr, ok := err.(*handlers.AuthError) + Expect(ok).To(BeTrue()) + Expect(authErr.Rule).To(Equal("test:rule")) + Expect(authErr.Reason).To(Equal("test reason")) + }) + + It("returns generic errors as-is", func() { + logger := test_util.NewTestLogger("pipeline") + handler1.CheckReturns(genericErr) + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1) + + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(Equal(genericErr)) + Expect(err.Error()).To(Equal("generic error")) + }) + }) + + Context("handler state isolation", func() { + It("does not interfere with reqInfo modifications by handlers", func() { + logger := test_util.NewTestLogger("pipeline") + // Handler 1 modifies reqInfo + handler1.CheckStub = func(ep *route.Endpoint, ri *handlers.RequestInfo) error { + if ri.AuthResult == nil { + ri.AuthResult = &handlers.AuthResult{} + } + ri.AuthResult.Rule = "first-rule" + return nil + } + + // Handler 2 should see the modification + handler2.CheckStub = func(ep *route.Endpoint, ri *handlers.RequestInfo) error { + Expect(ri.AuthResult.Rule).To(Equal("first-rule")) + ri.AuthResult.Rule = "second-rule" + return nil + } + + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1, handler2) + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("second-rule")) + }) + }) + + Context("real-world scenario", func() { + It("runs scope check then route policies check", func() { + logger := test_util.NewTestLogger("pipeline") + // Simulate scope check (passes) + handler1.CheckStub = func(ep *route.Endpoint, ri *handlers.RequestInfo) error { + // Scope check passed - no error + return nil + } + + // Simulate route policies check (passes and sets AuthResult.Rule) + handler2.CheckStub = func(ep *route.Endpoint, ri *handlers.RequestInfo) error { + // Route policies matched + if ri.AuthResult == nil { + ri.AuthResult = &handlers.AuthResult{} + } + ri.AuthResult.Rule = "route:cf:app:allowed-app" + return nil + } + + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1, handler2) + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(BeNil()) + Expect(reqInfo.AuthResult.Rule).To(Equal("route:cf:app:allowed-app")) + }) + + It("returns error from scope check before running route policies", func() { + logger := test_util.NewTestLogger("pipeline") + scopeErr := handlers.NewAuthError( + "domain:scope=org:post-selection", + "caller org mismatch", + ) + + // Simulate scope check (fails) + handler1.CheckReturns(scopeErr) + + // Simulate route policies check (should not be called) + handler2.CheckStub = func(ep *route.Endpoint, ri *handlers.RequestInfo) error { + Fail("route policies handler should not be called") + return nil + } + + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1, handler2) + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(Equal(scopeErr)) + Expect(handler1.CheckCallCount()).To(Equal(1)) + Expect(handler2.CheckCallCount()).To(Equal(0)) + }) + + It("returns error from route policies check when scope passes", func() { + logger := test_util.NewTestLogger("pipeline") + accessErr := handlers.NewAuthError( + "route:route_policies", + "caller not in route policies", + ) + + // Simulate scope check (passes) + handler1.CheckReturns(nil) + + // Simulate route policies check (fails) + handler2.CheckReturns(accessErr) + + pipeline = handlers.NewPostSelectionPipeline(logger.Logger, handler1, handler2) + err := pipeline.Run(endpoint, reqInfo) + + Expect(err).To(Equal(accessErr)) + Expect(handler1.CheckCallCount()).To(Equal(1)) + Expect(handler2.CheckCallCount()).To(Equal(1)) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/reporter_test.go b/src/code.cloudfoundry.org/gorouter/handlers/reporter_test.go index 9cc4afb1c..a49a21683 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/reporter_test.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/reporter_test.go @@ -88,7 +88,9 @@ var _ = Describe("Reporter Handler", func() { }) It("emits routing response metrics", func() { + before := time.Now() handler.ServeHTTP(resp, req) + after := time.Now() Expect(fakeReporter.CaptureBadGatewayCallCount()).To(Equal(0)) @@ -102,7 +104,11 @@ var _ = Describe("Reporter Handler", func() { Expect(capturedEndpoint.PrivateInstanceId).To(Equal("id")) Expect(capturedEndpoint.PrivateInstanceIndex).To(Equal("1")) Expect(capturedRespCode).To(Equal(http.StatusTeapot)) - Expect(startTime).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) + // ReceivedAt is set to timeNow-1ms where timeNow is captured inside + // the handler (between before and after), so the exact bracket is: + // before-1ms <= startTime <= after-1ms + Expect(startTime).To(BeTemporally(">=", before.Add(-1*time.Millisecond))) + Expect(startTime).To(BeTemporally("<=", after.Add(-1*time.Millisecond))) Expect(latency).To(BeNumerically(">", 0)) Expect(latency).To(BeNumerically("<", 10*time.Millisecond)) diff --git a/src/code.cloudfoundry.org/gorouter/handlers/requestinfo.go b/src/code.cloudfoundry.org/gorouter/handlers/requestinfo.go index 6d2a76819..a7779e627 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/requestinfo.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/requestinfo.go @@ -23,6 +23,39 @@ type key string const RequestInfoCtxKey key = "RequestInfo" +// TLSConnStateKey is the context key type for TLSConnState. +// Exported so router.go can retrieve the pointer during the TLS handshake. +type TLSConnStateKey struct{} + +// TLSConnState captures per-connection TLS handshake state. +// It is stored in a connection-scoped context via http.Server.ConnContext +// (set by router.go) and retrieved per-request in authorization handlers. +type TLSConnState struct { + // SNI is the Server Name Indication value from the TLS ClientHello. + SNI string + // MtlsDomain is the matched mTLS domain name (empty if none matched). + MtlsDomain string + // ClientCertRequired is true when GoRouter required and validated a client + // certificate during the TLS handshake for this connection. + ClientCertRequired bool +} + +// SetTLSConnState stores the TLSConnState in a context (for use in ConnContext). +func SetTLSConnState(ctx context.Context, state *TLSConnState) context.Context { + return context.WithValue(ctx, TLSConnStateKey{}, state) +} + +// GetTLSConnectionState retrieves the TLSConnState from the request context. +// Returns a zero-value TLSConnState (not nil) if none was set (e.g. plain HTTP). +func GetTLSConnectionState(r *http.Request) TLSConnState { + if v := r.Context().Value(TLSConnStateKey{}); v != nil { + if state, ok := v.(*TLSConnState); ok && state != nil { + return *state + } + } + return TLSConnState{} +} + type TraceInfo struct { TraceID string SpanID string @@ -81,6 +114,17 @@ type RequestInfo struct { TraceInfo TraceInfo BackendReqHeaders http.Header + + // CallerIdentity contains the identity of the calling application extracted + // from the client certificate. Will be nil for requests without identity. + CallerIdentity *CallerIdentity + + // AuthResult captures the outcome of identity-aware routing authorization. + // Will be nil if no authorization was performed. + AuthResult *AuthResult + + // TlsSNI is the SNI value used during the TLS handshake (for RTR log on 421). + TlsSNI string } func (r *RequestInfo) ProvideTraceInfo() (TraceInfo, error) { diff --git a/src/code.cloudfoundry.org/gorouter/handlers/requestinfo_test.go b/src/code.cloudfoundry.org/gorouter/handlers/requestinfo_test.go index 1841aa4f6..0f363918c 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/requestinfo_test.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/requestinfo_test.go @@ -59,16 +59,17 @@ var _ = Describe("RequestInfoHandler", func() { }) It("sets RequestInfo with StartTime on the context", func() { + before := time.Now() handler.ServeHTTP(resp, req, nextHandler) var contextReq *http.Request Eventually(reqChan).Should(Receive(&contextReq)) - - expectedStartTime := time.Now() + after := time.Now() ri, err := handlers.ContextRequestInfo(contextReq) Expect(err).ToNot(HaveOccurred()) Expect(ri).ToNot(BeNil()) - Expect(ri.ReceivedAt).To(BeTemporally("~", expectedStartTime, 10*time.Millisecond)) + Expect(ri.ReceivedAt).To(BeTemporally(">=", before)) + Expect(ri.ReceivedAt).To(BeTemporally("<=", after)) }) }) diff --git a/src/code.cloudfoundry.org/gorouter/integration/common_integration_test.go b/src/code.cloudfoundry.org/gorouter/integration/common_integration_test.go index 74ec5d238..af33cf741 100644 --- a/src/code.cloudfoundry.org/gorouter/integration/common_integration_test.go +++ b/src/code.cloudfoundry.org/gorouter/integration/common_integration_test.go @@ -1,12 +1,14 @@ package integration import ( + "context" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io" "math/rand" + "net" "net/http" "net/http/httptest" "os" @@ -63,7 +65,9 @@ func (s *testState) SetOnlyTrustClientCACertsTrue() { func NewTestState() *testState { // TODO: don't hide so much behind these test_util methods - cfg, clientTLSConfig := test_util.SpecSSLConfig(test_util.NextAvailPort(), test_util.NextAvailPort(), test_util.NextAvailPort(), test_util.NextAvailPort(), test_util.NextAvailPort(), test_util.NextAvailPort(), test_util.NextAvailPort()) + // Use ReservePort to keep listeners open until the gorouter process + // starts, preventing other processes from grabbing these ports. + cfg, clientTLSConfig := test_util.SpecSSLConfig(test_util.ReservePort(), test_util.ReservePort(), test_util.ReservePort(), test_util.ReservePort(), test_util.ReservePort(), test_util.ReservePort(), test_util.ReservePort()) cfg.SkipSSLValidation = false cfg.RouteServicesHairpinning = false cfg.CipherString = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384" @@ -71,7 +75,7 @@ func NewTestState() *testState { // TODO: why these magic numbers? cfg.PruneStaleDropletsInterval = 2 * time.Second cfg.DropletStaleThreshold = 10 * time.Second - cfg.StartResponseDelayInterval = 1 * time.Second + cfg.StartResponseDelayInterval = 0 cfg.EndpointTimeout = 15 * time.Second cfg.EndpointDialTimeout = 500 * time.Millisecond cfg.DrainTimeout = 200 * time.Millisecond @@ -189,6 +193,65 @@ func (s *testState) newGetRequest(url string) *http.Request { return req } +// newMtlsGetRequest creates a GET request for mTLS domains (*.apps.mtls.internal). +// It uses a custom dialer to connect to 127.0.0.1 while preserving the original +// hostname for TLS SNI, which is required for GoRouter's SNI/Host validation. +// This helper returns a specialized client that should be used instead of testState.client. +func (s *testState) newMtlsGetRequest(url string) (*http.Request, *http.Client) { + req, err := http.NewRequest("GET", url, nil) + Expect(err).NotTo(HaveOccurred()) + + // Parse the original hostname for SNI + originalHost := req.URL.Hostname() + port := s.cfg.SSLPort + + // Get the base transport to access current TLS config (including any client certs set by tests) + baseTransport := s.client.Transport.(*http.Transport) + + // Create custom transport with dialer that connects to 127.0.0.1 but uses original hostname for SNI + transport := &http.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // Read certificates at dial time (not at closure creation time) so we get + // any certificates that tests set after calling newMtlsGetRequest() + currentCerts := baseTransport.TLSClientConfig.Certificates + + // Create TLS config for this connection + tlsConfig := &tls.Config{ + ServerName: originalHost, // SNI uses original hostname + RootCAs: baseTransport.TLSClientConfig.RootCAs, + Certificates: currentCerts, // Use current certificates from baseTransport + InsecureSkipVerify: true, // Skip cert verification since we connect to 127.0.0.1 + } + + // Create a plain dialer for the TCP connection + netDialer := &net.Dialer{} + rawConn, err := netDialer.DialContext(ctx, network, fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, err + } + + // Wrap with TLS + tlsConn := tls.Client(rawConn, tlsConfig) + + // Perform handshake + if err := tlsConn.HandshakeContext(ctx); err != nil { + rawConn.Close() + return nil, err + } + + return tlsConn, nil + }, + } + + // Create a new client with the custom transport + client := &http.Client{ + Transport: transport, + Timeout: s.client.Timeout, + } + + return req, client +} + func (s *testState) register(backend *httptest.Server, routeURI string) { s.registerAsTLS(backend, routeURI, "") } @@ -245,6 +308,105 @@ func (s *testState) registerWithInternalRouteService(appBackend, routeServiceSer s.registerAndWait(rm) } +func (s *testState) registerWithAccessRules(backend *httptest.Server, routeURI string, accessRules map[string]interface{}) { + _, backendPort := hostnameAndPort(backend.Listener.Addr().String()) + + // Build route policy sources from map (using RFC-compliant format) + var accessRulesList []string + if apps, ok := accessRules["apps"].([]string); ok { + for _, app := range apps { + accessRulesList = append(accessRulesList, fmt.Sprintf("cf:app:%s", app)) + } + } + if spaces, ok := accessRules["spaces"].([]string); ok { + for _, space := range spaces { + accessRulesList = append(accessRulesList, fmt.Sprintf("cf:space:%s", space)) + } + } + if orgs, ok := accessRules["orgs"].([]string); ok { + for _, org := range orgs { + accessRulesList = append(accessRulesList, fmt.Sprintf("cf:org:%s", org)) + } + } + if any, ok := accessRules["any"].(bool); ok && any { + accessRulesList = append(accessRulesList, "cf:any") + } + + // Join route policy sources into comma-separated string + accessRulesStr := "" + if len(accessRulesList) > 0 { + accessRulesStr = accessRulesList[0] + for i := 1; i < len(accessRulesList); i++ { + accessRulesStr = fmt.Sprintf("%s,%s", accessRulesStr, accessRulesList[i]) + } + } + + rm := mbus.RegistryMessage{ + Host: "127.0.0.1", + Port: uint16(backendPort), + Uris: []route.Uri{route.Uri(routeURI)}, + StaleThresholdInSeconds: 10, + PrivateInstanceID: fmt.Sprintf("%x", rand.Int31()), + Options: mbus.RegistryMessageOpts{ + RoutePolicyScope: "any", // Default to any scope + RoutePolicySources: accessRulesStr, + }, + } + s.registerAndWait(rm) +} + +// registerWithScopeAndAccessRules registers a route with RFC-compliant access control. +// scope: "any", "org", or "space" +// accessRules: map with "apps", "spaces", "orgs", or "any" keys +// tags: endpoint tags like "organization_id" and "space_id" +func (s *testState) registerWithScopeAndAccessRules(backend *httptest.Server, routeURI string, scope string, accessRules map[string]interface{}, tags map[string]string) { + _, backendPort := hostnameAndPort(backend.Listener.Addr().String()) + + // Build route policy sources from map + var accessRulesList []string + if apps, ok := accessRules["apps"].([]string); ok { + for _, app := range apps { + accessRulesList = append(accessRulesList, fmt.Sprintf("cf:app:%s", app)) + } + } + if spaces, ok := accessRules["spaces"].([]string); ok { + for _, space := range spaces { + accessRulesList = append(accessRulesList, fmt.Sprintf("cf:space:%s", space)) + } + } + if orgs, ok := accessRules["orgs"].([]string); ok { + for _, org := range orgs { + accessRulesList = append(accessRulesList, fmt.Sprintf("cf:org:%s", org)) + } + } + if any, ok := accessRules["any"].(bool); ok && any { + accessRulesList = append(accessRulesList, "cf:any") + } + + // Join route policy sources into comma-separated string + accessRulesStr := "" + if len(accessRulesList) > 0 { + accessRulesStr = accessRulesList[0] + for i := 1; i < len(accessRulesList); i++ { + accessRulesStr = fmt.Sprintf("%s,%s", accessRulesStr, accessRulesList[i]) + } + } + + rm := mbus.RegistryMessage{ + Host: "127.0.0.1", + Port: uint16(backendPort), + Uris: []route.Uri{route.Uri(routeURI)}, + StaleThresholdInSeconds: 10, + PrivateInstanceID: fmt.Sprintf("%x", rand.Int31()), + Tags: tags, + Options: mbus.RegistryMessageOpts{ + RoutePolicyScope: scope, + RoutePolicySources: accessRulesStr, + }, + } + s.registerAndWait(rm) +} + func (s *testState) registerAndWait(rm mbus.RegistryMessage) { b, _ := json.Marshal(rm) s.mbusClient.Publish("router.register", b) @@ -258,6 +420,10 @@ func (s *testState) registerAndWait(rm mbus.RegistryMessage) { func (s *testState) StartGorouter() *Session { Expect(s.cfg).NotTo(BeNil(), "set up test cfg before calling this function") + // Release NATS port first so the NATS server can bind it, while keeping + // the other ports reserved until the gorouter starts. + test_util.ReleasePort(s.cfg.Nats.Hosts[0].Port) + s.natsRunner = test_util.NewNATSRunner(int(s.cfg.Nats.Hosts[0].Port)) s.natsRunner.Start() @@ -271,6 +437,10 @@ func (s *testState) StartGorouter() *Session { Expect(err).ToNot(HaveOccurred()) Expect(os.WriteFile(cfgFile, cfgBytes, 0644)).To(Succeed()) + // Release remaining reserved ports just before the gorouter process + // starts, minimizing the TOCTOU window between release and bind. + test_util.ReleaseAllPorts() + cmd := exec.Command(gorouterPath, "-c", cfgFile) s.gorouterSession, err = Start(cmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) @@ -297,6 +467,12 @@ func (s *testState) StartGorouterOrFail() { } func (s *testState) StopAndCleanup() { + // Stop router before NATS to prevent subscriber's ClosedCB from + // firing log.Fatal → os.Exit(1), which kills the test proc + if s.gorouterSession != nil && s.gorouterSession.ExitCode() == -1 { + Eventually(s.gorouterSession.Terminate(), 5).Should(Exit(0)) + } + if s.natsRunner != nil { s.natsRunner.Stop() } @@ -308,10 +484,6 @@ func (s *testState) StopAndCleanup() { os.RemoveAll(s.tmpdir) - if s.gorouterSession != nil && s.gorouterSession.ExitCode() == -1 { - Eventually(s.gorouterSession.Terminate(), 5).Should(Exit(0)) - } - if s.fakeMetron != nil { s.StopMetron() } diff --git a/src/code.cloudfoundry.org/gorouter/integration/gdpr_test.go b/src/code.cloudfoundry.org/gorouter/integration/gdpr_test.go index 521dd83b7..dbeed7c4d 100644 --- a/src/code.cloudfoundry.org/gorouter/integration/gdpr_test.go +++ b/src/code.cloudfoundry.org/gorouter/integration/gdpr_test.go @@ -65,7 +65,7 @@ var _ = Describe("GDPR", func() { testState.EnableAccessLog() testState.cfg.Status.Pass = "pass" testState.cfg.Status.User = "user" - testState.cfg.Status.Routes.Port = 6705 + testState.cfg.Status.Routes.Port = test_util.ReservePort() testState.cfg.Logging.DisableLogForwardedFor = true testState.StartGorouterOrFail() @@ -136,7 +136,7 @@ var _ = Describe("GDPR", func() { testState.EnableAccessLog() testState.cfg.Status.Pass = "pass" testState.cfg.Status.User = "user" - testState.cfg.Status.Routes.Port = 6706 + testState.cfg.Status.Routes.Port = test_util.ReservePort() testState.cfg.Logging.DisableLogSourceIP = true testState.StartGorouterOrFail() diff --git a/src/code.cloudfoundry.org/gorouter/integration/identity_aware_routing_test.go b/src/code.cloudfoundry.org/gorouter/integration/identity_aware_routing_test.go new file mode 100644 index 000000000..1511abc0d --- /dev/null +++ b/src/code.cloudfoundry.org/gorouter/integration/identity_aware_routing_test.go @@ -0,0 +1,910 @@ +package integration + +import ( + "crypto/tls" + "fmt" + "io" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "code.cloudfoundry.org/gorouter/config" + "code.cloudfoundry.org/gorouter/test_util" +) + +var _ = Describe("Identity-Aware Routing", func() { + var testState *testState + + BeforeEach(func() { + testState = NewTestState() + }) + + AfterEach(func() { + if testState != nil { + testState.StopAndCleanup() + } + }) + + Describe("mTLS domain configuration", func() { + var ( + mtlsDomainCA *test_util.CertChain + appInstanceCert *test_util.CertChain + backendApp *httptest.Server + backendReceivedReqs chan *http.Request + ) + + BeforeEach(func() { + // Create CA for mTLS domain (simulates Diego instance identity CA) + mtlsDomainCA = &test_util.CertChain{} + *mtlsDomainCA = test_util.CreateSignedCertWithRootCA(test_util.CertNames{CommonName: "Diego Instance Identity CA"}) + + // Setup backend app + backendReceivedReqs = make(chan *http.Request, 10) + backendApp = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backendReceivedReqs <- r + w.WriteHeader(http.StatusOK) + w.Write([]byte("backend-response")) + })) + + // Configure GoRouter with mTLS domain + testState.cfg.EnableSSL = true + testState.cfg.ClientCertificateValidationString = "request" + }) + + AfterEach(func() { + if backendApp != nil { + backendApp.Close() + } + }) + + Context("when a request is made to an mTLS domain", func() { + var mtlsDomain string + + BeforeEach(func() { + mtlsDomain = "my-app.apps.mtls.internal" + + // Configure mTLS domain in GoRouter + testState.cfg.Domains = []config.MtlsDomainConfig{ + { + Domain: "*.apps.mtls.internal", + CACerts: string(mtlsDomainCA.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + }, + } + + testState.StartGorouterOrFail() + }) + + It("requires a client certificate", func() { + // Register route on mTLS domain + testState.register(backendApp, mtlsDomain) + + // Attempt request without client certificate + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + _, err := client.Do(req) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("tls")) + }) + + It("accepts valid client certificate from the configured CA", func() { + // Create instance identity certificate (need to use the same CA!) + appInstanceCert = &test_util.CertChain{} + // Recreate with SAME CA as configured in GoRouter + *appInstanceCert = test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "app-instance", + AppGUID: "app-guid-123", + SpaceGUID: "space-guid-456", + OrgGUID: "org-guid-789", + }, mtlsDomainCA) + + // Register route on mTLS domain with allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"app-guid-123"}, + }, + ) + + // Configure client to use instance identity cert + clientTLSConfig := &tls.Config{ + RootCAs: testState.client.Transport.(*http.Transport).TLSClientConfig.RootCAs, + Certificates: []tls.Certificate{ + appInstanceCert.TLSCert(), + }, + } + testState.client.Transport.(*http.Transport).TLSClientConfig = clientTLSConfig + + // Make request + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + Expect(string(body)).To(Equal("backend-response")) + + // Verify backend received the request + Eventually(backendReceivedReqs).Should(Receive()) + }) + + It("rejects client certificate from unknown CA", func() { + // Create certificate from different CA (not the configured mtlsDomainCA) + unknownCert := test_util.CreateInstanceIdentityCert(test_util.InstanceIdentityCertNames{ + CommonName: "app-instance", + AppGUID: "app-guid-123", + }) + + // Register route + testState.register(backendApp, mtlsDomain) + + // Configure client with unknown cert + clientTLSConfig := &tls.Config{ + RootCAs: testState.client.Transport.(*http.Transport).TLSClientConfig.RootCAs, + Certificates: []tls.Certificate{ + unknownCert.TLSCert(), + }, + } + testState.client.Transport.(*http.Transport).TLSClientConfig = clientTLSConfig + + // Make request - should fail TLS handshake + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + _, err := client.Do(req) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("tls")) + }) + }) + + Context("when requests are made to non-mTLS domains", func() { + var regularDomain string + + BeforeEach(func() { + regularDomain = "my-app.apps.internal" + + // Configure only the mTLS domain + testState.cfg.Domains = []config.MtlsDomainConfig{ + { + Domain: "*.apps.mtls.internal", + CACerts: string(mtlsDomainCA.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + }, + } + + testState.StartGorouterOrFail() + }) + + It("does not require client certificates", func() { + // Register route on regular domain + testState.register(backendApp, regularDomain) + + // Make request without client certificate (using HTTPS) + req := testState.newGetRequest(fmt.Sprintf("https://%s", regularDomain)) + resp, err := testState.client.Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(resp.Body) + Expect(string(body)).To(Equal("backend-response")) + }) + }) + }) + + Describe("App-to-App authorization", func() { + var ( + mtlsDomainCA *test_util.CertChain + backendApp *httptest.Server + backendReceivedReqs chan *http.Request + mtlsDomain string + ) + + BeforeEach(func() { + mtlsDomain = "secure-api.apps.mtls.internal" + + // Create CA for mTLS domain + mtlsDomainCA = &test_util.CertChain{} + *mtlsDomainCA = test_util.CreateSignedCertWithRootCA(test_util.CertNames{CommonName: "Diego Instance Identity CA"}) + + // Setup backend app + backendReceivedReqs = make(chan *http.Request, 10) + backendApp = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backendReceivedReqs <- r + w.WriteHeader(http.StatusOK) + w.Write([]byte("authorized")) + })) + + // Configure GoRouter + testState.cfg.EnableSSL = true + testState.cfg.ClientCertificateValidationString = "request" + testState.cfg.Domains = []config.MtlsDomainConfig{ + { + Domain: "*.apps.mtls.internal", + CACerts: string(mtlsDomainCA.CACertPEM), + ForwardedClientCert: config.SANITIZE_SET, + }, + } + + testState.StartGorouterOrFail() + }) + + AfterEach(func() { + if backendApp != nil { + backendApp.Close() + } + }) + + Describe("app-level authorization", func() { + It("allows requests from apps in the allowed list", func() { + callerAppGUID := "caller-app-guid-123" + + // Register route with app-level allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{callerAppGUID, "other-app-guid"}, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: callerAppGUID, + SpaceGUID: "caller-space-guid", + OrgGUID: "caller-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + Expect(string(body)).To(Equal("authorized")) + }) + + It("denies requests from apps not in the allowed list", func() { + // Register route with app-level allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"allowed-app-guid"}, + }, + ) + + // Create caller certificate with different app GUID + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "unauthorized-app-guid", + SpaceGUID: "caller-space-guid", + OrgGUID: "caller-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("space-level authorization", func() { + It("allows requests from apps in allowed spaces", func() { + callerSpaceGUID := "dev-space-guid" + + // Register route with space-level allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "spaces": []string{callerSpaceGUID, "other-space-guid"}, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: callerSpaceGUID, + OrgGUID: "caller-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + Expect(string(body)).To(Equal("authorized")) + }) + + It("denies requests from apps in non-allowed spaces", func() { + // Register route with space-level allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "spaces": []string{"allowed-space-guid"}, + }, + ) + + // Create caller certificate with different space GUID + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "unauthorized-space-guid", + OrgGUID: "caller-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("org-level authorization", func() { + It("allows requests from apps in allowed orgs", func() { + callerOrgGUID := "my-org-guid" + + // Register route with org-level allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "orgs": []string{callerOrgGUID}, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "caller-space-guid", + OrgGUID: callerOrgGUID, + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("denies requests from apps in non-allowed orgs", func() { + // Register route with org-level allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "orgs": []string{"allowed-org-guid"}, + }, + ) + + // Create caller certificate with different org GUID + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "caller-space-guid", + OrgGUID: "unauthorized-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("multi-level authorization", func() { + It("allows requests if ANY authorization level matches", func() { + // Register route with multiple authorization levels + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"specific-app-guid"}, + "spaces": []string{"dev-space-guid"}, + "orgs": []string{"my-org-guid"}, + }, + ) + + // Create caller that matches space level but not app level + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "different-app-guid", + SpaceGUID: "dev-space-guid", // Matches allowed space + OrgGUID: "different-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should succeed because space matches + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("denies requests if NO authorization level matches", func() { + // Register route with multiple authorization levels + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"allowed-app-guid"}, + "spaces": []string{"allowed-space-guid"}, + "orgs": []string{"allowed-org-guid"}, + }, + ) + + // Create caller that matches none + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "different-app-guid", + SpaceGUID: "different-space-guid", + OrgGUID: "different-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should fail + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("'any authenticated app' authorization", func() { + It("allows any authenticated app when any=true", func() { + // Register route with any=true + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "any": true, + }, + ) + + // Create arbitrary caller certificate + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "any-app-instance", + AppGUID: "random-app-guid-999", + SpaceGUID: "random-space-guid", + OrgGUID: "random-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should succeed + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + }) + + Describe("default-deny behavior", func() { + It("allows requests when route policy enforcement is not enabled", func() { + // Register route without route policy scope (enforcement disabled) + // Cloud Controller only sets RoutePolicyScope when the domain is configured + // with --enforce-route-policies flag + testState.register(backendApp, mtlsDomain) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should succeed (no enforcement, backend handles auth) + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("denies requests when route policies are empty", func() { + // Register route with empty allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{}, + "spaces": []string{}, + "orgs": []string{}, + "any": false, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request - should fail + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("X-Forwarded-Client-Cert header", func() { + It("forwards sanitized client certificate to backend on mTLS domains", func() { + // Register route with allowed sources + testState.registerWithAccessRules( + backendApp, + mtlsDomain, + map[string]interface{}{ + "apps": []string{"caller-app-guid"}, + }, + ) + + // Create caller certificate + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "caller-space-guid", + OrgGUID: "caller-org-guid", + }, mtlsDomainCA) + + // Configure client + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make request + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", mtlsDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + + // Check backend received XFCC header + var backendReq *http.Request + Eventually(backendReceivedReqs).Should(Receive(&backendReq)) + Expect(backendReq.Header.Get("X-Forwarded-Client-Cert")).NotTo(BeEmpty()) + }) + }) + + // RFC Scenario: Shared routes with post-selection authorization + // This test validates the expected intermittent 403 behavior described in + // RFC lines 475-517 (Post-Selection Authorization). + Describe("shared routes with scope boundaries (intermittent 403s)", func() { + var ( + sharedDomain string + backendApp1 *httptest.Server + backendApp2 *httptest.Server + app1Requests chan *http.Request + app2Requests chan *http.Request + ) + + BeforeEach(func() { + sharedDomain = "shared.apps.mtls.internal" + app1Requests = make(chan *http.Request, 10) + app2Requests = make(chan *http.Request, 10) + + // Setup two backend apps in DIFFERENT spaces + backendApp1 = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + app1Requests <- r + w.WriteHeader(http.StatusOK) + w.Write([]byte("backend-app-1")) + })) + + backendApp2 = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + app2Requests <- r + w.WriteHeader(http.StatusOK) + w.Write([]byte("backend-app-2")) + })) + }) + + AfterEach(func() { + if backendApp1 != nil { + backendApp1.Close() + } + if backendApp2 != nil { + backendApp2.Close() + } + }) + + Context("when two apps register the same route in different spaces", func() { + It("allows requests to the same space and denies to different space (intermittent 403s)", func() { + // Register SAME route from two different spaces with scope=space + // Backend 1 is in space-alpha + testState.registerWithScopeAndAccessRules( + backendApp1, + sharedDomain, + "space", + map[string]interface{}{ + "any": true, + }, + map[string]string{ + "space_id": "space-alpha", + }, + ) + + // Backend 2 is in space-beta + testState.registerWithScopeAndAccessRules( + backendApp2, + sharedDomain, + "space", + map[string]interface{}{ + "any": true, + }, + map[string]string{ + "space_id": "space-beta", + }, + ) + + // Create caller from space-alpha + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "space-alpha", + OrgGUID: "org-123", + }, mtlsDomainCA) + + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make multiple requests and observe intermittent behavior + successCount := 0 + forbiddenCount := 0 + attempts := 10 + + for i := 0; i < attempts; i++ { + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", sharedDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + + if resp.StatusCode == http.StatusOK { + body, _ := io.ReadAll(resp.Body) + // Should only succeed when routed to space-alpha backend + Expect(string(body)).To(Equal("backend-app-1")) + successCount++ + } else if resp.StatusCode == http.StatusForbidden { + // Expected: post-selection check failed (routed to space-beta backend) + forbiddenCount++ + } + resp.Body.Close() + } + + // Verify we got BOTH outcomes (RFC-compliant intermittent 403s) + // With round-robin load balancing, both endpoints should be hit + Expect(successCount).To(BeNumerically(">", 0), "Should have some successful requests (same-space)") + Expect(forbiddenCount).To(BeNumerically(">", 0), "Should have some 403 responses (cross-space)") + Expect(successCount + forbiddenCount).To(Equal(attempts)) + }) + + It("always succeeds when caller is in same org with scope=org", func() { + // Register SAME route from two different spaces but SAME org with scope=org + // Backend 1 is in org-alpha/space-alpha + testState.registerWithScopeAndAccessRules( + backendApp1, + sharedDomain, + "org", + map[string]interface{}{ + "any": true, + }, + map[string]string{ + "organization_id": "org-alpha", + "space_id": "space-alpha", + }, + ) + + // Backend 2 is in org-alpha/space-beta (same org, different space) + testState.registerWithScopeAndAccessRules( + backendApp2, + sharedDomain, + "org", + map[string]interface{}{ + "any": true, + }, + map[string]string{ + "organization_id": "org-alpha", + "space_id": "space-beta", + }, + ) + + // Create caller from org-alpha + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "space-gamma", // Different space, but same org + OrgGUID: "org-alpha", + }, mtlsDomainCA) + + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make multiple requests - ALL should succeed (same org) + for i := 0; i < 10; i++ { + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", sharedDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + } + }) + + It("always fails when caller is in different org with scope=org", func() { + // Register SAME route from two different orgs with scope=org + // Backend 1 is in org-alpha + testState.registerWithScopeAndAccessRules( + backendApp1, + sharedDomain, + "org", + map[string]interface{}{ + "any": true, + }, + map[string]string{ + "organization_id": "org-alpha", + }, + ) + + // Backend 2 is in org-beta + testState.registerWithScopeAndAccessRules( + backendApp2, + sharedDomain, + "org", + map[string]interface{}{ + "any": true, + }, + map[string]string{ + "organization_id": "org-beta", + }, + ) + + // Create caller from org-gamma (different from both backends) + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "caller-app-guid", + SpaceGUID: "space-123", + OrgGUID: "org-gamma", + }, mtlsDomainCA) + + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make multiple requests - ALL should fail (different org) + for i := 0; i < 10; i++ { + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", sharedDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + resp.Body.Close() + } + }) + }) + + Context("when shared route has app-specific route policies", func() { + It("allows only the specified app and denies others (per-endpoint rules)", func() { + // Backend 1 allows only "allowed-app-1" + testState.registerWithScopeAndAccessRules( + backendApp1, + sharedDomain, + "any", + map[string]interface{}{ + "apps": []string{"allowed-app-1"}, + }, + nil, + ) + + // Backend 2 allows only "allowed-app-2" + testState.registerWithScopeAndAccessRules( + backendApp2, + sharedDomain, + "any", + map[string]interface{}{ + "apps": []string{"allowed-app-2"}, + }, + nil, + ) + + // Create caller with allowed-app-1 + callerCert := test_util.CreateInstanceIdentityCertWithCA(test_util.InstanceIdentityCertNames{ + CommonName: "caller-app-instance", + AppGUID: "allowed-app-1", + SpaceGUID: "space-123", + OrgGUID: "org-123", + }, mtlsDomainCA) + + testState.client.Transport.(*http.Transport).TLSClientConfig.Certificates = []tls.Certificate{ + callerCert.TLSCert(), + } + + // Make multiple requests + successCount := 0 + forbiddenCount := 0 + attempts := 10 + + for i := 0; i < attempts; i++ { + req, client := testState.newMtlsGetRequest(fmt.Sprintf("https://%s", sharedDomain)) + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + + if resp.StatusCode == http.StatusOK { + body, _ := io.ReadAll(resp.Body) + // Should only succeed when routed to backend 1 + Expect(string(body)).To(Equal("backend-app-1")) + successCount++ + } else { + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + forbiddenCount++ + } + resp.Body.Close() + } + + // Verify intermittent behavior based on endpoint selection + Expect(successCount).To(BeNumerically(">", 0), "Should succeed when routed to backend-1") + Expect(forbiddenCount).To(BeNumerically(">", 0), "Should fail when routed to backend-2") + }) + }) + }) + }) +}) diff --git a/src/code.cloudfoundry.org/gorouter/integration/main_test.go b/src/code.cloudfoundry.org/gorouter/integration/main_test.go index 846974a43..c6868a187 100644 --- a/src/code.cloudfoundry.org/gorouter/integration/main_test.go +++ b/src/code.cloudfoundry.org/gorouter/integration/main_test.go @@ -65,29 +65,33 @@ var _ = Describe("Router Integration", func() { Expect(err).ToNot(HaveOccurred()) cfgFile = filepath.Join(tmpdir, "config.yml") - statusPort = test_util.NextAvailPort() - statusTLSPort = test_util.NextAvailPort() - statusRoutesPort = test_util.NextAvailPort() - proxyPort = test_util.NextAvailPort() - natsPort = test_util.NextAvailPort() - sslPort = test_util.NextAvailPort() - routeServiceServerPort = test_util.NextAvailPort() + statusPort = test_util.ReservePort() + statusTLSPort = test_util.ReservePort() + statusRoutesPort = test_util.ReservePort() + proxyPort = test_util.ReservePort() + natsPort = test_util.ReservePort() + sslPort = test_util.ReservePort() + routeServiceServerPort = test_util.ReservePort() natsRunner = test_util.NewNATSRunner(int(natsPort)) + test_util.ReleasePort(natsPort) natsRunner.Start() oauthServerURL = oauthServer.Addr() }) AfterEach(func() { + test_util.ReleaseAllPorts() + // Stop router before NATS to prevent subscriber's ClosedCB from + // firing log.Fatal → os.Exit(1), which kills the test proc. + if gorouterSession != nil && gorouterSession.ExitCode() == -1 { + stopGorouter(gorouterSession) + } + if natsRunner != nil { natsRunner.Stop() } os.RemoveAll(tmpdir) - - if gorouterSession != nil && gorouterSession.ExitCode() == -1 { - stopGorouter(gorouterSession) - } }) Context("when config is invalid", func() { @@ -609,6 +613,7 @@ var _ = Describe("Router Integration", func() { tempCfg.Logging.MetronAddress = "" writeConfig(tempCfg, cfgFile) + test_util.ReleaseAllPorts() gorouterCmd := exec.Command(gorouterPath, "-c", cfgFile) gorouterSession, _ = Start(gorouterCmd, GinkgoWriter, GinkgoWriter) Eventually(gorouterSession, 5*time.Second).Should(Exit(1)) @@ -635,7 +640,7 @@ var _ = Describe("Router Integration", func() { BeforeEach(func() { testState = NewTestState() - testState.cfg.DebugAddr = fmt.Sprintf("127.0.0.1:%d", test_util.NextAvailPort()) + testState.cfg.DebugAddr = fmt.Sprintf("127.0.0.1:%d", test_util.ReservePort()) testState.StartGorouterOrFail() gorouterSession = testState.gorouterSession @@ -1047,7 +1052,7 @@ var _ = Describe("Router Integration", func() { Describe("prometheus metrics", func() { It("starts a prometheus https server", func() { c := createConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, routeServiceServerPort, cfgFile, defaultPruneInterval, defaultPruneThreshold, 0, false, 0, natsPort) - metricsPort := test_util.NextAvailPort() + metricsPort := test_util.ReservePort() serverCAPath, serverCertPath, serverKeyPath, clientCert := tls_helpers.GenerateCaAndMutualTlsCerts() c.Prometheus.Enabled = true @@ -1421,6 +1426,7 @@ var _ = Describe("Router Integration", func() { It("does not exit", func() { writeConfig(cfg, cfgFile) + test_util.ReleaseAllPorts() gorouterCmd := exec.Command(gorouterPath, "-c", cfgFile) session, err := Start(gorouterCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) @@ -1436,6 +1442,7 @@ var _ = Describe("Router Integration", func() { It("gorouter exits with non-zero code", func() { writeConfig(cfg, cfgFile) + test_util.ReleaseAllPorts() gorouterCmd := exec.Command(gorouterPath, "-c", cfgFile) session, err := Start(gorouterCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) @@ -1453,6 +1460,7 @@ var _ = Describe("Router Integration", func() { routingApiServer.Close() writeConfig(cfg, cfgFile) + test_util.ReleaseAllPorts() gorouterCmd := exec.Command(gorouterPath, "-c", cfgFile) session, err := Start(gorouterCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) @@ -1468,6 +1476,7 @@ var _ = Describe("Router Integration", func() { cfg.OAuth.Port = 0 writeConfig(cfg, cfgFile) + test_util.ReleaseAllPorts() gorouterCmd := exec.Command(gorouterPath, "-c", cfgFile) session, err := Start(gorouterCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) diff --git a/src/code.cloudfoundry.org/gorouter/integration/nats_test.go b/src/code.cloudfoundry.org/gorouter/integration/nats_test.go index 0067d21a3..e095eed60 100644 --- a/src/code.cloudfoundry.org/gorouter/integration/nats_test.go +++ b/src/code.cloudfoundry.org/gorouter/integration/nats_test.go @@ -36,27 +36,31 @@ var _ = Describe("NATS Integration", func() { Expect(err).ToNot(HaveOccurred()) cfgFile = filepath.Join(tmpdir, "config.yml") - statusPort = test_util.NextAvailPort() - statusTLSPort = test_util.NextAvailPort() - statusRoutesPort = test_util.NextAvailPort() - proxyPort = test_util.NextAvailPort() - natsPort = test_util.NextAvailPort() - routeServiceServerPort = test_util.NextAvailPort() + statusPort = test_util.ReservePort() + statusTLSPort = test_util.ReservePort() + statusRoutesPort = test_util.ReservePort() + proxyPort = test_util.ReservePort() + natsPort = test_util.ReservePort() + routeServiceServerPort = test_util.ReservePort() natsRunner = test_util.NewNATSRunner(int(natsPort)) + test_util.ReleasePort(natsPort) natsRunner.Start() }) AfterEach(func() { + test_util.ReleaseAllPorts() + // Stop router before NATS to prevent subscriber's ClosedCB from + // firing log.Fatal → os.Exit(1), which kills the test proc. + if gorouterSession != nil && gorouterSession.ExitCode() == -1 { + stopGorouter(gorouterSession) + } + if natsRunner != nil { natsRunner.Stop() } os.RemoveAll(tmpdir) - - if gorouterSession != nil && gorouterSession.ExitCode() == -1 { - stopGorouter(gorouterSession) - } }) It("has Nats connectivity", func() { @@ -162,7 +166,7 @@ var _ = Describe("NATS Integration", func() { ) BeforeEach(func() { - natsPort2 = test_util.NextAvailPort() + natsPort2 = test_util.ReservePort() natsRunner2 = test_util.NewNATSRunner(int(natsPort2)) pruneInterval = 2 * time.Second @@ -206,6 +210,7 @@ var _ = Describe("NATS Integration", func() { time.Sleep(heartbeatInterval * 2) natsRunner.Stop() + test_util.ReleasePort(natsPort2) natsRunner2.Start() // Give router time to make a bad decision (i.e. prune routes) @@ -222,7 +227,7 @@ var _ = Describe("NATS Integration", func() { Context("when suspend_pruning_if_nats_unavailable enabled", func() { BeforeEach(func() { - natsPort2 = test_util.NextAvailPort() + natsPort2 = test_util.ReservePort() natsRunner2 = test_util.NewNATSRunner(int(natsPort2)) pruneInterval = 200 * time.Millisecond diff --git a/src/code.cloudfoundry.org/gorouter/integration/test_utils_test.go b/src/code.cloudfoundry.org/gorouter/integration/test_utils_test.go index c8d8d0978..3b3a8c65b 100644 --- a/src/code.cloudfoundry.org/gorouter/integration/test_utils_test.go +++ b/src/code.cloudfoundry.org/gorouter/integration/test_utils_test.go @@ -46,7 +46,7 @@ func configDrainSetup(cfg *config.Config, pruneInterval, pruneThreshold time.Dur // as part of pausing cfg.PruneStaleDropletsInterval = pruneInterval cfg.DropletStaleThreshold = pruneThreshold - cfg.StartResponseDelayInterval = 1 * time.Second + cfg.StartResponseDelayInterval = 0 cfg.EndpointTimeout = 5 * time.Second cfg.EndpointDialTimeout = 500 * time.Millisecond cfg.DrainTimeout = 200 * time.Millisecond @@ -60,6 +60,7 @@ func writeConfig(cfg *config.Config, cfgFile string) { } func startGorouterSession(cfgFile string) *Session { + test_util.ReleaseAllPorts() gorouterCmd := exec.Command(gorouterPath, "-c", cfgFile) session, err := Start(gorouterCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) diff --git a/src/code.cloudfoundry.org/gorouter/logger/logger.go b/src/code.cloudfoundry.org/gorouter/logger/logger.go index d41c57144..cb5aedba3 100644 --- a/src/code.cloudfoundry.org/gorouter/logger/logger.go +++ b/src/code.cloudfoundry.org/gorouter/logger/logger.go @@ -1,6 +1,7 @@ package logger import ( + "fmt" "io" "log/slog" "os" @@ -235,6 +236,8 @@ via os.Exit(1) after writing the log message. */ func Fatal(logger *slog.Logger, message string, slogAttrs ...any) { logger.Error(message, slogAttrs...) + // Write to stderr so the message survives os.Exit (stderr is unbuffered). + fmt.Fprintf(os.Stderr, "FATAL: %s %v\n", message, slogAttrs) os.Exit(1) } diff --git a/src/code.cloudfoundry.org/gorouter/mbus/registry_message_test.go b/src/code.cloudfoundry.org/gorouter/mbus/registry_message_test.go index 9162a97e6..948049a1d 100644 --- a/src/code.cloudfoundry.org/gorouter/mbus/registry_message_test.go +++ b/src/code.cloudfoundry.org/gorouter/mbus/registry_message_test.go @@ -60,4 +60,110 @@ var _ = Describe("RegistryMessage", func() { }) }) }) + + Describe("MakeEndpoint with route_policy_scope and route_policy_sources", func() { + var message *RegistryMessage + var payload []byte + + JustBeforeEach(func() { + message = new(RegistryMessage) + err := json.Unmarshal(payload, message) + Expect(err).NotTo(HaveOccurred()) + }) + + Describe("With route_policy_scope=any and no route_policy_sources", func() { + BeforeEach(func() { + payload = []byte(`{ + "app":"app1", + "uris":["test.com"], + "host":"1.2.3.4", + "port":1234, + "tags":{}, + "private_instance_id":"private_instance_id", + "options": { + "route_policy_scope": "any" + } + }`) + }) + + It("parses route_policy_scope correctly with empty sources", func() { + endpoint, err := message.MakeEndpoint(false, "round-robin") + Expect(err).NotTo(HaveOccurred()) + Expect(endpoint.RoutePolicyScope).To(Equal("any")) + Expect(endpoint.RoutePolicies).To(BeEmpty()) + }) + }) + + Describe("With route_policy_scope=org and route_policy_sources listing apps and spaces", func() { + BeforeEach(func() { + payload = []byte(`{ + "app":"app1", + "uris":["test.com"], + "host":"1.2.3.4", + "port":1234, + "tags":{}, + "private_instance_id":"private_instance_id", + "options": { + "route_policy_scope": "org", + "route_policy_sources": "cf:app:app-guid-1,cf:space:space-guid-1,cf:org:org-guid-1" + } + }`) + }) + + It("parses route_policy_scope and route_policy_sources correctly", func() { + endpoint, err := message.MakeEndpoint(false, "round-robin") + Expect(err).NotTo(HaveOccurred()) + Expect(endpoint.RoutePolicyScope).To(Equal("org")) + Expect(endpoint.RoutePolicies).To(ConsistOf( + "cf:app:app-guid-1", + "cf:space:space-guid-1", + "cf:org:org-guid-1", + )) + }) + }) + + Describe("With route_policy_scope=space and cf:any rule", func() { + BeforeEach(func() { + payload = []byte(`{ + "app":"app1", + "uris":["test.com"], + "host":"1.2.3.4", + "port":1234, + "tags":{}, + "private_instance_id":"private_instance_id", + "options": { + "route_policy_scope": "space", + "route_policy_sources": "cf:any" + } + }`) + }) + + It("parses cf:any rule correctly", func() { + endpoint, err := message.MakeEndpoint(false, "round-robin") + Expect(err).NotTo(HaveOccurred()) + Expect(endpoint.RoutePolicyScope).To(Equal("space")) + Expect(endpoint.RoutePolicies).To(ConsistOf("cf:any")) + }) + }) + + Describe("With no route_policy_scope or route_policy_sources", func() { + BeforeEach(func() { + payload = []byte(`{ + "app":"app1", + "uris":["test.com"], + "host":"1.2.3.4", + "port":1234, + "tags":{}, + "private_instance_id":"private_instance_id" + }`) + }) + + It("leaves RoutePolicyScope empty and RoutePolicies nil", func() { + endpoint, err := message.MakeEndpoint(false, "round-robin") + Expect(err).NotTo(HaveOccurred()) + Expect(endpoint.RoutePolicyScope).To(BeEmpty()) + Expect(endpoint.RoutePolicies).To(BeEmpty()) + }) + }) + }) }) diff --git a/src/code.cloudfoundry.org/gorouter/mbus/subscriber.go b/src/code.cloudfoundry.org/gorouter/mbus/subscriber.go index f36659621..9c9f0dca2 100644 --- a/src/code.cloudfoundry.org/gorouter/mbus/subscriber.go +++ b/src/code.cloudfoundry.org/gorouter/mbus/subscriber.go @@ -43,10 +43,33 @@ type RegistryMessage struct { type RegistryMessageOpts struct { LoadBalancingAlgorithm string `json:"loadbalancing"` HashHeaderName string `json:"hash_header"` - HashBalance float64 `json:"hash_balance,string"` + HashBalance float64 `json:"hash_balance"` + // RFC route policy options (from Cloud Controller via Diego sync) + RoutePolicyScope string `json:"route_policy_scope,omitempty"` + RoutePolicySources string `json:"route_policy_sources,omitempty"` } -func (rm *RegistryMessage) makeEndpoint(http2Enabled bool, globalRoutingAlgo string) (*route.Endpoint, error) { +// parseCommaSeparatedSources splits a comma-separated string into a slice of sources. +// Returns nil if the input is empty. +func parseCommaSeparatedSources(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) == 0 { + return nil + } + return result +} + +func (rm *RegistryMessage) MakeEndpoint(http2Enabled bool, globalRoutingAlgo string) (*route.Endpoint, error) { port, useTLS, err := rm.port() if err != nil { return nil, err @@ -85,6 +108,8 @@ func (rm *RegistryMessage) makeEndpoint(http2Enabled bool, globalRoutingAlgo str LoadBalancingAlgorithm: lbAlgo, HashHeaderName: rm.Options.HashHeaderName, HashBalanceFactor: rm.Options.HashBalance, + RoutePolicyScope: rm.Options.RoutePolicyScope, + RoutePolicies: parseCommaSeparatedSources(rm.Options.RoutePolicySources), }), nil } @@ -250,7 +275,7 @@ func (s *Subscriber) subscribeRoutes() (*nats.Subscription, error) { } func (s *Subscriber) registerEndpoint(msg *RegistryMessage) { - endpoint, err := msg.makeEndpoint(s.http2Enabled, s.globalRoutingAlgo) + endpoint, err := msg.MakeEndpoint(s.http2Enabled, s.globalRoutingAlgo) if err != nil { s.logger.Error("Unable to register route", log.ErrAttr(err), @@ -265,7 +290,7 @@ func (s *Subscriber) registerEndpoint(msg *RegistryMessage) { } func (s *Subscriber) unregisterEndpoint(msg *RegistryMessage) { - endpoint, err := msg.makeEndpoint(s.http2Enabled, s.globalRoutingAlgo) + endpoint, err := msg.MakeEndpoint(s.http2Enabled, s.globalRoutingAlgo) if err != nil { s.logger.Error("Unable to unregister route", log.ErrAttr(err), diff --git a/src/code.cloudfoundry.org/gorouter/mbus/subscriber_test.go b/src/code.cloudfoundry.org/gorouter/mbus/subscriber_test.go index 790307f7e..836ff36bc 100644 --- a/src/code.cloudfoundry.org/gorouter/mbus/subscriber_test.go +++ b/src/code.cloudfoundry.org/gorouter/mbus/subscriber_test.go @@ -38,9 +38,9 @@ var _ = Describe("Subscriber", func() { ) BeforeEach(func() { - natsPort = test_util.NextAvailPort() - + natsPort = test_util.ReservePort() natsRunner = test_util.NewNATSRunner(int(natsPort)) + test_util.ReleasePort(natsPort) natsRunner.Start() natsClient = natsRunner.MessageBus @@ -60,13 +60,13 @@ var _ = Describe("Subscriber", func() { }) AfterEach(func() { - if natsRunner != nil { - natsRunner.Stop() - } if process != nil { process.Signal(os.Interrupt) } process = nil + if natsRunner != nil { + natsRunner.Stop() + } }) It("exits when signaled", func() { diff --git a/src/code.cloudfoundry.org/gorouter/proxy/proxy.go b/src/code.cloudfoundry.org/gorouter/proxy/proxy.go index 790eca7b7..9ce9ff399 100644 --- a/src/code.cloudfoundry.org/gorouter/proxy/proxy.go +++ b/src/code.cloudfoundry.org/gorouter/proxy/proxy.go @@ -115,6 +115,15 @@ func NewProxy( IsInstrumented: cfg.SendHttpStartStopClientEvent, } + // Create post-selection authorization pipeline + // This runs after endpoint selection in the round tripper to enforce + // RFC-compliant strict scope and route policies checking. + postSelectionPipeline := handlers.NewPostSelectionPipeline( + logger, + handlers.NewMtlsScopeAuth(cfg, logger), + handlers.NewMtlsRoutePoliciesAuth(logger), + ) + prt := round_tripper.NewProxyRoundTripper( roundTripperFactory, fails.RetriableClassifiers, @@ -126,6 +135,7 @@ func NewProxy( }, routeServicesTransport, cfg, + postSelectionPipeline, ) rproxy := &httputil.ReverseProxy{ @@ -134,6 +144,19 @@ func NewProxy( FlushInterval: 50 * time.Millisecond, BufferPool: p.bufferPool, ModifyResponse: p.modifyResponse, + ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { + // Check if this is an authorization error + if authErr, ok := err.(*handlers.AuthError); ok { + // Return the HTTP status from the AuthError (typically 403) + rw.WriteHeader(authErr.HTTPStatus) + rw.Write([]byte(authErr.Error())) + return + } + + // For all other errors, use default behavior (502 Bad Gateway) + rw.WriteHeader(http.StatusBadGateway) + rw.Write([]byte(err.Error())) + }, } routeServiceHandler := handlers.NewRouteService(routeServiceConfig, registry, logger, errorWriter) @@ -172,9 +195,12 @@ func NewProxy( SkipSanitize(routeServiceHandler.(*handlers.RouteService)), ForceDeleteXFCCHeader(routeServiceHandler.(*handlers.RouteService), cfg.ForwardedClientCert, logger), cfg.ForwardedClientCert, + cfg, logger, errorWriter, )) + n.Use(handlers.NewIdentity()) + n.Use(handlers.NewMtlsPreAuth(cfg, logger)) n.Use(handlers.NewHopByHop(cfg, logger)) n.Use(&handlers.XForwardedProto{ SkipSanitization: SkipSanitizeXFP(routeServiceHandler.(*handlers.RouteService)), diff --git a/src/code.cloudfoundry.org/gorouter/proxy/proxy_test.go b/src/code.cloudfoundry.org/gorouter/proxy/proxy_test.go index 5d5842181..85bb6c470 100644 --- a/src/code.cloudfoundry.org/gorouter/proxy/proxy_test.go +++ b/src/code.cloudfoundry.org/gorouter/proxy/proxy_test.go @@ -3,8 +3,6 @@ package proxy_test import ( "bufio" "bytes" - "code.cloudfoundry.org/gorouter/proxy" - "code.cloudfoundry.org/gorouter/routeservice" "crypto/tls" "crypto/x509" "fmt" @@ -22,6 +20,9 @@ import ( "sync/atomic" "time" + "code.cloudfoundry.org/gorouter/proxy" + "code.cloudfoundry.org/gorouter/routeservice" + "github.com/cloudfoundry/dropsonde/factories" "github.com/cloudfoundry/sonde-go/events" uuid "github.com/nu7hatch/gouuid" @@ -2021,8 +2022,11 @@ var _ = Describe("Proxy", func() { Expect(body).To(Equal("ABCD")) - expectRsp := test_util.NewResponse(100) - conn.WriteResponse(expectRsp) + conn.WriteResponse(&http.Response{ + StatusCode: http.StatusContinue, + ProtoMajor: 1, + ProtoMinor: 1, + }) rsp := test_util.NewResponse(200) rsp.Body = io.NopCloser(strings.NewReader("valid-but-unimportant-response-data")) @@ -2076,8 +2080,11 @@ var _ = Describe("Proxy", func() { Expect(body).To(Equal("ABCD")) - expectRsp := test_util.NewResponse(100) - conn.WriteResponse(expectRsp) + conn.WriteResponse(&http.Response{ + StatusCode: http.StatusContinue, + ProtoMajor: 1, + ProtoMinor: 1, + }) rsp := test_util.NewResponse(201) rsp.Body = io.NopCloser(strings.NewReader("valid-but-unimportant-response-data")) @@ -2888,9 +2895,11 @@ var _ = Describe("Proxy", func() { conn := dialProxy(proxyServer) req := test_util.NewRequest("GET", "reporter-test", "/", nil) + before := time.Now() conn.WriteRequest(req) resp, _ := conn.ReadResponse() + after := time.Now() Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(fakeReporter.CaptureBadGatewayCallCount()).To(Equal(0)) @@ -2906,7 +2915,8 @@ var _ = Describe("Proxy", func() { Expect(capturedEndpoint.PrivateInstanceId).To(Equal("")) Expect(capturedEndpoint.PrivateInstanceIndex).To(Equal("2")) Expect(capturedRespCode).To(Equal(http.StatusOK)) - Expect(startTime).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) + Expect(startTime).To(BeTemporally(">=", before)) + Expect(startTime).To(BeTemporally("<=", after)) Expect(latency).To(BeNumerically(">", 0)) Expect(fakeReporter.CaptureRoutingRequestCallCount()).To(Equal(1)) diff --git a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go index cbe223136..ebf877686 100644 --- a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go +++ b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go @@ -76,6 +76,7 @@ func NewProxyRoundTripper( errHandler errorHandler, routeServicesTransport http.RoundTripper, cfg *config.Config, + postSelectionPipeline *handlers.PostSelectionPipeline, ) ProxyRoundTripper { return &roundTripper{ @@ -86,6 +87,7 @@ func NewProxyRoundTripper( errorHandler: errHandler, routeServicesTransport: routeServicesTransport, config: cfg, + postSelectionPipeline: postSelectionPipeline, } } @@ -97,6 +99,7 @@ type roundTripper struct { errorHandler errorHandler routeServicesTransport http.RoundTripper config *config.Config + postSelectionPipeline *handlers.PostSelectionPipeline } func (rt *roundTripper) RoundTrip(originalRequest *http.Request) (*http.Response, error) { @@ -193,6 +196,37 @@ func (rt *roundTripper) RoundTrip(originalRequest *http.Request) (*http.Response triedEndpoints[endpoint.CanonicalAddr()] = true reqInfo.RouteEndpoint = endpoint + // ── Post-selection authorization ────────────────────────────────────── + // Run post-selection authorization pipeline after endpoint selection but + // before making the backend request. This enforces RFC-compliant strict + // post-selection scope and route policies checking. + if rt.postSelectionPipeline != nil { + if authErr := rt.postSelectionPipeline.Run(endpoint, reqInfo); authErr != nil { + // Authorization failed - handle as AuthError + if authError, ok := authErr.(*handlers.AuthError); ok { + reqInfo.AuthResult = &handlers.AuthResult{ + Outcome: "denied", + Rule: authError.Rule, + DeniedReason: authError.Reason, + } + + logger.Info("post-selection-auth-denied", + slog.String("rule", authError.Rule), + slog.String("reason", authError.Reason), + slog.String("endpoint", endpoint.CanonicalAddr())) + + // Return authorization error - will be converted to 403 by error handler + return nil, authErr + } + + // Unknown error type + logger.Error("post-selection-auth-error", + log.ErrAttr(authErr), + slog.String("endpoint", endpoint.CanonicalAddr())) + return nil, authErr + } + } + logger.Debug("backend", slog.Int("attempt", attempt)) if endpoint.IsTLS() { request.URL.Scheme = "https" diff --git a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go index e4187b506..3c45e98ac 100644 --- a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go +++ b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go @@ -172,6 +172,7 @@ var _ = Describe("ProxyRoundTripper", func() { errorHandler, routeServicesTransport, cfg, + nil, // postSelectionPipeline - not testing mTLS auth in these tests ) }) @@ -3028,13 +3029,14 @@ var _ = Describe("ProxyRoundTripper", func() { } }) It("sets a http/1 timeout on the request context", func() { + before := time.Now() proxyRoundTripper.RoundTrip(req) var request *http.Request Eventually(reqCh).Should(Receive(&request)) deadLine, deadlineSet := request.Context().Deadline() Expect(deadlineSet).To(BeTrue()) - Expect(deadLine).To(BeTemporally("~", time.Now().Add(20*time.Millisecond), 11*time.Millisecond)) + Expect(deadLine).To(BeTemporally("~", before.Add(20*time.Millisecond), 20*time.Millisecond)) Eventually(func() string { err := request.Context().Err() if err != nil { @@ -3053,13 +3055,14 @@ var _ = Describe("ProxyRoundTripper", func() { } }) It("sets a http/2 timeout on the request context", func() { + before := time.Now() proxyRoundTripper.RoundTrip(req) var request *http.Request Eventually(reqCh).Should(Receive(&request)) deadLine, deadlineSet := request.Context().Deadline() Expect(deadlineSet).To(BeTrue()) - Expect(deadLine).To(BeTemporally("~", time.Now().Add(15*time.Millisecond), 6*time.Millisecond)) + Expect(deadLine).To(BeTemporally("~", before.Add(15*time.Millisecond), 15*time.Millisecond)) Eventually(func() string { err := request.Context().Err() if err != nil { diff --git a/src/code.cloudfoundry.org/gorouter/route/pool.go b/src/code.cloudfoundry.org/gorouter/route/pool.go index cff30523b..eb13e80cf 100644 --- a/src/code.cloudfoundry.org/gorouter/route/pool.go +++ b/src/code.cloudfoundry.org/gorouter/route/pool.go @@ -63,6 +63,14 @@ type Stats struct { NumberConnections *Counter } +// RoutePolicyScopeAny, RoutePolicyScopeOrg, RoutePolicyScopeSpace are the valid values for RoutePolicyScope. +// They correspond to the route_policies_scope field in Cloud Controller. +const ( + RoutePolicyScopeAny = "any" + RoutePolicyScopeOrg = "org" + RoutePolicyScopeSpace = "space" +) + func NewStats() *Stats { return &Stats{ NumberConnections: &Counter{}, @@ -118,6 +126,12 @@ type Endpoint struct { LoadBalancingAlgorithm string HashHeaderName string HashBalanceFactor float64 + // RoutePolicyScope is the operator-level scope boundary: "any", "org", or "space". + // Non-empty means access control is enforced for this endpoint's route. + RoutePolicyScope string + // RoutePolicies is the list of parsed sources (e.g. "cf:app:", "cf:space:", + // "cf:org:", "cf:any"). Empty with a non-empty RoutePolicyScope means default-deny. + RoutePolicies []string } func (e *Endpoint) RoundTripper() ProxyRoundTripper { @@ -163,7 +177,9 @@ func (e *Endpoint) Equal(e2 *Endpoint) bool { e.LoadBalancingAlgorithm == e2.LoadBalancingAlgorithm && e.HashHeaderName == e2.HashHeaderName && e.HashBalanceFactor == e2.HashBalanceFactor && - maps.Equal(e.Tags, e2.Tags) + maps.Equal(e.Tags, e2.Tags) && + e.RoutePolicyScope == e2.RoutePolicyScope && + slices.Equal(e.RoutePolicies, e2.RoutePolicies) } @@ -231,6 +247,12 @@ type EndpointOpts struct { LoadBalancingAlgorithm string HashHeaderName string HashBalanceFactor float64 + // RoutePolicyScope is the operator-level scope: "any", "org", or "space". + // Non-empty means enforcement is active for this route. + RoutePolicyScope string + // RoutePolicies are the parsed sources for this route. + // Empty + non-empty RoutePolicyScope means default-deny. + RoutePolicies []string } func NewEndpoint(opts *EndpointOpts) *Endpoint { @@ -251,6 +273,8 @@ func NewEndpoint(opts *EndpointOpts) *Endpoint { IsolationSegment: opts.IsolationSegment, UpdatedAt: opts.UpdatedAt, LoadBalancingAlgorithm: opts.LoadBalancingAlgorithm, + RoutePolicyScope: opts.RoutePolicyScope, + RoutePolicies: opts.RoutePolicies, } if opts.LoadBalancingAlgorithm == config.LOAD_BALANCE_HB && opts.HashHeaderName != "" { // BalanceFactor is optional @@ -579,6 +603,48 @@ func (p *EndpointPool) IsEmpty() bool { return l == 0 } +// RoutePolicyScope returns the route policy scope from the first endpoint in the pool. +// All endpoints in a pool share the same route policy scope since they represent +// instances of the same application route registered with the same options. +// Returns empty string if the pool is empty or enforcement is not active. +func (p *EndpointPool) RoutePolicyScope() string { + p.Lock() + defer p.Unlock() + + if len(p.endpoints) == 0 { + return "" + } + + return p.endpoints[0].endpoint.RoutePolicyScope +} + +// RoutePolicies returns the route policies from the first endpoint in the pool. +// All endpoints in a pool share the same route policies. +// Returns nil if the pool is empty. +func (p *EndpointPool) RoutePolicies() []string { + p.Lock() + defer p.Unlock() + + if len(p.endpoints) == 0 { + return nil + } + + return p.endpoints[0].endpoint.RoutePolicies +} + +// ApplicationId returns the ApplicationId from the first endpoint in the pool. +// All endpoints in a pool should have the same ApplicationId. +func (p *EndpointPool) ApplicationId() string { + p.Lock() + defer p.Unlock() + + if len(p.endpoints) == 0 { + return "" + } + + return p.endpoints[0].endpoint.ApplicationId +} + func (p *EndpointPool) NextIndex() int { if p.NextIdx == -1 { p.NextIdx = p.random.Intn(len(p.endpoints)) diff --git a/src/code.cloudfoundry.org/gorouter/router/route_service_server_test.go b/src/code.cloudfoundry.org/gorouter/router/route_service_server_test.go index c2af6c3ab..9992ddb8c 100644 --- a/src/code.cloudfoundry.org/gorouter/router/route_service_server_test.go +++ b/src/code.cloudfoundry.org/gorouter/router/route_service_server_test.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "code.cloudfoundry.org/gorouter/test_util" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -31,6 +32,7 @@ var _ = Describe("RouteServicesServer", func() { var err error cfg, err = config.DefaultConfig() Expect(err).NotTo(HaveOccurred()) + cfg.RouteServicesServerPort = test_util.NextAvailPort() req, err = http.NewRequest("GET", "/foo", nil) Expect(err).NotTo(HaveOccurred()) diff --git a/src/code.cloudfoundry.org/gorouter/router/router.go b/src/code.cloudfoundry.org/gorouter/router/router.go index 42b79b65e..50c7ba7e9 100644 --- a/src/code.cloudfoundry.org/gorouter/router/router.go +++ b/src/code.cloudfoundry.org/gorouter/router/router.go @@ -3,6 +3,7 @@ package router import ( "bytes" "compress/zlib" + "context" "crypto/tls" "crypto/x509" "encoding/json" @@ -215,6 +216,12 @@ func (r *Router) Run(signals <-chan os.Signal, ready chan<- struct{}) error { IdleTimeout: r.config.FrontendIdleTimeout, ReadHeaderTimeout: r.config.ReadHeaderTimeout, MaxHeaderBytes: MAX_HEADER_BYTES, + // ConnContext injects a mutable *TLSConnState per connection so that + // getTLSConfigForClient can populate it during the TLS handshake and + // the authorization handler can read it later. + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + return handlers.SetTLSConnState(ctx, &handlers.TLSConnState{}) + }, } err = r.serveHTTP(server, r.errChan) @@ -291,7 +298,8 @@ func (r *Router) serveHTTPS(server *http.Server, errChan chan error) error { return nil } - tlsConfig := &tls.Config{ + // Base TLS config for non-mTLS domains + baseTlsConfig := &tls.Config{ Certificates: r.config.SSLCertificates, CipherSuites: r.config.CipherSuites, MinVersion: r.config.MinTLSVersion, @@ -301,18 +309,25 @@ func (r *Router) serveHTTPS(server *http.Server, errChan chan error) error { } if r.config.VerifyClientCertificatesBasedOnProvidedMetadata && r.config.VerifyClientCertificateMetadataRules != nil { - tlsConfig.VerifyPeerCertificate = r.verifyMtlsMetadata + baseTlsConfig.VerifyPeerCertificate = r.verifyMtlsMetadata } if r.config.EnableHTTP2 { - tlsConfig.NextProtos = []string{"h2", "http/1.1"} + baseTlsConfig.NextProtos = []string{"h2", "http/1.1"} } // Although this functionality is deprecated there is no intention to remove it from the stdlib // due to the Go 1 compatibility promise. We rely on it to prefer more specific matches (a full // SNI match over wildcard matches) instead of relying on the order of certificates. //lint:ignore SA1019 - see ^^ - tlsConfig.BuildNameToCertificate() + baseTlsConfig.BuildNameToCertificate() + + // Wrap with GetConfigForClient for per-domain mTLS + tlsConfig := &tls.Config{ + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + return r.getTLSConfigForClient(hello, baseTlsConfig) + }, + } listener, err := net.Listen("tcp", fmt.Sprintf(":%d", r.config.SSLPort)) if err != nil { @@ -353,6 +368,41 @@ func (r *Router) verifyMtlsMetadata(_ [][]byte, chains [][]*x509.Certificate) er return nil } +// getTLSConfigForClient returns appropriate TLS config based on SNI (Server Name Indication) +// For mTLS domains, it requires and verifies client certificates using domain-specific CA pool +// For regular domains, it uses the base TLS configuration +func (r *Router) getTLSConfigForClient(hello *tls.ClientHelloInfo, baseConfig *tls.Config) (*tls.Config, error) { + serverName := hello.ServerName + + // Populate TLSConnState in the connection context (set by ConnContext above). + // The pointer was allocated in ConnContext; we mutate it here during the handshake. + if connState, ok := hello.Context().Value(handlers.TLSConnStateKey{}).(*handlers.TLSConnState); ok && connState != nil { + connState.SNI = serverName + } + + mtlsDomainConfig := r.config.GetMtlsDomainConfig(serverName) + if mtlsDomainConfig == nil { + // Not an mTLS domain, use base config + return baseConfig, nil + } + + // mTLS domain — require client certificate and record the state. + if connState, ok := hello.Context().Value(handlers.TLSConnStateKey{}).(*handlers.TLSConnState); ok && connState != nil { + connState.ClientCertRequired = true + connState.MtlsDomain = mtlsDomainConfig.Domain + } + + mtlsConfig := baseConfig.Clone() + mtlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + mtlsConfig.ClientCAs = mtlsDomainConfig.CAPool + + r.logger.Debug("mtls-domain-detected", + slog.String("server_name", serverName), + slog.String("domain", mtlsDomainConfig.Domain)) + + return mtlsConfig, nil +} + func (r *Router) serveHTTP(server *http.Server, errChan chan error) error { if r.config.DisableHTTP { r.logger.Info("tcp-listener-disabled") diff --git a/src/code.cloudfoundry.org/gorouter/router/router_drain_test.go b/src/code.cloudfoundry.org/gorouter/router/router_drain_test.go index 64d42b664..3fa59ff29 100644 --- a/src/code.cloudfoundry.org/gorouter/router/router_drain_test.go +++ b/src/code.cloudfoundry.org/gorouter/router/router_drain_test.go @@ -151,8 +151,9 @@ var _ = Describe("Router", func() { BeforeEach(func() { logger = test_util.NewTestLogger("test") - natsPort = test_util.NextAvailPort() + natsPort = test_util.ReservePort() natsRunner = test_util.NewNATSRunner(int(natsPort)) + test_util.ReleasePort(natsPort) natsRunner.Start() proxyPort := test_util.NextAvailPort() @@ -161,11 +162,12 @@ var _ = Describe("Router", func() { statusRoutesPort := test_util.NextAvailPort() sslPort := test_util.NextAvailPort() + routeServiceServerPort := test_util.NextAvailPort() defaultCert := test_util.CreateCert("default") cert2 := test_util.CreateCert("default") - config = test_util.SpecConfig(statusPort, statusTlsPort, statusRoutesPort, proxyPort, natsPort) + config = test_util.SpecConfig(statusPort, statusTlsPort, statusRoutesPort, proxyPort, routeServiceServerPort, natsPort) config.EnableSSL = true config.SSLPort = sslPort config.SSLCertificates = []tls.Certificate{defaultCert, cert2} @@ -202,13 +204,13 @@ var _ = Describe("Router", func() { }) AfterEach(func() { - if natsRunner != nil { - natsRunner.Stop() - } if subscriber != nil { subscriber.Signal(os.Interrupt) <-subscriber.Wait() } + if natsRunner != nil { + natsRunner.Stop() + } }) Context("Drain", func() { diff --git a/src/code.cloudfoundry.org/gorouter/router/router_test.go b/src/code.cloudfoundry.org/gorouter/router/router_test.go index b9217227d..9e8fbb61d 100644 --- a/src/code.cloudfoundry.org/gorouter/router/router_test.go +++ b/src/code.cloudfoundry.org/gorouter/router/router_test.go @@ -85,8 +85,9 @@ var _ = Describe("Router", func() { statusPort = test_util.NextAvailPort() statusTLSPort = test_util.NextAvailPort() statusRoutesPort = test_util.NextAvailPort() - natsPort = test_util.NextAvailPort() - config = test_util.SpecConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, natsPort) + natsPort = test_util.ReservePort() + routeServiceServerPort := test_util.NextAvailPort() + config = test_util.SpecConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, routeServiceServerPort, natsPort) backendIdleTimeout = config.EndpointTimeout requestTimeout = config.EndpointTimeout config.EnableSSL = true @@ -102,6 +103,7 @@ var _ = Describe("Router", func() { } natsRunner = test_util.NewNATSRunner(int(natsPort)) + test_util.ReleasePort(natsPort) natsRunner.Start() routeServicesServer = &sharedfakes.RouteServicesServer{} @@ -163,8 +165,9 @@ var _ = Describe("Router", func() { statusPort = test_util.NextAvailPort() statusTLSPort = test_util.NextAvailPort() statusRoutesPort = test_util.NextAvailPort() + routeServiceServerPort := test_util.NextAvailPort() - c := test_util.SpecConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, natsPort) + c := test_util.SpecConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, routeServiceServerPort, natsPort) c.StartResponseDelayInterval = 1 * time.Second rtr, err := initializeRouter(c, c.EndpointTimeout, c.EndpointTimeout, registry, varz, mbusClient, logger.Logger, rss) @@ -185,8 +188,9 @@ var _ = Describe("Router", func() { statusPort = test_util.NextAvailPort() statusTLSPort = test_util.NextAvailPort() statusRoutesPort = test_util.NextAvailPort() + routeServiceServerPort := test_util.NextAvailPort() - c := test_util.SpecConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, natsPort) + c := test_util.SpecConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, routeServiceServerPort, natsPort) c.StartResponseDelayInterval = 1 * time.Second rss := &sharedfakes.RouteServicesServer{} @@ -219,7 +223,8 @@ var _ = Describe("Router", func() { statusPort = test_util.NextAvailPort() statusTLSPort = test_util.NextAvailPort() statusRoutesPort = test_util.NextAvailPort() - c = test_util.SpecConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, natsPort) + routeServiceServerPort := test_util.NextAvailPort() + c = test_util.SpecConfig(statusPort, statusTLSPort, statusRoutesPort, proxyPort, routeServiceServerPort, natsPort) c.StartResponseDelayInterval = 1 * time.Second }) @@ -2312,7 +2317,7 @@ var _ = Describe("Router", func() { }) - Describe("frontend timeouts", func() { + Context("frontend timeouts", func() { Context("when the frontend connection idles for more than the configured IdleTimeout", func() { BeforeEach(func() { config.FrontendIdleTimeout = 500 * time.Millisecond diff --git a/src/code.cloudfoundry.org/gorouter/test/common/app.go b/src/code.cloudfoundry.org/gorouter/test/common/app.go index 689090427..8283ea3ac 100644 --- a/src/code.cloudfoundry.org/gorouter/test/common/app.go +++ b/src/code.cloudfoundry.org/gorouter/test/common/app.go @@ -68,8 +68,11 @@ func (a *TestApp) Endpoint() string { } func (a *TestApp) TlsListen(tlsConfig *tls.Config) chan error { + ln, err := tls.Listen("tcp", fmt.Sprintf(":%d", a.port), tlsConfig) + if err != nil { + panic("TestApp.TlsListen: " + err.Error()) + } a.server = &http.Server{ - Addr: fmt.Sprintf(":%d", a.port), Handler: a.mux, TLSConfig: tlsConfig, ReadHeaderTimeout: 5 * time.Second, @@ -77,7 +80,7 @@ func (a *TestApp) TlsListen(tlsConfig *tls.Config) chan error { errChan := make(chan error, 1) go func() { - err := a.server.ListenAndServeTLS("", "") + err := a.server.Serve(ln) errChan <- err }() return errChan @@ -89,12 +92,15 @@ func (a *TestApp) RegisterAndListen() { } func (a *TestApp) Listen() { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port)) + if err != nil { + panic("TestApp.Listen: " + err.Error()) + } server := &http.Server{ - Addr: fmt.Sprintf(":%d", a.port), Handler: a.mux, ReadHeaderTimeout: 5 * time.Second, } - go server.ListenAndServe() + go server.Serve(ln) } func (a *TestApp) RegisterRepeatedly(duration time.Duration) { diff --git a/src/code.cloudfoundry.org/gorouter/test_util/helpers.go b/src/code.cloudfoundry.org/gorouter/test_util/helpers.go index c98e341b8..d489af656 100644 --- a/src/code.cloudfoundry.org/gorouter/test_util/helpers.go +++ b/src/code.cloudfoundry.org/gorouter/test_util/helpers.go @@ -738,3 +738,130 @@ func CreateInvalidCertAndRule(cn string, invalidSubjects []string) ([]*x509.Cert // Return leaf + CA in chain return []*x509.Certificate{x509Leaf, x509CA}, rule, nil } + +// InstanceIdentityCertNames contains identity information for instance identity certificates +type InstanceIdentityCertNames struct { + CommonName string + AppGUID string // Required - will be added as OU "app:" + SpaceGUID string // Optional - will be added as OU "space:" + OrgGUID string // Optional - will be added as OU "organization:" + SANs SubjectAltNames +} + +// CreateInstanceIdentityCert creates a certificate chain with instance identity +// information embedded in OrganizationalUnit fields, matching Diego's format +func CreateInstanceIdentityCert(certNames InstanceIdentityCertNames) CertChain { + rootPrivateKey, rootCADER := CreateCertDER("Diego Instance Identity CA") + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + Expect(err).ToNot(HaveOccurred()) + + // Build OrganizationalUnit slice with instance identity info + organizationalUnits := []string{fmt.Sprintf("app:%s", certNames.AppGUID)} + if certNames.SpaceGUID != "" { + organizationalUnits = append(organizationalUnits, fmt.Sprintf("space:%s", certNames.SpaceGUID)) + } + if certNames.OrgGUID != "" { + organizationalUnits = append(organizationalUnits, fmt.Sprintf("organization:%s", certNames.OrgGUID)) + } + + subject := pkix.Name{ + Organization: []string{"Cloud Foundry"}, + OrganizationalUnit: organizationalUnits, + CommonName: certNames.CommonName, + } + + certTemplate := x509.Certificate{ + SerialNumber: serialNumber, + Subject: subject, + SignatureAlgorithm: x509.SHA256WithRSA, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + BasicConstraintsValid: true, + } + + if certNames.SANs.IP != "" { + certTemplate.IPAddresses = []net.IP{net.ParseIP(certNames.SANs.IP)} + } + if certNames.SANs.DNS != "" { + certTemplate.DNSNames = []string{certNames.SANs.DNS} + } + + rootCert, err := x509.ParseCertificate(rootCADER) + Expect(err).NotTo(HaveOccurred()) + + ownKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + certDER, err := x509.CreateCertificate(rand.Reader, &certTemplate, rootCert, &ownKey.PublicKey, rootPrivateKey) + Expect(err).NotTo(HaveOccurred()) + + ownKeyPEM, ownCertPEM := CreateKeyPairFromDER(certDER, ownKey) + rootKeyPEM, rootCertPEM := CreateKeyPairFromDER(rootCADER, rootPrivateKey) + + return CertChain{ + CertPEM: ownCertPEM, + PrivKeyPEM: ownKeyPEM, + CACertPEM: rootCertPEM, + CAPrivKeyPEM: rootKeyPEM, + CACert: rootCert, + CAPrivKey: rootPrivateKey, + } +} + +// CreateInstanceIdentityCertWithCA creates a certificate chain with instance identity +// information signed by the provided CA (instead of generating a new CA) +func CreateInstanceIdentityCertWithCA(certNames InstanceIdentityCertNames, ca *CertChain) CertChain { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + Expect(err).ToNot(HaveOccurred()) + + // Build OrganizationalUnit slice with instance identity info + organizationalUnits := []string{fmt.Sprintf("app:%s", certNames.AppGUID)} + if certNames.SpaceGUID != "" { + organizationalUnits = append(organizationalUnits, fmt.Sprintf("space:%s", certNames.SpaceGUID)) + } + if certNames.OrgGUID != "" { + organizationalUnits = append(organizationalUnits, fmt.Sprintf("organization:%s", certNames.OrgGUID)) + } + + subject := pkix.Name{ + Organization: []string{"Cloud Foundry"}, + OrganizationalUnit: organizationalUnits, + CommonName: certNames.CommonName, + } + + certTemplate := x509.Certificate{ + SerialNumber: serialNumber, + Subject: subject, + SignatureAlgorithm: x509.SHA256WithRSA, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + BasicConstraintsValid: true, + } + + if certNames.SANs.IP != "" { + certTemplate.IPAddresses = []net.IP{net.ParseIP(certNames.SANs.IP)} + } + if certNames.SANs.DNS != "" { + certTemplate.DNSNames = []string{certNames.SANs.DNS} + } + + ownKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + // Sign with the provided CA + certDER, err := x509.CreateCertificate(rand.Reader, &certTemplate, ca.CACert, &ownKey.PublicKey, ca.CAPrivKey) + Expect(err).NotTo(HaveOccurred()) + + ownKeyPEM, ownCertPEM := CreateKeyPairFromDER(certDER, ownKey) + + return CertChain{ + CertPEM: ownCertPEM, + PrivKeyPEM: ownKeyPEM, + CACertPEM: ca.CACertPEM, + CAPrivKeyPEM: ca.CAPrivKeyPEM, + CACert: ca.CACert, + CAPrivKey: ca.CAPrivKey, + } +} diff --git a/src/code.cloudfoundry.org/gorouter/test_util/ports.go b/src/code.cloudfoundry.org/gorouter/test_util/ports.go index aaeec5d70..d728c81a0 100644 --- a/src/code.cloudfoundry.org/gorouter/test_util/ports.go +++ b/src/code.cloudfoundry.org/gorouter/test_util/ports.go @@ -1,18 +1,122 @@ package test_util import ( + "fmt" "net" + "sync" + + . "github.com/onsi/ginkgo/v2" +) + +var ( + allocatedPorts = make(map[uint16]bool) + reservedListeners = make(map[uint16]net.Listener) + portMu sync.Mutex ) -// NextAvailPort asks the OS for a free port by binding to :0, then closing -// the listener and returning the assigned port. This avoids cross-suite port -// collisions that occur when multiple suites reuse the same static port range. +// portRange returns the base port and size of the range reserved for the +// current Ginkgo parallel process. It divides the port space [61000,65534] +// evenly across GinkgoConfiguration().ParallelTotal procs. +// +// Port space starts at 61000 to stay entirely above the Linux kernel's default +// ephemeral port range (32768–60999, see /proc/sys/net/ipv4/ip_local_port_range). +// Ports inside the ephemeral range can be grabbed by the OS for outgoing +// connections in the window between ReleaseAllPorts() and the moment the +// external process (gorouter) actually calls listen(), causing "address already +// in use" failures on loaded systems such as Docker VMs. +func portRange() (base, size uint16) { + suiteConfig, _ := GinkgoConfiguration() + total := suiteConfig.ParallelTotal + if total <= 0 { + total = 1 + } + // Stay above the Linux ephemeral range (32768-60999). + const portSpaceStart = 61000 + const portSpaceEnd = 65534 + size = uint16((portSpaceEnd - portSpaceStart) / total) + base = portSpaceStart + uint16(GinkgoParallelProcess()-1)*size + return +} + +// nextPortInRange returns the next free port in this process's dedicated range. +// Must be called with portMu held. +func nextPortInRange() uint16 { + base, size := portRange() + for port := base; port < base+size; port++ { + if allocatedPorts[port] { + continue + } + l, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) + if err != nil { + // Port in use by something outside our process – skip it. + allocatedPorts[port] = true + continue + } + l.Close() + allocatedPorts[port] = true + return port + } + panic(fmt.Sprintf("nextPortInRange: exhausted %d-port range starting at %d for Ginkgo proc %d", size, base, GinkgoParallelProcess())) +} + +// NextAvailPort returns a free port from the current Ginkgo process's dedicated +// port range. Using per-process ranges eliminates cross-process collisions when +// running with --nodes=N, removing the need for the ReservePort/ReleaseAllPorts +// dance for in-process port bindings. func NextAvailPort() uint16 { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - panic("NextAvailPort: " + err.Error()) + portMu.Lock() + defer portMu.Unlock() + return nextPortInRange() +} + +// ReservePort returns a free port and keeps the listener open so that no other +// process can grab it before the caller is ready. Call ReleaseAllPorts to +// close all held listeners just before starting the process that will bind +// these ports. This eliminates the TOCTOU race between port allocation and +// binding when ports are used by external processes (e.g. integration tests +// that spawn gorouter as a separate binary). +func ReservePort() uint16 { + portMu.Lock() + defer portMu.Unlock() + + base, size := portRange() + for port := base; port < base+size; port++ { + if allocatedPorts[port] { + continue + } + l, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) + if err != nil { + allocatedPorts[port] = true + continue + } + allocatedPorts[port] = true + reservedListeners[port] = l // keep open! + return port + } + panic(fmt.Sprintf("ReservePort: exhausted %d-port range starting at %d for Ginkgo proc %d", size, base, GinkgoParallelProcess())) +} + +// ReleaseAllPorts closes all listeners held by ReservePort. Call this just +// before starting an external process that needs to bind the reserved ports. +func ReleaseAllPorts() { + portMu.Lock() + defer portMu.Unlock() + + for port, l := range reservedListeners { + l.Close() + delete(reservedListeners, port) + } +} + +// ReleasePort closes the reservation listener for a single port. Use this +// when only one reserved port needs to be freed (e.g. before starting NATS +// while keeping the other ports reserved for the gorouter). +func ReleasePort(port uint16) { + portMu.Lock() + defer portMu.Unlock() + + if l, ok := reservedListeners[port]; ok { + l.Close() + delete(reservedListeners, port) } - defer l.Close() - // #nosec G115 - ephemeral ports are always in uint16 range - return uint16(l.Addr().(*net.TCPAddr).Port) } diff --git a/src/code.cloudfoundry.org/routing-api b/src/code.cloudfoundry.org/routing-api index fbc073c74..e62fb1010 160000 --- a/src/code.cloudfoundry.org/routing-api +++ b/src/code.cloudfoundry.org/routing-api @@ -1 +1 @@ -Subproject commit fbc073c74b125e9a9b215a674fa0fa6c740d93bd +Subproject commit e62fb1010fae0bdd4f88e44c39088391deab5746 diff --git a/src/code.cloudfoundry.org/vendor/modules.txt b/src/code.cloudfoundry.org/vendor/modules.txt index f6d12ba23..5fa4e782d 100644 --- a/src/code.cloudfoundry.org/vendor/modules.txt +++ b/src/code.cloudfoundry.org/vendor/modules.txt @@ -3,24 +3,24 @@ code.cloudfoundry.org/bbs/db/sqldb/helpers code.cloudfoundry.org/bbs/db/sqldb/helpers/monitor code.cloudfoundry.org/bbs/guidprovider -# code.cloudfoundry.org/cfhttp/v2 v2.74.0 +# code.cloudfoundry.org/cfhttp/v2 v2.73.0 ## explicit; go 1.25.0 code.cloudfoundry.org/cfhttp/v2 -# code.cloudfoundry.org/clock v1.66.0 +# code.cloudfoundry.org/clock v1.65.0 ## explicit; go 1.25.0 code.cloudfoundry.org/clock code.cloudfoundry.org/clock/fakeclock -# code.cloudfoundry.org/debugserver v0.92.0 +# code.cloudfoundry.org/debugserver v0.91.0 ## explicit; go 1.25.0 code.cloudfoundry.org/debugserver -# code.cloudfoundry.org/diego-logging-client v0.101.0 +# code.cloudfoundry.org/diego-logging-client v0.100.0 ## explicit; go 1.25.0 code.cloudfoundry.org/diego-logging-client code.cloudfoundry.org/diego-logging-client/testhelpers -# code.cloudfoundry.org/durationjson v0.69.0 +# code.cloudfoundry.org/durationjson v0.68.0 ## explicit; go 1.25.0 code.cloudfoundry.org/durationjson -# code.cloudfoundry.org/eventhub v0.69.0 +# code.cloudfoundry.org/eventhub v0.68.0 ## explicit; go 1.25.0 code.cloudfoundry.org/eventhub # code.cloudfoundry.org/go-diodes v0.0.0-20260209061029-a81ffbc46978 @@ -36,14 +36,14 @@ code.cloudfoundry.org/go-loggregator/v9/runtimeemitter code.cloudfoundry.org/go-metric-registry # code.cloudfoundry.org/inigo v0.0.0-20210615140442-4bdc4f6e44d5 ## explicit -# code.cloudfoundry.org/lager/v3 v3.66.0 +# code.cloudfoundry.org/lager/v3 v3.65.0 ## explicit; go 1.25.0 code.cloudfoundry.org/lager/v3 code.cloudfoundry.org/lager/v3/internal/truncate code.cloudfoundry.org/lager/v3/lagerctx code.cloudfoundry.org/lager/v3/lagerflags code.cloudfoundry.org/lager/v3/lagertest -# code.cloudfoundry.org/localip v0.68.0 +# code.cloudfoundry.org/localip v0.67.0 ## explicit; go 1.25.0 code.cloudfoundry.org/localip # code.cloudfoundry.org/locket v0.0.0-20251117222557-be612341b29d @@ -61,7 +61,7 @@ code.cloudfoundry.org/locket/lock code.cloudfoundry.org/locket/metrics code.cloudfoundry.org/locket/metrics/helpers code.cloudfoundry.org/locket/models -# code.cloudfoundry.org/tlsconfig v0.51.0 +# code.cloudfoundry.org/tlsconfig v0.50.0 ## explicit; go 1.25.0 code.cloudfoundry.org/tlsconfig # filippo.io/edwards25519 v1.2.0 @@ -173,8 +173,8 @@ github.com/google/go-tpm/tpmutil/tbs # github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 ## explicit; go 1.24.0 github.com/google/pprof/profile -# github.com/honeycombio/libhoney-go v1.27.1 -## explicit; go 1.24 +# github.com/honeycombio/libhoney-go v1.26.0 +## explicit; go 1.21 github.com/honeycombio/libhoney-go github.com/honeycombio/libhoney-go/transmission github.com/honeycombio/libhoney-go/version @@ -184,7 +184,7 @@ github.com/jackc/pgpassfile # github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 ## explicit; go 1.14 github.com/jackc/pgservicefile -# github.com/jackc/pgx/v5 v5.9.2 +# github.com/jackc/pgx/v5 v5.9.1 ## explicit; go 1.25.0 github.com/jackc/pgx/v5 github.com/jackc/pgx/v5/internal/iobufpool @@ -249,7 +249,7 @@ github.com/munnerz/goautoneg # github.com/nats-io/jwt/v2 v2.8.1 ## explicit; go 1.25.0 github.com/nats-io/jwt/v2 -# github.com/nats-io/nats-server/v2 v2.12.7 +# github.com/nats-io/nats-server/v2 v2.12.6 ## explicit; go 1.25.0 github.com/nats-io/nats-server/v2 github.com/nats-io/nats-server/v2/conf @@ -268,7 +268,7 @@ github.com/nats-io/nats-server/v2/server/stree github.com/nats-io/nats-server/v2/server/sysmem github.com/nats-io/nats-server/v2/server/thw github.com/nats-io/nats-server/v2/server/tpm -# github.com/nats-io/nats.go v1.51.0 +# github.com/nats-io/nats.go v1.50.0 ## explicit; go 1.25.0 github.com/nats-io/nats.go github.com/nats-io/nats.go/encoders/builtin @@ -362,7 +362,7 @@ github.com/russross/blackfriday/v2 # github.com/square/certstrap v1.3.0 ## explicit; go 1.18 github.com/square/certstrap/pkix -# github.com/tedsuo/ifrit v0.0.0-20260418191334-846868129986 +# github.com/tedsuo/ifrit v0.0.0-20230516164442-7862c310ad26 ## explicit; go 1.16 github.com/tedsuo/ifrit github.com/tedsuo/ifrit/ginkgomon_v2 @@ -550,7 +550,7 @@ golang.org/x/tools/internal/stdlib golang.org/x/tools/internal/typeparams golang.org/x/tools/internal/typesinternal golang.org/x/tools/internal/versions -# google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 +# google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d ## explicit; go 1.25.0 google.golang.org/genproto/googleapis/rpc/status # google.golang.org/grpc v1.80.0