diff --git a/console-webapp/package-lock.json b/console-webapp/package-lock.json index d14e459c809..5b0bec4b47b 100644 --- a/console-webapp/package-lock.json +++ b/console-webapp/package-lock.json @@ -628,18 +628,6 @@ } } }, - "node_modules/@angular/build/node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, "node_modules/@angular/build/node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -653,24 +641,6 @@ "vite": "^6.0.0 || ^7.0.0" } }, - "node_modules/@angular/build/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/build/node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -694,22 +664,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular/build/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/build/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -743,15 +697,6 @@ "node": ">= 12" } }, - "node_modules/@angular/build/node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@angular/build/node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", @@ -971,24 +916,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@angular/cli/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/cli-spinners": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", @@ -1092,22 +1019,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@angular/cli/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular/cli/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -4695,24 +4606,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@schematics/angular/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@schematics/angular/node_modules/cli-spinners": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", @@ -4816,22 +4709,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@schematics/angular/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://us-npm.pkg.dev/artifact-foundry-prod/ah-3p-staging-npm/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@schematics/angular/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", diff --git a/core/build.gradle b/core/build.gradle index 0a670832790..83a0d41d8a7 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -126,6 +126,7 @@ dependencies { implementation deps['com.google.api:gax'] implementation deps['com.google.api.grpc:proto-google-common-protos'] implementation deps['com.google.api.grpc:proto-google-cloud-secretmanager-v1'] + implementation deps['com.google.apis:google-api-services-cloudkms'] implementation deps['com.google.api-client:google-api-client'] implementation deps['com.google.api-client:google-api-client-servlet'] implementation deps['com.google.monitoring-client:metrics'] @@ -244,6 +245,7 @@ dependencies { implementation project(':common') testImplementation project(path: ':common', configuration: 'testing') implementation project(':util') + implementation project(':networking') // Import NomulusPostreSql from ':db' for implementation but exclude dependencies. implementation project(path: ':db', configuration: 'implementationApi') nonprodRuntimeOnly project(':db') @@ -277,6 +279,16 @@ dependencies { testImplementation deps['org.mockito:mockito-core'] testImplementation deps['org.mockito:mockito-junit-jupiter'] + implementation deps['io.netty:netty-buffer'] + implementation deps['io.netty:netty-codec-http'] + implementation deps['io.netty:netty-codec'] + implementation deps['io.netty:netty-common'] + implementation deps['io.netty:netty-handler'] + implementation deps['io.netty:netty-transport'] + implementation deps['redis.clients:jedis'] + runtimeOnly deps['io.netty:netty-tcnative-boringssl-static'] + implementation deps['org.yaml:snakeyaml'] + // Indirect dependency found by undeclared-dependency check. Such // dependencies should go after all other implementation and testImplementation // dependencies to avoid overriding them accidentally. diff --git a/core/gradle.lockfile b/core/gradle.lockfile index 0050906ed8f..df9507370d1 100644 --- a/core/gradle.lockfile +++ b/core/gradle.lockfile @@ -86,10 +86,12 @@ com.google.api:gax-httpjson:2.82.0=compileClasspath,deploy_jar,nonprodCompileCla com.google.api:gax:2.82.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.apis:google-api-services-admin-directory:directory_v1-rev20260531-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.apis:google-api-services-bigquery:v2-rev20251012-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.apis:google-api-services-cloudkms:v1-rev20260702-2.0.0=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath com.google.apis:google-api-services-cloudresourcemanager:v1-rev20250606-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.apis:google-api-services-dataflow:v1b3-rev20260615-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.apis:google-api-services-dns:v1-rev20260616-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.google.apis:google-api-services-drive:v3-rev20260624-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.google.apis:google-api-services-drive:v3-rev20260624-2.0.0=deploy_jar,testRuntimeClasspath +com.google.apis:google-api-services-drive:v3-rev20260712-2.0.0=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath com.google.apis:google-api-services-gmail:v1-rev20260525-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.apis:google-api-services-groupssettings:v1-rev20220614-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.apis:google-api-services-healthcare:v1-rev20240130-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -268,19 +270,31 @@ io.grpc:grpc-stub:1.81.0=compileClasspath,deploy_jar,nonprodCompileClasspath,non io.grpc:grpc-testing:1.70.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-util:1.81.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-xds:1.81.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-buffer:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath +io.netty:netty-codec-base:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath +io.netty:netty-codec-compression:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath io.netty:netty-codec-http2:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-codec-http:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-codec-http:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath +io.netty:netty-codec-marshalling:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath +io.netty:netty-codec-protobuf:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath io.netty:netty-codec-socks:4.1.132.Final=deploy_jar,nonprodRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-common:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-codec:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-codec:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath +io.netty:netty-common:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-common:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath io.netty:netty-handler-proxy:4.1.132.Final=deploy_jar,nonprodRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-handler:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-handler:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath +io.netty:netty-resolver:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-resolver:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath io.netty:netty-tcnative-boringssl-static:2.0.52.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-tcnative-classes:2.0.52.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.netty:netty-transport:4.1.132.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath +io.netty:netty-transport:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-transport:4.2.16.Final=compileClasspath,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath io.opencensus:opencensus-api:0.31.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opencensus:opencensus-contrib-exemplar-util:0.31.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opencensus:opencensus-contrib-grpc-metrics:0.31.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index cfbf40c122c..3f7683834c0 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -1521,6 +1521,103 @@ public static Optional> provideValkeyHostsAndPorts( .map(valkey -> ImmutableList.copyOf(valkey.hostsAndPorts)); } + @Provides + @Config("eppServerPort") + public static int provideEppServerPort(RegistryConfigSettings config) { + return config.eppServer.port; + } + + @Provides + @Config("eppServerHealthCheckPort") + public static int provideEppServerHealthCheckPort(RegistryConfigSettings config) { + return config.eppServer.healthCheckPort; + } + + @Provides + @Config("eppServerSslPemBucket") + public static String provideEppServerSslPemBucket(RegistryConfigSettings config) { + return config.eppServer.sslPemBucket; + } + + @Provides + @Config("eppServerSslPemFilename") + public static String provideEppServerSslPemFilename(RegistryConfigSettings config) { + return config.eppServer.sslPemFilename; + } + + @Provides + @Config("eppServerKmsLocation") + public static String provideEppServerKmsLocation(RegistryConfigSettings config) { + return config.eppServer.kmsLocation; + } + + @Provides + @Config("eppServerKmsKeyRing") + public static String provideEppServerKmsKeyRing(RegistryConfigSettings config) { + return config.eppServer.kmsKeyRing; + } + + @Provides + @Config("eppServerKmsCryptoKey") + public static String provideEppServerKmsCryptoKey(RegistryConfigSettings config) { + return config.eppServer.kmsCryptoKey; + } + + @Provides + @Config("eppServerMaxMessageLengthBytes") + public static int provideEppServerMaxMessageLengthBytes(RegistryConfigSettings config) { + return config.eppServer.maxMessageLengthBytes; + } + + @Provides + @Config("eppServerHeaderLengthBytes") + public static int provideEppServerHeaderLengthBytes(RegistryConfigSettings config) { + return config.eppServer.headerLengthBytes; + } + + @Provides + @Config("eppServerReadTimeoutSeconds") + public static int provideEppServerReadTimeoutSeconds(RegistryConfigSettings config) { + return config.eppServer.readTimeoutSeconds; + } + + @Provides + @Config("eppServerMaxConnectionsPerIp") + public static int provideEppServerMaxConnectionsPerIp(RegistryConfigSettings config) { + return config.eppServer.maxConnectionsPerIp; + } + + @Provides + @Config("eppServerMaxConnectionsPerCert") + public static int provideEppServerMaxConnectionsPerCert(RegistryConfigSettings config) { + return config.eppServer.maxConnectionsPerCert; + } + + @Provides + @Config("eppServerCertificateCacheSeconds") + public static int provideEppServerCertificateCacheSeconds(RegistryConfigSettings config) { + return config.eppServer.serverCertificateCacheSeconds; + } + + @Provides + @Config("eppServerQuota") + public static RegistryConfigSettings.Quota provideEppServerQuota( + RegistryConfigSettings config) { + return config.eppServer.quota; + } + + @Provides + @Config("eppServerFrontendMetricsRatio") + public static double provideEppServerFrontendMetricsRatio(RegistryConfigSettings config) { + return config.monitoring.frontendMetricsRatio; + } + + @Provides + @Config("writeIntervalSeconds") + public static int provideWriteIntervalSeconds(RegistryConfigSettings config) { + return config.monitoring.writeIntervalSeconds; + } + private static String formatComments(String text) { return Splitter.on('\n').omitEmptyStrings().trimResults().splitToList(text).stream() .map(s -> "# " + s) diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 455d298f483..e4b7b939865 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -45,6 +45,7 @@ public class RegistryConfigSettings { public Bsa bsa; public MosApi mosapi; public Valkey valkey; + public EppServer eppServer; /** Configuration options that apply to the entire GCP project. */ public static class GcpProject { @@ -199,6 +200,42 @@ public static class Monitoring { public int stackdriverMaxQps; public int stackdriverMaxPointsPerRequest; public int writeIntervalSeconds; + public double frontendMetricsRatio; + public double backendMetricsRatio; + } + + /** Configuration for EppServer. */ + public static class EppServer { + public int port; + public int healthCheckPort; + public String sslPemFilename; + public String sslPemBucket; + public String kmsLocation; + public String kmsKeyRing; + public String kmsCryptoKey; + public int maxMessageLengthBytes; + public int headerLengthBytes; + public int readTimeoutSeconds; + public int maxConnectionsPerIp; + public int maxConnectionsPerCert; + public int serverCertificateCacheSeconds; + public Quota quota; + } + + /** Configuration options that apply to quota management. */ + public static class Quota { + + /** Quota configuration for a specific set of users. */ + public static class QuotaGroup { + public List userId; + public int tokenAmount; + public int refillSeconds; + public int batchSize; + } + + public int refreshSeconds; + public QuotaGroup defaultQuota; + public List customQuota; } /** Miscellaneous configuration that doesn't quite fit in anywhere else. */ diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 42270ea5730..8cfdcf20ee6 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -420,6 +420,57 @@ monitoring: # How often metrics are exported to BigQuery. writeIntervalSeconds: 60 + # What ratio of frontend request metrics should be stochastically recorded + # (0.0 means none, 1.0 means all). This is useful for reducing metrics volume, + # and thus cost, while still recording some information for performance + # monitoring purposes. + frontendMetricsRatio: 1.0 + + # What ratio of backend request metrics should be stochastically recorded + # (0.0 means none, 1.0 means all). This is useful for reducing metrics volume, + # and thus cost, while still recording some information for performance + # monitoring purposes. + backendMetricsRatio: 1.0 + +eppServer: + # The integrated EPP server listens on this port. + port: 30002 + # Health check port used by GCP load balancer. + healthCheckPort: 30000 + # GCS bucket that stores the encrypted PEM file. + sslPemBucket: your-gcs-bucket + # Name of the encrypted PEM file. + sslPemFilename: your-pem-filename + # Cloud KMS details for certificate decryption. + kmsLocation: your-kms-location + kmsKeyRing: your-kms-keyRing + kmsCryptoKey: your-kms-cryptoKey + + # Maximum input message length in bytes. + maxMessageLengthBytes: 1073741824 + # Length of the header field in bytes (RFC 5734). + headerLengthBytes: 4 + # Time after which an idle connection will be closed. + readTimeoutSeconds: 3600 + # Max concurrent connections per IP address. + maxConnectionsPerIp: 10 + # Max concurrent connections per authenticated certificate. + maxConnectionsPerCert: 10 + # Server certificate cache duration. + serverCertificateCacheSeconds: 1800 + + # Quota configuration for EPP + quota: + refreshSeconds: 0 + defaultQuota: + userId: [] + tokenAmount: 100 + refillSeconds: 0 + # To implement a shared quota group across multiple registrars, place a virtual + # group name as the FIRST element of the userId list. + # e.g., userId: ["my_group", "registrar1", "registrar2"] + customQuota: [] + misc: # The ID of the Google Sheet (as found in the URL) to export registrar details # to. Leave this null to disable syncing. diff --git a/core/src/main/java/google/registry/eppserver/EppProtocolModule.java b/core/src/main/java/google/registry/eppserver/EppProtocolModule.java new file mode 100644 index 00000000000..97c09d0989d --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/EppProtocolModule.java @@ -0,0 +1,147 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver; + +import static google.registry.util.ResourceUtils.readResourceBytes; + +import com.google.common.collect.ImmutableList; +import dagger.Module; +import dagger.Provides; +import dagger.multibindings.IntoSet; +import google.registry.config.RegistryConfig.Config; +import google.registry.config.RegistryConfigSettings; +import google.registry.eppserver.Protocol.FrontendProtocol; +import google.registry.eppserver.handler.EppProxyProtocolHandler; +import google.registry.eppserver.handler.EppServiceHandler; +import google.registry.eppserver.quota.QuotaManager; +import google.registry.networking.handler.SslServerInitializer; +import io.netty.channel.ChannelHandler; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.LengthFieldBasedFrameDecoder; +import io.netty.handler.codec.LengthFieldPrepender; +import io.netty.handler.ssl.SslProvider; +import io.netty.handler.timeout.ReadTimeoutHandler; +import jakarta.inject.Named; +import jakarta.inject.Provider; +import jakarta.inject.Qualifier; +import jakarta.inject.Singleton; +import java.io.IOException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Optional; +import java.util.function.Supplier; +import redis.clients.jedis.UnifiedJedis; + +/** A module that provides the {@link FrontendProtocol} used for epp protocol. */ +@Module +public final class EppProtocolModule { + + private EppProtocolModule() {} + + @Qualifier + public @interface EppProtocol {} + + @Qualifier + public @interface IpQuota {} + + @Qualifier + public @interface CommandQuota {} + + private static final String PROTOCOL_NAME = "epp"; + + @Singleton + @Provides + @IntoSet + static FrontendProtocol provideProtocol( + @EppProtocol int eppPort, + @EppProtocol ImmutableList> handlerProviders) { + return Protocol.frontendBuilder() + .name(PROTOCOL_NAME) + .port(eppPort) + .handlerProviders(handlerProviders) + .hasBackend(false) + .build(); + } + + @Provides + @EppProtocol + static ImmutableList> provideHandlerProviders( + Provider proxyProtocolHandlerProvider, + @EppProtocol Provider> sslServerInitializerProvider, + @EppProtocol Provider readTimeoutHandlerProvider, + Provider lengthFieldBasedFrameDecoderProvider, + Provider lengthFieldPrependerProvider, + Provider eppServiceHandlerProvider) { + return ImmutableList.of( + proxyProtocolHandlerProvider, + sslServerInitializerProvider, + readTimeoutHandlerProvider, + lengthFieldBasedFrameDecoderProvider, + lengthFieldPrependerProvider, + eppServiceHandlerProvider); + } + + @Provides + static LengthFieldBasedFrameDecoder provideLengthFieldBasedFrameDecoder( + @Config("eppServerMaxMessageLengthBytes") int maxMessageLengthBytes, + @Config("eppServerHeaderLengthBytes") int headerLengthBytes) { + return new LengthFieldBasedFrameDecoder( + maxMessageLengthBytes, 0, headerLengthBytes, -headerLengthBytes, headerLengthBytes); + } + + @Singleton + @Provides + static LengthFieldPrepender provideLengthFieldPrepender( + @Config("eppServerHeaderLengthBytes") int headerLengthBytes) { + return new LengthFieldPrepender(headerLengthBytes, true); + } + + @Provides + @EppProtocol + static ReadTimeoutHandler provideReadTimeoutHandler( + @Config("eppServerReadTimeoutSeconds") int readTimeoutSeconds) { + return new ReadTimeoutHandler(readTimeoutSeconds); + } + + @Singleton + @Provides + @Named("hello") + static byte[] provideHelloBytes() { + try { + return readResourceBytes(EppProtocolModule.class, "hello.xml").read(); + } catch (IOException e) { + throw new RuntimeException("Cannot read EPP message file.", e); + } + } + + @Singleton + @Provides + @EppProtocol + static SslServerInitializer provideSslServerInitializer( + SslProvider sslProvider, + Supplier privateKeySupplier, + Supplier> certificatesSupplier) { + return new SslServerInitializer<>( + true, false, sslProvider, privateKeySupplier, certificatesSupplier); + } + + @Provides + @Singleton + @CommandQuota + static QuotaManager provideCommandQuotaManager( + @Config("eppServerQuota") RegistryConfigSettings.Quota quota, Optional jedis) { + return new QuotaManager(quota, jedis.orElse(null), "command"); + } +} diff --git a/core/src/main/java/google/registry/eppserver/EppServer.java b/core/src/main/java/google/registry/eppserver/EppServer.java new file mode 100644 index 00000000000..e9170b551e7 --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/EppServer.java @@ -0,0 +1,128 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver; + +import static google.registry.eppserver.Protocol.PROTOCOL_KEY; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.flogger.FluentLogger; +import google.registry.eppserver.EppServerModule.EppServerComponent; +import google.registry.eppserver.Protocol.FrontendProtocol; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.util.concurrent.DefaultEventExecutorGroup; +import io.netty.util.concurrent.EventExecutorGroup; +import io.netty.util.concurrent.Future; +import jakarta.inject.Provider; +import java.util.HashMap; + +/** An integrated EPP server that listens for EPP traffic and processes it in-process. */ +public class EppServer { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + /** Maximum length of the queue of incoming connections. */ + private static final int MAX_SOCKET_BACKLOG = 128; + + private final ImmutableSet protocols; + private final HashMap portToChannelMap = new HashMap<>(); + private final EventLoopGroup eventGroup = new NioEventLoopGroup(); + private final EventExecutorGroup businessGroup = + new DefaultEventExecutorGroup(Math.max(4, Runtime.getRuntime().availableProcessors() * 4)); + + public EppServer(EppServerComponent eppServerComponent) { + this.protocols = ImmutableSet.copyOf(eppServerComponent.protocols()); + } + + private class ServerChannelInitializer extends ChannelInitializer { + @Override + protected void initChannel(NioSocketChannel inboundChannel) { + FrontendProtocol inboundProtocol = + (FrontendProtocol) inboundChannel.parent().attr(PROTOCOL_KEY).get(); + inboundChannel.attr(PROTOCOL_KEY).set(inboundProtocol); + + addHandlers(inboundChannel.pipeline(), inboundProtocol.handlerProviders()); + + // Start reading immediately since we don't have a backend relay. + inboundChannel.config().setAutoRead(true); + + logger.atInfo().log("Connection established: %s %s", inboundProtocol.name(), inboundChannel); + } + + @SuppressWarnings("deprecation") + private void addHandlers( + ChannelPipeline channelPipeline, + ImmutableList> handlerProviders) { + for (Provider handlerProvider : handlerProviders) { + ChannelHandler handler = handlerProvider.get(); + String handlerName = handler.getClass().getSimpleName(); + if (handlerName.equals("EppServiceHandler")) { + channelPipeline.addLast(businessGroup, handlerName, handler); + } else { + channelPipeline.addLast(handlerName, handler); + } + } + } + } + + public void start() { + ServerBootstrap serverBootstrap = + new ServerBootstrap() + .group(eventGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new ServerChannelInitializer()) + .option(ChannelOption.SO_BACKLOG, MAX_SOCKET_BACKLOG) + .childOption(ChannelOption.SO_KEEPALIVE, true); + + protocols.forEach( + protocol -> { + int port = protocol.port(); + try { + ChannelFuture serverChannelFuture = serverBootstrap.bind(port).sync(); + if (serverChannelFuture.isSuccess()) { + logger.atInfo().log( + "Start listening on port %s for %s protocol.", port, protocol.name()); + Channel serverChannel = serverChannelFuture.channel(); + serverChannel.attr(PROTOCOL_KEY).set(protocol); + portToChannelMap.put(port, serverChannel); + } + } catch (InterruptedException e) { + logger.atSevere().withCause(e).log( + "Cannot listen on port %d for %s protocol.", port, protocol.name()); + } + }); + } + + public void stop() { + logger.atInfo().log("Shutting down EPP server..."); + portToChannelMap + .values() + .forEach( + channel -> { + Future unusedFuture = channel.close(); + }); + Future unusedFuture = eventGroup.shutdownGracefully(); + Future unusedFutureBusiness = businessGroup.shutdownGracefully(); + } +} diff --git a/core/src/main/java/google/registry/eppserver/EppServerModule.java b/core/src/main/java/google/registry/eppserver/EppServerModule.java new file mode 100644 index 00000000000..b59e2d7f806 --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/EppServerModule.java @@ -0,0 +1,259 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver; + +import com.google.api.services.cloudkms.v1.CloudKMS; +import com.google.api.services.cloudkms.v1.model.DecryptRequest; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.http.HttpTransportOptions; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import com.google.cloud.storage.StorageOptions; +import com.google.common.base.Suppliers; +import com.google.monitoring.metrics.MetricReporter; +import dagger.Component; +import dagger.Module; +import dagger.Provides; +import google.registry.cache.CacheModule; +import google.registry.config.CredentialModule; +import google.registry.config.CredentialModule.ApplicationDefaultCredential; +import google.registry.config.RegistryConfig.Config; +import google.registry.config.RegistryConfig.ConfigModule; +import google.registry.eppserver.EppProtocolModule.EppProtocol; +import google.registry.eppserver.HealthCheckProtocolModule.HealthCheckProtocol; +import google.registry.eppserver.Protocol.FrontendProtocol; +import google.registry.keyring.KeyringModule; +import google.registry.keyring.api.KeyModule; +import google.registry.networking.module.CertificateSupplierModule; +import google.registry.networking.module.CertificateSupplierModule.Mode; +import google.registry.privileges.secretmanager.SecretManagerModule; +import google.registry.util.GoogleCredentialsBundle; +import google.registry.util.OidcTokenUtils; +import google.registry.util.RegistryEnvironment; +import google.registry.util.UtilsModule; +import io.netty.handler.logging.LogLevel; +import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.ssl.OpenSsl; +import io.netty.handler.ssl.SslProvider; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.io.IOException; +import java.time.Duration; +import java.util.Base64; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import redis.clients.jedis.UnifiedJedis; + +/** + * A module that provides the port-to-protocol map and other configs that are used to bootstrap the + * server. + */ +@Module +public class EppServerModule { + + @Provides + @EppProtocol + static int provideEppPort(@Config("eppServerPort") int eppPort) { + return eppPort; + } + + @Provides + @HealthCheckProtocol + static int provideHealthCheckPort(@Config("eppServerHealthCheckPort") int healthCheckPort) { + return healthCheckPort; + } + + @Singleton + @Provides + LoggingHandler provideLoggingHandler() { + return new LoggingHandler(LogLevel.DEBUG); + } + + @Singleton + @Provides + static Supplier provideRefreshedCredentialsSupplier( + @ApplicationDefaultCredential GoogleCredentialsBundle credentialsBundle) { + return () -> { + GoogleCredentials credentials = credentialsBundle.getGoogleCredentials(); + try { + credentials.refreshIfExpired(); + } catch (IOException e) { + throw new RuntimeException("Cannot refresh credentials.", e); + } + return credentials; + }; + } + + @Singleton + @Provides + @Named("idToken") + static Supplier provideOidcToken( + @ApplicationDefaultCredential GoogleCredentialsBundle credentialsBundle, + @Config("oauthClientId") String clientId) { + return Suppliers.memoizeWithExpiration( + () -> OidcTokenUtils.createOidcToken(credentialsBundle, clientId), 1, TimeUnit.HOURS); + } + + @Singleton + @Provides + @Named("canary") + static boolean provideIsCanary() { + return RegistryEnvironment.get().name().endsWith("_CANARY"); + } + + @Singleton + @Provides + static CloudKMS provideCloudKms( + @ApplicationDefaultCredential GoogleCredentialsBundle credentialsBundle, + @Config("projectId") String projectId) { + return new CloudKMS.Builder( + credentialsBundle.getHttpTransport(), + credentialsBundle.getJsonFactory(), + credentialsBundle.getHttpRequestInitializer()) + .setApplicationName(projectId) + .build(); + } + + @Singleton + @Provides + static Storage provideStorage( + @ApplicationDefaultCredential GoogleCredentialsBundle credentialsBundle) { + return StorageOptions.newBuilder() + .setTransportOptions( + HttpTransportOptions.newBuilder() + .setHttpTransportFactory(credentialsBundle::getHttpTransport) + .build()) + .setCredentials(credentialsBundle.getGoogleCredentials()) + .build() + .getService(); + } + + @Provides + @Named("encryptedPemBytes") + static byte[] provideEncryptedPemBytes( + Storage storage, + @Config("eppServerSslPemBucket") String bucket, + @Config("eppServerSslPemFilename") String sslPemFilename) { + try { + return Base64.getMimeDecoder() + .decode(storage.readAllBytes(BlobId.of(bucket, sslPemFilename))); + } catch (StorageException e) { + throw new RuntimeException( + String.format( + "Error reading encrypted PEM file %s from GCS bucket %s", sslPemFilename, bucket), + e); + } + } + + @Provides + @Named("pemBytes") + static byte[] providePemBytes( + CloudKMS cloudKms, + @Named("encryptedPemBytes") byte[] encryptedPemBytes, + @Config("projectId") String projectId, + @Config("eppServerKmsLocation") String location, + @Config("eppServerKmsKeyRing") String keyRing, + @Config("eppServerKmsCryptoKey") String cryptoKey) { + String cryptoKeyUrl = + String.format( + "projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s", + projectId, location, keyRing, cryptoKey); + try { + DecryptRequest decryptRequest = new DecryptRequest().encodeCiphertext(encryptedPemBytes); + return cloudKms + .projects() + .locations() + .keyRings() + .cryptoKeys() + .decrypt(cryptoKeyUrl, decryptRequest) + .execute() + .decodePlaintext(); + } catch (IOException e) { + throw new RuntimeException( + String.format("PEM file decryption failed using CryptoKey: %s", cryptoKeyUrl), e); + } + } + + @Provides + static SslProvider provideSslProvider() { + return OpenSsl.isAvailable() ? SslProvider.OPENSSL : SslProvider.JDK; + } + + @Provides + static ExecutorService provideExecutorService() { + return Executors.newWorkStealingPool(); + } + + @Provides + static ScheduledExecutorService provideScheduledExecutorService() { + return Executors.newSingleThreadScheduledExecutor(); + } + + @Singleton + @Provides + static Mode provideMode() { + if (RegistryEnvironment.get() == RegistryEnvironment.LOCAL) { + return Mode.SELF_SIGNED; + } else { + return Mode.PEM_FILE; + } + } + + @Singleton + @Provides + @Named("remoteCertCachingDuration") + static Duration provideCertCachingDuration( + @Config("eppServerCertificateCacheSeconds") int cacheSeconds) { + return Duration.ofSeconds(cacheSeconds); + } + + @Singleton + @Provides + @Named("frontendMetricsRatio") + static double provideFrontendMetricsRatio(@Config("eppServerFrontendMetricsRatio") double ratio) { + return ratio; + } + + /** Root level component that exposes the port-to-protocol map. */ + @Singleton + @Component( + modules = { + EppServerModule.class, + CacheModule.class, + CertificateSupplierModule.class, + ConfigModule.class, + EppProtocolModule.class, + HealthCheckProtocolModule.class, + MetricsModule.class, + CredentialModule.class, + KeyModule.class, + KeyringModule.class, + SecretManagerModule.class, + UtilsModule.class + }) + public interface EppServerComponent { + Set protocols(); + + MetricReporter metricReporter(); + + Optional jedis(); + } +} diff --git a/core/src/main/java/google/registry/eppserver/HealthCheckProtocolModule.java b/core/src/main/java/google/registry/eppserver/HealthCheckProtocolModule.java new file mode 100644 index 00000000000..fc4204ab1f9 --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/HealthCheckProtocolModule.java @@ -0,0 +1,66 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver; + +import com.google.common.collect.ImmutableList; +import dagger.Module; +import dagger.Provides; +import dagger.multibindings.IntoSet; +import google.registry.eppserver.Protocol.FrontendProtocol; +import google.registry.eppserver.handler.HealthCheckHandler; +import io.netty.channel.ChannelHandler; +import jakarta.inject.Provider; +import jakarta.inject.Qualifier; +import jakarta.inject.Singleton; + +/** A module that provides the {@link FrontendProtocol} used for health check protocol. */ +@Module +public final class HealthCheckProtocolModule { + + /** Dagger qualifier to provide health check protocol related handlers and other bindings. */ + @Qualifier + public @interface HealthCheckProtocol {} + + private static final String PROTOCOL_NAME = "health_check"; + + @Singleton + @Provides + @IntoSet + static FrontendProtocol provideProtocol( + @HealthCheckProtocol int healthCheckPort, + @HealthCheckProtocol ImmutableList> handlerProviders) { + return Protocol.frontendBuilder() + .name(PROTOCOL_NAME) + .port(healthCheckPort) + .handlerProviders(handlerProviders) + .hasBackend(false) + .build(); + } + + @Provides + @HealthCheckProtocol + static ImmutableList> provideHandlerProviders( + @HealthCheckProtocol Provider healthCheckHandlerProvider) { + return ImmutableList.of(healthCheckHandlerProvider); + } + + @Provides + @HealthCheckProtocol + static HealthCheckHandler provideHealthCheckHandler() { + // These are currently hardcoded in the handler or defaulted in RegistryConfig. + // For now, we use the standard GCP health check strings. + return new HealthCheckHandler("HEALTH_CHECK_REQUEST", "HEALTH_CHECK_RESPONSE"); + } +} diff --git a/core/src/main/java/google/registry/eppserver/MetricsModule.java b/core/src/main/java/google/registry/eppserver/MetricsModule.java new file mode 100644 index 00000000000..e5bada6baf9 --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/MetricsModule.java @@ -0,0 +1,115 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver; + +import com.google.api.services.monitoring.v3.Monitoring; +import com.google.api.services.monitoring.v3.model.MonitoredResource; +import com.google.common.collect.ImmutableMap; +import com.google.common.flogger.FluentLogger; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.monitoring.metrics.MetricReporter; +import com.google.monitoring.metrics.MetricWriter; +import com.google.monitoring.metrics.stackdriver.StackdriverWriter; +import dagger.Component; +import dagger.Module; +import dagger.Provides; +import google.registry.config.CredentialModule; +import google.registry.config.CredentialModule.ApplicationDefaultCredential; +import google.registry.config.RegistryConfig.Config; +import google.registry.config.RegistryConfig.ConfigModule; +import google.registry.util.GoogleCredentialsBundle; +import google.registry.util.MetricParameters; +import google.registry.util.RegistryEnvironment; +import jakarta.inject.Singleton; + +/** Module that provides necessary bindings to instantiate a {@link MetricReporter} */ +@Module +public class MetricsModule { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + @Singleton + @Provides + static Monitoring provideMonitoring( + @ApplicationDefaultCredential GoogleCredentialsBundle credentialsBundle, + @Config("projectId") String projectId) { + return new Monitoring.Builder( + credentialsBundle.getHttpTransport(), + credentialsBundle.getJsonFactory(), + credentialsBundle.getHttpRequestInitializer()) + .setApplicationName(projectId) + .build(); + } + + @Singleton + @Provides + static MetricWriter provideMetricWriter( + Monitoring monitoringClient, + MonitoredResource monitoredResource, + @Config("projectId") String projectId, + @Config("stackdriverMaxQps") int stackdriverMaxQps, + @Config("stackdriverMaxPointsPerRequest") int stackdriverMaxPointsPerRequest) { + return new StackdriverWriter( + monitoringClient, + projectId, + monitoredResource, + stackdriverMaxQps, + stackdriverMaxPointsPerRequest); + } + + @Singleton + @Provides + static MetricReporter provideMetricReporter( + MetricWriter metricWriter, @Config("writeIntervalSeconds") int writeIntervalSeconds) { + return new MetricReporter( + metricWriter, writeIntervalSeconds, new ThreadFactoryBuilder().setDaemon(true).build()); + } + + /** + * Provides a {@link MonitoredResource} appropriate for environment the EPP server runs in. + * + *

When running locally, the type of the monitored resource is set to {@code global}, otherwise + * it is {@code gke_container}. + * + * @see + * Choosing a monitored resource type + */ + @Singleton + @Provides + static MonitoredResource provideMonitoredResource( + @Config("projectId") String projectId, MetricParameters metricParameters) { + MonitoredResource monitoredResource = new MonitoredResource(); + if (RegistryEnvironment.get() == RegistryEnvironment.LOCAL) { + monitoredResource.setType("global").setLabels(ImmutableMap.of("project_id", projectId)); + } else { + monitoredResource.setType("gke_container").setLabels(metricParameters.makeLabelsMap()); + } + logger.atInfo().log("Monitored resource: %s", monitoredResource); + return monitoredResource; + } + + @Singleton + @Component( + modules = { + MetricsModule.class, + EppServerModule.class, + CredentialModule.class, + ConfigModule.class + }) + interface MetricsComponent { + MetricReporter metricReporter(); + } +} diff --git a/core/src/main/java/google/registry/eppserver/Protocol.java b/core/src/main/java/google/registry/eppserver/Protocol.java new file mode 100644 index 00000000000..722b0b941cc --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/Protocol.java @@ -0,0 +1,147 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver; + +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.util.Attribute; +import io.netty.util.AttributeKey; +import jakarta.inject.Provider; +import javax.annotation.Nullable; + +/** Value class that encapsulates parameters of a specific connection. */ +public interface Protocol { + + /** Key used to retrieve the {@link Protocol} from a {@link Channel}'s {@link Attribute}. */ + AttributeKey PROTOCOL_KEY = AttributeKey.valueOf("PROTOCOL_KEY"); + + /** Protocol name. */ + String name(); + + /** + * Port to bind to (for {@link FrontendProtocol}) or to connect to (for {@link BackendProtocol}). + */ + int port(); + + /** The {@link ChannelHandler} providers to use for the protocol, in order. */ + ImmutableList> handlerProviders(); + + /** A builder for {@link FrontendProtocol}, by default there is a backend associated with it. */ + static FrontendProtocol.Builder frontendBuilder() { + return new AutoValue_Protocol_FrontendProtocol.Builder().hasBackend(true); + } + + /** A builder for {@link FrontendProtocol}, by default it connects to a remote host. */ + static BackendProtocol.Builder backendBuilder() { + return new AutoValue_Protocol_BackendProtocol.Builder().isLocal(false); + } + + /** + * Generic builder enabling chaining for concrete implementations. + * + * @param builder of the concrete subtype of {@link Protocol}. + * @param

type of the concrete subtype of {@link Protocol}. + */ + abstract class Builder, P extends Protocol> { + + public abstract B name(String value); + + public abstract B port(int port); + + public abstract B handlerProviders(ImmutableList> value); + + public abstract P build(); + } + + /** + * Connection parameters for a connection from the client to the proxy. + * + *

This protocol is associated to a {@link NioSocketChannel} established by remote peer + * connecting to the given {@code port} that the proxy is listening on. + */ + @AutoValue + abstract class FrontendProtocol implements Protocol { + + /** + * The {@link BackendProtocol} used to establish a relay channel and relay the traffic to. Not + * required for health check protocol or HTTP(S) redirect. + */ + @Nullable + public abstract BackendProtocol relayProtocol(); + + /** + * Whether this {@code FrontendProtocol} relays to a {@code BackendProtocol}. All proxied + * traffic must be represented by a protocol that has a backend. + */ + public abstract boolean hasBackend(); + + @AutoValue.Builder + public abstract static class Builder extends Protocol.Builder { + public abstract Builder relayProtocol(BackendProtocol value); + + public abstract Builder hasBackend(boolean value); + + abstract FrontendProtocol autoBuild(); + + @Override + public FrontendProtocol build() { + FrontendProtocol frontendProtocol = autoBuild(); + Preconditions.checkState( + !frontendProtocol.hasBackend() || frontendProtocol.relayProtocol() != null, + "Frontend protocol %s must define a relay protocol.", + frontendProtocol.name()); + return frontendProtocol; + } + } + } + + /** + * Connection parameters for a connection from the EPP server to Nomulus. + * + *

This protocol is associated to a {@link NioSocketChannel} established by the EPP server + * connecting to a remote peer. + */ + @AutoValue + abstract class BackendProtocol implements Protocol { + /** The hostname that the EPP server connects to. */ + public abstract String host(); + + /** Whether the protocol is expected to connect to localhost. */ + public abstract boolean isLocal(); + + /** Builder of {@link BackendProtocol}. */ + @AutoValue.Builder + public abstract static class Builder extends Protocol.Builder { + public abstract Builder host(String value); + + public abstract Builder isLocal(boolean value); + + abstract BackendProtocol autoBuild(); + + @Override + public BackendProtocol build() { + BackendProtocol protocol = autoBuild(); + Preconditions.checkState( + !protocol.isLocal() || protocol.host().equals("localhost"), + "Local backend protocol must connect to localhost"); + return autoBuild(); + } + } + } +} diff --git a/core/src/main/java/google/registry/eppserver/handler/EppProxyProtocolHandler.java b/core/src/main/java/google/registry/eppserver/handler/EppProxyProtocolHandler.java new file mode 100644 index 00000000000..60c88f8b0c8 --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/handler/EppProxyProtocolHandler.java @@ -0,0 +1,199 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.handler; + +import static com.google.common.base.Preconditions.checkState; +import static java.nio.charset.StandardCharsets.US_ASCII; + +import com.google.common.flogger.FluentLogger; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.util.AttributeKey; +import jakarta.inject.Inject; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; + +/** + * Handler that processes possible existence of a PROXY protocol v1 header. + * + *

When an EPP client connects to the registry (through the proxy), the registry performs two + * validations to ensure that only known registrars are allowed. First it checks the sha265 hash of + * the client SSL certificate and match it to the hash stored in the database for the registrar. It + * then checks if the connection is from an allow-listed IP address that belongs to that registrar. + * + *

The proxy receives client connects via the GCP load balancer, which results in the loss of + * original client IP from the channel. Luckily, the load balancer supports the PROXY protocol v1, + * which adds a header with source IP information, among other things, to the TCP request at the + * start of the connection. + * + *

This handler determines if a connection is proxied (PROXY protocol v1 header present) and + * correctly sets the source IP address to the channel's attribute regardless of whether it is + * proxied. After that it removes itself from the channel pipeline because the proxy header is only + * present at the beginning of the connection. + * + *

This handler must be the very first handler in a protocol, even before SSL handlers, because + * PROXY protocol header comes as the very first thing, even before SSL handshake request. + * + * @see The PROXY protocol + */ +public class EppProxyProtocolHandler extends ByteToMessageDecoder { + + /** Key used to retrieve origin IP address from a channel's attribute. */ + public static final AttributeKey REMOTE_ADDRESS_KEY = + AttributeKey.valueOf("REMOTE_ADDRESS_KEY"); + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + // The proxy header must start with this prefix. + // Sample header: "PROXY TCP4 255.255.255.255 255.255.255.255 65535 65535\r\n". + private static final byte[] HEADER_PREFIX = "PROXY".getBytes(US_ASCII); + + private boolean finished = false; + private String proxyHeader = null; + + @Inject + EppProxyProtocolHandler() {} + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + super.channelRead(ctx, msg); + if (finished) { + String remoteIP; + if (proxyHeader != null) { + logger.atFine().log("PROXIED CONNECTION: %s", ctx.channel()); + logger.atFine().log("PROXY HEADER for channel %s: %s", ctx.channel(), proxyHeader); + String[] headerArray = proxyHeader.split(" ", -1); + if (headerArray.length == 6) { + remoteIP = headerArray[2]; + logger.atFine().log( + "Header parsed, using %s as remote IP for channel %s", remoteIP, ctx.channel()); + // If the header is "PROXY UNKNOWN" + // (see https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt), likely when the + // remote connection to the external load balancer is through special means, make it + // 0.0.0.0 so that it can be treated accordingly by the relevant quota configs. + } else if (headerArray.length == 2 && headerArray[1].equals("UNKNOWN")) { + logger.atFine().log( + "Header parsed, source IP unknown, using 0.0.0.0 as remote IP for channel %s", + ctx.channel()); + remoteIP = "0.0.0.0"; + } else { + logger.atFine().log( + "Cannot parse the header, using source IP as remote IP for channel %s", + ctx.channel()); + remoteIP = getSourceIP(ctx); + } + } else { + logger.atFine().log( + "No header present, using source IP directly for channel %s", ctx.channel()); + remoteIP = getSourceIP(ctx); + } + if (remoteIP != null) { + ctx.channel().attr(REMOTE_ADDRESS_KEY).set(remoteIP); + } else { + logger.atWarning().log("Not able to obtain remote IP for channel %s", ctx.channel()); + } + // ByteToMessageDecoder automatically flushes unread bytes in the ByteBuf to the next handler + // when itself is being removed. + ctx.pipeline().remove(this); + } + } + + private static String getSourceIP(ChannelHandlerContext ctx) { + SocketAddress remoteAddress = ctx.channel().remoteAddress(); + return (remoteAddress instanceof InetSocketAddress inetSocketAddress) + ? inetSocketAddress.getAddress().getHostAddress() + : null; + } + + /** + * Attempts to decode an internally accumulated buffer and find the proxy protocol header. + * + *

When the connection is not proxied (i. e. the initial bytes are not "PROXY"), simply set + * {@link #finished} to true and allow the handler to be removed. Otherwise the handler waits + * until there's enough bytes to parse the header, save the parsed header to {@link #proxyHeader}, + * and then mark {@link #finished}. + * + * @param in internally accumulated buffer, newly arrived bytes are appended to it. + * @param out objects passed to the next handler, in this case nothing is ever passed because the + * header itself is processed and written to the attribute of the proxy, and the handler is + * then removed from the pipeline. + */ + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + // Wait until there are more bytes available than the header's length before processing. + if (in.readableBytes() >= HEADER_PREFIX.length) { + if (containsHeader(in)) { + // The inbound message contains the header, it must be a proxied connection. Note that + // currently proxied connection is only used for EPP protocol, which requires the connection + // to be SSL enabled. So the beginning of the inbound message upon connection can only be + // either the proxy header (when proxied), or SSL handshake request (when not proxied), + // which does not start with "PROXY". Therefore it is safe to assume that if the beginning + // of the message contains "PROXY", it must be proxied, and must contain \r\n. + int eol = findEndOfLine(in); + // If eol is not found, that is because that we do not yet have enough inbound message, do + // nothing and wait for more bytes to be readable. eol will eventually be positive because + // of the reasoning above: The connection starts with "PROXY", so it must be a proxied + // connection and contain \r\n. + if (eol >= 0) { + // ByteBuf.readBytes is called so that the header is processed and not passed to handlers + // further in the pipeline. + byte[] headerBytes = new byte[eol]; + in.readBytes(headerBytes); + proxyHeader = new String(headerBytes, US_ASCII); + // Skip \r\n. + in.skipBytes(2); + // Proxy header processed, mark finished so that this handler is removed. + finished = true; + } + } else { + // The inbound message does not contain a proxy header, mark finished so that this handler + // is removed. Note that no inbound bytes are actually processed by this handler because we + // did not call ByteBuf.readBytes(), but ByteBuf.getByte(), which does not change reader + // index of the ByteBuf. So any inbound byte is then passed to the next handler to process. + finished = true; + } + } + } + + /** + * Returns the index in the buffer of the end of line found. Returns -1 if no end of line was + * found in the buffer. + */ + private static int findEndOfLine(final ByteBuf buffer) { + final int n = buffer.writerIndex(); + for (int i = buffer.readerIndex(); i < n; i++) { + final byte b = buffer.getByte(i); + if (b == '\r' && i < n - 1 && buffer.getByte(i + 1) == '\n') { + return i; // \r\n + } + } + return -1; // Not found. + } + + /** Checks if the given buffer contains the proxy header prefix. */ + private boolean containsHeader(ByteBuf buffer) { + // The readable bytes is always more or equal to the size of the header prefix because this + // method is only called when this condition is true. + checkState(buffer.readableBytes() >= HEADER_PREFIX.length); + for (int i = 0; i < HEADER_PREFIX.length; ++i) { + if (buffer.getByte(buffer.readerIndex() + i) != HEADER_PREFIX[i]) { + return false; + } + } + return true; + } +} diff --git a/core/src/main/java/google/registry/eppserver/handler/EppServiceHandler.java b/core/src/main/java/google/registry/eppserver/handler/EppServiceHandler.java new file mode 100644 index 00000000000..412255a95d4 --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/handler/EppServiceHandler.java @@ -0,0 +1,233 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.handler; + +import static google.registry.eppserver.handler.EppProxyProtocolHandler.REMOTE_ADDRESS_KEY; +import static google.registry.networking.handler.SslServerInitializer.CLIENT_CERTIFICATE_PROMISE_KEY; +import static google.registry.util.GcpJsonFormatter.setCurrentRequest; +import static google.registry.util.GcpJsonFormatter.setCurrentTraceId; +import static google.registry.util.GcpJsonFormatter.unsetCurrentRequest; +import static google.registry.util.X509Utils.getCertificateHash; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryConfig.Config; +import google.registry.eppserver.EppProtocolModule.CommandQuota; +import google.registry.eppserver.metric.FrontendMetrics; +import google.registry.eppserver.quota.LocalConnectionLimiter; +import google.registry.eppserver.quota.QuotaManager; +import google.registry.module.RegistryServlet; +import google.registry.request.RequestHandler; +import google.registry.util.FakeHttpServletRequest; +import google.registry.util.FakeHttpServletResponse; +import google.registry.util.ProxyHttpHeaders; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.util.AttributeKey; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.Promise; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import java.security.cert.X509Certificate; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Unified processor for EPP protocol traffic. + * + *

Consolidates throttling, session management, and in-process execution. Extracts registrar ID + * (clID) directly from EPP login XML for accurate throttling. + */ +public class EppServiceHandler extends SimpleChannelInboundHandler { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + // Fast regex to extract clID from EPP login command without full XML parsing. + private static final Pattern CLID_PATTERN = + Pattern.compile("([^<]+)", Pattern.CASE_INSENSITIVE); + + public static final AttributeKey CLIENT_CERTIFICATE_HASH_KEY = + AttributeKey.valueOf("CLIENT_CERTIFICATE_HASH_KEY"); + + private final byte[] helloBytes; + private final FrontendMetrics metrics; + private final LocalConnectionLimiter localConnectionLimiter; + private final QuotaManager commandQuotaManager; + private final Supplier idTokenSupplier; + private final String projectId; + + private String sslClientCertificateHash; + private String clientAddress; + private String registrarId; // The clID extracted from login + private String sessionCookie; + + private boolean ipAcquired = false; + private boolean certAcquired = false; + + @VisibleForTesting RequestHandler requestHandler = RegistryServlet.component.requestHandler(); + + @Inject + public EppServiceHandler( + @Named("hello") byte[] helloBytes, + FrontendMetrics metrics, + LocalConnectionLimiter localConnectionLimiter, + @CommandQuota QuotaManager commandQuotaManager, + @Named("idToken") Supplier idTokenSupplier, + @Config("projectId") String projectId) { + this.helloBytes = helloBytes.clone(); + this.metrics = metrics; + this.localConnectionLimiter = localConnectionLimiter; + this.commandQuotaManager = commandQuotaManager; + this.idTokenSupplier = idTokenSupplier; + this.projectId = projectId; + } + + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + Promise certPromise = ctx.channel().attr(CLIENT_CERTIFICATE_PROMISE_KEY).get(); + if (certPromise != null) { + certPromise.addListener( + (Promise promise) -> { + if (promise.isSuccess()) { + onSslHandshakeComplete(ctx, promise.get()); + } else { + logger.atWarning().withCause(promise.cause()).log("SSL handshake failed"); + @SuppressWarnings("unused") + Future unusedFuture = ctx.close(); + } + }); + } + super.channelActive(ctx); + } + + private void onSslHandshakeComplete(ChannelHandlerContext ctx, X509Certificate cert) { + sslClientCertificateHash = getCertificateHash(cert); + clientAddress = ctx.channel().attr(REMOTE_ADDRESS_KEY).get(); + ctx.channel().attr(CLIENT_CERTIFICATE_HASH_KEY).set(sslClientCertificateHash); + + // 1. Connection throttling (IP and Certificate) + if (!localConnectionLimiter.acquireIp(clientAddress)) { + metrics.registerQuotaRejection("epp_connection_ip", clientAddress); + @SuppressWarnings("unused") + Future unusedFuture = ctx.close(); + return; + } + ipAcquired = true; + + if (!localConnectionLimiter.acquireCert(sslClientCertificateHash)) { + metrics.registerQuotaRejection("epp_connection", sslClientCertificateHash); + @SuppressWarnings("unused") + Future unusedFuture = ctx.close(); + return; + } + certAcquired = true; + + metrics.registerActiveConnection("epp", sslClientCertificateHash, ctx.channel()); + + // 2. Trigger initial EPP + handleEppFrame(ctx, Unpooled.wrappedBuffer(helloBytes)); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, ByteBuf frame) { + handleEppFrame(ctx, frame); + } + + private void handleEppFrame(ChannelHandlerContext ctx, ByteBuf frame) { + String xml = frame.toString(UTF_8); + + // 1. Maturing Identity: If we don't have clID yet, try to extract it from a login command. + if (registrarId == null) { + Matcher matcher = CLID_PATTERN.matcher(xml); + if (matcher.find()) { + registrarId = matcher.group(1).trim(); + logger.atInfo().log("Identified registrar: %s", registrarId); + } + } + + // 2. Command-level rate limiting + // Use clID if identified, otherwise fallback to cert hash (for the login command itself). + String throttleId = (registrarId != null) ? registrarId : sslClientCertificateHash; + if (throttleId != null) { + if (!commandQuotaManager.acquireQuota(new QuotaManager.QuotaRequest(throttleId)).success()) { + metrics.registerQuotaRejection("epp_command", throttleId); + @SuppressWarnings("unused") + Future unusedFuture = ctx.close(); + return; + } + } + + // 3. Execute command in-process + FakeHttpServletRequest req = new FakeHttpServletRequest(); + req.setRequestUri("/_dr/epp"); + req.setBody(xml.getBytes(UTF_8)); + req.setHeader(ProxyHttpHeaders.CERTIFICATE_HASH, sslClientCertificateHash); + req.setHeader(ProxyHttpHeaders.IP_ADDRESS, clientAddress); + if (registrarId != null) { + req.setHeader(ProxyHttpHeaders.REGISTRAR_ID, registrarId); + } + if (sessionCookie != null) { + req.setHeader("Cookie", sessionCookie); + } + req.setHeader("Authorization", "Bearer " + idTokenSupplier.get()); + + FakeHttpServletResponse rsp = new FakeHttpServletResponse(); + String traceId = + String.format( + "projects/%s/traces/%s", projectId, UUID.randomUUID().toString().replace("-", "")); + setCurrentTraceId(traceId); + setCurrentRequest("POST", "/_dr/epp", "Netty-EPP", "EPP/1.0"); + try { + requestHandler.handleRequest(req, rsp); + String setCookie = rsp.getHeader("Set-Cookie"); + if (setCookie != null) { + sessionCookie = setCookie; + } + + ByteBuf out = Unpooled.wrappedBuffer(rsp.getPayload()); + if ("close".equals(rsp.getHeader(ProxyHttpHeaders.EPP_SESSION))) { + @SuppressWarnings("unused") + Future unusedFuture = ctx.writeAndFlush(out).addListener(ChannelFutureListener.CLOSE); + } else { + @SuppressWarnings("unused") + Future unusedFuture = ctx.writeAndFlush(out); + } + } catch (Exception e) { + logger.atSevere().withCause(e).log("Internal EPP processing error"); + @SuppressWarnings("unused") + Future unusedFuture = ctx.close(); + } finally { + setCurrentTraceId(null); + unsetCurrentRequest(); + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + if (certAcquired) { + localConnectionLimiter.releaseCert(sslClientCertificateHash); + } + if (ipAcquired) { + localConnectionLimiter.releaseIp(clientAddress); + } + super.channelInactive(ctx); + } +} diff --git a/core/src/main/java/google/registry/eppserver/handler/HealthCheckHandler.java b/core/src/main/java/google/registry/eppserver/handler/HealthCheckHandler.java new file mode 100644 index 00000000000..d2030446c58 --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/handler/HealthCheckHandler.java @@ -0,0 +1,43 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.handler; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import java.nio.charset.StandardCharsets; + +/** A handler that responds to GCP load balancer health check message */ +public class HealthCheckHandler extends ChannelInboundHandlerAdapter { + + private final ByteBuf checkRequest; + private final ByteBuf checkResponse; + + public HealthCheckHandler(String checkRequest, String checkResponse) { + this.checkRequest = Unpooled.wrappedBuffer(checkRequest.getBytes(StandardCharsets.US_ASCII)); + this.checkResponse = Unpooled.wrappedBuffer(checkResponse.getBytes(StandardCharsets.US_ASCII)); + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + ByteBuf buf = (ByteBuf) msg; + if (buf.equals(checkRequest)) { + ChannelFuture unusedFuture = ctx.writeAndFlush(checkResponse); + } + buf.release(); + } +} diff --git a/core/src/main/java/google/registry/eppserver/metric/BaseMetrics.java b/core/src/main/java/google/registry/eppserver/metric/BaseMetrics.java new file mode 100644 index 00000000000..a07cfe5091a --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/metric/BaseMetrics.java @@ -0,0 +1,61 @@ +// Copyright 2019 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.metric; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableSet; +import com.google.monitoring.metrics.CustomFitter; +import com.google.monitoring.metrics.ExponentialFitter; +import com.google.monitoring.metrics.FibonacciFitter; +import com.google.monitoring.metrics.LabelDescriptor; + +/** Base class for metrics. */ +public abstract class BaseMetrics { + + /** + * Labels to register metrics with. + * + *

The client certificate hash value is only used for EPP metrics. In order to get the actual + * registrar name, one can use the {@code nomulus} tool: + * + *

+   * nomulus -e production list_registrars -f clientCertificateHash | grep $HASH
+   * 
+ */ + protected static final ImmutableSet LABELS = + ImmutableSet.of( + LabelDescriptor.create("protocol", "Name of the protocol."), + LabelDescriptor.create( + "client_cert_hash", "SHA256 hash of the client certificate, if available.")); + + // Maximum request size is defined in the config file, this is not realistic and we'd be out of + // memory when the size approaches 1 GB. + protected static final CustomFitter DEFAULT_SIZE_FITTER = FibonacciFitter.create(1073741824); + + // Maximum 1 hour latency, this is not specified by the spec, but given we have a one hour idle + // timeout, it seems reasonable that maximum latency is set to 1 hour as well. If we are + // approaching anywhere near 1 hour latency, we'd be way out of SLO anyway. + protected static final ExponentialFitter DEFAULT_LATENCY_FITTER = + ExponentialFitter.create(22, 2, 1.0); + + /** + * Resets all metrics. + * + *

This should only be used in tests to reset states. Production code should not call this + * method. + */ + @VisibleForTesting + abstract void resetMetrics(); +} diff --git a/core/src/main/java/google/registry/eppserver/metric/FrontendMetrics.java b/core/src/main/java/google/registry/eppserver/metric/FrontendMetrics.java new file mode 100644 index 00000000000..fb64bf325d0 --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/metric/FrontendMetrics.java @@ -0,0 +1,126 @@ +// Copyright 2017 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.metric; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.monitoring.metrics.EventMetric; +import com.google.monitoring.metrics.IncrementableMetric; +import com.google.monitoring.metrics.Metric; +import com.google.monitoring.metrics.MetricRegistryImpl; +import google.registry.util.NonFinalForTesting; +import io.netty.channel.Channel; +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.util.concurrent.GlobalEventExecutor; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.time.Duration; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** Frontend metrics instrumentation. */ +@Singleton +public class FrontendMetrics extends BaseMetrics { + + private static final ConcurrentMap, ChannelGroup> activeConnections = + new ConcurrentHashMap<>(); + + static final Metric activeConnectionsGauge = + MetricRegistryImpl.getDefault() + .newGauge( + "/eppserver/active_connections", + "Number of active connections from clients to the EPP server.", + "Active Connections", + LABELS, + () -> + activeConnections.entrySet().stream() + .collect( + ImmutableMap.toImmutableMap( + Map.Entry::getKey, entry -> (long) entry.getValue().size())), + Long.class); + + static final IncrementableMetric totalConnectionsCounter = + MetricRegistryImpl.getDefault() + .newIncrementableMetric( + "/eppserver/total_connections", + "Total number connections ever made from clients to the EPP server.", + "Total Connections", + LABELS); + + static final IncrementableMetric quotaRejectionsCounter = + MetricRegistryImpl.getDefault() + .newIncrementableMetric( + "/eppserver/quota_rejections", + "Total number rejected quota request made by EPP server for each connection.", + "Quota Rejections", + LABELS); + + static final EventMetric latencyMs = + MetricRegistryImpl.getDefault() + .newEventMetric( + "/eppserver/latency_ms", + "Round-trip time between a request received and its corresponding response is sent.", + "Latency Milliseconds", + LABELS, + DEFAULT_LATENCY_FITTER); + + private final Random random; + private final double frontendMetricsRatio; + + @Inject + FrontendMetrics(@Named("frontendMetricsRatio") double frontendMetricsRatio, Random random) { + this.frontendMetricsRatio = frontendMetricsRatio; + this.random = random; + } + + @Override + void resetMetrics() { + totalConnectionsCounter.reset(); + activeConnections.clear(); + latencyMs.reset(); + } + + @NonFinalForTesting + public void registerActiveConnection(String protocol, String certHash, Channel channel) { + totalConnectionsCounter.increment(protocol, certHash); + ImmutableList labels = ImmutableList.of(protocol, certHash); + ChannelGroup channelGroup; + if (activeConnections.containsKey(labels)) { + channelGroup = activeConnections.get(labels); + } else { + channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); + activeConnections.put(labels, channelGroup); + } + channelGroup.add(channel); + } + + @NonFinalForTesting + public void registerQuotaRejection(String protocol, String certHash) { + quotaRejectionsCounter.increment(protocol, certHash); + } + + @NonFinalForTesting + public void responseSent(String protocol, String certHash, Duration latency) { + // Short-circuit metrics recording randomly according to the configured ratio. + if (random.nextDouble() > frontendMetricsRatio) { + return; + } + latencyMs.record(latency.toMillis(), protocol, certHash); + } +} diff --git a/core/src/main/java/google/registry/eppserver/quota/LocalConnectionLimiter.java b/core/src/main/java/google/registry/eppserver/quota/LocalConnectionLimiter.java new file mode 100644 index 00000000000..dfd534ef02c --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/quota/LocalConnectionLimiter.java @@ -0,0 +1,83 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.quota; + +import google.registry.config.RegistryConfig.Config; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Thread-safe, in-memory rate limiter for restricting the number of concurrent connections allowed + * per IP address and per authenticated certificate. + */ +@ThreadSafe +@Singleton +public class LocalConnectionLimiter { + + private final int maxConnectionsPerIp; + private final int maxConnectionsPerCert; + + private final ConcurrentHashMap ipConnections = new ConcurrentHashMap<>(); + private final ConcurrentHashMap certConnections = + new ConcurrentHashMap<>(); + + @Inject + public LocalConnectionLimiter( + @Config("eppServerMaxConnectionsPerIp") int maxConnectionsPerIp, + @Config("eppServerMaxConnectionsPerCert") int maxConnectionsPerCert) { + this.maxConnectionsPerIp = maxConnectionsPerIp; + this.maxConnectionsPerCert = maxConnectionsPerCert; + } + + /** Attempts to acquire a slot for the given IP address. */ + public boolean acquireIp(String ipAddress) { + return acquire(ipAddress, ipConnections, maxConnectionsPerIp); + } + + /** Releases a slot for the given IP address. */ + public void releaseIp(String ipAddress) { + release(ipAddress, ipConnections); + } + + /** Attempts to acquire a slot for the given certificate hash. */ + public boolean acquireCert(String certHash) { + return acquire(certHash, certConnections, maxConnectionsPerCert); + } + + /** Releases a slot for the given certificate hash. */ + public void releaseCert(String certHash) { + release(certHash, certConnections); + } + + private boolean acquire(String key, ConcurrentHashMap map, int limit) { + AtomicInteger count = map.computeIfAbsent(key, k -> new AtomicInteger(0)); + if (count.incrementAndGet() <= limit) { + return true; + } + // Limit exceeded, decrement to revert the increment + count.decrementAndGet(); + return false; + } + + private void release(String key, ConcurrentHashMap map) { + AtomicInteger count = map.get(key); + if (count != null && count.decrementAndGet() <= 0) { + map.remove(key); + } + } +} diff --git a/core/src/main/java/google/registry/eppserver/quota/QuotaManager.java b/core/src/main/java/google/registry/eppserver/quota/QuotaManager.java new file mode 100644 index 00000000000..c493c53988f --- /dev/null +++ b/core/src/main/java/google/registry/eppserver/quota/QuotaManager.java @@ -0,0 +1,171 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.quota; + +import com.google.common.collect.ImmutableMap; +import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryConfigSettings.Quota; +import google.registry.config.RegistryConfigSettings.Quota.QuotaGroup; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import javax.annotation.Nullable; +import javax.annotation.concurrent.ThreadSafe; +import redis.clients.jedis.UnifiedJedis; + +/** + * Unified manager for distributed quota enforcement using Redis/Valkey. + * + *

Handles both configuration lookup and atomic Redis operations for connection and command-level + * throttling. + */ +@ThreadSafe +public class QuotaManager { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final int DEFAULT_TTL_SECONDS = 3600; + + /** Lua script to atomically decrement a token bucket with a TTL. */ + private static final String DECR_LUA = + "local current = redis.call('GET', KEYS[1]) " + + "if not current then " + + " redis.call('SET', KEYS[1], ARGV[1] - 1, 'EX', ARGV[2]) " + + " return tonumber(ARGV[1]) - 1 " + + "end " + + "if tonumber(current) <= 0 then " + + " return -1 " + + "end " + + "return redis.call('DECR', KEYS[1])"; + + /** Lua script to atomically increment back a connection token (capped at max). */ + private static final String INCR_LUA = + "local current = redis.call('GET', KEYS[1]) " + + "if current and tonumber(current) < tonumber(ARGV[1]) then " + + " return redis.call('INCR', KEYS[1]) " + + "end " + + "return nil"; + + /** Lua script to refresh the TTL of an existing token bucket. */ + private static final String EXPIRE_LUA = + "if redis.call('EXISTS', KEYS[1]) == 1 then " + + " return redis.call('EXPIRE', KEYS[1], ARGV[1]) " + + "end " + + "return 0"; + + private final UnifiedJedis jedis; + private final String quotaNamespace; + private final QuotaGroup defaultQuota; + private final ImmutableMap customQuotas; + + public QuotaManager(Quota quota, @Nullable UnifiedJedis jedis, String quotaNamespace) { + this.jedis = jedis; + this.quotaNamespace = quotaNamespace; + this.defaultQuota = quota.defaultQuota; + + ImmutableMap.Builder builder = ImmutableMap.builder(); + quota.customQuota.forEach(group -> group.userId.forEach(userId -> builder.put(userId, group))); + this.customQuotas = builder.build(); + } + + public record QuotaRequest(String userId) {} + + public record QuotaResponse(boolean success) {} + + public record QuotaRebate(String userId) {} + + /** Attempts to acquire a quota token from Redis. */ + public QuotaResponse acquireQuota(QuotaRequest request) { + String userId = request.userId(); + QuotaGroup group = customQuotas.getOrDefault(userId, defaultQuota); + + // Unlimited quota check + if (group.tokenAmount < 0) { + return new QuotaResponse(true); + } + + if (jedis == null) { + return new QuotaResponse(true); // Fail open if no Valkey configured + } + + // Use the first ID as the virtual group identity if it's a custom group, + // otherwise isolate each default user by their actual ID. + String redisId = + (group == defaultQuota || group.userId.isEmpty()) ? userId : group.userId.get(0); + String key = String.format("%s:%s", quotaNamespace, redisId); + int ttl = group.refillSeconds > 0 ? group.refillSeconds : DEFAULT_TTL_SECONDS; + + try { + Object result = + jedis.eval(DECR_LUA, 1, key, String.valueOf(group.tokenAmount), String.valueOf(ttl)); + + return new QuotaResponse(((Long) result) >= 0); + } catch (Exception e) { + logger.atSevere().withCause(e).log( + "Valkey error for quota key: %s", URLEncoder.encode(key, StandardCharsets.UTF_8)); + return new QuotaResponse(true); // Fail open + } + } + + /** Refreshes the TTL of an existing quota token. */ + public void refreshQuota(QuotaRequest request) { + if (jedis == null) { + return; + } + + String userId = request.userId(); + QuotaGroup group = customQuotas.getOrDefault(userId, defaultQuota); + if (group.tokenAmount < 0) { + return; + } + + // Use the first ID as the virtual group identity if it's a custom group, + // otherwise isolate each default user by their actual ID. + String redisId = + (group == defaultQuota || group.userId.isEmpty()) ? userId : group.userId.get(0); + String key = String.format("%s:%s", quotaNamespace, redisId); + int ttl = group.refillSeconds > 0 ? group.refillSeconds : DEFAULT_TTL_SECONDS; + + try { + jedis.eval(EXPIRE_LUA, 1, key, String.valueOf(ttl)); + } catch (Exception e) { + logger.atSevere().withCause(e).log( + "Valkey error refreshing quota for: %s", URLEncoder.encode(key, StandardCharsets.UTF_8)); + } + } + + /** Returns a token to the pool (used for connection throttling). */ + public void releaseQuota(QuotaRebate rebate) { + if (jedis == null) { + return; + } + + String userId = rebate.userId(); + QuotaGroup group = customQuotas.getOrDefault(userId, defaultQuota); + if (group.tokenAmount < 0) { + return; + } + + // Use the first ID as the virtual group identity if it's a custom group, + // otherwise isolate each default user by their actual ID. + String redisId = + (group == defaultQuota || group.userId.isEmpty()) ? userId : group.userId.get(0); + String key = String.format("%s:%s", quotaNamespace, redisId); + try { + jedis.eval(INCR_LUA, 1, key, String.valueOf(group.tokenAmount)); + } catch (Exception e) { + logger.atSevere().withCause(e).log( + "Valkey error releasing quota for: %s", URLEncoder.encode(key, StandardCharsets.UTF_8)); + } + } +} diff --git a/core/src/main/java/google/registry/flows/TlsCredentials.java b/core/src/main/java/google/registry/flows/TlsCredentials.java index 3394f5fcbb1..97795eaf0b7 100644 --- a/core/src/main/java/google/registry/flows/TlsCredentials.java +++ b/core/src/main/java/google/registry/flows/TlsCredentials.java @@ -260,7 +260,7 @@ static Optional provideClientCertificateHash(HttpServletRequest req) { static Optional provideIpAddress(HttpServletRequest req) { Optional clientAddress = extractOptionalHeader(req, ProxyHttpHeaders.IP_ADDRESS); Optional fallbackClientAddress = - extractOptionalHeader(req, ProxyHttpHeaders.IP_ADDRESS); + extractOptionalHeader(req, ProxyHttpHeaders.FALLBACK_IP_ADDRESS); Optional clientInetAddr = clientAddress.map(TlsCredentials::parseInetAddress); return clientInetAddr.isPresent() ? clientInetAddr diff --git a/core/src/main/java/google/registry/module/EppServerLifecycleListener.java b/core/src/main/java/google/registry/module/EppServerLifecycleListener.java new file mode 100644 index 00000000000..485bbca8f47 --- /dev/null +++ b/core/src/main/java/google/registry/module/EppServerLifecycleListener.java @@ -0,0 +1,78 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.module; + +import com.google.common.flogger.FluentLogger; +import google.registry.eppserver.DaggerEppServerModule_EppServerComponent; +import google.registry.eppserver.EppServer; +import google.registry.eppserver.EppServerModule; +import jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; +import jakarta.servlet.annotation.WebListener; + +/** A {@link ServletContextListener} that starts and stops the integrated Netty EPP server. */ +@WebListener +public class EppServerLifecycleListener implements ServletContextListener { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private EppServer eppServer; + private EppServerModule.EppServerComponent eppServerComponent; + + @Override + public void contextInitialized(ServletContextEvent sce) { + if (!Boolean.parseBoolean(System.getenv("TCP_SERVER_ENABLED"))) { + logger.atInfo().log( + "TCP_SERVER_ENABLED is false or not set. Skipping integrated EPP server initialization."); + return; + } + + logger.atInfo().log("Initializing integrated EPP server..."); + try { + EppServerModule eppServerModule = new EppServerModule(); + eppServerComponent = + DaggerEppServerModule_EppServerComponent.builder() + .eppServerModule(eppServerModule) + .build(); + + eppServer = new EppServer(eppServerComponent); + eppServer.start(); + logger.atInfo().log("Integrated EPP server started successfully."); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Failed to start integrated EPP server."); + } + } + + @Override + public void contextDestroyed(ServletContextEvent sce) { + if (eppServer != null) { + logger.atInfo().log("Stopping integrated EPP server..."); + eppServer.stop(); + logger.atInfo().log("Integrated EPP server stopped."); + } + if (eppServerComponent != null) { + eppServerComponent + .jedis() + .ifPresent( + jedis -> { + try { + jedis.close(); + logger.atInfo().log("Closed UnifiedJedis client."); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Failed to close UnifiedJedis client."); + } + }); + } + } +} diff --git a/core/src/main/java/google/registry/module/MetricsLifecycleListener.java b/core/src/main/java/google/registry/module/MetricsLifecycleListener.java new file mode 100644 index 00000000000..5e04ce4fe29 --- /dev/null +++ b/core/src/main/java/google/registry/module/MetricsLifecycleListener.java @@ -0,0 +1,54 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.module; + +import com.google.common.flogger.FluentLogger; +import com.google.monitoring.metrics.MetricReporter; +import jakarta.servlet.ServletContextEvent; +import jakarta.servlet.ServletContextListener; +import jakarta.servlet.annotation.WebListener; +import java.time.Duration; + +/** Starts and stops the single global JVM MetricReporter on application startup/shutdown. */ +@WebListener +public class MetricsLifecycleListener implements ServletContextListener { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private MetricReporter metricReporter; + + @Override + public void contextInitialized(ServletContextEvent sce) { + logger.atInfo().log("Initializing global MetricReporter..."); + try { + metricReporter = RegistryServlet.component.metricReporter().get(); + metricReporter.startAsync().awaitRunning(Duration.ofSeconds(10)); + logger.atInfo().log("Global MetricReporter started successfully."); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Failed to start global MetricReporter."); + } + } + + @Override + public void contextDestroyed(ServletContextEvent sce) { + if (metricReporter != null) { + try { + metricReporter.stopAsync().awaitTerminated(Duration.ofSeconds(10)); + logger.atInfo().log("Global MetricReporter stopped."); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Failed to stop global MetricReporter."); + } + } + } +} diff --git a/core/src/main/java/google/registry/module/RegistryComponent.java b/core/src/main/java/google/registry/module/RegistryComponent.java index 6c6fe280e46..cc9806115b7 100644 --- a/core/src/main/java/google/registry/module/RegistryComponent.java +++ b/core/src/main/java/google/registry/module/RegistryComponent.java @@ -38,8 +38,6 @@ import google.registry.groups.GroupssettingsModule; import google.registry.keyring.KeyringModule; import google.registry.keyring.api.KeyModule; -import google.registry.module.RegistryComponent.RegistryModule; -import google.registry.module.RequestComponent.RequestComponentModule; import google.registry.monitoring.whitebox.StackdriverModule; import google.registry.mosapi.module.MosApiModule; import google.registry.persistence.PersistenceModule; @@ -55,7 +53,7 @@ import jakarta.inject.Provider; import jakarta.inject.Singleton; -/** Dagger component with instance lifetime. */ +/** Dagger component for the registry servlet. */ @Singleton @Component( modules = { @@ -74,14 +72,13 @@ GroupsModule.class, GroupssettingsModule.class, GsonModule.class, - MosApiModule.class, JSchModule.class, KeyModule.class, KeyringModule.class, + MosApiModule.class, NetHttpTransportModule.class, PersistenceModule.class, - RegistryModule.class, - RequestComponentModule.class, + RegistryComponent.RegistryModule.class, SecretManagerModule.class, ServerTridProviderModule.class, SheetsServiceModule.class, @@ -90,15 +87,18 @@ UtilsModule.class, VoidDnsWriterModule.class, }) -interface RegistryComponent { +public interface RegistryComponent { RequestHandler requestHandler(); + RequestAuthenticator requestAuthenticator(); + Lazy metricReporter(); @Config("projectId") String projectId(); - @Module + /** Module for {@link RegistryComponent}. */ + @Module(subcomponents = RequestComponent.class) class RegistryModule { @Provides static RequestHandler provideRequestHandler( diff --git a/core/src/main/java/google/registry/module/RegistryServlet.java b/core/src/main/java/google/registry/module/RegistryServlet.java index 173076d6f93..e9efc9a40ef 100644 --- a/core/src/main/java/google/registry/module/RegistryServlet.java +++ b/core/src/main/java/google/registry/module/RegistryServlet.java @@ -22,8 +22,6 @@ import static google.registry.util.RandomStringGenerator.insecureRandomStringGenerator; import static google.registry.util.StringGenerator.Alphabets.HEX_DIGITS_ONLY; -import com.google.monitoring.metrics.MetricReporter; -import dagger.Lazy; import google.registry.request.RequestHandler; import google.registry.util.GcpJsonFormatter; import google.registry.util.JdkLoggerConfig; @@ -49,9 +47,8 @@ public class RegistryServlet extends ServletBase { private static final RandomStringGenerator LOG_TRACE_ID_GENERATOR = insecureRandomStringGenerator(HEX_DIGITS_ONLY); - private static final RegistryComponent component = DaggerRegistryComponent.create(); + public static final RegistryComponent component = DaggerRegistryComponent.create(); private static final RequestHandler requestHandler = component.requestHandler(); - private static final Lazy metricReporter = component.metricReporter(); // The regex pattern to capture the cookies that we want to log. private static final Pattern COOKIE_REGEX_PATTERN = @@ -71,7 +68,7 @@ public class RegistryServlet extends ServletBase { } public RegistryServlet() { - super(requestHandler, metricReporter); + super(requestHandler); this.projectId = component.projectId(); } diff --git a/core/src/main/java/google/registry/module/ServletBase.java b/core/src/main/java/google/registry/module/ServletBase.java index 9f5d9b4b3b6..a64dce76290 100644 --- a/core/src/main/java/google/registry/module/ServletBase.java +++ b/core/src/main/java/google/registry/module/ServletBase.java @@ -15,8 +15,6 @@ package google.registry.module; import com.google.common.flogger.FluentLogger; -import com.google.monitoring.metrics.MetricReporter; -import dagger.Lazy; import google.registry.request.RequestHandler; import google.registry.util.SystemClock; import jakarta.servlet.http.HttpServlet; @@ -26,46 +24,22 @@ import java.security.Security; import java.time.Duration; import java.time.Instant; -import java.util.concurrent.TimeoutException; import org.bouncycastle.jce.provider.BouncyCastleProvider; /** Base for Servlets that handle all requests to our modules. */ public class ServletBase extends HttpServlet { private final RequestHandler requestHandler; - private final Lazy metricReporter; private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final SystemClock clock = new SystemClock(); - public ServletBase(RequestHandler requestHandler, Lazy metricReporter) { + public ServletBase(RequestHandler requestHandler) { this.requestHandler = requestHandler; - this.metricReporter = metricReporter; } @Override public void init() { Security.addProvider(new BouncyCastleProvider()); - - // If the metric reporter failed to instantiate for any reason (bad keyring, bad json - // credential, etc.), we log the error but keep the main thread running. Also, the shutdown hook - // will only be registered if the metric reporter starts up correctly. - try { - metricReporter.get().startAsync().awaitRunning(Duration.ofSeconds(10)); - logger.atInfo().log("Started up MetricReporter."); - Runtime.getRuntime() - .addShutdownHook( - new Thread( - () -> { - try { - metricReporter.get().stopAsync().awaitTerminated(Duration.ofSeconds(10)); - logger.atInfo().log("Shut down MetricReporter."); - } catch (TimeoutException e) { - logger.atSevere().withCause(e).log("Failed to stop MetricReporter."); - } - })); - } catch (Exception e) { - logger.atSevere().withCause(e).log("Failed to initialize MetricReporter."); - } } @Override diff --git a/core/src/main/java/google/registry/util/FakeHttpServletRequest.java b/core/src/main/java/google/registry/util/FakeHttpServletRequest.java new file mode 100644 index 00000000000..087dc127265 --- /dev/null +++ b/core/src/main/java/google/registry/util/FakeHttpServletRequest.java @@ -0,0 +1,456 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.util; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import jakarta.servlet.AsyncContext; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.ReadListener; +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.ServletConnection; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpUpgradeHandler; +import jakarta.servlet.http.Part; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.security.Principal; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** Simple fake {@link HttpServletRequest} for internal use. */ +public class FakeHttpServletRequest implements HttpServletRequest { + + private final Map headers = new HashMap<>(); + private final Map attributes = new HashMap<>(); + private String method = "POST"; + private String requestUri = "/_dr/epp"; + private String serverName = "localhost"; + private byte[] body = new byte[0]; + private String contentType = "application/epp+xml"; + + public void setHeader(String name, String value) { + headers.put(name, value); + } + + public void setMethod(String method) { + this.method = method; + } + + public void setRequestUri(String requestUri) { + this.requestUri = requestUri; + } + + public void setBody(byte[] body) { + this.body = body.clone(); + } + + @Override + public String getHeader(String name) { + return headers.get(name); + } + + @Override + public Enumeration getHeaders(String name) { + String value = headers.get(name); + return Collections.enumeration( + value == null ? Collections.emptyList() : Collections.singletonList(value)); + } + + @Override + public Enumeration getHeaderNames() { + return Collections.enumeration(headers.keySet()); + } + + @Override + public int getIntHeader(String name) { + String value = headers.get(name); + if (value == null) { + return -1; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + String.format("Invalid integer value for header '%s': %s", name, value), e); + } + } + + @Override + public String getMethod() { + return method; + } + + @Override + public String getRequestURI() { + return requestUri; + } + + @Override + public String getServerName() { + return serverName; + } + + @Override + public ServletInputStream getInputStream() throws IOException { + final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) {} + + @Override + public int read() throws IOException { + return byteArrayInputStream.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return byteArrayInputStream.read(b, off, len); + } + }; + } + + @Override + public String getContentType() { + return contentType; + } + + // --- Implement other methods with defaults --- + @Override + public String getAuthType() { + return null; + } + + @Override + public Cookie[] getCookies() { + return new Cookie[0]; + } + + @Override + public long getDateHeader(String name) { + return -1; + } + + @Override + public String getPathInfo() { + return null; + } + + @Override + public String getPathTranslated() { + return null; + } + + @Override + public String getContextPath() { + return ""; + } + + @Override + public String getQueryString() { + return null; + } + + @Override + public String getRemoteUser() { + return null; + } + + @Override + public boolean isUserInRole(String role) { + return false; + } + + @Override + public Principal getUserPrincipal() { + return null; + } + + @Override + public String getRequestedSessionId() { + return null; + } + + @Override + public StringBuffer getRequestURL() { + return new StringBuffer(requestUri); + } + + @Override + public String getServletPath() { + return ""; + } + + @Override + public HttpSession getSession(boolean create) { + return null; + } + + @Override + public HttpSession getSession() { + return null; + } + + @Override + public String changeSessionId() { + return null; + } + + @Override + public boolean isRequestedSessionIdValid() { + return false; + } + + @Override + public boolean isRequestedSessionIdFromCookie() { + return false; + } + + @Override + public boolean isRequestedSessionIdFromURL() { + return false; + } + + @Override + public boolean authenticate(HttpServletResponse response) throws IOException, ServletException { + return false; + } + + @Override + public void login(String username, String password) throws ServletException {} + + @Override + public void logout() throws ServletException {} + + @Override + public Collection getParts() throws IOException, ServletException { + return null; + } + + @Override + public Part getPart(String name) throws IOException, ServletException { + return null; + } + + @Override + public T upgrade(Class handlerClass) + throws IOException, ServletException { + return null; + } + + @Override + public Object getAttribute(String name) { + return attributes.get(name); + } + + @Override + public Enumeration getAttributeNames() { + return Collections.enumeration(attributes.keySet()); + } + + @Override + public String getCharacterEncoding() { + return "UTF-8"; + } + + @Override + public void setCharacterEncoding(String env) throws UnsupportedEncodingException {} + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + + @Override + public String getParameter(String name) { + return null; + } + + @Override + public Enumeration getParameterNames() { + return Collections.emptyEnumeration(); + } + + @Override + public String[] getParameterValues(String name) { + return null; + } + + @Override + public Map getParameterMap() { + return Collections.emptyMap(); + } + + @Override + public String getProtocol() { + return "HTTP/1.1"; + } + + @Override + public String getScheme() { + return "http"; + } + + @Override + public int getServerPort() { + return 8080; + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader(new InputStreamReader(getInputStream(), UTF_8)); + } + + @Override + public String getRemoteAddr() { + return "127.0.0.1"; + } + + @Override + public String getRemoteHost() { + return "localhost"; + } + + @Override + public void setAttribute(String name, Object o) { + attributes.put(name, o); + } + + @Override + public void removeAttribute(String name) { + attributes.remove(name); + } + + @Override + public Locale getLocale() { + return Locale.US; + } + + @Override + public Enumeration getLocales() { + return Collections.enumeration(Collections.singleton(Locale.US)); + } + + @Override + public boolean isSecure() { + return false; + } + + @Override + public RequestDispatcher getRequestDispatcher(String path) { + return null; + } + + @Override + public int getRemotePort() { + return 0; + } + + @Override + public String getLocalName() { + return "localhost"; + } + + @Override + public String getLocalAddr() { + return "127.0.0.1"; + } + + @Override + public int getLocalPort() { + return 8080; + } + + @Override + public ServletContext getServletContext() { + return null; + } + + @Override + public AsyncContext startAsync() throws IllegalStateException { + return null; + } + + @Override + public AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) + throws IllegalStateException { + return null; + } + + @Override + public boolean isAsyncStarted() { + return false; + } + + @Override + public boolean isAsyncSupported() { + return false; + } + + @Override + public AsyncContext getAsyncContext() { + return null; + } + + @Override + public DispatcherType getDispatcherType() { + return DispatcherType.REQUEST; + } + + @Override + public String getRequestId() { + return null; + } + + @Override + public String getProtocolRequestId() { + return null; + } + + @Override + public ServletConnection getServletConnection() { + return null; + } +} diff --git a/core/src/main/java/google/registry/util/FakeHttpServletResponse.java b/core/src/main/java/google/registry/util/FakeHttpServletResponse.java new file mode 100644 index 00000000000..68b0d68eba3 --- /dev/null +++ b/core/src/main/java/google/registry/util/FakeHttpServletResponse.java @@ -0,0 +1,222 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.util; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** Simple fake {@link HttpServletResponse} for internal use. */ +public class FakeHttpServletResponse implements HttpServletResponse { + + private int status = SC_OK; + private String contentType; + private final Map headers = new HashMap<>(); + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + private final PrintWriter writer = new PrintWriter(outputStream, false, UTF_8); + + @Override + public void setStatus(int sc) { + this.status = sc; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public void setContentType(String type) { + this.contentType = type; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public void setHeader(String name, String value) { + headers.put(name, value); + } + + @Override + public void addHeader(String name, String value) { + headers.put(name, value); + } + + @Override + public String getHeader(String name) { + return headers.get(name); + } + + @Override + public Collection getHeaders(String name) { + String value = headers.get(name); + return value == null ? Collections.emptyList() : Collections.singletonList(value); + } + + @Override + public Collection getHeaderNames() { + return headers.keySet(); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + return new ServletOutputStream() { + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) {} + + @Override + public void write(int b) throws IOException { + outputStream.write(b); + } + }; + } + + @Override + public PrintWriter getWriter() throws IOException { + return writer; + } + + public byte[] getPayload() { + writer.flush(); + return outputStream.toByteArray(); + } + + // --- Implement other methods with defaults --- + @Override + public void addCookie(Cookie cookie) {} + + @Override + public boolean containsHeader(String name) { + return headers.containsKey(name); + } + + @Override + public String encodeURL(String url) { + return url; + } + + @Override + public String encodeRedirectURL(String url) { + return url; + } + + @Override + public void sendError(int sc, String msg) throws IOException { + this.status = sc; + } + + @Override + public void sendError(int sc) throws IOException { + this.status = sc; + } + + @Override + public void sendRedirect(String location) throws IOException { + this.status = SC_FOUND; + } + + @Override + public void sendRedirect(String location, int sc, boolean clearBuffer) throws IOException { + this.status = sc; + } + + @Override + public void setDateHeader(String name, long date) {} + + @Override + public void addDateHeader(String name, long date) {} + + @Override + public void setIntHeader(String name, int value) {} + + @Override + public void addIntHeader(String name, int value) {} + + @Override + public void setCharacterEncoding(String charset) {} + + @Override + public void setContentLength(int len) {} + + @Override + public void setContentLengthLong(long len) {} + + @Override + public void setBufferSize(int size) {} + + @Override + public int getBufferSize() { + return 0; + } + + @Override + public void flushBuffer() throws IOException { + writer.flush(); + } + + @Override + public void resetBuffer() { + writer.flush(); + outputStream.reset(); + } + + @Override + public boolean isCommitted() { + return false; + } + + @Override + public void reset() { + writer.flush(); + outputStream.reset(); + headers.clear(); + status = SC_OK; + } + + @Override + public void setLocale(Locale loc) {} + + @Override + public Locale getLocale() { + return Locale.US; + } + + @Override + public String getCharacterEncoding() { + return "UTF-8"; + } + + @Override + public void sendEarlyHints() {} +} diff --git a/core/src/main/resources/google/registry/eppserver/hello.xml b/core/src/main/resources/google/registry/eppserver/hello.xml new file mode 100644 index 00000000000..30fb4a0f7a1 --- /dev/null +++ b/core/src/main/resources/google/registry/eppserver/hello.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/src/test/java/google/registry/eppserver/handler/EppProxyProtocolHandlerTest.java b/core/src/test/java/google/registry/eppserver/handler/EppProxyProtocolHandlerTest.java new file mode 100644 index 00000000000..d974e5711a9 --- /dev/null +++ b/core/src/test/java/google/registry/eppserver/handler/EppProxyProtocolHandlerTest.java @@ -0,0 +1,75 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.handler; + +import static com.google.common.truth.Truth.assertThat; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class EppProxyProtocolHandlerTest { + + @Test + void testProxyProtocol_parsesValidHeader() { + EppProxyProtocolHandler handler = new EppProxyProtocolHandler(); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + String proxyHeader = "PROXY TCP4 192.168.1.1 10.0.0.1 50000 443\r\n"; + ByteBuf buffer = Unpooled.wrappedBuffer(proxyHeader.getBytes(StandardCharsets.US_ASCII)); + + channel.writeInbound(buffer); + + String remoteAddress = channel.attr(EppProxyProtocolHandler.REMOTE_ADDRESS_KEY).get(); + assertThat(remoteAddress).isEqualTo("192.168.1.1"); + assertThat(channel.pipeline().get(EppProxyProtocolHandler.class)).isNull(); + } + + @Test + void testProxyProtocol_unknownHeader() { + EppProxyProtocolHandler handler = new EppProxyProtocolHandler(); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + String proxyHeader = "PROXY UNKNOWN\r\n"; + ByteBuf buffer = Unpooled.wrappedBuffer(proxyHeader.getBytes(StandardCharsets.US_ASCII)); + + channel.writeInbound(buffer); + + String remoteAddress = channel.attr(EppProxyProtocolHandler.REMOTE_ADDRESS_KEY).get(); + assertThat(remoteAddress).isEqualTo("0.0.0.0"); + assertThat(channel.pipeline().get(EppProxyProtocolHandler.class)).isNull(); + } + + @Test + void testProxyProtocol_noHeader_notProxied() { + EppProxyProtocolHandler handler = new EppProxyProtocolHandler(); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + String normalData = "NOT_A_PROXY_HEADER"; + ByteBuf buffer = Unpooled.wrappedBuffer(normalData.getBytes(StandardCharsets.US_ASCII)); + + channel.writeInbound(buffer); + + String remoteAddress = channel.attr(EppProxyProtocolHandler.REMOTE_ADDRESS_KEY).get(); + // In EmbeddedChannel without remoteAddress mock, getSourceIP returns null + assertThat(remoteAddress).isNull(); + assertThat(channel.pipeline().get(EppProxyProtocolHandler.class)).isNull(); + + ByteBuf passedOn = channel.readInbound(); + assertThat(passedOn.toString(StandardCharsets.US_ASCII)).isEqualTo("NOT_A_PROXY_HEADER"); + } +} diff --git a/core/src/test/java/google/registry/eppserver/handler/EppServiceHandlerTest.java b/core/src/test/java/google/registry/eppserver/handler/EppServiceHandlerTest.java new file mode 100644 index 00000000000..b3e630edc0e --- /dev/null +++ b/core/src/test/java/google/registry/eppserver/handler/EppServiceHandlerTest.java @@ -0,0 +1,296 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.handler; + +import static google.registry.eppserver.handler.EppProxyProtocolHandler.REMOTE_ADDRESS_KEY; +import static google.registry.networking.handler.SslServerInitializer.CLIENT_CERTIFICATE_PROMISE_KEY; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import google.registry.eppserver.metric.FrontendMetrics; +import google.registry.eppserver.quota.LocalConnectionLimiter; +import google.registry.eppserver.quota.QuotaManager; +import google.registry.eppserver.quota.QuotaManager.QuotaRequest; +import google.registry.eppserver.quota.QuotaManager.QuotaResponse; +import google.registry.request.RequestHandler; +import google.registry.util.FakeHttpServletRequest; +import google.registry.util.FakeHttpServletResponse; +import google.registry.util.ProxyHttpHeaders; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.util.Attribute; +import io.netty.util.concurrent.DefaultPromise; +import io.netty.util.concurrent.ImmediateEventExecutor; +import io.netty.util.concurrent.Promise; +import java.security.cert.X509Certificate; +import java.util.function.Supplier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class EppServiceHandlerTest { + + @Mock private FrontendMetrics metrics; + @Mock private LocalConnectionLimiter localConnectionLimiter; + @Mock private QuotaManager commandQuotaManager; + @Mock private Supplier idTokenSupplier; + @Mock private ChannelHandlerContext ctx; + @Mock private Channel channel; + @Mock private RequestHandler requestHandler; + + @Mock private Attribute> certPromiseAttr; + @Mock private Attribute remoteAddressAttr; + @Mock private Attribute certHashAttr; + @Mock private X509Certificate certificate; + + private EppServiceHandler handler; + private DefaultPromise certPromise; + + @BeforeEach + void setUp() { + handler = + new EppServiceHandler( + new byte[] {'h', 'e', 'l', 'l', 'o'}, + metrics, + localConnectionLimiter, + commandQuotaManager, + idTokenSupplier, + "test-project"); + + handler.requestHandler = requestHandler; + + when(ctx.channel()).thenReturn(channel); + } + + private void setUpSuccessfulHandshake() throws Exception { + certPromise = new DefaultPromise<>(ImmediateEventExecutor.INSTANCE); + when(channel.attr(CLIENT_CERTIFICATE_PROMISE_KEY)).thenReturn(certPromiseAttr); + when(certPromiseAttr.get()).thenReturn(certPromise); + + handler.channelActive(ctx); + + when(channel.attr(REMOTE_ADDRESS_KEY)).thenReturn(remoteAddressAttr); + when(remoteAddressAttr.get()).thenReturn("192.168.1.1"); + when(channel.attr(EppServiceHandler.CLIENT_CERTIFICATE_HASH_KEY)).thenReturn(certHashAttr); + when(certificate.getEncoded()).thenReturn(new byte[] {1, 2, 3}); + + when(localConnectionLimiter.acquireIp(any(String.class))).thenReturn(true); + when(localConnectionLimiter.acquireCert(any(String.class))).thenReturn(true); + + certPromise.setSuccess(certificate); + } + + @Test + void testChannelActive_success() throws Exception { + doAnswer( + invocation -> { + FakeHttpServletResponse rsp = invocation.getArgument(1); + rsp.getWriter().write(""); + return null; + }) + .when(requestHandler) + .handleRequest(any(FakeHttpServletRequest.class), any(FakeHttpServletResponse.class)); + + when(commandQuotaManager.acquireQuota(any(QuotaRequest.class))) + .thenReturn(new QuotaResponse(true)); + when(idTokenSupplier.get()).thenReturn("fake_id_token"); + + setUpSuccessfulHandshake(); + + verify(metrics).registerActiveConnection(eq("epp"), any(String.class), eq(channel)); + // Verify the initial frame is sent back to the client + verify(ctx) + .writeAndFlush( + argThat( + (ByteBuf buf) -> { + String xml = buf.toString(UTF_8); + return xml.equals(""); + })); + } + + @Test + void testChannelActive_ipQuotaRejected() throws Exception { + certPromise = new DefaultPromise<>(ImmediateEventExecutor.INSTANCE); + when(channel.attr(CLIENT_CERTIFICATE_PROMISE_KEY)).thenReturn(certPromiseAttr); + when(certPromiseAttr.get()).thenReturn(certPromise); + + handler.channelActive(ctx); + + when(channel.attr(REMOTE_ADDRESS_KEY)).thenReturn(remoteAddressAttr); + when(remoteAddressAttr.get()).thenReturn("192.168.1.1"); + when(channel.attr(EppServiceHandler.CLIENT_CERTIFICATE_HASH_KEY)).thenReturn(certHashAttr); + when(certificate.getEncoded()).thenReturn(new byte[] {1, 2, 3}); + + when(localConnectionLimiter.acquireIp(any(String.class))).thenReturn(false); + + certPromise.setSuccess(certificate); + + verify(metrics).registerQuotaRejection(eq("epp_connection_ip"), eq("192.168.1.1")); + verify(ctx).close(); + } + + @Test + void testChannelActive_certQuotaRejected() throws Exception { + certPromise = new DefaultPromise<>(ImmediateEventExecutor.INSTANCE); + when(channel.attr(CLIENT_CERTIFICATE_PROMISE_KEY)).thenReturn(certPromiseAttr); + when(certPromiseAttr.get()).thenReturn(certPromise); + + handler.channelActive(ctx); + + when(channel.attr(REMOTE_ADDRESS_KEY)).thenReturn(remoteAddressAttr); + when(remoteAddressAttr.get()).thenReturn("192.168.1.1"); + when(channel.attr(EppServiceHandler.CLIENT_CERTIFICATE_HASH_KEY)).thenReturn(certHashAttr); + when(certificate.getEncoded()).thenReturn(new byte[] {1, 2, 3}); + + when(localConnectionLimiter.acquireIp(any(String.class))).thenReturn(true); + when(localConnectionLimiter.acquireCert(any(String.class))).thenReturn(false); + + certPromise.setSuccess(certificate); + + verify(metrics).registerQuotaRejection(eq("epp_connection"), any(String.class)); + verify(ctx).close(); + } + + @Test + void testChannelRead0_extractsClidAndForwardsRequest() throws Exception { + setUpSuccessfulHandshake(); + + when(idTokenSupplier.get()).thenReturn("fake_id_token"); + when(commandQuotaManager.acquireQuota(any(QuotaRequest.class))) + .thenReturn(new QuotaResponse(true)); + + String eppLoginXml = "RegistrarA"; + ByteBuf inFrame = Unpooled.wrappedBuffer(eppLoginXml.getBytes(UTF_8)); + + // Simulate FakeHttpServletResponse modifying headers and writing payload + doAnswer( + invocation -> { + FakeHttpServletRequest req = invocation.getArgument(0); + FakeHttpServletResponse rsp = invocation.getArgument(1); + + rsp.setHeader("Set-Cookie", "SESSION_INFO=xyz123"); + rsp.getWriter().write("success"); + return null; + }) + .when(requestHandler) + .handleRequest(any(FakeHttpServletRequest.class), any(FakeHttpServletResponse.class)); + + handler.channelRead0(ctx, inFrame); + + // Verify command quota was requested for the extracted clID "RegistrarA" + verify(commandQuotaManager).acquireQuota(eq(new QuotaRequest("RegistrarA"))); + + // Verify the response from the servlet was written back to the channel + verify(ctx) + .writeAndFlush( + argThat( + (ByteBuf buf) -> { + String xml = buf.toString(UTF_8); + return xml.equals("success"); + })); + + // On the next request, the cookie should be injected + String eppCommandXml = ""; + ByteBuf inFrame2 = Unpooled.wrappedBuffer(eppCommandXml.getBytes(UTF_8)); + + doAnswer( + invocation -> { + FakeHttpServletRequest req = invocation.getArgument(0); + // Verify the cookie was properly propagated + if (!"SESSION_INFO=xyz123".equals(req.getHeader("Cookie"))) { + throw new AssertionError("Missing or incorrect cookie"); + } + // Verify the registrar ID was properly propagated + if (!"RegistrarA".equals(req.getHeader(ProxyHttpHeaders.REGISTRAR_ID))) { + throw new AssertionError("Missing or incorrect registrar ID"); + } + return null; + }) + .when(requestHandler) + .handleRequest(any(FakeHttpServletRequest.class), any(FakeHttpServletResponse.class)); + + handler.channelRead0(ctx, inFrame2); + } + + @Test + void testChannelRead0_commandQuotaRejected() throws Exception { + setUpSuccessfulHandshake(); + + when(commandQuotaManager.acquireQuota(any(QuotaRequest.class))) + .thenReturn(new QuotaResponse(false)); + + String eppXml = ""; + ByteBuf inFrame = Unpooled.wrappedBuffer(eppXml.getBytes(UTF_8)); + + handler.channelRead0(ctx, inFrame); + + verify(metrics).registerQuotaRejection(eq("epp_command"), any(String.class)); + verify(ctx).close(); + } + + @Test + void testChannelRead0_closeSessionHeaderClosesChannel() throws Exception { + setUpSuccessfulHandshake(); + + when(idTokenSupplier.get()).thenReturn("fake_id_token"); + when(commandQuotaManager.acquireQuota(any(QuotaRequest.class))) + .thenReturn(new QuotaResponse(true)); + + String eppLogoutXml = ""; + ByteBuf inFrame = Unpooled.wrappedBuffer(eppLogoutXml.getBytes(UTF_8)); + + ChannelFuture mockFuture = mock(ChannelFuture.class); + when(ctx.writeAndFlush(any(ByteBuf.class))).thenReturn(mockFuture); + + doAnswer( + invocation -> { + FakeHttpServletResponse rsp = invocation.getArgument(1); + rsp.setHeader(ProxyHttpHeaders.EPP_SESSION, "close"); + rsp.getWriter().write("logout success"); + return null; + }) + .when(requestHandler) + .handleRequest(any(FakeHttpServletRequest.class), any(FakeHttpServletResponse.class)); + + handler.channelRead0(ctx, inFrame); + + // Verify the handler added the CLOSE listener to the flush future + verify(mockFuture).addListener(ChannelFutureListener.CLOSE); + } + + @Test + void testChannelInactive_releasesQuotas() throws Exception { + setUpSuccessfulHandshake(); + + handler.channelInactive(ctx); + + // Verify the in-memory limiter releases both IP and Cert + verify(localConnectionLimiter).releaseIp(eq("192.168.1.1")); + verify(localConnectionLimiter).releaseCert(any(String.class)); + } +} diff --git a/core/src/test/java/google/registry/eppserver/handler/HealthCheckHandlerTest.java b/core/src/test/java/google/registry/eppserver/handler/HealthCheckHandlerTest.java new file mode 100644 index 00000000000..ddffce317e3 --- /dev/null +++ b/core/src/test/java/google/registry/eppserver/handler/HealthCheckHandlerTest.java @@ -0,0 +1,52 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.handler; + +import static com.google.common.truth.Truth.assertThat; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class HealthCheckHandlerTest { + + @Test + void testHealthCheck_success() { + HealthCheckHandler handler = new HealthCheckHandler("ping", "pong"); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + ByteBuf pingMsg = Unpooled.wrappedBuffer("ping".getBytes(StandardCharsets.US_ASCII)); + channel.writeInbound(pingMsg); + + ByteBuf response = channel.readOutbound(); + assertThat(response.toString(StandardCharsets.US_ASCII)).isEqualTo("pong"); + assertThat(channel.isOpen()).isTrue(); + } + + @Test + void testHealthCheck_ignoresOtherMessages() { + HealthCheckHandler handler = new HealthCheckHandler("ping", "pong"); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + ByteBuf otherMsg = Unpooled.wrappedBuffer("foo".getBytes(StandardCharsets.US_ASCII)); + channel.writeInbound(otherMsg); + + ByteBuf response = channel.readOutbound(); + assertThat(response).isNull(); + assertThat(channel.isOpen()).isTrue(); + } +} diff --git a/core/src/test/java/google/registry/eppserver/quota/LocalConnectionLimiterTest.java b/core/src/test/java/google/registry/eppserver/quota/LocalConnectionLimiterTest.java new file mode 100644 index 00000000000..0249ab69249 --- /dev/null +++ b/core/src/test/java/google/registry/eppserver/quota/LocalConnectionLimiterTest.java @@ -0,0 +1,101 @@ +// Copyright 2026 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.quota; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LocalConnectionLimiterTest { + + private LocalConnectionLimiter limiter; + + @BeforeEach + void setUp() { + // Set a small limit of 2 for testing purposes. + limiter = new LocalConnectionLimiter(2, 2); + } + + @Test + void testAcquireIp_successUpToLimit() { + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + } + + @Test + void testAcquireIp_rejectsOverLimit() { + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + // 3rd attempt from same IP should be rejected + assertThat(limiter.acquireIp("192.168.1.1")).isFalse(); + } + + @Test + void testAcquireIp_independentAcrossIps() { + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + assertThat(limiter.acquireIp("192.168.1.1")).isFalse(); + + // A different IP should still be allowed + assertThat(limiter.acquireIp("10.0.0.1")).isTrue(); + } + + @Test + void testReleaseIp_freesSlot() { + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + assertThat(limiter.acquireIp("192.168.1.1")).isFalse(); + + limiter.releaseIp("192.168.1.1"); + // Now we should be able to acquire again + assertThat(limiter.acquireIp("192.168.1.1")).isTrue(); + } + + @Test + void testAcquireCert_successUpToLimit() { + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + } + + @Test + void testAcquireCert_rejectsOverLimit() { + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + // 3rd attempt from same cert should be rejected + assertThat(limiter.acquireCert("cert_hash_1")).isFalse(); + } + + @Test + void testAcquireCert_independentAcrossCerts() { + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + assertThat(limiter.acquireCert("cert_hash_1")).isFalse(); + + // A different cert should still be allowed + assertThat(limiter.acquireCert("cert_hash_2")).isTrue(); + } + + @Test + void testReleaseCert_freesSlot() { + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + assertThat(limiter.acquireCert("cert_hash_1")).isFalse(); + + limiter.releaseCert("cert_hash_1"); + // Now we should be able to acquire again + assertThat(limiter.acquireCert("cert_hash_1")).isTrue(); + } +} diff --git a/core/src/test/java/google/registry/eppserver/quota/QuotaManagerTest.java b/core/src/test/java/google/registry/eppserver/quota/QuotaManagerTest.java new file mode 100644 index 00000000000..0af0e659e4e --- /dev/null +++ b/core/src/test/java/google/registry/eppserver/quota/QuotaManagerTest.java @@ -0,0 +1,159 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.eppserver.quota; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import google.registry.config.RegistryConfigSettings.Quota; +import google.registry.config.RegistryConfigSettings.Quota.QuotaGroup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import redis.clients.jedis.UnifiedJedis; + +@ExtendWith(MockitoExtension.class) +class QuotaManagerTest { + + @Mock private UnifiedJedis jedis; + + private Quota quotaConfig; + private QuotaManager manager; + + @BeforeEach + void setUp() { + quotaConfig = new Quota(); + QuotaGroup defaultGroup = new QuotaGroup(); + defaultGroup.tokenAmount = 10; + defaultGroup.refillSeconds = 60; + quotaConfig.defaultQuota = defaultGroup; + + QuotaGroup customGroup = new QuotaGroup(); + customGroup.tokenAmount = 5; + customGroup.refillSeconds = 30; + customGroup.userId = ImmutableList.of("user1"); + quotaConfig.customQuota = ImmutableList.of(customGroup); + + manager = new QuotaManager(quotaConfig, jedis, "testQuota"); + } + + @Test + void testAcquireQuota_success() { + when(jedis.eval(anyString(), anyInt(), anyString(), anyString(), anyString())).thenReturn(5L); + + QuotaManager.QuotaResponse response = + manager.acquireQuota(new QuotaManager.QuotaRequest("user2")); + assertThat(response.success()).isTrue(); + verify(jedis).eval(anyString(), eq(1), eq("testQuota:user2"), eq("10"), eq("60")); + } + + @Test + void testAcquireQuota_failure() { + when(jedis.eval(anyString(), anyInt(), anyString(), anyString(), anyString())).thenReturn(-1L); + + QuotaManager.QuotaResponse response = + manager.acquireQuota(new QuotaManager.QuotaRequest("user1")); + assertThat(response.success()).isFalse(); + verify(jedis).eval(anyString(), eq(1), eq("testQuota:user1"), eq("5"), eq("30")); + } + + @Test + void testAcquireQuota_unlimited() { + quotaConfig.defaultQuota.tokenAmount = -1; + manager = new QuotaManager(quotaConfig, jedis, "testQuota"); + + QuotaManager.QuotaResponse response = + manager.acquireQuota(new QuotaManager.QuotaRequest("user2")); + assertThat(response.success()).isTrue(); + } + + @Test + void testAcquireQuota_jedisException_failsOpen() { + when(jedis.eval(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenThrow(new RuntimeException("Redis error")); + + QuotaManager.QuotaResponse response = + manager.acquireQuota(new QuotaManager.QuotaRequest("user2")); + assertThat(response.success()).isTrue(); + } + + @Test + void testRefreshQuota_success() { + manager.refreshQuota(new QuotaManager.QuotaRequest("user2")); + verify(jedis).eval(anyString(), eq(1), eq("testQuota:user2"), eq("60")); + } + + @Test + void testReleaseQuota_success() { + manager.releaseQuota(new QuotaManager.QuotaRebate("user2")); + verify(jedis).eval(anyString(), eq(1), eq("testQuota:user2"), eq("10")); + } + + @Test + void testGroupVirtualIdentity_usesFirstIdInList() { + // Modify config so "user1" is accompanied by a virtual group ID "my_group" + quotaConfig.customQuota.get(0).userId = ImmutableList.of("my_group", "user1", "user3"); + manager = new QuotaManager(quotaConfig, jedis, "testQuota"); + + when(jedis.eval(anyString(), anyInt(), anyString(), anyString(), anyString())).thenReturn(5L); + + QuotaManager.QuotaResponse response1 = + manager.acquireQuota(new QuotaManager.QuotaRequest("user1")); + QuotaManager.QuotaResponse response2 = + manager.acquireQuota(new QuotaManager.QuotaRequest("user3")); + + assertThat(response1.success()).isTrue(); + assertThat(response2.success()).isTrue(); + // 5 tokens, 30 seconds ttl + verify(jedis, times(2)).eval(anyString(), eq(1), eq("testQuota:my_group"), eq("5"), eq("30")); + } + + @Test + void testGroupVirtualIdentity_exceedsQuota_fails() { + // Modify config so "user1", "user2", "user3" share virtual group ID "my_group" + quotaConfig.customQuota.get(0).userId = ImmutableList.of("my_group", "user1", "user2", "user3"); + manager = new QuotaManager(quotaConfig, jedis, "testQuota"); + + // Simulate Redis returning 1, 0 for successful decrements, and -1 when empty + when(jedis.eval(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(1L) + .thenReturn(0L) + .thenReturn(-1L); + + // Act + QuotaManager.QuotaResponse response1 = + manager.acquireQuota(new QuotaManager.QuotaRequest("user1")); + QuotaManager.QuotaResponse response2 = + manager.acquireQuota(new QuotaManager.QuotaRequest("user2")); + QuotaManager.QuotaResponse response3 = + manager.acquireQuota(new QuotaManager.QuotaRequest("user3")); + + // Assert that the third request to the same group fails + assertThat(response1.success()).isTrue(); + assertThat(response2.success()).isTrue(); + assertThat(response3.success()).isFalse(); + + // Verify all 3 requests went to the shared bucket + verify(jedis, times(3)).eval(anyString(), eq(1), eq("testQuota:my_group"), eq("5"), eq("30")); + } +} diff --git a/core/src/test/java/google/registry/module/TestServlet.java b/core/src/test/java/google/registry/module/TestServlet.java index 173b1f622d5..f84bc24989c 100644 --- a/core/src/test/java/google/registry/module/TestServlet.java +++ b/core/src/test/java/google/registry/module/TestServlet.java @@ -14,9 +14,6 @@ package google.registry.module; -import com.google.monitoring.metrics.MetricReporter; -import dagger.Lazy; - /** * Servlet used in the test server to handle routing. * @@ -27,9 +24,8 @@ public class TestServlet extends ServletBase { private static final TestRegistryComponent component = DaggerTestRegistryComponent.create(); private static final TestRequestHandler requestHandler = component.requestHandler(); - private static final Lazy metricReporter = component.metricReporter(); public TestServlet() { - super(requestHandler, metricReporter); + super(requestHandler); } } diff --git a/core/src/test/java/google/registry/util/FakeHttpServletRequestTest.java b/core/src/test/java/google/registry/util/FakeHttpServletRequestTest.java new file mode 100644 index 00000000000..4048371c508 --- /dev/null +++ b/core/src/test/java/google/registry/util/FakeHttpServletRequestTest.java @@ -0,0 +1,78 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.util; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link FakeHttpServletRequest}. */ +class FakeHttpServletRequestTest { + + private FakeHttpServletRequest request; + + @BeforeEach + void setUp() { + request = new FakeHttpServletRequest(); + } + + @Test + void testHeaders() { + request.setHeader("X-My-Header", "Value1"); + assertThat(request.getHeader("X-My-Header")).isEqualTo("Value1"); + assertThat(Collections.list(request.getHeaders("X-My-Header"))).containsExactly("Value1"); + assertThat(Collections.list(request.getHeaderNames())).containsExactly("X-My-Header"); + } + + @Test + void testIntHeader() { + request.setHeader("X-Int-Header", "42"); + assertThat(request.getIntHeader("X-Int-Header")).isEqualTo(42); + assertThat(request.getIntHeader("Missing")).isEqualTo(-1); + } + + @Test + void testMethodAndUri() { + request.setMethod("GET"); + request.setRequestUri("/test/path"); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getRequestURI()).isEqualTo("/test/path"); + } + + @Test + void testBodyAndReader() throws IOException { + String payload = "test body"; + request.setBody(payload.getBytes(UTF_8)); + + BufferedReader reader = request.getReader(); + assertThat(reader.readLine()).isEqualTo(payload); + assertThat(request.getContentLength()).isEqualTo(payload.length()); + } + + @Test + void testAttributes() { + request.setAttribute("attr1", "val1"); + assertThat(request.getAttribute("attr1")).isEqualTo("val1"); + assertThat(Collections.list(request.getAttributeNames())).containsExactly("attr1"); + + request.removeAttribute("attr1"); + assertThat(request.getAttribute("attr1")).isNull(); + } +} diff --git a/core/src/test/java/google/registry/util/FakeHttpServletResponseTest.java b/core/src/test/java/google/registry/util/FakeHttpServletResponseTest.java new file mode 100644 index 00000000000..3cced2b21d4 --- /dev/null +++ b/core/src/test/java/google/registry/util/FakeHttpServletResponseTest.java @@ -0,0 +1,105 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.util; + +import static com.google.common.truth.Truth.assertThat; +import static jakarta.servlet.http.HttpServletResponse.SC_FOUND; +import static jakarta.servlet.http.HttpServletResponse.SC_OK; +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.IOException; +import java.io.PrintWriter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link FakeHttpServletResponse}. */ +class FakeHttpServletResponseTest { + + private FakeHttpServletResponse response; + + @BeforeEach + void setUp() { + response = new FakeHttpServletResponse(); + } + + @Test + void testStatus() throws IOException { + assertThat(response.getStatus()).isEqualTo(SC_OK); + + response.setStatus(400); + assertThat(response.getStatus()).isEqualTo(400); + + response.sendError(500); + assertThat(response.getStatus()).isEqualTo(500); + + response.sendRedirect("/redirect"); + assertThat(response.getStatus()).isEqualTo(SC_FOUND); + } + + @Test + void testContentType() { + response.setContentType("text/plain"); + assertThat(response.getContentType()).isEqualTo("text/plain"); + } + + @Test + void testHeaders() { + response.setHeader("X-Test", "Val"); + assertThat(response.getHeader("X-Test")).isEqualTo("Val"); + assertThat(response.getHeaders("X-Test")).containsExactly("Val"); + assertThat(response.getHeaderNames()).containsExactly("X-Test"); + assertThat(response.containsHeader("X-Test")).isTrue(); + } + + @Test + void testWriterAndPayload() throws IOException { + PrintWriter writer = response.getWriter(); + writer.print("hello world"); + + assertThat(new String(response.getPayload(), UTF_8)).isEqualTo("hello world"); + } + + @Test + void testOutputStreamAndPayload() throws IOException { + byte[] data = "test bytes".getBytes(UTF_8); + response.getOutputStream().write(data); + + assertThat(response.getPayload()).isEqualTo(data); + } + + @Test + void testReset() throws IOException { + response.setStatus(404); + response.setHeader("A", "B"); + response.getWriter().print("content"); + + response.reset(); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + assertThat(response.getHeaderNames()).isEmpty(); + assertThat(response.getPayload()).isEmpty(); + } + + @Test + void testSendRedirectWithStatus() throws IOException { + response.sendRedirect("/redirect", 301, false); + assertThat(response.getStatus()).isEqualTo(301); + } + + @Test + void testSendEarlyHints() { + response.sendEarlyHints(); // Should not throw + } +} diff --git a/jetty/deploy-nomulus-for-env.sh b/jetty/deploy-nomulus-for-env.sh index 01675e20836..fabd0fb4044 100755 --- a/jetty/deploy-nomulus-for-env.sh +++ b/jetty/deploy-nomulus-for-env.sh @@ -33,7 +33,7 @@ line=$(gcloud container clusters list --project "${project}" | grep nomulus | gr parts=(${line}) echo "Updating cluster ${parts[0]} in location ${parts[1]}..." gcloud container fleet memberships get-credentials "${parts[0]}" --project "${project}" -for service in frontend backend pubapi console +for service in frontend backend pubapi console epp-server do sed s/GCP_PROJECT/"${project}"/g "./kubernetes/nomulus-${service}.yaml" | \ sed s/ENVIRONMENT/"${environment}"/g | \ diff --git a/jetty/gradle.lockfile b/jetty/gradle.lockfile index 25d4c0d7643..04c82daac59 100644 --- a/jetty/gradle.lockfile +++ b/jetty/gradle.lockfile @@ -72,10 +72,12 @@ com.google.api:gax-httpjson:2.82.0=deploy_jar,runtimeClasspath,testRuntimeClassp com.google.api:gax:2.82.0=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.apis:google-api-services-admin-directory:directory_v1-rev20260531-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.apis:google-api-services-bigquery:v2-rev20251012-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath +com.google.apis:google-api-services-cloudkms:v1-rev20260702-2.0.0=runtimeClasspath com.google.apis:google-api-services-cloudresourcemanager:v1-rev20250606-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.apis:google-api-services-dataflow:v1b3-rev20260615-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.apis:google-api-services-dns:v1-rev20260616-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath -com.google.apis:google-api-services-drive:v3-rev20260624-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath +com.google.apis:google-api-services-drive:v3-rev20260624-2.0.0=deploy_jar,testRuntimeClasspath +com.google.apis:google-api-services-drive:v3-rev20260712-2.0.0=runtimeClasspath com.google.apis:google-api-services-gmail:v1-rev20260525-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.apis:google-api-services-groupssettings:v1-rev20220614-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.apis:google-api-services-healthcare:v1-rev20240130-2.0.0=deploy_jar,runtimeClasspath,testRuntimeClasspath @@ -218,19 +220,31 @@ io.grpc:grpc-stub:1.81.0=deploy_jar,runtimeClasspath,testRuntimeClasspath io.grpc:grpc-testing:1.70.0=deploy_jar,runtimeClasspath,testRuntimeClasspath io.grpc:grpc-util:1.81.0=deploy_jar,runtimeClasspath,testRuntimeClasspath io.grpc:grpc-xds:1.81.0=deploy_jar,runtimeClasspath,testRuntimeClasspath -io.netty:netty-buffer:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.netty:netty-buffer:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-buffer:4.2.16.Final=runtimeClasspath +io.netty:netty-codec-base:4.2.16.Final=runtimeClasspath +io.netty:netty-codec-compression:4.2.16.Final=runtimeClasspath io.netty:netty-codec-http2:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec-http:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec-http:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-codec-http:4.2.16.Final=runtimeClasspath +io.netty:netty-codec-marshalling:4.2.16.Final=runtimeClasspath +io.netty:netty-codec-protobuf:4.2.16.Final=runtimeClasspath io.netty:netty-codec-socks:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath -io.netty:netty-codec:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath -io.netty:netty-common:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.netty:netty-codec:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-codec:4.2.16.Final=runtimeClasspath +io.netty:netty-common:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-common:4.2.16.Final=runtimeClasspath io.netty:netty-handler-proxy:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath -io.netty:netty-handler:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath -io.netty:netty-resolver:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.netty:netty-handler:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-handler:4.2.16.Final=runtimeClasspath +io.netty:netty-resolver:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-resolver:4.2.16.Final=runtimeClasspath io.netty:netty-tcnative-boringssl-static:2.0.52.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath io.netty:netty-tcnative-classes:2.0.52.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport-native-unix-common:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath -io.netty:netty-transport:4.1.132.Final=deploy_jar,runtimeClasspath,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.16.Final=runtimeClasspath +io.netty:netty-transport:4.1.132.Final=deploy_jar,testRuntimeClasspath +io.netty:netty-transport:4.2.16.Final=runtimeClasspath io.opencensus:opencensus-api:0.31.1=deploy_jar,runtimeClasspath,testRuntimeClasspath io.opencensus:opencensus-contrib-exemplar-util:0.31.1=deploy_jar,runtimeClasspath,testRuntimeClasspath io.opencensus:opencensus-contrib-grpc-metrics:0.31.1=deploy_jar,runtimeClasspath,testRuntimeClasspath diff --git a/jetty/kubernetes/nomulus-epp-server.yaml b/jetty/kubernetes/nomulus-epp-server.yaml new file mode 100644 index 00000000000..30db21b293e --- /dev/null +++ b/jetty/kubernetes/nomulus-epp-server.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: epp-server + annotations: + tag: "latest" +spec: + selector: + matchLabels: + service: epp-server + template: + metadata: + labels: + service: epp-server + spec: + serviceAccountName: nomulus + nodeSelector: + cloud.google.com/machine-family: c4 + containers: + - name: frontend + image: gcr.io/GCP_PROJECT/nomulus + ports: + - containerPort: 8080 + name: http + - containerPort: 30002 + name: epp + - containerPort: 30000 + name: health-check + resources: + requests: + # explicit pod-slots 0 is required in order to downgrade node + # class from performance, which has implicit pod-slots 1 + cloud.google.com/pod-slots: 0 + cpu: "1200m" + memory: "3Gi" + limits: + # explicit pod-slots 0 is required in order to downgrade node + # class from performance, which has implicit pod-slots 1 + cloud.google.com/pod-slots: 0 + cpu: "2000m" + memory: "6Gi" + args: [ENVIRONMENT] + env: + - name: TCP_SERVER_ENABLED + value: "true" + - name: POD_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: JETTY_WORKER_INSTANCE + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE_ID + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CONTAINER_NAME + value: frontend +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: epp-server +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: epp-server + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: epp-server + annotations: + cloud.google.com/l4-rbs: enabled + networking.gke.io/weighted-load-balancing: pods-per-node + # MUST RESERVE THESE NEW IPS IN GCP BEFORE DEPLOYING + networking.gke.io/load-balancer-ip-addresses: "EPP-v2-ipv6-main,EPP-v2-ipv4-main" +spec: + type: LoadBalancer + # Traffic is directly delivered to a node, preserving the original source IP. + externalTrafficPolicy: Local + ipFamilies: [IPv4, IPv6] + ipFamilyPolicy: RequireDualStack + selector: + service: epp-server + ports: + - port: 700 + targetPort: epp + name: epp diff --git a/jetty/src/main/webapp/WEB-INF/web.xml b/jetty/src/main/webapp/WEB-INF/web.xml index af4bfe6ead5..f2f2858a0de 100644 --- a/jetty/src/main/webapp/WEB-INF/web.xml +++ b/jetty/src/main/webapp/WEB-INF/web.xml @@ -2,6 +2,14 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd" version="6.0"> + + + google.registry.module.MetricsLifecycleListener + + + google.registry.module.EppServerLifecycleListener + + CspFilter