mirror of
https://github.com/google/nomulus
synced 2026-07-26 18:12:38 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc44e095d4 | ||
|
|
355e8ba423 | ||
|
|
dca5a010d5 | ||
|
|
147e7dabcb | ||
|
|
0b8025d3db | ||
|
|
9c4a558d7b | ||
|
|
7347dbf762 | ||
|
|
53b6044ff8 | ||
|
|
12ab7f47e2 | ||
|
|
d3a1d0709e | ||
|
|
f0fae9898b | ||
|
|
302faa21dd | ||
|
|
b187193e79 | ||
|
|
b1c6e07fa2 | ||
|
|
718ed29070 | ||
|
|
a2f0035f89 | ||
|
|
7e0489e5f9 | ||
|
|
d29a98bdd3 | ||
|
|
d6dd95b052 | ||
|
|
6b74924067 | ||
|
|
71e9bc95a7 | ||
|
|
bf54a0d0c4 | ||
|
|
2a04b9be9b | ||
|
|
675354ae65 | ||
|
|
21421726e0 | ||
|
|
192a7e6c4e | ||
|
|
542084eb70 | ||
|
|
54e605fa27 |
@@ -72,10 +72,19 @@ This document outlines foundational mandates, architectural patterns, and projec
|
||||
- **No CodeSearch:** This project is hosted on GitHub, not Google3. Do NOT use `mcp_Coding_search_for_files_codesearch` or other internal Google3 search tools.
|
||||
- **Local Grep:** Use local shell commands like `git grep` or `grep` via `run_shell_command` to search the codebase.
|
||||
|
||||
### 8. Git Operation Boundaries & Remote Push Prohibition
|
||||
- **Zero-Bundling Rule for Remote Transfers:** NEVER bundle remote transfer commands (`git push`, `repo upload`, `g4 upload`, `g4 mail`, `hg push`, `jj git push`) inside compound bash pipelines (`&&`, `||`, `;`) with local staging or commit commands (e.g., `git commit --amend && git push ...`). Every remote transfer command MUST be executed as a standalone, single-command `run_command` invocation.
|
||||
- **Mandatory Two-Step Handover Protocol:** When working on pull requests, changelists, or branches, strictly halt right at the local repository boundary (`git commit` / `g4 change`). Report the local commit SHA, `git status`, and `git diff` verification to the user, stop calling tools, and explicitly prompt: *"Local commit `[SHA]` is verified. Do you authorize running `git push origin [branch]` (or `--force-with-lease` when force-pushing an amended branch) to update the remote repository?"*
|
||||
- **Absolute Prohibition on Unsolicited Pushes:** NEVER propose or run any remote synchronization or push command (`git push`, `repo upload`, `g4 upload`) on your own volition without explicit, unambiguous textual authorization in the immediate turn from the user.
|
||||
- **Mandatory GitHub PR Description & Reviewable Synchronization:** Whenever pushing an amended commit to a branch associated with an active pull request, the agent MUST immediately synchronize the PR description on GitHub (`gh pr edit`). To do this safely:
|
||||
1. Check `gh pr view --json body` first to inspect existing content.
|
||||
2. Explicitly preserve any existing Reviewable bot links (`This change is https://reviewable.io/reviews/...`) when updating the body rather than blindly overwriting the entire description.
|
||||
|
||||
## Performance and Efficiency
|
||||
- **Turn Minimization:** Aim for "perfect" code in the first iteration. Iterative fixes for checkstyle or compilation errors consume significant context and time.
|
||||
- **Context Management:** Use sub-agents for batch refactoring or high-volume output tasks to keep the main session history lean and efficient.
|
||||
- **Code Formatting:** Do not write custom Python scripts or manual regex replacements to fix code formatting issues (e.g., unused imports, import ordering, line length). Instead, use the project's built-in formatting tools: run `./gradlew spotlessApply` to fix unused/unordered imports and `./gradlew javaIncrementalFormatApply` (or `google-java-format --replace <files>`) to automatically fix Java formatting and indentation errors.
|
||||
- **Programmatic Measurement for Markdown:** When writing Markdown files (`.md`)—and especially when formatting or line-wrapping them—you **MUST** explicitly use programmatic functions (such as `awk '{print length($0)}'` or Python `len(line)`) to measure the exact string length of every line from column 1, rather than making assumptions based on what appears in your context window.
|
||||
|
||||
## General Code Review Lessons & Avoidable Mistakes
|
||||
Based on historical PR reviews, avoid the following common mistakes:
|
||||
|
||||
@@ -61,6 +61,14 @@
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
"scripts": true,
|
||||
"styles": {
|
||||
"minify": true,
|
||||
"inlineCritical": false
|
||||
},
|
||||
"fonts": true
|
||||
},
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"sandbox": {
|
||||
@@ -82,6 +90,14 @@
|
||||
"with": "src/environments/environment.sandbox.ts"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
"scripts": true,
|
||||
"styles": {
|
||||
"minify": true,
|
||||
"inlineCritical": false
|
||||
},
|
||||
"fonts": true
|
||||
},
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"crash": {
|
||||
|
||||
Generated
-123
@@ -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",
|
||||
|
||||
@@ -288,8 +288,9 @@ export class BackendService {
|
||||
verifyRegistryLockRequest(
|
||||
lockVerificationCode: string
|
||||
): Observable<RegistryLockVerificationResponse> {
|
||||
return this.http.get<RegistryLockVerificationResponse>(
|
||||
`/console-api/registry-lock-verify?lockVerificationCode=${lockVerificationCode}`
|
||||
return this.http.post<RegistryLockVerificationResponse>(
|
||||
`/console-api/registry-lock-verify?lockVerificationCode=${lockVerificationCode}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+24
-19
@@ -86,10 +86,11 @@ 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,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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-rev20260712-2.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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 +269,23 @@ 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.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-compression:4.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-marshalling:4.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-protobuf:4.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-common:4.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.16.Final=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
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
|
||||
@@ -441,8 +446,8 @@ org.eclipse.jetty:jetty-server:12.1.11=testCompileClasspath,testRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-session:12.1.11=testCompileClasspath,testRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-util:12.1.11=testCompileClasspath,testRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-xml:12.1.11=testCompileClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-core:12.11.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-database-postgresql:12.11.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-core:13.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.flywaydb:flyway-database-postgresql:13.0.0=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.freemarker:freemarker:2.3.34=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.glassfish.jaxb:codemodel:4.0.9=jaxb
|
||||
org.glassfish.jaxb:jaxb-core:4.0.6=deploy_jar,nonprodRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||
@@ -561,16 +566,16 @@ org.xmlresolver:xmlresolver:5.2.2=checkstyle
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
redis.clients:jedis:8.0.0-beta1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.1.4=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.1.4=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.1.4=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.1.5=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.1.5=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.1.5=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-api:17.1.7=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-diagram:17.12.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-operations:17.12.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-postgresql:17.12.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-text:17.12.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-diagram:17.12.2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-operations:17.12.2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-postgresql:17.12.2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-text:17.12.2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-tools:17.1.7=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler-utility:17.1.7=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler:17.12.1=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
us.fatehi:schemacrawler:17.12.2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
xerces:xmlParserAPIs:2.6.2=compileClasspath,deploy_jar,nonprodCompileClasspath,nonprodRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=devtool,shadow
|
||||
|
||||
@@ -16,6 +16,7 @@ package google.registry.config;
|
||||
|
||||
import static com.google.common.base.Suppliers.memoize;
|
||||
import static com.google.common.collect.ImmutableList.toImmutableList;
|
||||
import static com.google.common.collect.ImmutableMap.toImmutableMap;
|
||||
import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
|
||||
import static google.registry.config.ConfigUtils.makeUrl;
|
||||
import static google.registry.util.DateTimeUtils.START_INSTANT;
|
||||
@@ -49,6 +50,7 @@ import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.time.DayOfWeek;
|
||||
@@ -59,6 +61,7 @@ import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
import org.joda.money.CurrencyUnit;
|
||||
|
||||
/**
|
||||
* Central clearing-house for all configuration.
|
||||
@@ -162,6 +165,12 @@ public final class RegistryConfig {
|
||||
return config.misc.isEmailSendingEnabled;
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Config("consoleIapServiceId")
|
||||
public static Optional<String> provideConsoleIapServiceId(RegistryConfigSettings config) {
|
||||
return Optional.ofNullable(config.registrarConsole.consoleIapServiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The e-mail address for general support. Used in the "contact-us" section of the registrar
|
||||
* console.
|
||||
@@ -1110,6 +1119,40 @@ public final class RegistryConfig {
|
||||
return config.registryPolicy.registryAdminClientId;
|
||||
}
|
||||
|
||||
/** Returns the total length of the Expiry Access Period. */
|
||||
@Provides
|
||||
@Config("domainExpiryAccessPeriodTotalLength")
|
||||
public static Duration provideDomainExpiryAccessPeriodTotalLength(
|
||||
RegistryConfigSettings config) {
|
||||
return Duration.ofSeconds(config.registryPolicy.domainExpiryAccessPeriod.totalLengthSeconds);
|
||||
}
|
||||
|
||||
/** Returns the length of each tier of the Expiry Access Period. */
|
||||
@Provides
|
||||
@Config("domainExpiryAccessPeriodTierLength")
|
||||
public static Duration provideDomainExpiryAccessPeriodTierLength(
|
||||
RegistryConfigSettings config) {
|
||||
return Duration.ofSeconds(config.registryPolicy.domainExpiryAccessPeriod.tierLengthSeconds);
|
||||
}
|
||||
|
||||
/** Returns a map of the fee for the first tier of the Expiry Access Period, by currency. */
|
||||
@Provides
|
||||
@Config("domainExpiryAccessPeriodInitialFee")
|
||||
public static ImmutableMap<CurrencyUnit, BigDecimal> provideDomainExpiryAccessPeriodInitialFee(
|
||||
RegistryConfigSettings config) {
|
||||
return config.registryPolicy.domainExpiryAccessPeriod.initialFee.entrySet().stream()
|
||||
.collect(toImmutableMap(entry -> CurrencyUnit.of(entry.getKey()), Entry::getValue));
|
||||
}
|
||||
|
||||
/** Returns a map of the fee for the last tier of the Expiry Access Period, by currency. */
|
||||
@Provides
|
||||
@Config("domainExpiryAccessPeriodFinalFee")
|
||||
public static ImmutableMap<CurrencyUnit, BigDecimal> provideDomainExpiryAccessPeriodFinalFee(
|
||||
RegistryConfigSettings config) {
|
||||
return config.registryPolicy.domainExpiryAccessPeriod.finalFee.entrySet().stream()
|
||||
.collect(toImmutableMap(entry -> CurrencyUnit.of(entry.getKey()), Entry::getValue));
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
static RegistryConfigSettings provideRegistryConfigSettings() {
|
||||
@@ -1484,6 +1527,103 @@ public final class RegistryConfig {
|
||||
.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)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
package google.registry.config;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -44,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 {
|
||||
@@ -94,6 +96,7 @@ public class RegistryConfigSettings {
|
||||
public String tmchCrlUrl;
|
||||
public String tmchMarksDbUrl;
|
||||
public String registryAdminClientId;
|
||||
public DomainExpiryAccessPeriod domainExpiryAccessPeriod;
|
||||
public String premiumTermsExportDisclaimer;
|
||||
public String reservedTermsExportDisclaimer;
|
||||
public String rdapTos;
|
||||
@@ -106,6 +109,13 @@ public class RegistryConfigSettings {
|
||||
public Set<String> noPollMessageOnDeletionRegistrarIds;
|
||||
}
|
||||
|
||||
public static class DomainExpiryAccessPeriod {
|
||||
public long totalLengthSeconds;
|
||||
public long tierLengthSeconds;
|
||||
public Map<String, BigDecimal> initialFee;
|
||||
public Map<String, BigDecimal> finalFee;
|
||||
}
|
||||
|
||||
/** Configuration for Hibernate. */
|
||||
public static class Hibernate {
|
||||
public boolean allowNestedTransactions;
|
||||
@@ -177,6 +187,7 @@ public class RegistryConfigSettings {
|
||||
|
||||
/** Configuration for the web-based registrar console. */
|
||||
public static class RegistrarConsole {
|
||||
public String consoleIapServiceId;
|
||||
public String dumFileName;
|
||||
public String supportPhoneNumber;
|
||||
public String supportEmailAddress;
|
||||
@@ -190,6 +201,42 @@ public class RegistryConfigSettings {
|
||||
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<String> userId;
|
||||
public int tokenAmount;
|
||||
public int refillSeconds;
|
||||
public int batchSize;
|
||||
}
|
||||
|
||||
public int refreshSeconds;
|
||||
public QuotaGroup defaultQuota;
|
||||
public List<QuotaGroup> customQuota;
|
||||
}
|
||||
|
||||
/** Miscellaneous configuration that doesn't quite fit in anywhere else. */
|
||||
|
||||
@@ -88,6 +88,28 @@ registryPolicy:
|
||||
# registrar
|
||||
registryAdminClientId: TheRegistrar
|
||||
|
||||
# Configuration for the Expiry Access Period (XAP) that occurs immediately
|
||||
# following completion of a domain's deletion. This needs to be enabled on a
|
||||
# per-TLD basis as well.
|
||||
domainExpiryAccessPeriod:
|
||||
# The length of the total expiry access period; defaults to 10 days.
|
||||
totalLengthSeconds: 864000
|
||||
# The length of each tier during the expiry access period; defaults to 1
|
||||
# hour. The XAP fee remains constant for the duration of each tier. The
|
||||
# total length must be evenly divisible by the tier length, i.e. there
|
||||
# should be a whole number of tiers all the same length.
|
||||
tierLengthSeconds: 3600
|
||||
# The initial fee for the first tier when the expiry access period starts,
|
||||
# specified for each currency this registry platform uses.
|
||||
initialFee:
|
||||
USD: 100000
|
||||
JPY: 10000000
|
||||
# The final fee for the last tier when the expiry access period ends,
|
||||
# specified for each currency this registry platform uses.
|
||||
finalFee:
|
||||
USD: 10
|
||||
JPY: 1000
|
||||
|
||||
# Disclaimer at the top of the exported premium terms list.
|
||||
premiumTermsExportDisclaimer: |
|
||||
This list contains domains for the TLD offered at a premium price. This
|
||||
@@ -368,6 +390,11 @@ rde:
|
||||
sshIdentityEmailAddress: rde@example.com
|
||||
|
||||
registrarConsole:
|
||||
# IAP service ID of the HTTP(s) load balancer that serves the console-api backend.
|
||||
# Users created in the console will be bound only to this service to limit access.
|
||||
# This is only applicable to non-group-based auth (if there is no consoleUserGroupEmailAddress)
|
||||
consoleIapServiceId: null
|
||||
|
||||
# DUM download file name, excluding the extension
|
||||
dumFileName: dum_file_name
|
||||
|
||||
@@ -398,6 +425,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.
|
||||
|
||||
+10
@@ -323,4 +323,14 @@
|
||||
<schedule>*/5 * * * *</schedule>
|
||||
</task>
|
||||
|
||||
<task>
|
||||
<url><![CDATA[/_dr/task/syncRemoteCache]]></url>
|
||||
<name>syncRemoteCache</name>
|
||||
<description>
|
||||
Syncs remote (Valkey/Redis) EPP resource caches with changes made recently.
|
||||
</description>
|
||||
<!-- Runs every 5 minutes. RDAP needs to be updated within 60 minutes of changes. -->
|
||||
<schedule>*/5 * * * *</schedule>
|
||||
</task>
|
||||
|
||||
</entries>
|
||||
|
||||
@@ -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<Provider<? extends ChannelHandler>> handlerProviders) {
|
||||
return Protocol.frontendBuilder()
|
||||
.name(PROTOCOL_NAME)
|
||||
.port(eppPort)
|
||||
.handlerProviders(handlerProviders)
|
||||
.hasBackend(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Provides
|
||||
@EppProtocol
|
||||
static ImmutableList<Provider<? extends ChannelHandler>> provideHandlerProviders(
|
||||
Provider<EppProxyProtocolHandler> proxyProtocolHandlerProvider,
|
||||
@EppProtocol Provider<SslServerInitializer<NioSocketChannel>> sslServerInitializerProvider,
|
||||
@EppProtocol Provider<ReadTimeoutHandler> readTimeoutHandlerProvider,
|
||||
Provider<LengthFieldBasedFrameDecoder> lengthFieldBasedFrameDecoderProvider,
|
||||
Provider<LengthFieldPrepender> lengthFieldPrependerProvider,
|
||||
Provider<EppServiceHandler> 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 <hello> message file.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
@EppProtocol
|
||||
static SslServerInitializer<NioSocketChannel> provideSslServerInitializer(
|
||||
SslProvider sslProvider,
|
||||
Supplier<PrivateKey> privateKeySupplier,
|
||||
Supplier<ImmutableList<X509Certificate>> certificatesSupplier) {
|
||||
return new SslServerInitializer<>(
|
||||
true, false, sslProvider, privateKeySupplier, certificatesSupplier);
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@CommandQuota
|
||||
static QuotaManager provideCommandQuotaManager(
|
||||
@Config("eppServerQuota") RegistryConfigSettings.Quota quota, Optional<UnifiedJedis> jedis) {
|
||||
return new QuotaManager(quota, jedis.orElse(null), "command");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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 google.registry.eppserver.handler.EppServiceHandler;
|
||||
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<FrontendProtocol> protocols;
|
||||
private final HashMap<Integer, Channel> 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<NioSocketChannel> {
|
||||
@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<Provider<? extends ChannelHandler>> handlerProviders) {
|
||||
for (Provider<? extends ChannelHandler> handlerProvider : handlerProviders) {
|
||||
ChannelHandler handler = handlerProvider.get();
|
||||
String handlerName = handler.getClass().getSimpleName();
|
||||
if (handler.getClass() == EppServiceHandler.class) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<GoogleCredentials> 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<String> 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<FrontendProtocol> protocols();
|
||||
|
||||
MetricReporter metricReporter();
|
||||
|
||||
Optional<UnifiedJedis> jedis();
|
||||
}
|
||||
}
|
||||
@@ -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<Provider<? extends ChannelHandler>> handlerProviders) {
|
||||
return Protocol.frontendBuilder()
|
||||
.name(PROTOCOL_NAME)
|
||||
.port(healthCheckPort)
|
||||
.handlerProviders(handlerProviders)
|
||||
.hasBackend(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Provides
|
||||
@HealthCheckProtocol
|
||||
static ImmutableList<Provider<? extends ChannelHandler>> provideHandlerProviders(
|
||||
@HealthCheckProtocol Provider<HealthCheckHandler> 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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>When running locally, the type of the monitored resource is set to {@code global}, otherwise
|
||||
* it is {@code gke_container}.
|
||||
*
|
||||
* @see <a
|
||||
* href="https://cloud.google.com/monitoring/custom-metrics/creating-metrics#which-resource">
|
||||
* Choosing a monitored resource type</a>
|
||||
*/
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -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> 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<Provider<? extends ChannelHandler>> 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 <B> builder of the concrete subtype of {@link Protocol}.
|
||||
* @param <P> type of the concrete subtype of {@link Protocol}.
|
||||
*/
|
||||
abstract class Builder<B extends Builder<B, P>, P extends Protocol> {
|
||||
|
||||
public abstract B name(String value);
|
||||
|
||||
public abstract B port(int port);
|
||||
|
||||
public abstract B handlerProviders(ImmutableList<Provider<? extends ChannelHandler>> value);
|
||||
|
||||
public abstract P build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection parameters for a connection from the client to the proxy.
|
||||
*
|
||||
* <p>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<Builder, FrontendProtocol> {
|
||||
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.
|
||||
*
|
||||
* <p>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<Builder, BackendProtocol> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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 <a href="https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt">The PROXY protocol</a>
|
||||
*/
|
||||
public class EppProxyProtocolHandler extends ByteToMessageDecoder {
|
||||
|
||||
/** Key used to retrieve origin IP address from a channel's attribute. */
|
||||
public static final AttributeKey<String> 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.
|
||||
*
|
||||
* <p>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<Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<ByteBuf> {
|
||||
|
||||
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("<clID>([^<]+)</clID>", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
public static final AttributeKey<String> 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<String> 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<String> 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<X509Certificate> certPromise = ctx.channel().attr(CLIENT_CERTIFICATE_PROMISE_KEY).get();
|
||||
if (certPromise != null) {
|
||||
certPromise.addListener(
|
||||
(Promise<X509Certificate> promise) -> {
|
||||
if (promise.isSuccess()) {
|
||||
ctx.executor().execute(() -> onSslHandshakeComplete(ctx, promise.getNow()));
|
||||
} 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 <greeting>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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:
|
||||
*
|
||||
* <pre>
|
||||
* nomulus -e production list_registrars -f clientCertificateHash | grep $HASH
|
||||
* </pre>
|
||||
*/
|
||||
protected static final ImmutableSet<LabelDescriptor> 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.
|
||||
*
|
||||
* <p>This should only be used in tests to reset states. Production code should not call this
|
||||
* method.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
abstract void resetMetrics();
|
||||
}
|
||||
@@ -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<ImmutableList<String>, ChannelGroup> activeConnections =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
static final Metric<Long> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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 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<String, Integer> ipConnections = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, Integer> 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<String, Integer> map, int limit) {
|
||||
boolean[] acquired = new boolean[1];
|
||||
map.compute(
|
||||
key,
|
||||
(k, v) -> {
|
||||
if (v == null) {
|
||||
acquired[0] = true;
|
||||
return 1;
|
||||
}
|
||||
if (v < limit) {
|
||||
acquired[0] = true;
|
||||
return v + 1;
|
||||
}
|
||||
acquired[0] = false;
|
||||
return v;
|
||||
});
|
||||
return acquired[0];
|
||||
}
|
||||
|
||||
private void release(String key, ConcurrentHashMap<String, Integer> map) {
|
||||
map.computeIfPresent(
|
||||
key,
|
||||
(k, v) -> {
|
||||
if (v <= 1) {
|
||||
return null;
|
||||
}
|
||||
return v - 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String, QuotaGroup> customQuotas;
|
||||
|
||||
public QuotaManager(Quota quota, @Nullable UnifiedJedis jedis, String quotaNamespace) {
|
||||
this.jedis = jedis;
|
||||
this.quotaNamespace = quotaNamespace;
|
||||
this.defaultQuota = quota.defaultQuota;
|
||||
|
||||
ImmutableMap.Builder<String, QuotaGroup> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
package google.registry.flows;
|
||||
|
||||
import static com.google.common.collect.ImmutableSet.toImmutableSet;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.toLogSafeLabel;
|
||||
import static java.util.Collections.EMPTY_LIST;
|
||||
|
||||
import com.google.common.base.Ascii;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
@@ -90,9 +90,7 @@ public class FlowReporter {
|
||||
*/
|
||||
private static Optional<String> extractTld(String domainName) {
|
||||
int index = domainName.indexOf('.');
|
||||
return index == -1
|
||||
? Optional.empty()
|
||||
: Optional.of(Ascii.toLowerCase(domainName.substring(index + 1)));
|
||||
return index == -1 ? Optional.empty() : toLogSafeLabel(domainName.substring(index + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,7 @@ import google.registry.model.transfer.TransferStatus;
|
||||
import google.registry.persistence.VKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -100,8 +101,30 @@ public final class ResourceFlowUtils {
|
||||
|
||||
public static <R extends EppResource> void verifyResourceDoesNotExist(
|
||||
Class<R> clazz, String targetId, Instant now, String registrarId) throws EppException {
|
||||
Optional<R> resource = ForeignKeyUtils.loadResource(clazz, targetId, now);
|
||||
verifyResourceDoesNotExist(clazz, targetId, now, Duration.ZERO, registrarId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a resource with the specified type and id did not exist at the specified time.
|
||||
*
|
||||
* <p>This will throw an exception if the resource exists with a deletionTime greater than the
|
||||
* specified time (i.e. a registrar is attempting to create a duplicate domain, host, or contact).
|
||||
* If the resource had existed within the specified lookback window, but is not active now, i.e.
|
||||
* it is recently deleted, then don't throw an exception, but do return the recently deleted
|
||||
* resource for further processing (this is used by the Expiry Access Period).
|
||||
*
|
||||
* <p>If there is no need to specially handle the situation of recently deleted resources, then
|
||||
* simply pass a lookback duration of {@link Duration#ZERO}.
|
||||
*/
|
||||
public static <R extends EppResource> Optional<R> verifyResourceDoesNotExist(
|
||||
Class<R> clazz, String targetId, Instant now, Duration lookback, String registrarId)
|
||||
throws EppException {
|
||||
Instant startOfWindow = now.minus(lookback);
|
||||
Optional<R> resource = ForeignKeyUtils.loadResource(clazz, targetId, startOfWindow);
|
||||
if (resource.isPresent()) {
|
||||
if (resource.get().getDeletionTime().isBefore(now)) {
|
||||
return resource;
|
||||
}
|
||||
// These are similar exceptions, but we can track them internally as log-based metrics.
|
||||
if (Objects.equals(registrarId, resource.get().getPersistedCurrentSponsorRegistrarId())) {
|
||||
throw new ResourceAlreadyExistsForThisClientException(targetId);
|
||||
@@ -109,6 +132,7 @@ public final class ResourceFlowUtils {
|
||||
throw new ResourceCreateContentionException(targetId);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Check that the given AuthInfo is present for a resource being transferred. */
|
||||
|
||||
@@ -260,7 +260,7 @@ public class TlsCredentials implements TransportCredentials {
|
||||
static Optional<InetAddress> provideIpAddress(HttpServletRequest req) {
|
||||
Optional<String> clientAddress = extractOptionalHeader(req, ProxyHttpHeaders.IP_ADDRESS);
|
||||
Optional<String> fallbackClientAddress =
|
||||
extractOptionalHeader(req, ProxyHttpHeaders.IP_ADDRESS);
|
||||
extractOptionalHeader(req, ProxyHttpHeaders.FALLBACK_IP_ADDRESS);
|
||||
Optional<InetAddress> clientInetAddr = clientAddress.map(TlsCredentials::parseInetAddress);
|
||||
return clientInetAddr.isPresent()
|
||||
? clientInetAddr
|
||||
|
||||
@@ -25,6 +25,7 @@ import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccoun
|
||||
import static google.registry.flows.domain.DomainFlowUtils.getReservationTypes;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.handleFeeRequest;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isAnchorTenant;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isRegisterBsaCreate;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isReserved;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isValidReservedCreate;
|
||||
@@ -75,6 +76,7 @@ import google.registry.model.eppoutput.CheckData.DomainCheck;
|
||||
import google.registry.model.eppoutput.CheckData.DomainCheckData;
|
||||
import google.registry.model.eppoutput.EppResponse;
|
||||
import google.registry.model.eppoutput.EppResponse.ResponseExtension;
|
||||
import google.registry.model.registrar.Registrar;
|
||||
import google.registry.model.reporting.IcannReportingTypes.ActivityReportField;
|
||||
import google.registry.model.tld.Tld;
|
||||
import google.registry.model.tld.Tld.TldState;
|
||||
@@ -82,6 +84,7 @@ import google.registry.model.tld.label.ReservationType;
|
||||
import google.registry.persistence.VKey;
|
||||
import google.registry.util.Clock;
|
||||
import jakarta.inject.Inject;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
@@ -138,6 +141,10 @@ public final class DomainCheckFlow implements TransactionalFlow {
|
||||
@Config("maxChecks")
|
||||
int maxChecks;
|
||||
|
||||
@Inject
|
||||
@Config("domainExpiryAccessPeriodTotalLength")
|
||||
Duration domainExpiryAccessPeriodTotalLength;
|
||||
|
||||
@Inject @Superuser boolean isSuperuser;
|
||||
@Inject Clock clock;
|
||||
@Inject EppResponse.Builder responseBuilder;
|
||||
@@ -249,7 +256,14 @@ public final class DomainCheckFlow implements TransactionalFlow {
|
||||
return Optional.of(e.getMessage());
|
||||
}
|
||||
return getMessageForCheckWithToken(
|
||||
idn, existingDomains, bsaBlockedDomainNames, tldStates, token);
|
||||
idn, existingDomains, bsaBlockedDomainNames, tldStates, token, now);
|
||||
}
|
||||
|
||||
private static Optional<Domain> loadDomainIfInXap(
|
||||
String domainName, Instant now, Duration domainExpiryAccessPeriodTotalLength) {
|
||||
return ForeignKeyUtils.loadResource(
|
||||
Domain.class, domainName, now.minus(domainExpiryAccessPeriodTotalLength))
|
||||
.filter(domain -> isDomainEligibleForXap(domain, Tld.get(domain.getTld()), now));
|
||||
}
|
||||
|
||||
private Optional<String> getMessageForCheckWithToken(
|
||||
@@ -257,10 +271,18 @@ public final class DomainCheckFlow implements TransactionalFlow {
|
||||
ImmutableMap<String, VKey<Domain>> existingDomains,
|
||||
ImmutableSet<InternetDomainName> bsaBlockedDomains,
|
||||
ImmutableMap<String, TldState> tldStates,
|
||||
Optional<AllocationToken> allocationToken) {
|
||||
Optional<AllocationToken> allocationToken,
|
||||
Instant now) {
|
||||
if (existingDomains.containsKey(domainName.toString())) {
|
||||
return Optional.of("In use");
|
||||
}
|
||||
Tld tld = Tld.get(domainName.parent().toString());
|
||||
if (tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED
|
||||
&& !Registrar.loadByRegistrarIdCached(registrarId).get().getExpiryAccessPeriodEnabled()
|
||||
&& loadDomainIfInXap(domainName.toString(), now, domainExpiryAccessPeriodTotalLength)
|
||||
.isPresent()) {
|
||||
return Optional.of("Reserved");
|
||||
}
|
||||
TldState tldState = tldStates.get(domainName.parent().toString());
|
||||
if (isReserved(domainName, START_DATE_SUNRISE.equals(tldState))) {
|
||||
if (!isValidReservedCreate(domainName, allocationToken)
|
||||
@@ -339,16 +361,27 @@ public final class DomainCheckFlow implements TransactionalFlow {
|
||||
.build());
|
||||
continue;
|
||||
}
|
||||
Optional<Domain> domainForFee = domain;
|
||||
boolean isAvailable = availableDomains.contains(domainName);
|
||||
if (isAvailable
|
||||
&& feeCheckItem.getCommandName().equals(FeeQueryCommandExtensionItem.CommandName.CREATE)
|
||||
&& tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED) {
|
||||
Optional<Domain> recentlyDeletedDomain =
|
||||
loadDomainIfInXap(domainName, now, domainExpiryAccessPeriodTotalLength);
|
||||
if (recentlyDeletedDomain.isPresent()) {
|
||||
domainForFee = recentlyDeletedDomain;
|
||||
}
|
||||
}
|
||||
handleFeeRequest(
|
||||
feeCheckItem,
|
||||
builder,
|
||||
domainNames.get(domainName),
|
||||
domain,
|
||||
domainForFee,
|
||||
feeCheck.getCurrency(),
|
||||
now,
|
||||
pricingLogic,
|
||||
token,
|
||||
availableDomains.contains(domainName),
|
||||
isAvailable,
|
||||
recurrences.getOrDefault(domainName, null));
|
||||
// In the case of a registrar that is running a tiered pricing promotion, we issue two
|
||||
// responses for the CREATE fee check command: one (the default response) with the
|
||||
|
||||
@@ -18,6 +18,7 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet;
|
||||
import static google.registry.dns.DnsUtils.requestDomainDnsRefresh;
|
||||
import static google.registry.flows.FlowUtils.persistEntityChanges;
|
||||
import static google.registry.flows.FlowUtils.validateRegistrarIsLoggedIn;
|
||||
import static google.registry.flows.ResourceFlowUtils.verifyResourceDoesNotExist;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.COLLISION_MESSAGE;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.checkAllowedAccessToTld;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccount;
|
||||
@@ -25,6 +26,7 @@ import static google.registry.flows.domain.DomainFlowUtils.cloneAndLinkReference
|
||||
import static google.registry.flows.domain.DomainFlowUtils.createFeeCreateResponse;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.getReservationTypes;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isAnchorTenant;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isReserved;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isValidReservedCreate;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.validateCreateCommandContactsAndNameservers;
|
||||
@@ -58,6 +60,7 @@ import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.net.InternetDomainName;
|
||||
import google.registry.config.RegistryConfig;
|
||||
import google.registry.config.RegistryConfig.Config;
|
||||
import google.registry.flows.EppException;
|
||||
import google.registry.flows.EppException.CommandUseErrorException;
|
||||
import google.registry.flows.EppException.ParameterValuePolicyErrorException;
|
||||
@@ -71,6 +74,7 @@ import google.registry.flows.custom.DomainCreateFlowCustomLogic;
|
||||
import google.registry.flows.custom.DomainCreateFlowCustomLogic.BeforeResponseParameters;
|
||||
import google.registry.flows.custom.DomainCreateFlowCustomLogic.BeforeResponseReturnData;
|
||||
import google.registry.flows.custom.EntityChanges;
|
||||
import google.registry.flows.domain.DomainFlowUtils.DomainReservedException;
|
||||
import google.registry.flows.domain.DomainFlowUtils.RegistrantProhibitedException;
|
||||
import google.registry.flows.domain.token.AllocationTokenFlowUtils;
|
||||
import google.registry.flows.exceptions.ContactsProhibitedException;
|
||||
@@ -107,6 +111,7 @@ import google.registry.model.eppoutput.EppResponse;
|
||||
import google.registry.model.poll.PendingActionNotificationResponse.DomainPendingActionNotificationResponse;
|
||||
import google.registry.model.poll.PollMessage;
|
||||
import google.registry.model.poll.PollMessage.Autorenew;
|
||||
import google.registry.model.registrar.Registrar;
|
||||
import google.registry.model.reporting.DomainTransactionRecord;
|
||||
import google.registry.model.reporting.DomainTransactionRecord.TransactionReportField;
|
||||
import google.registry.model.reporting.HistoryEntry;
|
||||
@@ -123,6 +128,7 @@ import jakarta.inject.Inject;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.joda.money.Money;
|
||||
|
||||
/**
|
||||
* An EPP flow that creates a new domain resource.
|
||||
@@ -219,6 +225,10 @@ public final class DomainCreateFlow implements MutatingFlow {
|
||||
@Inject DomainPricingLogic pricingLogic;
|
||||
@Inject DomainDeletionTimeCache domainDeletionTimeCache;
|
||||
|
||||
@Inject
|
||||
@Config("domainExpiryAccessPeriodTotalLength")
|
||||
Duration domainExpiryAccessPeriodTotalLength;
|
||||
|
||||
@Inject
|
||||
DomainCreateFlow() {}
|
||||
|
||||
@@ -245,6 +255,13 @@ public final class DomainCreateFlow implements MutatingFlow {
|
||||
InternetDomainName domainName = validateDomainName(command.getDomainName());
|
||||
String domainLabel = domainName.parts().getFirst();
|
||||
Tld tld = Tld.get(domainName.parent().toString());
|
||||
Duration lookback =
|
||||
tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED
|
||||
? domainExpiryAccessPeriodTotalLength
|
||||
: Duration.ZERO;
|
||||
Optional<Domain> recentlyDeletedDomain =
|
||||
verifyResourceDoesNotExist(Domain.class, targetId, now, lookback, registrarId)
|
||||
.filter(domain -> isDomainEligibleForXap(domain, tld, now));
|
||||
validateCreateCommandContactsAndNameservers(command, tld, domainName);
|
||||
TldState tldState = tld.getTldState(now);
|
||||
Optional<LaunchCreateExtension> launchCreate =
|
||||
@@ -280,6 +297,10 @@ public final class DomainCreateFlow implements MutatingFlow {
|
||||
if (!isSuperuser) {
|
||||
checkAllowedAccessToTld(registrarId, tld.getTldStr());
|
||||
checkHasBillingAccount(registrarId, tld.getTldStr());
|
||||
if (recentlyDeletedDomain.isPresent()
|
||||
&& !Registrar.loadByRegistrarIdCached(registrarId).get().getExpiryAccessPeriodEnabled()) {
|
||||
throw new DomainReservedException(domainName.toString());
|
||||
}
|
||||
boolean isValidReservedCreate = isValidReservedCreate(domainName, allocationToken);
|
||||
ClaimsList claimsList = ClaimsListDao.get();
|
||||
verifyIsGaOrSpecialCase(
|
||||
@@ -327,7 +348,14 @@ public final class DomainCreateFlow implements MutatingFlow {
|
||||
eppInput.getSingleExtension(FeeCreateCommandExtension.class);
|
||||
FeesAndCredits feesAndCredits =
|
||||
pricingLogic.getCreatePrice(
|
||||
tld, targetId, now, years, isAnchorTenant, isSunriseCreate, allocationToken);
|
||||
tld,
|
||||
targetId,
|
||||
now,
|
||||
recentlyDeletedDomain,
|
||||
years,
|
||||
isAnchorTenant,
|
||||
isSunriseCreate,
|
||||
allocationToken);
|
||||
validateFeeChallenge(feeCreate, feesAndCredits, defaultTokenUsed);
|
||||
Optional<SecDnsCreateExtension> secDnsCreate =
|
||||
validateSecDnsExtension(eppInput.getSingleExtension(SecDnsCreateExtension.class));
|
||||
@@ -358,7 +386,15 @@ public final class DomainCreateFlow implements MutatingFlow {
|
||||
entitiesToInsert.add(createBillingEvent, autorenewBillingEvent, autorenewPollMessage);
|
||||
// Bill for EAP cost, if any.
|
||||
if (!feesAndCredits.getEapCost().isZero()) {
|
||||
entitiesToInsert.add(createEapBillingEvent(feesAndCredits, createBillingEvent));
|
||||
entitiesToInsert.add(
|
||||
createAdditionalBillingEvent(
|
||||
Reason.FEE_EARLY_ACCESS, feesAndCredits.getEapCost(), createBillingEvent));
|
||||
}
|
||||
// Bill for XAP cost, if any.
|
||||
if (!feesAndCredits.getXapCost().isZero()) {
|
||||
entitiesToInsert.add(
|
||||
createAdditionalBillingEvent(
|
||||
Reason.FEE_EXPIRY_ACCESS, feesAndCredits.getXapCost(), createBillingEvent));
|
||||
}
|
||||
|
||||
ImmutableSet<ReservationType> reservationTypes = getReservationTypes(domainName);
|
||||
@@ -434,7 +470,14 @@ public final class DomainCreateFlow implements MutatingFlow {
|
||||
FeesAndCredits responseFeesAndCredits =
|
||||
shouldShowDefaultPrice
|
||||
? pricingLogic.getCreatePrice(
|
||||
tld, targetId, now, years, isAnchorTenant, isSunriseCreate, Optional.empty())
|
||||
tld,
|
||||
targetId,
|
||||
now,
|
||||
recentlyDeletedDomain,
|
||||
years,
|
||||
isAnchorTenant,
|
||||
isSunriseCreate,
|
||||
Optional.empty())
|
||||
: feesAndCredits;
|
||||
|
||||
BeforeResponseReturnData responseData =
|
||||
@@ -656,14 +699,14 @@ public final class DomainCreateFlow implements MutatingFlow {
|
||||
}
|
||||
}
|
||||
|
||||
private static BillingEvent createEapBillingEvent(
|
||||
FeesAndCredits feesAndCredits, BillingEvent createBillingEvent) {
|
||||
private static BillingEvent createAdditionalBillingEvent(
|
||||
Reason reason, Money cost, BillingEvent createBillingEvent) {
|
||||
return new BillingEvent.Builder()
|
||||
.setReason(Reason.FEE_EARLY_ACCESS)
|
||||
.setReason(reason)
|
||||
.setTargetId(createBillingEvent.getTargetId())
|
||||
.setRegistrarId(createBillingEvent.getRegistrarId())
|
||||
.setPeriodYears(1)
|
||||
.setCost(feesAndCredits.getEapCost())
|
||||
.setCost(cost)
|
||||
.setEventTime(createBillingEvent.getEventTime())
|
||||
.setBillingTime(createBillingEvent.getBillingTime())
|
||||
.setFlags(createBillingEvent.getFlags())
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
package google.registry.flows.domain;
|
||||
|
||||
import static com.google.common.base.Ascii.toLowerCase;
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static com.google.common.base.Preconditions.checkState;
|
||||
import static com.google.common.base.Strings.emptyToNull;
|
||||
@@ -175,6 +176,27 @@ public class DomainFlowUtils {
|
||||
/** Maximum number of characters in a domain label, from RFC 2181. */
|
||||
private static final int MAX_LABEL_SIZE = 63;
|
||||
|
||||
/// Given a tld name or domain label, returns a label that can be safely logged, or
|
||||
/// `Optional.empty()` if `label` is null or blank.
|
||||
///
|
||||
/// If `label` contains disallowed characters, they are replaced with '-'. If the resulting
|
||||
/// string has a valid length, it is returned as is; if not, the first {@link #MAX_LABEL_SIZE}
|
||||
/// chars plus a suffix of `...` is returned.
|
||||
///
|
||||
/// This method is mainly for use by `FlowReporter#recordToLogs()` to ensure that the logs
|
||||
/// can be safely and correctly queried by SQL queries. See b/534931586 for more information.
|
||||
public static Optional<String> toLogSafeLabel(String label) {
|
||||
if (label == null || label.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var newLabel = ALLOWED_CHARS.negate().replaceFrom(toLowerCase(label), '-');
|
||||
if (newLabel.length() <= MAX_LABEL_SIZE) {
|
||||
return Optional.of(newLabel);
|
||||
} else {
|
||||
return Optional.of(newLabel.substring(0, MAX_LABEL_SIZE) + "...");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns parsed version of {@code name} if domain name label follows our naming rules and is
|
||||
* under one of the given allowed TLDs.
|
||||
@@ -642,6 +664,7 @@ public class DomainFlowUtils {
|
||||
tld,
|
||||
domainNameString,
|
||||
now,
|
||||
domain,
|
||||
years,
|
||||
isAnchorTenant(domainName, allocationToken, Optional.empty()),
|
||||
isSunrise,
|
||||
@@ -765,6 +788,9 @@ public class DomainFlowUtils {
|
||||
if (!feesAndCredits.getEapCost().isZero()) {
|
||||
throw new FeesRequiredDuringEarlyAccessProgramException(feesAndCredits.getEapCost());
|
||||
}
|
||||
if (!feesAndCredits.getXapCost().isZero()) {
|
||||
throw new FeesRequiredDuringExpiryAccessPeriodException(feesAndCredits.getXapCost());
|
||||
}
|
||||
if (feesAndCredits.getTotalCost().isZero() || !feesAndCredits.isFeeExtensionRequired()) {
|
||||
return;
|
||||
}
|
||||
@@ -1168,6 +1194,32 @@ public class DomainFlowUtils {
|
||||
.getResultList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the domain was deleted during its Add Grace Period (AGP).
|
||||
*
|
||||
* <p>Per policy, domains deleted during AGP are not subject to Expiry Access Period (XAP) fees or
|
||||
* reservation restrictions upon subsequent re-registration.
|
||||
*/
|
||||
public static boolean wasDeletedDuringAddGracePeriod(Domain domain, Tld tld) {
|
||||
if (domain.getCreationTime() == null || domain.getDeletionTime() == null) {
|
||||
return false;
|
||||
}
|
||||
return !domain
|
||||
.getDeletionTime()
|
||||
.isAfter(domain.getCreationTime().plus(tld.getAddGracePeriodLength()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the domain was deleted before {@code now} and is eligible for Expiry Access
|
||||
* Period (XAP) evaluation.
|
||||
*/
|
||||
public static boolean isDomainEligibleForXap(Domain domain, Tld tld, Instant now) {
|
||||
if (domain.getDeletionTime() == null || !domain.getDeletionTime().isBefore(now)) {
|
||||
return false;
|
||||
}
|
||||
return !wasDeletedDuringAddGracePeriod(domain, tld);
|
||||
}
|
||||
|
||||
/** Resource linked to this domain does not exist. */
|
||||
static class LinkedResourcesDoNotExistException extends ObjectDoesNotExistException {
|
||||
public LinkedResourcesDoNotExistException(Class<?> type, ImmutableSet<String> resourceIds) {
|
||||
@@ -1355,6 +1407,18 @@ public class DomainFlowUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fees must be explicitly acknowledged when creating domains during the Expiry Access Period. */
|
||||
static class FeesRequiredDuringExpiryAccessPeriodException
|
||||
extends RequiredParameterMissingException {
|
||||
|
||||
public FeesRequiredDuringExpiryAccessPeriodException(Money expectedFee) {
|
||||
super(
|
||||
"Fees must be explicitly acknowledged when creating domains "
|
||||
+ "during the Expiry Access Period. The XAP fee is: "
|
||||
+ expectedFee);
|
||||
}
|
||||
}
|
||||
|
||||
/** The 'grace-period', 'applied' and 'refundable' fields are disallowed by server policy. */
|
||||
static class UnsupportedFeeAttributeException extends UnimplementedOptionException {
|
||||
UnsupportedFeeAttributeException() {
|
||||
|
||||
@@ -15,13 +15,18 @@
|
||||
package google.registry.flows.domain;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkArgument;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
|
||||
import static google.registry.flows.domain.DomainFlowUtils.zeroInCurrency;
|
||||
import static google.registry.flows.domain.token.AllocationTokenFlowUtils.discountTokenInvalidForPremiumName;
|
||||
import static google.registry.pricing.PricingEngineProxy.getPricesForDomainName;
|
||||
import static google.registry.util.PreconditionsUtils.checkArgumentPresent;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.Range;
|
||||
import com.google.common.flogger.FluentLogger;
|
||||
import com.google.common.net.InternetDomainName;
|
||||
import google.registry.config.RegistryConfig;
|
||||
import google.registry.config.RegistryConfig.Config;
|
||||
import google.registry.flows.EppException;
|
||||
import google.registry.flows.custom.DomainPricingCustomLogic;
|
||||
import google.registry.flows.custom.DomainPricingCustomLogic.CreatePriceParameters;
|
||||
@@ -31,6 +36,7 @@ import google.registry.flows.custom.DomainPricingCustomLogic.TransferPriceParame
|
||||
import google.registry.flows.custom.DomainPricingCustomLogic.UpdatePriceParameters;
|
||||
import google.registry.model.billing.BillingBase.RenewalPriceBehavior;
|
||||
import google.registry.model.billing.BillingRecurrence;
|
||||
import google.registry.model.domain.Domain;
|
||||
import google.registry.model.domain.fee.BaseFee;
|
||||
import google.registry.model.domain.fee.BaseFee.FeeType;
|
||||
import google.registry.model.domain.fee.Fee;
|
||||
@@ -40,7 +46,9 @@ import google.registry.model.domain.token.AllocationToken.TokenBehavior;
|
||||
import google.registry.model.pricing.PremiumPricingEngine.DomainPrices;
|
||||
import google.registry.model.tld.Tld;
|
||||
import jakarta.inject.Inject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nullable;
|
||||
@@ -54,11 +62,28 @@ import org.joda.money.Money;
|
||||
*/
|
||||
public final class DomainPricingLogic {
|
||||
|
||||
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||
|
||||
private final DomainPricingCustomLogic customLogic;
|
||||
private final Duration expiryAccessPeriodTotalLength;
|
||||
private final Duration expiryAccessPeriodTierLength;
|
||||
private final ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodInitialFee;
|
||||
private final ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodFinalFee;
|
||||
|
||||
@Inject
|
||||
public DomainPricingLogic(DomainPricingCustomLogic customLogic) {
|
||||
public DomainPricingLogic(
|
||||
DomainPricingCustomLogic customLogic,
|
||||
@Config("domainExpiryAccessPeriodTotalLength") Duration expiryAccessPeriodTotalLength,
|
||||
@Config("domainExpiryAccessPeriodTierLength") Duration expiryAccessPeriodTierLength,
|
||||
@Config("domainExpiryAccessPeriodInitialFee")
|
||||
ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodInitialFee,
|
||||
@Config("domainExpiryAccessPeriodFinalFee")
|
||||
ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodFinalFee) {
|
||||
this.customLogic = customLogic;
|
||||
this.expiryAccessPeriodTotalLength = expiryAccessPeriodTotalLength;
|
||||
this.expiryAccessPeriodTierLength = expiryAccessPeriodTierLength;
|
||||
this.expiryAccessPeriodInitialFee = expiryAccessPeriodInitialFee;
|
||||
this.expiryAccessPeriodFinalFee = expiryAccessPeriodFinalFee;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,35 +96,23 @@ public final class DomainPricingLogic {
|
||||
Tld tld,
|
||||
String domainName,
|
||||
Instant dateTime,
|
||||
Optional<Domain> recentlyDeletedDomain,
|
||||
int years,
|
||||
boolean isAnchorTenant,
|
||||
boolean isSunriseCreate,
|
||||
Optional<AllocationToken> allocationToken)
|
||||
throws EppException {
|
||||
CurrencyUnit currency = tld.getCurrency();
|
||||
|
||||
BaseFee createFee;
|
||||
// Domain create cost is always zero for anchor tenants
|
||||
if (isAnchorTenant) {
|
||||
createFee = Fee.create(zeroInCurrency(currency), FeeType.CREATE, false);
|
||||
} else {
|
||||
DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime);
|
||||
if (allocationToken.isPresent()) {
|
||||
// Handle any special NONPREMIUM / SPECIFIED cases configured in the token
|
||||
domainPrices =
|
||||
applyTokenToDomainPrices(domainPrices, tld, dateTime, years, allocationToken.get());
|
||||
}
|
||||
Money domainCreateCost =
|
||||
getDomainCreateCostWithDiscount(domainPrices, years, allocationToken, tld);
|
||||
// Apply a sunrise discount if configured and applicable
|
||||
if (isSunriseCreate) {
|
||||
domainCreateCost =
|
||||
domainCreateCost.multipliedBy(
|
||||
1.0d - RegistryConfig.getSunriseDomainCreateDiscount(), RoundingMode.HALF_EVEN);
|
||||
}
|
||||
createFee =
|
||||
Fee.create(domainCreateCost.getAmount(), FeeType.CREATE, domainPrices.isPremium());
|
||||
}
|
||||
Fee createFee =
|
||||
computeCreateFee(
|
||||
tld,
|
||||
domainName,
|
||||
dateTime,
|
||||
years,
|
||||
isAnchorTenant,
|
||||
isSunriseCreate,
|
||||
allocationToken,
|
||||
currency);
|
||||
|
||||
// Create fees for the cost and the EAP fee, if any.
|
||||
Fee eapFee = tld.getEapFeeFor(dateTime);
|
||||
@@ -109,6 +122,7 @@ public final class DomainPricingLogic {
|
||||
if (!isAnchorTenant && !eapFee.hasZeroCost()) {
|
||||
feesBuilder.addFeeOrCredit(eapFee);
|
||||
}
|
||||
maybeAddXapFee(tld, dateTime, recentlyDeletedDomain, currency, feesBuilder);
|
||||
|
||||
// Apply custom logic to the create fee, if any.
|
||||
return customLogic.customizeCreatePrice(
|
||||
@@ -121,6 +135,113 @@ public final class DomainPricingLogic {
|
||||
.build());
|
||||
}
|
||||
|
||||
private Fee computeCreateFee(
|
||||
Tld tld,
|
||||
String domainName,
|
||||
Instant dateTime,
|
||||
int years,
|
||||
boolean isAnchorTenant,
|
||||
boolean isSunriseCreate,
|
||||
Optional<AllocationToken> allocationToken,
|
||||
CurrencyUnit currency)
|
||||
throws EppException {
|
||||
// Domain create cost is always zero for anchor tenants
|
||||
if (isAnchorTenant) {
|
||||
return Fee.create(zeroInCurrency(currency), FeeType.CREATE, false);
|
||||
}
|
||||
DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime);
|
||||
if (allocationToken.isPresent()) {
|
||||
// Handle any special NONPREMIUM / SPECIFIED cases configured in the token
|
||||
domainPrices =
|
||||
applyTokenToDomainPrices(domainPrices, tld, dateTime, years, allocationToken.get());
|
||||
}
|
||||
Money domainCreateCost =
|
||||
getDomainCreateCostWithDiscount(domainPrices, years, allocationToken, tld);
|
||||
// Apply a sunrise discount if configured and applicable
|
||||
if (isSunriseCreate) {
|
||||
domainCreateCost =
|
||||
domainCreateCost.multipliedBy(
|
||||
1.0d - RegistryConfig.getSunriseDomainCreateDiscount(), RoundingMode.HALF_EVEN);
|
||||
}
|
||||
return Fee.create(domainCreateCost.getAmount(), FeeType.CREATE, domainPrices.isPremium());
|
||||
}
|
||||
|
||||
private void maybeAddXapFee(
|
||||
Tld tld,
|
||||
Instant dateTime,
|
||||
Optional<Domain> recentlyDeletedDomain,
|
||||
CurrencyUnit currency,
|
||||
FeesAndCredits.Builder feesBuilder) {
|
||||
if (tld.getExpiryAccessPeriodModeAt(dateTime) == Tld.ExpiryAccessPeriodMode.ENABLED
|
||||
&& recentlyDeletedDomain.isPresent()
|
||||
&& isDomainEligibleForXap(recentlyDeletedDomain.get(), tld, dateTime)) {
|
||||
Optional<Fee> xapFee =
|
||||
getXapFeeFor(dateTime, recentlyDeletedDomain.get().getDeletionTime(), currency);
|
||||
xapFee.ifPresent(
|
||||
fee -> {
|
||||
feesBuilder.addFeeOrCredit(fee);
|
||||
if (!fee.hasZeroCost()) {
|
||||
feesBuilder.setFeeExtensionRequired(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates and returns the Expiry Access Fee for a recently deleted domain at the given time.
|
||||
*/
|
||||
Optional<Fee> getXapFeeFor(Instant dateTime, Instant deletionTime, CurrencyUnit currency) {
|
||||
Duration elapsedTimeInXap = Duration.between(deletionTime, dateTime);
|
||||
if (!expiryAccessPeriodInitialFee.containsKey(currency)
|
||||
|| !expiryAccessPeriodFinalFee.containsKey(currency)) {
|
||||
// If the XAP schedule hasn't been configured in YAML for the currency this TLD uses, log the
|
||||
// error and then short-circuit return (to allow the EPP flow to continue normally).
|
||||
logger.atSevere().log(
|
||||
"Expiry Access Period configuration is lacking initial or final fees for currency %s.",
|
||||
currency);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Determine which tier the current time falls into (0-indexed).
|
||||
long tier = elapsedTimeInXap.toMillis() / expiryAccessPeriodTierLength.toMillis();
|
||||
long numTiers =
|
||||
expiryAccessPeriodTotalLength.toMillis() / expiryAccessPeriodTierLength.toMillis();
|
||||
if (tier < 0 || tier >= numTiers) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Calculate the parameters for the geometric sequence: XAP fee = initialFee * ratio ^ tier.
|
||||
BigDecimal xapFee;
|
||||
if (expiryAccessPeriodInitialFee.get(currency).signum() == 0 || numTiers <= 1 || tier == 0) {
|
||||
xapFee =
|
||||
expiryAccessPeriodInitialFee
|
||||
.get(currency)
|
||||
.setScale(currency.getDecimalPlaces(), RoundingMode.HALF_EVEN);
|
||||
} else {
|
||||
double base =
|
||||
expiryAccessPeriodFinalFee.get(currency).doubleValue()
|
||||
/ expiryAccessPeriodInitialFee.get(currency).doubleValue();
|
||||
double exponent = 1.0 / (numTiers - 1.0);
|
||||
BigDecimal ratio = BigDecimal.valueOf(Math.pow(base, exponent));
|
||||
BigDecimal fee = expiryAccessPeriodInitialFee.get(currency).multiply(ratio.pow((int) tier));
|
||||
xapFee = fee.setScale(currency.getDecimalPlaces(), RoundingMode.HALF_EVEN);
|
||||
}
|
||||
|
||||
Range<Instant> validPeriod =
|
||||
Range.closedOpen(
|
||||
deletionTime.plus(expiryAccessPeriodTierLength.multipliedBy(tier)),
|
||||
deletionTime.plus(expiryAccessPeriodTierLength.multipliedBy(tier + 1)));
|
||||
return Optional.of(
|
||||
Fee.create(
|
||||
xapFee,
|
||||
FeeType.XAP,
|
||||
// An XAP fee does not count as premium -- it's a separate one-time fee, independent of
|
||||
// which the domain is separately considered standard vs premium.
|
||||
false,
|
||||
validPeriod,
|
||||
validPeriod.upperEndpoint()));
|
||||
}
|
||||
|
||||
/** Returns a new renewal cost for the pricer. */
|
||||
public FeesAndCredits getRenewPrice(
|
||||
Tld tld,
|
||||
|
||||
@@ -77,6 +77,11 @@ public class FeesAndCredits extends ImmutableObject implements Buildable {
|
||||
return getTotalCostForType(FeeType.EAP);
|
||||
}
|
||||
|
||||
/** Returns the XAP cost for the event. */
|
||||
public Money getXapCost() {
|
||||
return getTotalCostForType(FeeType.XAP);
|
||||
}
|
||||
|
||||
/** Returns the renew cost for the event. */
|
||||
public Money getRenewCost() {
|
||||
return getTotalCostForType(FeeType.RENEW);
|
||||
|
||||
+15
-5
@@ -242,7 +242,14 @@ public class AllocationTokenFlowUtils {
|
||||
case CREATE ->
|
||||
pricingLogic
|
||||
.getCreatePrice(
|
||||
tld, domainName, now, yearsForAction, false, false, Optional.of(token))
|
||||
tld,
|
||||
domainName,
|
||||
now,
|
||||
Optional.empty(),
|
||||
yearsForAction,
|
||||
false,
|
||||
false,
|
||||
Optional.of(token))
|
||||
.getTotalCost()
|
||||
.getAmount();
|
||||
case RENEW ->
|
||||
@@ -270,11 +277,14 @@ public class AllocationTokenFlowUtils {
|
||||
return maybeTokenEntity.get();
|
||||
}
|
||||
|
||||
maybeTokenEntity = AllocationToken.get(VKey.create(AllocationToken.class, token));
|
||||
if (maybeTokenEntity.isEmpty()) {
|
||||
throw new NonexistentAllocationTokenException();
|
||||
VKey<AllocationToken> tokenKey = VKey.create(AllocationToken.class, token);
|
||||
AllocationToken tokenEntity =
|
||||
AllocationToken.get(tokenKey).orElseThrow(NonexistentAllocationTokenException::new);
|
||||
if (tokenEntity.getTokenType().equals(AllocationToken.TokenType.SINGLE_USE)) {
|
||||
// Reload the token to avoid possible cache race conditions where the token may have already
|
||||
// been redeemed
|
||||
tokenEntity = tm().loadByKey(tokenKey);
|
||||
}
|
||||
AllocationToken tokenEntity = maybeTokenEntity.get();
|
||||
validateTokenEntity(tokenEntity, registrarId, domainName, now);
|
||||
return tokenEntity;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import static google.registry.flows.ResourceFlowUtils.verifyResourceDoesNotExist
|
||||
import static google.registry.flows.host.HostFlowUtils.lookupSuperordinateDomain;
|
||||
import static google.registry.flows.host.HostFlowUtils.validateHostName;
|
||||
import static google.registry.flows.host.HostFlowUtils.verifySuperordinateDomainNotInPendingDelete;
|
||||
import static google.registry.flows.host.HostFlowUtils.verifySuperordinateDomainNotServerUpdateProhibited;
|
||||
import static google.registry.flows.host.HostFlowUtils.verifySuperordinateDomainOwnership;
|
||||
import static google.registry.model.EppResourceUtils.createRepoId;
|
||||
import static google.registry.model.reporting.HistoryEntry.Type.HOST_CREATE;
|
||||
@@ -73,6 +74,7 @@ import java.util.Optional;
|
||||
* @error {@link HostFlowUtils.HostNameNotPunyCodedException}
|
||||
* @error {@link HostFlowUtils.SuperordinateDomainDoesNotExistException}
|
||||
* @error {@link HostFlowUtils.SuperordinateDomainInPendingDeleteException}
|
||||
* @error {@link HostFlowUtils.SuperordinateDomainServerUpdateProhibitedException}
|
||||
* @error {@link SubordinateHostMustHaveIpException}
|
||||
* @error {@link UnexpectedExternalHostIpException}
|
||||
*/
|
||||
@@ -107,6 +109,7 @@ public final class HostCreateFlow implements MutatingFlow {
|
||||
Optional<Domain> superordinateDomain =
|
||||
lookupSuperordinateDomain(validateHostName(targetId), now);
|
||||
verifySuperordinateDomainNotInPendingDelete(superordinateDomain.orElse(null));
|
||||
verifySuperordinateDomainNotServerUpdateProhibited(superordinateDomain.orElse(null));
|
||||
verifySuperordinateDomainOwnership(registrarId, superordinateDomain.orElse(null));
|
||||
boolean willBeSubordinate = superordinateDomain.isPresent();
|
||||
boolean hasIpAddresses = !isNullOrEmpty(command.getInetAddresses());
|
||||
|
||||
@@ -22,6 +22,7 @@ import static google.registry.flows.ResourceFlowUtils.loadAndVerifyExistence;
|
||||
import static google.registry.flows.ResourceFlowUtils.verifyNoDisallowedStatuses;
|
||||
import static google.registry.flows.ResourceFlowUtils.verifyResourceOwnership;
|
||||
import static google.registry.flows.host.HostFlowUtils.validateHostName;
|
||||
import static google.registry.flows.host.HostFlowUtils.verifySuperordinateDomainNotServerUpdateProhibited;
|
||||
import static google.registry.model.eppoutput.Result.Code.SUCCESS;
|
||||
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
|
||||
|
||||
@@ -33,7 +34,7 @@ import google.registry.flows.FlowModule.Superuser;
|
||||
import google.registry.flows.FlowModule.TargetId;
|
||||
import google.registry.flows.MutatingFlow;
|
||||
import google.registry.flows.annotations.ReportingSpec;
|
||||
import google.registry.model.EppResource;
|
||||
import google.registry.model.domain.Domain;
|
||||
import google.registry.model.domain.metadata.MetadataExtension;
|
||||
import google.registry.model.eppcommon.StatusValue;
|
||||
import google.registry.model.eppcommon.Trid;
|
||||
@@ -62,6 +63,7 @@ import java.time.Instant;
|
||||
* @error {@link HostFlowUtils.HostNameNotLowerCaseException}
|
||||
* @error {@link HostFlowUtils.HostNameNotNormalizedException}
|
||||
* @error {@link HostFlowUtils.HostNameNotPunyCodedException}
|
||||
* @error {@link HostFlowUtils.SuperordinateDomainServerUpdateProhibitedException}
|
||||
*/
|
||||
@ReportingSpec(ActivityReportField.HOST_DELETE)
|
||||
public final class HostDeleteFlow implements MutatingFlow {
|
||||
@@ -90,12 +92,15 @@ public final class HostDeleteFlow implements MutatingFlow {
|
||||
if (!isSuperuser) {
|
||||
verifyNoDisallowedStatuses(existingHost, DELETE_PROHIBITED_STATUSES);
|
||||
// Hosts transfer with their superordinate domains, so for hosts with a superordinate domain,
|
||||
// the client id, needs to be read off of it.
|
||||
EppResource owningResource =
|
||||
existingHost.isSubordinate()
|
||||
? tm().loadByKey(existingHost.getSuperordinateDomain()).cloneProjectedAtTime(now)
|
||||
: existingHost;
|
||||
verifyResourceOwnership(registrarId, owningResource);
|
||||
// the client id needs to be read off of it.
|
||||
if (existingHost.isSubordinate()) {
|
||||
Domain superordinateDomain =
|
||||
tm().loadByKey(existingHost.getSuperordinateDomain()).cloneProjectedAtTime(now);
|
||||
verifyResourceOwnership(registrarId, superordinateDomain);
|
||||
verifySuperordinateDomainNotServerUpdateProhibited(superordinateDomain);
|
||||
} else {
|
||||
verifyResourceOwnership(registrarId, existingHost);
|
||||
}
|
||||
}
|
||||
Host newHost = existingHost.asBuilder().setStatusValues(null).setDeletionTime(now).build();
|
||||
if (existingHost.isSubordinate()) {
|
||||
|
||||
@@ -174,6 +174,31 @@ public class HostFlowUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the superordinate domain does not have {@code serverUpdateProhibited} status.
|
||||
*
|
||||
* <p>Subordinate hosts publish glue A/AAAA records in the TLD zone on behalf of their
|
||||
* superordinate domain. If the superordinate domain is registry-locked (carries {@link
|
||||
* StatusValue#SERVER_UPDATE_PROHIBITED}), non-superuser modifications to its subordinate hosts
|
||||
* must be rejected as well; otherwise a compromised sponsoring registrar could hijack DNS for a
|
||||
* locked domain by swapping the glue addresses of its in-bailiwick nameservers.
|
||||
*/
|
||||
static void verifySuperordinateDomainNotServerUpdateProhibited(Domain superordinateDomain)
|
||||
throws EppException {
|
||||
if ((superordinateDomain != null)
|
||||
&& superordinateDomain.getStatusValues().contains(StatusValue.SERVER_UPDATE_PROHIBITED)) {
|
||||
throw new SuperordinateDomainServerUpdateProhibitedException();
|
||||
}
|
||||
}
|
||||
|
||||
/** Superordinate domain for this hostname has serverUpdateProhibited status. */
|
||||
static class SuperordinateDomainServerUpdateProhibitedException
|
||||
extends StatusProhibitsOperationException {
|
||||
public SuperordinateDomainServerUpdateProhibitedException() {
|
||||
super("Superordinate domain for this hostname has serverUpdateProhibited status");
|
||||
}
|
||||
}
|
||||
|
||||
/** Host names are limited to 253 characters. */
|
||||
static class HostNameTooLongException extends ParameterValueRangeErrorException {
|
||||
public HostNameTooLongException() {
|
||||
|
||||
@@ -28,6 +28,7 @@ import static google.registry.flows.ResourceFlowUtils.verifyResourceOwnership;
|
||||
import static google.registry.flows.host.HostFlowUtils.lookupSuperordinateDomain;
|
||||
import static google.registry.flows.host.HostFlowUtils.validateHostName;
|
||||
import static google.registry.flows.host.HostFlowUtils.verifySuperordinateDomainNotInPendingDelete;
|
||||
import static google.registry.flows.host.HostFlowUtils.verifySuperordinateDomainNotServerUpdateProhibited;
|
||||
import static google.registry.flows.host.HostFlowUtils.verifySuperordinateDomainOwnership;
|
||||
import static google.registry.model.reporting.HistoryEntry.Type.HOST_UPDATE;
|
||||
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
|
||||
@@ -98,6 +99,7 @@ import java.util.Optional;
|
||||
* @error {@link HostFlowUtils.InvalidHostNameException}
|
||||
* @error {@link HostFlowUtils.SuperordinateDomainDoesNotExistException}
|
||||
* @error {@link HostFlowUtils.SuperordinateDomainInPendingDeleteException}
|
||||
* @error {@link HostFlowUtils.SuperordinateDomainServerUpdateProhibitedException}
|
||||
* @error {@link CannotAddIpToExternalHostException}
|
||||
* @error {@link CannotRemoveSubordinateHostLastIpException}
|
||||
* @error {@link CannotRenameExternalHostException}
|
||||
@@ -152,7 +154,12 @@ public final class HostUpdateFlow implements MutatingFlow {
|
||||
verifySuperordinateDomainNotInPendingDelete(newSuperordinateDomain.orElse(null));
|
||||
EppResource owningResource = firstNonNull(oldSuperordinateDomain, existingHost);
|
||||
verifyUpdateAllowed(
|
||||
command, existingHost, newSuperordinateDomain.orElse(null), owningResource, isHostRename);
|
||||
command,
|
||||
existingHost,
|
||||
oldSuperordinateDomain,
|
||||
newSuperordinateDomain.orElse(null),
|
||||
owningResource,
|
||||
isHostRename);
|
||||
if (isHostRename && ForeignKeyUtils.loadKey(Host.class, newHostName, now).isPresent()) {
|
||||
throw new HostAlreadyExistsException(newHostName);
|
||||
}
|
||||
@@ -211,30 +218,38 @@ public final class HostUpdateFlow implements MutatingFlow {
|
||||
private void verifyUpdateAllowed(
|
||||
Update command,
|
||||
Host existingHost,
|
||||
Domain oldSuperordinateDomain,
|
||||
Domain newSuperordinateDomain,
|
||||
EppResource owningResource,
|
||||
boolean isHostRename)
|
||||
throws EppException {
|
||||
if (!isSuperuser) {
|
||||
// Verify that the host belongs to this registrar, either directly or because it is currently
|
||||
// subordinate to a domain owned by this registrar.
|
||||
verifyResourceOwnership(registrarId, owningResource);
|
||||
if (isHostRename && !existingHost.isSubordinate()) {
|
||||
throw new CannotRenameExternalHostException();
|
||||
}
|
||||
// Verify that the new superordinate domain belongs to this registrar.
|
||||
verifySuperordinateDomainOwnership(registrarId, newSuperordinateDomain);
|
||||
ImmutableSet<StatusValue> statusesToAdd = command.getInnerAdd().getStatusValues();
|
||||
ImmutableSet<StatusValue> statusesToRemove = command.getInnerRemove().getStatusValues();
|
||||
// If the resource is marked with clientUpdateProhibited, and this update does not clear that
|
||||
// status, then the update must be disallowed.
|
||||
if (existingHost.getStatusValues().contains(StatusValue.CLIENT_UPDATE_PROHIBITED)
|
||||
&& !statusesToRemove.contains(StatusValue.CLIENT_UPDATE_PROHIBITED)) {
|
||||
throw new ResourceHasClientUpdateProhibitedException();
|
||||
}
|
||||
verifyAllStatusesAreClientSettable(union(statusesToAdd, statusesToRemove));
|
||||
}
|
||||
verifyNoDisallowedStatuses(existingHost, DISALLOWED_STATUSES);
|
||||
if (isSuperuser) {
|
||||
return;
|
||||
}
|
||||
// Verify that the host belongs to this registrar, either directly or because it is currently
|
||||
// subordinate to a domain owned by this registrar.
|
||||
verifyResourceOwnership(registrarId, owningResource);
|
||||
if (isHostRename && !existingHost.isSubordinate()) {
|
||||
throw new CannotRenameExternalHostException();
|
||||
}
|
||||
// Verify that the new superordinate domain belongs to this registrar.
|
||||
verifySuperordinateDomainOwnership(registrarId, newSuperordinateDomain);
|
||||
// Subordinate hosts inherit the registry-lock protection of their superordinate domain: if
|
||||
// either the current or the target superordinate domain carries SERVER_UPDATE_PROHIBITED,
|
||||
// non-superuser updates (including glue IP changes and renames) must be rejected so that a
|
||||
// compromised sponsoring registrar cannot hijack DNS for a registry-locked domain.
|
||||
verifySuperordinateDomainNotServerUpdateProhibited(oldSuperordinateDomain);
|
||||
verifySuperordinateDomainNotServerUpdateProhibited(newSuperordinateDomain);
|
||||
ImmutableSet<StatusValue> statusesToAdd = command.getInnerAdd().getStatusValues();
|
||||
ImmutableSet<StatusValue> statusesToRemove = command.getInnerRemove().getStatusValues();
|
||||
// If the resource is marked with clientUpdateProhibited, and this update does not clear that
|
||||
// status, then the update must be disallowed.
|
||||
if (existingHost.getStatusValues().contains(StatusValue.CLIENT_UPDATE_PROHIBITED)
|
||||
&& !statusesToRemove.contains(StatusValue.CLIENT_UPDATE_PROHIBITED)) {
|
||||
throw new ResourceHasClientUpdateProhibitedException();
|
||||
}
|
||||
verifyAllStatusesAreClientSettable(union(statusesToAdd, statusesToRemove));
|
||||
}
|
||||
|
||||
private static void verifyHasIpsIffIsExternal(Update command, Host existingHost, Host newHost)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<host:create
|
||||
xmlns:host="urn:ietf:params:xml:ns:host-1.0">
|
||||
<host:name>%host%.example.com</host:name>
|
||||
<host:addr ip="v4">8.8.8.8</host:addr>
|
||||
</host:create>
|
||||
</create>
|
||||
<clTRID>trid</clTRID>
|
||||
|
||||
@@ -21,6 +21,7 @@ import static google.registry.util.DateTimeUtils.parseInstant;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.KeyDeserializer;
|
||||
@@ -38,6 +39,7 @@ import com.google.common.collect.ImmutableSortedSet;
|
||||
import google.registry.model.common.FeatureFlag.FeatureStatus;
|
||||
import google.registry.model.common.TimedTransitionProperty;
|
||||
import google.registry.model.domain.token.AllocationToken;
|
||||
import google.registry.model.tld.Tld.ExpiryAccessPeriodMode;
|
||||
import google.registry.model.tld.Tld.TldState;
|
||||
import google.registry.persistence.VKey;
|
||||
import java.io.IOException;
|
||||
@@ -336,7 +338,8 @@ public class EntityYamlUtils {
|
||||
@Override
|
||||
public TimedTransitionProperty<TldState> deserialize(
|
||||
JsonParser jp, DeserializationContext context) throws IOException {
|
||||
SortedMap<String, String> valueMap = jp.readValueAs(SortedMap.class);
|
||||
SortedMap<String, String> valueMap =
|
||||
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
|
||||
return TimedTransitionProperty.fromValueMap(
|
||||
valueMap.keySet().stream()
|
||||
.collect(
|
||||
@@ -347,6 +350,37 @@ public class EntityYamlUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom JSON deserializer for a {@link TimedTransitionProperty} of {@link
|
||||
* ExpiryAccessPeriodMode}.
|
||||
*/
|
||||
public static class TimedTransitionPropertyExpiryAccessPeriodModeDeserializer
|
||||
extends StdDeserializer<TimedTransitionProperty<ExpiryAccessPeriodMode>> {
|
||||
|
||||
public TimedTransitionPropertyExpiryAccessPeriodModeDeserializer() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
public TimedTransitionPropertyExpiryAccessPeriodModeDeserializer(
|
||||
Class<TimedTransitionProperty<ExpiryAccessPeriodMode>> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TimedTransitionProperty<ExpiryAccessPeriodMode> deserialize(
|
||||
JsonParser jp, DeserializationContext context) throws IOException {
|
||||
SortedMap<String, String> valueMap =
|
||||
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
|
||||
return TimedTransitionProperty.fromValueMap(
|
||||
valueMap.keySet().stream()
|
||||
.collect(
|
||||
toImmutableSortedMap(
|
||||
natural(),
|
||||
key -> parseInstant(key),
|
||||
key -> ExpiryAccessPeriodMode.valueOf(valueMap.get(key)))));
|
||||
}
|
||||
}
|
||||
|
||||
/** A custom JSON deserializer for a {@link TimedTransitionProperty} of {@link Money}. */
|
||||
public static class TimedTransitionPropertyMoneyDeserializer
|
||||
extends StdDeserializer<TimedTransitionProperty<Money>> {
|
||||
@@ -362,7 +396,8 @@ public class EntityYamlUtils {
|
||||
@Override
|
||||
public TimedTransitionProperty<Money> deserialize(JsonParser jp, DeserializationContext context)
|
||||
throws IOException {
|
||||
SortedMap<String, LinkedHashMap<String, Object>> valueMap = jp.readValueAs(SortedMap.class);
|
||||
SortedMap<String, LinkedHashMap<String, Object>> valueMap =
|
||||
jp.readValueAs(new TypeReference<SortedMap<String, LinkedHashMap<String, Object>>>() {});
|
||||
return TimedTransitionProperty.fromValueMap(
|
||||
valueMap.keySet().stream()
|
||||
.collect(
|
||||
@@ -392,7 +427,8 @@ public class EntityYamlUtils {
|
||||
@Override
|
||||
public TimedTransitionProperty<FeatureStatus> deserialize(
|
||||
JsonParser jp, DeserializationContext context) throws IOException {
|
||||
SortedMap<String, String> valueMap = jp.readValueAs(SortedMap.class);
|
||||
SortedMap<String, String> valueMap =
|
||||
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
|
||||
return TimedTransitionProperty.fromValueMap(
|
||||
valueMap.keySet().stream()
|
||||
.collect(
|
||||
|
||||
@@ -16,6 +16,7 @@ package google.registry.model;
|
||||
|
||||
import static com.google.common.collect.ImmutableList.toImmutableList;
|
||||
import static com.google.common.collect.ImmutableMap.toImmutableMap;
|
||||
import static com.google.common.collect.ImmutableSet.toImmutableSet;
|
||||
import static google.registry.config.RegistryConfig.getEppResourceCachingDuration;
|
||||
import static google.registry.config.RegistryConfig.getEppResourceMaxCachedEntries;
|
||||
import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm;
|
||||
@@ -400,4 +401,24 @@ public final class ForeignKeyUtils {
|
||||
.filter(e -> now.isBefore(e.getDeletionTime()))
|
||||
.map(e -> e.cloneProjectedAtTime(now));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the last created version of multiple {@link EppResource}s from the replica database by
|
||||
* foreign keys, using a cache.
|
||||
*
|
||||
* <p>This method ignores the config setting for caching, and is reserved for use cases that can
|
||||
* tolerate slightly stale data.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <E extends EppResource> ImmutableMap<String, E> loadResourcesByCache(
|
||||
Class<E> clazz, Collection<String> foreignKeys, Instant now) {
|
||||
ImmutableSet<VKey<? extends EppResource>> vkeys =
|
||||
foreignKeys.stream().map(fk -> VKey.create(clazz, fk)).collect(toImmutableSet());
|
||||
return foreignKeyToResourceCache.getAll(vkeys).entrySet().stream()
|
||||
.filter(e -> e.getValue().isPresent() && now.isBefore(e.getValue().get().getDeletionTime()))
|
||||
.collect(
|
||||
toImmutableMap(
|
||||
e -> (String) e.getKey().getKey(),
|
||||
e -> (E) e.getValue().get().cloneProjectedAtTime(now)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +197,7 @@ public final class OteAccountBuilder {
|
||||
registrars.stream()
|
||||
.collect(
|
||||
toImmutableMap(
|
||||
Registrar::getRegistrarId,
|
||||
registrar -> RegistrarRole.ACCOUNT_MANAGER)))
|
||||
Registrar::getRegistrarId, _ -> RegistrarRole.ACCOUNT_MANAGER)))
|
||||
.build())
|
||||
.build());
|
||||
return this;
|
||||
@@ -265,10 +264,18 @@ public final class OteAccountBuilder {
|
||||
|
||||
/** Grants the users permission to pass IAP. */
|
||||
public void grantIapPermission(
|
||||
Optional<String> groupEmailAddress, CloudTasksUtils cloudTasksUtils, IamClient iamClient) {
|
||||
Optional<String> groupEmailAddress,
|
||||
Optional<String> consoleIapServiceId,
|
||||
CloudTasksUtils cloudTasksUtils,
|
||||
IamClient iamClient) {
|
||||
for (User user : users) {
|
||||
User.grantIapPermission(
|
||||
user.getEmailAddress(), groupEmailAddress, cloudTasksUtils, null, iamClient);
|
||||
user.getEmailAddress(),
|
||||
groupEmailAddress,
|
||||
consoleIapServiceId,
|
||||
cloudTasksUtils,
|
||||
null,
|
||||
iamClient);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ public abstract class BillingBase extends ImmutableObject
|
||||
@Deprecated // DO NOT USE THIS REASON. IT REMAINS BECAUSE OF HISTORICAL DATA. SEE b/31676071.
|
||||
ERROR(false),
|
||||
FEE_EARLY_ACCESS(true),
|
||||
FEE_EXPIRY_ACCESS(true),
|
||||
RENEW(true),
|
||||
RESTORE(true),
|
||||
SERVER_STATUS(false),
|
||||
|
||||
@@ -200,7 +200,7 @@ public class BillingEvent extends BillingBase {
|
||||
checkState(
|
||||
instance.reason.hasPeriodYears() == (instance.periodYears != null),
|
||||
"Period years must be set if and only if reason is "
|
||||
+ "CREATE, FEE_EARLY_ACCESS, RENEW, RESTORE or TRANSFER.");
|
||||
+ "CREATE, FEE_EARLY_ACCESS, FEE_EXPIRY_ACCESS, RENEW, RESTORE or TRANSFER.");
|
||||
}
|
||||
checkState(
|
||||
instance.getFlags().contains(Flag.SYNTHETIC) == (instance.syntheticCreationTime != null),
|
||||
|
||||
@@ -86,6 +86,7 @@ public class User extends UpdateAutoTimestampEntity implements Buildable {
|
||||
public static void grantIapPermission(
|
||||
String emailAddress,
|
||||
Optional<String> groupEmailAddress,
|
||||
Optional<String> consoleIapServiceId,
|
||||
@Nullable CloudTasksUtils cloudTasksUtils,
|
||||
@Nullable ServiceConnection connection,
|
||||
IamClient iamClient) {
|
||||
@@ -93,14 +94,14 @@ public class User extends UpdateAutoTimestampEntity implements Buildable {
|
||||
return;
|
||||
}
|
||||
checkArgument(
|
||||
cloudTasksUtils != null || connection != null,
|
||||
"At least one of cloudTasksUtils or connection must be set");
|
||||
cloudTasksUtils == null ^ connection == null,
|
||||
"Precisely one of cloudTasksUtils or connection can be set");
|
||||
checkArgument(
|
||||
cloudTasksUtils == null || connection == null,
|
||||
"Only one of cloudTasksUtils or connection can be set");
|
||||
groupEmailAddress.isPresent() || consoleIapServiceId.isPresent(),
|
||||
"At least one of groupEmailAddress or consoleIapServiceId must be present");
|
||||
if (groupEmailAddress.isEmpty()) {
|
||||
logger.atInfo().log("Granting IAP role to user %s", emailAddress);
|
||||
iamClient.addBinding(emailAddress, IAP_SECURED_WEB_APP_USER_ROLE);
|
||||
iamClient.addBinding(emailAddress, IAP_SECURED_WEB_APP_USER_ROLE, consoleIapServiceId.get());
|
||||
} else {
|
||||
logger.atInfo().log("Adding %s to group %s", emailAddress, groupEmailAddress.get());
|
||||
if (cloudTasksUtils != null) {
|
||||
@@ -129,11 +130,8 @@ public class User extends UpdateAutoTimestampEntity implements Buildable {
|
||||
return;
|
||||
}
|
||||
checkArgument(
|
||||
cloudTasksUtils != null || connection != null,
|
||||
"At least one of cloudTasksUtils or connection must be set");
|
||||
checkArgument(
|
||||
cloudTasksUtils == null || connection == null,
|
||||
"Only one of cloudTasksUtils or connection can be set");
|
||||
cloudTasksUtils == null ^ connection == null,
|
||||
"Precisely one of cloudTasksUtils or connection can be set");
|
||||
if (groupEmailAddress.isEmpty()) {
|
||||
logger.atInfo().log("Removing IAP role from user %s", emailAddress);
|
||||
iamClient.removeBinding(emailAddress, IAP_SECURED_WEB_APP_USER_ROLE);
|
||||
|
||||
@@ -51,6 +51,10 @@ public abstract class BaseFee extends ImmutableObject {
|
||||
public enum FeeType {
|
||||
CREATE("create"),
|
||||
EAP("Early Access Period, fee expires: %s", "Early Access Period"),
|
||||
/**
|
||||
* A one-time fee for registering a recently dropped domain, during the Expiry Access Period.
|
||||
*/
|
||||
XAP("Expiry Access Period, fee expires: %s", "Expiry Access Period"),
|
||||
RENEW("renew"),
|
||||
RESTORE("restore"),
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,7 @@ import google.registry.model.reporting.HistoryEntry.HistoryEntryId;
|
||||
import google.registry.persistence.VKey;
|
||||
import google.registry.persistence.WithVKey;
|
||||
import google.registry.persistence.converter.AllocationTokenStatusTransitionUserType;
|
||||
import google.registry.util.NonFinalForTesting;
|
||||
import jakarta.persistence.AttributeOverride;
|
||||
import jakarta.persistence.AttributeOverrides;
|
||||
import jakarta.persistence.Column;
|
||||
@@ -61,6 +62,7 @@ import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -374,28 +376,39 @@ public class AllocationToken extends UpdateAutoTimestampEntity implements Builda
|
||||
return ALLOCATION_TOKENS_CACHE.getAll(keys);
|
||||
}
|
||||
|
||||
/** A cache that loads the {@link AllocationToken} object for a given AllocationToken VKey. */
|
||||
private static final LoadingCache<VKey<AllocationToken>, Optional<AllocationToken>>
|
||||
ALLOCATION_TOKENS_CACHE =
|
||||
CacheUtils.newCacheBuilder(getSingletonCacheRefreshDuration())
|
||||
.build(
|
||||
new CacheLoader<>() {
|
||||
@Override
|
||||
public Optional<AllocationToken> load(VKey<AllocationToken> key) {
|
||||
return tm().reTransact(() -> tm().loadByKeyIfPresent(key));
|
||||
}
|
||||
@VisibleForTesting
|
||||
public static void setCacheForTest(Optional<Duration> expiry) {
|
||||
Duration effectiveExpiry = expiry.orElse(getSingletonCacheRefreshDuration());
|
||||
ALLOCATION_TOKENS_CACHE = createAllocationTokensCache(effectiveExpiry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<? extends VKey<AllocationToken>, ? extends Optional<AllocationToken>>
|
||||
loadAll(Set<? extends VKey<AllocationToken>> keys) {
|
||||
return tm().reTransact(
|
||||
() ->
|
||||
keys.stream()
|
||||
.collect(
|
||||
toImmutableMap(
|
||||
key -> key, key -> tm().loadByKeyIfPresent(key))));
|
||||
}
|
||||
});
|
||||
/** A cache that loads the {@link AllocationToken} object for a given AllocationToken VKey. */
|
||||
@NonFinalForTesting
|
||||
private static LoadingCache<VKey<AllocationToken>, Optional<AllocationToken>>
|
||||
ALLOCATION_TOKENS_CACHE = createAllocationTokensCache(getSingletonCacheRefreshDuration());
|
||||
|
||||
private static LoadingCache<VKey<AllocationToken>, Optional<AllocationToken>>
|
||||
createAllocationTokensCache(Duration expiry) {
|
||||
return CacheUtils.newCacheBuilder(expiry)
|
||||
.build(
|
||||
new CacheLoader<>() {
|
||||
@Override
|
||||
public Optional<AllocationToken> load(VKey<AllocationToken> key) {
|
||||
return tm().reTransact(() -> tm().loadByKeyIfPresent(key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<? extends VKey<AllocationToken>, ? extends Optional<AllocationToken>>
|
||||
loadAll(Set<? extends VKey<AllocationToken>> keys) {
|
||||
return tm().reTransact(
|
||||
() ->
|
||||
keys.stream()
|
||||
.collect(
|
||||
toImmutableMap(
|
||||
key -> key, key -> tm().loadByKeyIfPresent(key))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public VKey<AllocationToken> createVKey() {
|
||||
|
||||
@@ -49,6 +49,10 @@ import java.util.stream.Stream;
|
||||
* also matches the "addrType" type from <a
|
||||
* href="http://tools.ietf.org/html/draft-lozano-tmch-smd">Mark and Signed Mark Objects Mapping</a>.
|
||||
*
|
||||
* <p>The lengths of the fields are limited to match the constraints defined in XSD schemas like
|
||||
* {@code rde-registrar.xsd}. Specifically, {@code zip} is limited to 16 characters and {@code
|
||||
* city}, {@code state}, and each {@code street} line are limited to 255 characters.
|
||||
*
|
||||
* @see google.registry.model.mark.MarkAddress
|
||||
* @see google.registry.model.registrar.RegistrarAddress
|
||||
*/
|
||||
@@ -155,6 +159,7 @@ public class Address extends ImmutableObject
|
||||
|
||||
@Override
|
||||
public void postProcess() {
|
||||
validateState();
|
||||
if (street == null || street.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -163,6 +168,30 @@ public class Address extends ImmutableObject
|
||||
streetLine3 = street.size() >= 3 ? street.get(2) : null;
|
||||
}
|
||||
|
||||
public void validateState() {
|
||||
checkArgument(
|
||||
street == null || (!street.isEmpty() && street.size() <= 3),
|
||||
"Street address must have [1-3] lines: %s",
|
||||
street);
|
||||
if (street != null) {
|
||||
checkArgument(
|
||||
street.stream().noneMatch(String::isEmpty),
|
||||
"Street address cannot contain empty string: %s",
|
||||
street);
|
||||
checkArgument(
|
||||
street.stream().allMatch(s -> s.length() <= 255),
|
||||
"Street address lines cannot be longer than 255 characters");
|
||||
}
|
||||
checkArgument(
|
||||
city == null || city.length() <= 255, "City cannot be longer than 255 characters");
|
||||
checkArgument(
|
||||
state == null || state.length() <= 255, "State cannot be longer than 255 characters");
|
||||
checkArgument(zip == null || zip.length() <= 16, "Zip cannot be longer than 16 characters");
|
||||
checkArgument(
|
||||
countryCode == null || countryCode.length() == 2,
|
||||
"Country code should be a 2 character string");
|
||||
}
|
||||
|
||||
/** A builder for constructing {@link Address}. */
|
||||
public static class Builder<T extends Address> extends Buildable.Builder<T> {
|
||||
|
||||
@@ -172,20 +201,17 @@ public class Address extends ImmutableObject
|
||||
super(instance);
|
||||
}
|
||||
|
||||
@Override
|
||||
public T build() {
|
||||
getInstance().validateState();
|
||||
return super.build();
|
||||
}
|
||||
|
||||
public Builder<T> setStreet(ImmutableList<String> street) {
|
||||
checkArgument(
|
||||
street == null || (!street.isEmpty() && street.size() <= 3),
|
||||
"Street address must have [1-3] lines: %s",
|
||||
street);
|
||||
//noinspection ConstantConditions
|
||||
checkArgument(
|
||||
street.stream().noneMatch(String::isEmpty),
|
||||
"Street address cannot contain empty string: %s",
|
||||
street);
|
||||
getInstance().street = street;
|
||||
getInstance().streetLine1 = street.get(0);
|
||||
getInstance().streetLine2 = street.size() >= 2 ? street.get(1) : null;
|
||||
getInstance().streetLine3 = street.size() == 3 ? street.get(2) : null;
|
||||
getInstance().streetLine1 = (street != null && street.size() >= 1) ? street.get(0) : null;
|
||||
getInstance().streetLine2 = (street != null && street.size() >= 2) ? street.get(1) : null;
|
||||
getInstance().streetLine3 = (street != null && street.size() == 3) ? street.get(2) : null;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,18 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
|
||||
@Column(nullable = false)
|
||||
boolean blockPremiumNames;
|
||||
|
||||
/**
|
||||
* Whether this registrar has opted into participating in the Expiry Access Period (XAP).
|
||||
*
|
||||
* <p>If this is false, any domain currently in XAP will appear as reserved/unavailable on checks
|
||||
* and creates.
|
||||
*
|
||||
* <p>TODO(mcilwain): Drop the temporary database default for expiry_access_period_enabled in a
|
||||
* subsequent schema migration once this code is deployed.
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
boolean expiryAccessPeriodEnabled;
|
||||
|
||||
// Authentication.
|
||||
|
||||
/** X.509 PEM client certificate(s) used to authenticate registrar to EPP service. */
|
||||
@@ -560,6 +572,10 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
|
||||
return blockPremiumNames;
|
||||
}
|
||||
|
||||
public boolean getExpiryAccessPeriodEnabled() {
|
||||
return expiryAccessPeriodEnabled;
|
||||
}
|
||||
|
||||
public boolean getContactsRequireSyncing() {
|
||||
return contactsRequireSyncing;
|
||||
}
|
||||
@@ -615,7 +631,7 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link RegistrarPoc} that is the RDAP abuse contact for this registrar, or empty if
|
||||
* Returns a {@link RegistrarPoc} that is the RDAP abuse contact for this registrar, or empty if
|
||||
* one does not exist.
|
||||
*/
|
||||
public Optional<RegistrarPoc> getRdapAbuseContact() {
|
||||
@@ -654,6 +670,7 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
|
||||
.put("whoisServer", getWhoisServer())
|
||||
.putListOfStrings("rdapBaseUrls", getRdapBaseUrls())
|
||||
.put("blockPremiumNames", blockPremiumNames)
|
||||
.put("expiryAccessPeriodEnabled", expiryAccessPeriodEnabled)
|
||||
.put("url", url)
|
||||
.put("icannReferralEmail", getIcannReferralEmail())
|
||||
.put("driveFolderId", driveFolderId)
|
||||
@@ -918,6 +935,11 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setExpiryAccessPeriodEnabled(boolean expiryAccessPeriodEnabled) {
|
||||
getInstance().expiryAccessPeriodEnabled = expiryAccessPeriodEnabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setUrl(String url) {
|
||||
getInstance().url = url;
|
||||
return this;
|
||||
|
||||
@@ -37,6 +37,14 @@ public class RegistrarAddress extends Address {
|
||||
return new Builder(clone(this));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateState() {
|
||||
super.validateState();
|
||||
checkNotNull(forceEmptyToNull(getStreet()), "Missing street");
|
||||
checkNotNull(getCity(), "Missing city");
|
||||
checkNotNull(getCountryCode(), "Missing country code");
|
||||
}
|
||||
|
||||
/** Builder for {@link RegistrarAddress}. */
|
||||
public static class Builder extends Address.Builder<RegistrarAddress> {
|
||||
public Builder() {}
|
||||
@@ -44,14 +52,5 @@ public class RegistrarAddress extends Address {
|
||||
private Builder(RegistrarAddress instance) {
|
||||
super(instance);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RegistrarAddress build() {
|
||||
RegistrarAddress instance = getInstance();
|
||||
checkNotNull(forceEmptyToNull(instance.getStreet()), "Missing street");
|
||||
checkNotNull(instance.getCity(), "Missing city");
|
||||
checkNotNull(instance.getCountryCode(), "Missing country code");
|
||||
return super.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import google.registry.model.EntityYamlUtils.OptionalDurationSerializer;
|
||||
import google.registry.model.EntityYamlUtils.OptionalStringSerializer;
|
||||
import google.registry.model.EntityYamlUtils.SortedEnumSetSerializer;
|
||||
import google.registry.model.EntityYamlUtils.SortedSetSerializer;
|
||||
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyExpiryAccessPeriodModeDeserializer;
|
||||
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyMoneyDeserializer;
|
||||
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyTldStateDeserializer;
|
||||
import google.registry.model.EntityYamlUtils.TokenVKeyListDeserializer;
|
||||
@@ -76,6 +77,7 @@ import google.registry.persistence.EntityCallbacksListener.RecursivePostUpdate;
|
||||
import google.registry.persistence.VKey;
|
||||
import google.registry.persistence.converter.AllocationTokenVkeyListUserType;
|
||||
import google.registry.persistence.converter.BillingCostTransitionUserType;
|
||||
import google.registry.persistence.converter.ExpiryAccessPeriodModeTransitionUserType;
|
||||
import google.registry.persistence.converter.TldStateTransitionUserType;
|
||||
import google.registry.tldconfig.idn.IdnTableEnum;
|
||||
import google.registry.util.Idn;
|
||||
@@ -120,6 +122,8 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
|
||||
|
||||
public static final boolean DEFAULT_ESCROW_ENABLED = false;
|
||||
public static final boolean DEFAULT_DNS_PAUSED = false;
|
||||
public static final ExpiryAccessPeriodMode DEFAULT_EXPIRY_ACCESS_PERIOD_MODE =
|
||||
ExpiryAccessPeriodMode.DISABLED;
|
||||
public static final Duration DEFAULT_ADD_GRACE_PERIOD = Duration.ofDays(5);
|
||||
public static final Duration DEFAULT_AUTO_RENEW_GRACE_PERIOD = Duration.ofDays(45);
|
||||
public static final Duration DEFAULT_REDEMPTION_GRACE_PERIOD = Duration.ofDays(30);
|
||||
@@ -197,6 +201,12 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
|
||||
PDT
|
||||
}
|
||||
|
||||
/** The modes an Expiry Access Period (XAP) can be in at any given point in time. */
|
||||
public enum ExpiryAccessPeriodMode {
|
||||
DISABLED,
|
||||
ENABLED
|
||||
}
|
||||
|
||||
/** Returns the TLD for a given TLD, throwing if none exists. */
|
||||
public static Tld get(String tld) {
|
||||
return CACHE.get(tld).orElseThrow(() -> new TldNotFoundException(tld));
|
||||
@@ -426,6 +436,19 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
|
||||
@Column(nullable = false)
|
||||
boolean dnsPaused = DEFAULT_DNS_PAUSED;
|
||||
|
||||
/**
|
||||
* Whether the Expiry Access Period following domain deletes is enabled for this TLD.
|
||||
*
|
||||
* <p>TODO(mcilwain): Once this Java code has been fully deployed to production, drop the
|
||||
* temporary database-level DEFAULT constraint on the "expiry_access_period_transitions" column in
|
||||
* the "Tld" table in a subsequent schema release.
|
||||
*/
|
||||
@Column(nullable = false, columnDefinition = "hstore")
|
||||
@Type(ExpiryAccessPeriodModeTransitionUserType.class)
|
||||
@JsonDeserialize(using = TimedTransitionPropertyExpiryAccessPeriodModeDeserializer.class)
|
||||
TimedTransitionProperty<ExpiryAccessPeriodMode> expiryAccessPeriodTransitions =
|
||||
TimedTransitionProperty.withInitialValue(DEFAULT_EXPIRY_ACCESS_PERIOD_MODE);
|
||||
|
||||
/**
|
||||
* The length of the add grace period for this TLD.
|
||||
*
|
||||
@@ -634,6 +657,14 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
|
||||
return dnsPaused;
|
||||
}
|
||||
|
||||
public ExpiryAccessPeriodMode getExpiryAccessPeriodModeAt(Instant now) {
|
||||
return expiryAccessPeriodTransitions.getValueAtTime(now);
|
||||
}
|
||||
|
||||
public ImmutableSortedMap<Instant, ExpiryAccessPeriodMode> getExpiryAccessPeriodTransitions() {
|
||||
return expiryAccessPeriodTransitions.toValueMap();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getDriveFolderId() {
|
||||
return driveFolderId;
|
||||
@@ -830,6 +861,7 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
|
||||
createBillingCostTransitions.checkValidity();
|
||||
renewBillingCostTransitions.checkValidity();
|
||||
eapFeeSchedule.checkValidity();
|
||||
expiryAccessPeriodTransitions.checkValidity();
|
||||
// All costs must be in the expected currency.
|
||||
checkArgumentNotNull(getCurrency(), "Currency must be set");
|
||||
Predicate<Money> currencyCheck =
|
||||
@@ -913,6 +945,13 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap<Instant, ExpiryAccessPeriodMode> transitionsMap) {
|
||||
getInstance().expiryAccessPeriodTransitions =
|
||||
TimedTransitionProperty.fromValueMap(transitionsMap);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setDriveFolderId(String driveFolderId) {
|
||||
getInstance().driveFolderId = driveFolderId;
|
||||
return this;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,6 @@ import google.registry.groups.GroupsModule;
|
||||
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 google.registry.util.UtilsModule;
|
||||
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 @@ import jakarta.inject.Singleton;
|
||||
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 @@ import jakarta.inject.Singleton;
|
||||
UtilsModule.class,
|
||||
VoidDnsWriterModule.class,
|
||||
})
|
||||
interface RegistryComponent {
|
||||
public interface RegistryComponent {
|
||||
RequestHandler<RequestComponent> requestHandler();
|
||||
|
||||
RequestAuthenticator requestAuthenticator();
|
||||
|
||||
Lazy<MetricReporter> metricReporter();
|
||||
|
||||
@Config("projectId")
|
||||
String projectId();
|
||||
|
||||
@Module
|
||||
/** Module for {@link RegistryComponent}. */
|
||||
@Module(subcomponents = RequestComponent.class)
|
||||
class RegistryModule {
|
||||
@Provides
|
||||
static RequestHandler<RequestComponent> provideRequestHandler(
|
||||
|
||||
@@ -22,8 +22,6 @@ import static google.registry.util.GcpJsonFormatter.unsetLabels;
|
||||
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<RequestComponent> requestHandler = component.requestHandler();
|
||||
private static final Lazy<MetricReporter> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.io.IOException;
|
||||
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> metricReporter;
|
||||
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||
private static final SystemClock clock = new SystemClock();
|
||||
|
||||
public ServletBase(RequestHandler<?> requestHandler, Lazy<MetricReporter> 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
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// 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.persistence.converter;
|
||||
|
||||
import google.registry.model.common.TimedTransitionProperty;
|
||||
import google.registry.model.tld.Tld.ExpiryAccessPeriodMode;
|
||||
|
||||
/** Hibernate custom type for {@link TimedTransitionProperty} of {@link ExpiryAccessPeriodMode}. */
|
||||
public class ExpiryAccessPeriodModeTransitionUserType
|
||||
extends TimedTransitionBaseUserType<ExpiryAccessPeriodMode> {
|
||||
|
||||
@Override
|
||||
String valueToString(ExpiryAccessPeriodMode value) {
|
||||
return value.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
ExpiryAccessPeriodMode stringToValue(String string) {
|
||||
return ExpiryAccessPeriodMode.valueOf(string);
|
||||
}
|
||||
}
|
||||
@@ -454,9 +454,8 @@ public class RdapDomainSearchAction extends RdapSearchActionBase {
|
||||
// We must break the query up into chunks, because the in operator is limited to 30 subqueries.
|
||||
// Since it is possible for the same domain to show up more than once in our result list (if
|
||||
// we do a wildcard nameserver search that returns multiple nameservers used by the same
|
||||
// domain), we must create a set of resulting {@link Domain} objects. Use a sorted set,
|
||||
// and fetch all domains, to make sure that we can return the first domains in alphabetical
|
||||
// order.
|
||||
// domain), we must create a set of resulting {@link Domain}s. Use a sorted set, fetch all
|
||||
// domains, to make sure that we can return the first domains in alphabetical order.
|
||||
ImmutableSortedSet.Builder<Domain> domainSetBuilder =
|
||||
ImmutableSortedSet.orderedBy(Comparator.comparing(Domain::getDomainName));
|
||||
int numHostKeysSearched = 0;
|
||||
@@ -465,7 +464,7 @@ public class RdapDomainSearchAction extends RdapSearchActionBase {
|
||||
replicaTm()
|
||||
.transact(
|
||||
() -> {
|
||||
for (VKey<Host> hostKey : hostKeys) {
|
||||
for (VKey<Host> hostKey : chunk) {
|
||||
CriteriaQueryBuilder<Domain> queryBuilder =
|
||||
CriteriaQueryBuilder.create(replicaTm(), Domain.class)
|
||||
.whereFieldContains("nsHosts", hostKey)
|
||||
|
||||
@@ -185,7 +185,7 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase {
|
||||
throw new UnprocessableEntityException(
|
||||
"A suffix after a wildcard in a nameserver lookup must be an in-bailiwick domain");
|
||||
}
|
||||
List<Host> hostList = new ArrayList<>();
|
||||
List<String> matchingFqhns = new ArrayList<>();
|
||||
for (String fqhn : ImmutableSortedSet.copyOf(domain.get().getSubordinateHosts())) {
|
||||
if (cursorString.isPresent() && (fqhn.compareTo(cursorString.get()) <= 0)) {
|
||||
continue;
|
||||
@@ -193,10 +193,22 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase {
|
||||
// We can't just check that the host name starts with the initial query string, because
|
||||
// then the query ns.exam*.example.com would match against nameserver ns.example.com.
|
||||
if (partialStringQuery.matches(fqhn)) {
|
||||
Optional<Host> host =
|
||||
ForeignKeyUtils.loadResourceByCache(Host.class, fqhn, getRequestTime());
|
||||
if (shouldBeVisible(host)) {
|
||||
hostList.add(host.get());
|
||||
matchingFqhns.add(fqhn);
|
||||
}
|
||||
}
|
||||
List<Host> hostList = new ArrayList<>();
|
||||
int chunkSize = getStandardQuerySizeLimit();
|
||||
// Batch load from cache in chunks to avoid sequential N+1 database queries on cache misses.
|
||||
for (List<String> fqhnChunk : Iterables.partition(matchingFqhns, chunkSize)) {
|
||||
if (hostList.size() > rdapResultSetMaxSize) {
|
||||
break;
|
||||
}
|
||||
ImmutableMap<String, Host> cachedHosts =
|
||||
ForeignKeyUtils.loadResourcesByCache(Host.class, fqhnChunk, getRequestTime());
|
||||
for (String fqhn : fqhnChunk) {
|
||||
Host host = cachedHosts.get(fqhn);
|
||||
if (host != null && shouldBeVisible(host)) {
|
||||
hostList.add(host);
|
||||
if (hostList.size() > rdapResultSetMaxSize) {
|
||||
break;
|
||||
}
|
||||
@@ -204,10 +216,7 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase {
|
||||
}
|
||||
}
|
||||
return makeSearchResults(
|
||||
hostList,
|
||||
IncompletenessWarningType.COMPLETE,
|
||||
domain.get().getSubordinateHosts().size(),
|
||||
CursorType.NAME);
|
||||
hostList, IncompletenessWarningType.COMPLETE, hostList.size(), CursorType.NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,6 +119,18 @@ public abstract class HttpException extends RuntimeException {
|
||||
}
|
||||
}
|
||||
|
||||
/** Exception that causes a 413 response. */
|
||||
public static final class PayloadTooLargeException extends HttpException {
|
||||
public PayloadTooLargeException(String message) {
|
||||
super(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, message, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getResponseCodeString() {
|
||||
return "Payload Too Large";
|
||||
}
|
||||
}
|
||||
|
||||
/** Exception that causes a 404 response. */
|
||||
public static final class NotFoundException extends HttpException {
|
||||
public NotFoundException() {
|
||||
|
||||
@@ -32,7 +32,6 @@ import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.collect.ImmutableListMultimap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.io.ByteStreams;
|
||||
import com.google.common.io.CharStreams;
|
||||
import com.google.common.net.MediaType;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
@@ -42,6 +41,7 @@ import com.google.protobuf.ByteString;
|
||||
import dagger.Module;
|
||||
import dagger.Provides;
|
||||
import google.registry.request.HttpException.BadRequestException;
|
||||
import google.registry.request.HttpException.PayloadTooLargeException;
|
||||
import google.registry.request.HttpException.UnsupportedMediaTypeException;
|
||||
import google.registry.request.auth.AuthResult;
|
||||
import google.registry.request.lock.LockHandler;
|
||||
@@ -50,6 +50,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -57,6 +58,8 @@ import java.util.Optional;
|
||||
@Module
|
||||
public final class RequestModule {
|
||||
|
||||
public static final int MAX_PAYLOAD_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
private final HttpServletRequest req;
|
||||
private final HttpServletResponse rsp;
|
||||
private final AuthResult authResult;
|
||||
@@ -167,9 +170,37 @@ public final class RequestModule {
|
||||
|
||||
@Provides
|
||||
@Payload
|
||||
static String providePayloadAsString(HttpServletRequest req) {
|
||||
public static String providePayloadAsString(
|
||||
@Payload byte[] payloadBytes, HttpServletRequest req) {
|
||||
String charsetName = req.getCharacterEncoding();
|
||||
Charset charset;
|
||||
try {
|
||||
return CharStreams.toString(req.getReader());
|
||||
charset = (charsetName != null) ? Charset.forName(charsetName) : UTF_8;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new UnsupportedMediaTypeException("Unsupported charset: " + charsetName, e);
|
||||
}
|
||||
return new String(payloadBytes, charset);
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Payload
|
||||
public static byte[] providePayloadAsBytes(HttpServletRequest req) {
|
||||
try {
|
||||
if (req.getContentLengthLong() > MAX_PAYLOAD_BYTES) {
|
||||
throw new PayloadTooLargeException(
|
||||
String.format(
|
||||
"Payload size %d exceeds limit of %d bytes",
|
||||
req.getContentLengthLong(), MAX_PAYLOAD_BYTES));
|
||||
}
|
||||
if (req.getInputStream() == null) {
|
||||
return new byte[0];
|
||||
}
|
||||
byte[] bytes =
|
||||
ByteStreams.toByteArray(ByteStreams.limit(req.getInputStream(), MAX_PAYLOAD_BYTES + 1));
|
||||
if (bytes.length > MAX_PAYLOAD_BYTES) {
|
||||
throw new PayloadTooLargeException("Payload exceeds maximum allowed size");
|
||||
}
|
||||
return bytes;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
@@ -177,22 +208,8 @@ public final class RequestModule {
|
||||
|
||||
@Provides
|
||||
@Payload
|
||||
static byte[] providePayloadAsBytes(HttpServletRequest req) {
|
||||
try {
|
||||
return ByteStreams.toByteArray(req.getInputStream());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Payload
|
||||
static ByteString providePayloadAsByteString(HttpServletRequest req) {
|
||||
try {
|
||||
return ByteString.copyFrom(ByteStreams.toByteArray(req.getInputStream()));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
public static ByteString providePayloadAsByteString(@Payload byte[] payloadBytes) {
|
||||
return ByteString.copyFrom(payloadBytes);
|
||||
}
|
||||
|
||||
@Provides
|
||||
@@ -251,12 +268,10 @@ public final class RequestModule {
|
||||
|
||||
@Provides
|
||||
@OptionalJsonPayload
|
||||
public static Optional<JsonElement> provideJsonBody(HttpServletRequest req, Gson gson) {
|
||||
try {
|
||||
// GET requests return a null reader and thus a null JsonObject, which is fine
|
||||
return Optional.ofNullable(gson.fromJson(req.getReader(), JsonElement.class));
|
||||
} catch (IOException e) {
|
||||
public static Optional<JsonElement> provideJsonBody(@Payload String payloadString, Gson gson) {
|
||||
if (payloadString.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(gson.fromJson(payloadString, JsonElement.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import static com.google.common.net.HttpHeaders.CONTENT_LENGTH;
|
||||
import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import com.google.common.flogger.FluentLogger;
|
||||
import com.google.common.io.ByteStreams;
|
||||
import com.google.common.net.MediaType;
|
||||
import java.io.ByteArrayInputStream;
|
||||
@@ -37,6 +38,11 @@ import org.apache.commons.compress.utils.IOUtils;
|
||||
/** Utilities for common functionality relating to {@link URLConnection}s. */
|
||||
public final class UrlConnectionUtils {
|
||||
|
||||
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||
|
||||
// 100 MB is arbitrary, not specifically selected for any reason
|
||||
public static final int MAX_RESPONSE_PAYLOAD_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
private UrlConnectionUtils() {}
|
||||
|
||||
/**
|
||||
@@ -48,10 +54,26 @@ public final class UrlConnectionUtils {
|
||||
* @see HttpURLConnection#getErrorStream()
|
||||
*/
|
||||
public static byte[] getResponseBytes(HttpURLConnection connection) throws IOException {
|
||||
int responseCode = connection.getResponseCode();
|
||||
try (InputStream is =
|
||||
responseCode < 400 ? connection.getInputStream() : connection.getErrorStream()) {
|
||||
return ByteStreams.toByteArray(is);
|
||||
try {
|
||||
if (connection.getContentLengthLong() > MAX_RESPONSE_PAYLOAD_BYTES) {
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Response size %d exceeds limit of %d bytes",
|
||||
connection.getContentLengthLong(), MAX_RESPONSE_PAYLOAD_BYTES));
|
||||
} else if ((double) connection.getContentLengthLong() / MAX_RESPONSE_PAYLOAD_BYTES >= 0.9) {
|
||||
logger.atSevere().log(
|
||||
"Response from %s was within 90%% of the maximum response size", connection.getURL());
|
||||
}
|
||||
int responseCode = connection.getResponseCode();
|
||||
try (InputStream is =
|
||||
responseCode < 400 ? connection.getInputStream() : connection.getErrorStream()) {
|
||||
byte[] bytes =
|
||||
ByteStreams.toByteArray(ByteStreams.limit(is, MAX_RESPONSE_PAYLOAD_BYTES + 1));
|
||||
if (bytes.length > MAX_RESPONSE_PAYLOAD_BYTES) {
|
||||
throw new IOException("Response exceeds maximum allowed size");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
} catch (NullPointerException e) {
|
||||
return new byte[] {};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import javax.xml.validation.Schema;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
@@ -84,6 +85,27 @@ public class TmchXmlSignature {
|
||||
checkArgument(smdXml.length > 0);
|
||||
Document doc = parseSmdDocument(new ByteArrayInputStream(smdXml));
|
||||
|
||||
// Verify root element is <smd:signedMark> in correct namespace
|
||||
Element rootElement = doc.getDocumentElement();
|
||||
if (!"signedMark".equals(rootElement.getLocalName())
|
||||
|| !"urn:ietf:params:xml:ns:signedMark-1.0".equals(rootElement.getNamespaceURI())) {
|
||||
throw new XMLSignatureException(
|
||||
"Root element must be signedMark in namespace urn:ietf:params:xml:ns:signedMark-1.0");
|
||||
}
|
||||
|
||||
// Assert exactly one <smd:signedMark> element exists in the DOM to prevent wrapping/nesting
|
||||
NodeList signedMarkNodes =
|
||||
doc.getElementsByTagNameNS("urn:ietf:params:xml:ns:signedMark-1.0", "signedMark");
|
||||
if (signedMarkNodes.getLength() != 1) {
|
||||
throw new XMLSignatureException(
|
||||
"Expected exactly one <smd:signedMark> element in the document.");
|
||||
}
|
||||
|
||||
String rootId = rootElement.getAttribute("id");
|
||||
if (rootId.isEmpty()) {
|
||||
throw new XMLSignatureException("Root signedMark element must have an id attribute.");
|
||||
}
|
||||
|
||||
NodeList signatureNodes = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
|
||||
if (signatureNodes.getLength() != 1) {
|
||||
throw new XMLSignatureException("Expected exactly one <ds:Signature> element.");
|
||||
@@ -91,8 +113,20 @@ public class TmchXmlSignature {
|
||||
XMLSignatureFactory factory = XMLSignatureFactory.getInstance("DOM");
|
||||
KeyValueKeySelector selector = new KeyValueKeySelector(tmchCertificateAuthority);
|
||||
DOMValidateContext context = new DOMValidateContext(selector, signatureNodes.item(0));
|
||||
|
||||
// Explicitly register the root ID attribute in the validation context just in case
|
||||
context.setIdAttributeNS(rootElement, null, "id");
|
||||
|
||||
XMLSignature signature = factory.unmarshalXMLSignature(context);
|
||||
|
||||
// Verify that the signature Reference URI matches the root element ID ("#" + rootId)
|
||||
String expectedUri = String.format("#%s", rootId);
|
||||
List<Reference> references = signature.getSignedInfo().getReferences();
|
||||
if (references.stream().noneMatch(ref -> expectedUri.equals(ref.getURI()))) {
|
||||
throw new XMLSignatureException(
|
||||
"Signature Reference URI does not match the root element ID.");
|
||||
}
|
||||
|
||||
boolean isValid;
|
||||
try {
|
||||
isValid = signature.validate(context);
|
||||
|
||||
@@ -88,7 +88,8 @@ public class BulkDomainTransferCommand extends ConfirmingCommand implements Comm
|
||||
|
||||
@Parameter(
|
||||
names = {"--registrar_request"},
|
||||
description = "Whether the change was requested by a registrar.")
|
||||
description = "Whether the change was requested by a registrar.",
|
||||
arity = 1)
|
||||
private boolean requestedByRegistrar = false;
|
||||
|
||||
@Parameter(
|
||||
|
||||
@@ -38,6 +38,10 @@ public class CreateUserCommand extends CreateOrUpdateUserCommand implements Comm
|
||||
@Config("gSuiteConsoleUserGroupEmailAddress")
|
||||
Optional<String> maybeGroupEmailAddress;
|
||||
|
||||
@Inject
|
||||
@Config("consoleIapServiceId")
|
||||
Optional<String> consoleIapServiceId;
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
User getExistingUser(String email) {
|
||||
@@ -49,7 +53,8 @@ public class CreateUserCommand extends CreateOrUpdateUserCommand implements Comm
|
||||
@Override
|
||||
protected String execute() throws Exception {
|
||||
String ret = super.execute();
|
||||
grantIapPermission(email, maybeGroupEmailAddress, null, connection, iamClient);
|
||||
grantIapPermission(
|
||||
email, maybeGroupEmailAddress, consoleIapServiceId, null, connection, iamClient);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@ final class DeleteDomainCommand extends MutatingEppToolCommand {
|
||||
|
||||
@Parameter(
|
||||
names = {"--registrar_request"},
|
||||
description = "Whether the change was requested by a registrar.")
|
||||
description = "Whether the change was requested by a registrar.",
|
||||
arity = 1)
|
||||
private boolean requestedByRegistrar = false;
|
||||
|
||||
@Override
|
||||
|
||||
@@ -28,6 +28,9 @@ import google.registry.config.RegistryConfig.Config;
|
||||
import google.registry.model.ForeignKeyUtils;
|
||||
import google.registry.model.billing.BillingBase.Reason;
|
||||
import google.registry.model.billing.BillingEvent;
|
||||
import google.registry.model.console.ConsolePermission;
|
||||
import google.registry.model.console.User;
|
||||
import google.registry.model.console.UserRoles;
|
||||
import google.registry.model.domain.Domain;
|
||||
import google.registry.model.domain.DomainHistory;
|
||||
import google.registry.model.domain.RegistryLock;
|
||||
@@ -100,16 +103,16 @@ public final class DomainLockUtils {
|
||||
*
|
||||
* <p>This assumes that the lock object / domain in question has a pending lock or unlock.
|
||||
*/
|
||||
public RegistryLock verifyVerificationCode(String verificationCode, boolean isAdmin) {
|
||||
public RegistryLock verifyVerificationCode(String verificationCode, User user) {
|
||||
RegistryLock result =
|
||||
tm().transact(
|
||||
() -> {
|
||||
RegistryLock lock = getByVerificationCode(verificationCode);
|
||||
if (lock.getLockCompletionTime().isEmpty()) {
|
||||
return verifyAndApplyLock(lock, isAdmin);
|
||||
return verifyAndApplyLock(lock, user);
|
||||
} else if (lock.getUnlockRequestTime().isPresent()
|
||||
&& lock.getUnlockCompletionTime().isEmpty()) {
|
||||
return verifyAndApplyUnlock(lock, isAdmin);
|
||||
return verifyAndApplyUnlock(lock, user);
|
||||
} else {
|
||||
throw new IllegalArgumentException(
|
||||
String.format(
|
||||
@@ -203,11 +206,18 @@ public final class DomainLockUtils {
|
||||
countdown));
|
||||
}
|
||||
|
||||
private RegistryLock verifyAndApplyLock(RegistryLock lock, boolean isAdmin) {
|
||||
private RegistryLock verifyAndApplyLock(RegistryLock lock, User user) {
|
||||
Instant now = tm().getTxTime();
|
||||
checkArgument(
|
||||
!lock.isLockRequestExpired(now), "The pending lock has expired; please try again");
|
||||
|
||||
UserRoles userRoles = user.getUserRoles();
|
||||
boolean isAdmin = userRoles.isAdmin();
|
||||
checkArgument(
|
||||
userRoles.hasPermission(lock.getRegistrarId(), ConsolePermission.REGISTRY_LOCK),
|
||||
"User %s does not have registry lock permission on registrar %s",
|
||||
user.getEmailAddress(),
|
||||
lock.getRegistrarId());
|
||||
checkArgument(!lock.isSuperuser() || isAdmin, "Non-admin user cannot complete admin lock");
|
||||
|
||||
RegistryLock newLock =
|
||||
@@ -217,13 +227,29 @@ public final class DomainLockUtils {
|
||||
return newLock;
|
||||
}
|
||||
|
||||
private RegistryLock verifyAndApplyUnlock(RegistryLock lock, boolean isAdmin) {
|
||||
private RegistryLock verifyAndApplyUnlock(RegistryLock lock, User user) {
|
||||
Instant now = tm().getTxTime();
|
||||
checkArgument(
|
||||
!lock.isUnlockRequestExpired(now), "The pending unlock has expired; please try again");
|
||||
|
||||
checkArgument(isAdmin || !lock.isSuperuser(), "Non-admin user cannot complete admin unlock");
|
||||
UserRoles userRoles = user.getUserRoles();
|
||||
boolean isAdmin = userRoles.isAdmin();
|
||||
checkArgument(
|
||||
userRoles.hasPermission(lock.getRegistrarId(), ConsolePermission.REGISTRY_LOCK),
|
||||
"User %s does not have registry lock permission on registrar %s",
|
||||
user.getEmailAddress(),
|
||||
lock.getRegistrarId());
|
||||
checkArgument(!lock.isSuperuser() || isAdmin, "Non-admin user cannot complete admin unlock");
|
||||
|
||||
// The pending unlock must still be the most recent RegistryLock for this domain. If a newer
|
||||
// lock exists (e.g. an admin/superuser lock applied after this unlock was requested or a
|
||||
// different unlock+relock), the verification code being redeemed is for a superseded row and
|
||||
// must not be allowed to strip the lock statuses that the newer row applied.
|
||||
Optional<RegistryLock> mostRecent = RegistryLockDao.getMostRecentByRepoId(lock.getRepoId());
|
||||
checkArgument(
|
||||
mostRecent.isPresent() && mostRecent.get().getRevisionId().equals(lock.getRevisionId()),
|
||||
"The pending unlock on %s has been superseded; please request a new unlock",
|
||||
lock.getDomainName());
|
||||
RegistryLock newLock =
|
||||
RegistryLockDao.save(lock.asBuilder().setUnlockCompletionTime(now).build());
|
||||
removeLockStatuses(newLock, isAdmin, now);
|
||||
|
||||
@@ -20,6 +20,8 @@ import com.beust.jcommander.Parameter;
|
||||
import com.beust.jcommander.Parameters;
|
||||
import google.registry.model.ForeignKeyUtils;
|
||||
import google.registry.model.domain.Domain;
|
||||
import google.registry.model.domain.DomainAuthInfo;
|
||||
import google.registry.model.eppcommon.AuthInfo.PasswordAuth;
|
||||
import google.registry.persistence.transaction.QueryComposer.Comparator;
|
||||
import google.registry.util.DomainNameUtils;
|
||||
import java.util.List;
|
||||
@@ -32,6 +34,12 @@ final class GetDomainCommand extends GetEppResourceCommand {
|
||||
@Parameter(names = "--show_deleted", description = "Include deleted domains in the print out")
|
||||
private boolean showDeleted = false;
|
||||
|
||||
@Parameter(
|
||||
names = "--show_authcode",
|
||||
description = "Include domain authentication code in output",
|
||||
arity = 1)
|
||||
private boolean showAuthcode = false;
|
||||
|
||||
@Parameter(
|
||||
description = "Fully qualified domain name(s)",
|
||||
required = true)
|
||||
@@ -51,14 +59,26 @@ final class GetDomainCommand extends GetEppResourceCommand {
|
||||
.stream()
|
||||
.forEach(
|
||||
d -> {
|
||||
printResource("Domain", canonicalDomain, Optional.of(d));
|
||||
printResource(
|
||||
"Domain", canonicalDomain, Optional.of(censorAuthcode(d)));
|
||||
}));
|
||||
} else {
|
||||
printResource(
|
||||
"Domain",
|
||||
canonicalDomain,
|
||||
ForeignKeyUtils.loadResource(Domain.class, canonicalDomain, readTimestamp));
|
||||
ForeignKeyUtils.loadResource(Domain.class, canonicalDomain, readTimestamp)
|
||||
.map(this::censorAuthcode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Domain censorAuthcode(Domain domain) {
|
||||
if (showAuthcode || domain.getAuthInfo() == null) {
|
||||
return domain;
|
||||
}
|
||||
return domain
|
||||
.asBuilder()
|
||||
.setAuthInfo(DomainAuthInfo.create(PasswordAuth.create("[REDACTED]")))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package google.registry.tools;
|
||||
|
||||
import com.google.api.services.cloudresourcemanager.CloudResourceManager;
|
||||
import com.google.api.services.cloudresourcemanager.model.Binding;
|
||||
import com.google.api.services.cloudresourcemanager.model.Expr;
|
||||
import com.google.api.services.cloudresourcemanager.model.GetIamPolicyRequest;
|
||||
import com.google.api.services.cloudresourcemanager.model.Policy;
|
||||
import com.google.api.services.cloudresourcemanager.model.SetIamPolicyRequest;
|
||||
@@ -31,7 +32,10 @@ import java.util.Optional;
|
||||
|
||||
@Singleton
|
||||
public class IamClient {
|
||||
|
||||
private static final String MEMBER_FORMAT = "user:%s";
|
||||
private static final String IAP_ACCESS_EXPRESSION_FORMAT =
|
||||
"resource.name == 'projects/%s/iap_web/compute/services/%s'";
|
||||
|
||||
private final CloudResourceManager resourceManager;
|
||||
private final String projectId;
|
||||
@@ -60,7 +64,7 @@ public class IamClient {
|
||||
*
|
||||
* <p>No-op if the role is already bound to the account.
|
||||
*/
|
||||
public void addBinding(String account, String role) {
|
||||
public void addBinding(String account, String role, String consoleIapServiceId) {
|
||||
String member = String.format(MEMBER_FORMAT, account);
|
||||
Policy policy = getPolicy();
|
||||
Binding binding =
|
||||
@@ -69,7 +73,21 @@ public class IamClient {
|
||||
.findFirst()
|
||||
.orElseGet(
|
||||
() -> {
|
||||
Binding newBinding = new Binding().setRole(role).setMembers(new ArrayList<>());
|
||||
Binding newBinding =
|
||||
new Binding()
|
||||
.setRole(role)
|
||||
.setMembers(new ArrayList<>())
|
||||
.setCondition(
|
||||
new Expr()
|
||||
.setTitle("Registrar Console IAP access")
|
||||
.setDescription(
|
||||
"Restrict IAP access only to the Registrar Console HTTP(s)"
|
||||
+ " load balancer")
|
||||
.setExpression(
|
||||
String.format(
|
||||
IAP_ACCESS_EXPRESSION_FORMAT,
|
||||
projectId,
|
||||
consoleIapServiceId)));
|
||||
policy.getBindings().add(newBinding);
|
||||
return newBinding;
|
||||
});
|
||||
|
||||
@@ -86,6 +86,10 @@ final class SetupOteCommand extends ConfirmingCommand {
|
||||
@Config("gSuiteConsoleUserGroupEmailAddress")
|
||||
Optional<String> maybeGroupEmailAddress;
|
||||
|
||||
@Inject
|
||||
@Config("consoleIapServiceId")
|
||||
Optional<String> consoleIapServiceId;
|
||||
|
||||
OteAccountBuilder oteAccountBuilder;
|
||||
String password;
|
||||
|
||||
@@ -138,7 +142,8 @@ you sure you didn't mean to run this against sandbox (e.g. "-e SANDBOX")?\
|
||||
@Override
|
||||
public String execute() {
|
||||
ImmutableMap<String, String> clientIdToTld = oteAccountBuilder.buildAndPersist();
|
||||
oteAccountBuilder.grantIapPermission(maybeGroupEmailAddress, cloudTasksUtils, iamClient);
|
||||
oteAccountBuilder.grantIapPermission(
|
||||
maybeGroupEmailAddress, consoleIapServiceId, cloudTasksUtils, iamClient);
|
||||
|
||||
StringBuilder output = new StringBuilder();
|
||||
|
||||
|
||||
@@ -91,10 +91,11 @@ public class ShellCommand implements Command {
|
||||
* flags aren't available in the constructor, so we have to do it in the {@link #run} function.
|
||||
*/
|
||||
private final CommandRunner originalRunner;
|
||||
|
||||
private final LineReader lineReader;
|
||||
private final Clock clock;
|
||||
|
||||
private LineReader lineReader;
|
||||
private Terminal terminal;
|
||||
private JCommander jcommanderForCompletions;
|
||||
private String prompt = null;
|
||||
|
||||
@Parameter(
|
||||
@@ -118,21 +119,22 @@ public class ShellCommand implements Command {
|
||||
""")
|
||||
boolean encapsulateOutput = false;
|
||||
|
||||
ShellCommand(CommandRunner runner) throws IOException {
|
||||
this(TerminalBuilder.terminal(), new SystemClock(), runner);
|
||||
prompt = "nom > ";
|
||||
lineReader.variable(LineReader.HISTORY_FILE, Path.of(USER_HOME.value(), HISTORY_FILE));
|
||||
ShellCommand(CommandRunner runner) {
|
||||
this.originalRunner = runner;
|
||||
this.clock = new SystemClock();
|
||||
this.prompt = "nom > ";
|
||||
}
|
||||
|
||||
ShellCommand(Terminal terminal, Clock clock, CommandRunner runner) {
|
||||
this.originalRunner = runner;
|
||||
this.terminal = terminal;
|
||||
this.lineReader = LineReaderBuilder.builder().terminal(terminal).build();
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
private void setPrompt(RegistryToolEnvironment environment, boolean alert) {
|
||||
// Do not set the prompt in tests.
|
||||
if (lineReader.getTerminal() instanceof DumbTerminal) {
|
||||
if (lineReader == null || lineReader.getTerminal() instanceof DumbTerminal) {
|
||||
return;
|
||||
}
|
||||
prompt =
|
||||
@@ -143,7 +145,10 @@ public class ShellCommand implements Command {
|
||||
}
|
||||
|
||||
public ShellCommand buildCompletions(JCommander jcommander) {
|
||||
((LineReaderImpl) lineReader).setCompleter(new JCommanderCompleter(jcommander));
|
||||
this.jcommanderForCompletions = jcommander;
|
||||
if (lineReader != null) {
|
||||
((LineReaderImpl) lineReader).setCompleter(new JCommanderCompleter(jcommander));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -209,6 +214,19 @@ public class ShellCommand implements Command {
|
||||
/** Run the shell until the user presses "Ctrl-D". */
|
||||
@Override
|
||||
public void run() {
|
||||
if (lineReader == null) {
|
||||
try {
|
||||
this.terminal = TerminalBuilder.terminal();
|
||||
this.lineReader = LineReaderBuilder.builder().terminal(terminal).build();
|
||||
lineReader.variable(LineReader.HISTORY_FILE, Path.of(USER_HOME.value(), HISTORY_FILE));
|
||||
if (jcommanderForCompletions != null) {
|
||||
((LineReaderImpl) lineReader)
|
||||
.setCompleter(new JCommanderCompleter(jcommanderForCompletions));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to initialize terminal", e);
|
||||
}
|
||||
}
|
||||
// Wrap standard output and error if requested. We have to do so here in run because the flags
|
||||
// haven't been processed in the constructor.
|
||||
CommandRunner runner =
|
||||
|
||||
@@ -107,6 +107,18 @@ public abstract class ConsoleApiAction implements Runnable {
|
||||
|
||||
protected static void checkPermission(
|
||||
User user, String registrarId, ConsolePermission permission) {
|
||||
Registrar registrar =
|
||||
Registrar.loadByRegistrarIdCached(registrarId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ConsolePermissionForbiddenException(
|
||||
String.format("Registrar %s does not exist", registrarId)));
|
||||
if (!registrar.isLive()) {
|
||||
throw new ConsolePermissionForbiddenException(
|
||||
String.format(
|
||||
"Permission forbidden because registrar %s is currently %s",
|
||||
registrarId, registrar.getState()));
|
||||
}
|
||||
if (!user.getUserRoles().hasPermission(registrarId, permission)) {
|
||||
throw new ConsolePermissionForbiddenException(
|
||||
String.format(
|
||||
@@ -115,6 +127,14 @@ public abstract class ConsoleApiAction implements Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
protected static void checkGlobalPermission(User user, ConsolePermission permission) {
|
||||
if (!user.getUserRoles().hasGlobalPermission(permission)) {
|
||||
throw new ConsolePermissionForbiddenException(
|
||||
String.format(
|
||||
"User %s does not have global permission %s", user.getEmailAddress(), permission));
|
||||
}
|
||||
}
|
||||
|
||||
protected void postHandler(User user) {
|
||||
throw new UnsupportedOperationException("Console API POST handler not implemented");
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ public class ConsoleOteAction extends ConsoleApiAction {
|
||||
private final StringGenerator passwordGenerator;
|
||||
private final Optional<OteCreateData> oteCreateData;
|
||||
private final Optional<String> maybeGroupEmailAddress;
|
||||
private final Optional<String> consoleIapServiceId;
|
||||
private final IamClient iamClient;
|
||||
private final String registrarId;
|
||||
|
||||
@@ -71,12 +72,14 @@ public class ConsoleOteAction extends ConsoleApiAction {
|
||||
IamClient iamClient,
|
||||
@Parameter("registrarId") String registrarId, // Get request param
|
||||
@Config("gSuiteConsoleUserGroupEmailAddress") Optional<String> maybeGroupEmailAddress,
|
||||
@Config("consoleIapServiceId") Optional<String> consoleIapServiceId,
|
||||
@Named("base58StringGenerator") StringGenerator passwordGenerator,
|
||||
@Parameter("oteCreateData") Optional<OteCreateData> oteCreateData) {
|
||||
super(consoleApiParams);
|
||||
this.passwordGenerator = passwordGenerator;
|
||||
this.oteCreateData = oteCreateData;
|
||||
this.maybeGroupEmailAddress = maybeGroupEmailAddress;
|
||||
this.consoleIapServiceId = consoleIapServiceId;
|
||||
this.iamClient = iamClient;
|
||||
this.registrarId = registrarId;
|
||||
}
|
||||
@@ -105,7 +108,8 @@ public class ConsoleOteAction extends ConsoleApiAction {
|
||||
.setPassword(password);
|
||||
|
||||
ImmutableMap<String, String> registrarIdToTld = oteAccountBuilder.buildAndPersist();
|
||||
oteAccountBuilder.grantIapPermission(maybeGroupEmailAddress, cloudTasksUtils, iamClient);
|
||||
oteAccountBuilder.grantIapPermission(
|
||||
maybeGroupEmailAddress, consoleIapServiceId, cloudTasksUtils, iamClient);
|
||||
consoleApiParams.response().setStatus(SC_OK);
|
||||
consoleApiParams
|
||||
.response()
|
||||
|
||||
+17
-19
@@ -15,7 +15,7 @@
|
||||
package google.registry.ui.server.console;
|
||||
|
||||
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
|
||||
import static google.registry.request.Action.Method.GET;
|
||||
import static google.registry.request.Action.Method.POST;
|
||||
|
||||
import com.google.common.base.Ascii;
|
||||
import com.google.gson.annotations.Expose;
|
||||
@@ -34,7 +34,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
@Action(
|
||||
service = Service.CONSOLE,
|
||||
path = ConsoleRegistryLockVerifyAction.PATH,
|
||||
method = {GET},
|
||||
method = {POST},
|
||||
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
|
||||
public class ConsoleRegistryLockVerifyAction extends ConsoleApiAction {
|
||||
|
||||
@@ -54,9 +54,8 @@ public class ConsoleRegistryLockVerifyAction extends ConsoleApiAction {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void getHandler(User user) {
|
||||
RegistryLock lock =
|
||||
domainLockUtils.verifyVerificationCode(lockVerificationCode, user.getUserRoles().isAdmin());
|
||||
protected void postHandler(User user) {
|
||||
RegistryLock lock = domainLockUtils.verifyVerificationCode(lockVerificationCode, user);
|
||||
RegistryLockAction action =
|
||||
lock.getUnlockCompletionTime().isPresent()
|
||||
? RegistryLockAction.UNLOCKED
|
||||
@@ -65,20 +64,19 @@ public class ConsoleRegistryLockVerifyAction extends ConsoleApiAction {
|
||||
new RegistryLockVerificationResponse(
|
||||
Ascii.toLowerCase(action.toString()), lock.getDomainName(), lock.getRegistrarId());
|
||||
tm().transact(
|
||||
() -> {
|
||||
finishAndPersistConsoleUpdateHistory(
|
||||
new ConsoleUpdateHistory.Builder()
|
||||
.setType(
|
||||
action == RegistryLockAction.LOCKED
|
||||
? ConsoleUpdateHistory.Type.REGISTRY_LOCK
|
||||
: ConsoleUpdateHistory.Type.REGISTRY_UNLOCK)
|
||||
.setDescription(
|
||||
String.format(
|
||||
"%s%s%s",
|
||||
lock.getRegistrarId(),
|
||||
ConsoleUpdateHistory.DESCRIPTION_SEPARATOR,
|
||||
lockResponse)));
|
||||
});
|
||||
() ->
|
||||
finishAndPersistConsoleUpdateHistory(
|
||||
new ConsoleUpdateHistory.Builder()
|
||||
.setType(
|
||||
action == RegistryLockAction.LOCKED
|
||||
? ConsoleUpdateHistory.Type.REGISTRY_LOCK
|
||||
: ConsoleUpdateHistory.Type.REGISTRY_UNLOCK)
|
||||
.setDescription(
|
||||
String.format(
|
||||
"%s%s%s",
|
||||
lock.getRegistrarId(),
|
||||
ConsoleUpdateHistory.DESCRIPTION_SEPARATOR,
|
||||
lockResponse))));
|
||||
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(lockResponse));
|
||||
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
|
||||
}
|
||||
|
||||
+31
-26
@@ -15,6 +15,7 @@
|
||||
package google.registry.ui.server.console;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkArgument;
|
||||
import static com.google.common.collect.ImmutableSet.toImmutableSet;
|
||||
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
|
||||
import static google.registry.request.Action.Method.POST;
|
||||
import static google.registry.util.DateTimeUtils.START_INSTANT;
|
||||
@@ -38,7 +39,6 @@ import google.registry.util.RegistryEnvironment;
|
||||
import jakarta.inject.Inject;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Action(
|
||||
service = Service.CONSOLE,
|
||||
@@ -69,22 +69,23 @@ public class ConsoleUpdateRegistrarAction extends ConsoleApiAction {
|
||||
|
||||
tm().transact(
|
||||
() -> {
|
||||
Optional<Registrar> existingRegistrar =
|
||||
Registrar.loadByRegistrarId(registrarParam.getRegistrarId());
|
||||
checkArgument(
|
||||
!existingRegistrar.isEmpty(),
|
||||
"Registrar with registrarId %s doesn't exists",
|
||||
registrarParam.getRegistrarId());
|
||||
Registrar existingRegistrar =
|
||||
Registrar.loadByRegistrarId(registrarParam.getRegistrarId())
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
String.format(
|
||||
"Registrar %s does not exist",
|
||||
registrarParam.getRegistrarId())));
|
||||
|
||||
// Only allow modifying allowed TLDs if we're in a non-PRODUCTION environment, if the
|
||||
// registrar is not REAL, or the registrar has a RDAP abuse contact set.
|
||||
if (!registrarParam.getAllowedTlds().isEmpty()) {
|
||||
boolean isRealRegistrar =
|
||||
Registrar.Type.REAL.equals(existingRegistrar.get().getType());
|
||||
boolean isRealRegistrar = Registrar.Type.REAL.equals(existingRegistrar.getType());
|
||||
if (RegistryEnvironment.PRODUCTION.equals(RegistryEnvironment.get())
|
||||
&& isRealRegistrar) {
|
||||
checkArgumentPresent(
|
||||
existingRegistrar.get().getRdapAbuseContact(),
|
||||
existingRegistrar.getRdapAbuseContact(),
|
||||
"Cannot modify allowed TLDs if there is no RDAP abuse contact set. Please"
|
||||
+ " use the \"nomulus registrar_contact\" command on this registrar to"
|
||||
+ " set a RDAP abuse contact.");
|
||||
@@ -103,20 +104,27 @@ public class ConsoleUpdateRegistrarAction extends ConsoleApiAction {
|
||||
|
||||
var updatedRegistrarBuilder =
|
||||
existingRegistrar
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setLastPocVerificationDate(newLastPocVerificationDate);
|
||||
|
||||
if (user.getUserRoles()
|
||||
.hasGlobalPermission(ConsolePermission.EDIT_REGISTRAR_DETAILS)) {
|
||||
updatedRegistrarBuilder =
|
||||
updatedRegistrarBuilder
|
||||
.setAllowedTlds(
|
||||
registrarParam.getAllowedTlds().stream()
|
||||
.map(DomainNameUtils::canonicalizeHostname)
|
||||
.collect(Collectors.toSet()))
|
||||
.setRegistryLockAllowed(registrarParam.isRegistryLockAllowed())
|
||||
.setLastPocVerificationDate(newLastPocVerificationDate);
|
||||
if (!registrarParam.getAllowedTlds().equals(existingRegistrar.getAllowedTlds())) {
|
||||
// The global permission EDIT_REGISTRAR_DETAILS signifies a support agent who *does*
|
||||
// have the ability to edit allowed TLDs. See support docs 2.19: "Enabling TLD
|
||||
// Access in Production"
|
||||
checkGlobalPermission(user, ConsolePermission.EDIT_REGISTRAR_DETAILS);
|
||||
updatedRegistrarBuilder.setAllowedTlds(
|
||||
registrarParam.getAllowedTlds().stream()
|
||||
.map(DomainNameUtils::canonicalizeHostname)
|
||||
.collect(toImmutableSet()));
|
||||
}
|
||||
|
||||
if (registrarParam.isRegistryLockAllowed()
|
||||
!= existingRegistrar.isRegistryLockAllowed()) {
|
||||
// Enabling registry lock requires a support lead or FTE, which maps to
|
||||
// MANAGE_REGISTRARS. See support docs 2.33: "Registry Lock Onboarding Process"
|
||||
checkGlobalPermission(user, ConsolePermission.MANAGE_REGISTRARS);
|
||||
updatedRegistrarBuilder.setRegistryLockAllowed(
|
||||
registrarParam.isRegistryLockAllowed());
|
||||
}
|
||||
|
||||
var updatedRegistrar = updatedRegistrarBuilder.build();
|
||||
@@ -126,14 +134,11 @@ public class ConsoleUpdateRegistrarAction extends ConsoleApiAction {
|
||||
.setType(ConsoleUpdateHistory.Type.REGISTRAR_UPDATE)
|
||||
.setDescription(updatedRegistrar.getRegistrarId()));
|
||||
|
||||
logConsoleChangesIfNecessary(updatedRegistrar, existingRegistrar.get());
|
||||
logConsoleChangesIfNecessary(updatedRegistrar, existingRegistrar);
|
||||
|
||||
sendExternalUpdatesIfNecessary(
|
||||
EmailInfo.create(
|
||||
existingRegistrar.get(),
|
||||
updatedRegistrar,
|
||||
ImmutableSet.of(),
|
||||
ImmutableSet.of()));
|
||||
existingRegistrar, updatedRegistrar, ImmutableSet.of(), ImmutableSet.of()));
|
||||
});
|
||||
|
||||
consoleApiParams.response().setStatus(SC_OK);
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.google.api.services.directory.model.UserName;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.flogger.FluentLogger;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import google.registry.config.RegistryConfig.Config;
|
||||
import google.registry.model.console.ConsolePermission;
|
||||
@@ -58,6 +59,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
@@ -69,6 +71,7 @@ import javax.annotation.Nullable;
|
||||
public class ConsoleUsersAction extends ConsoleApiAction {
|
||||
static final String PATH = "/console-api/users";
|
||||
|
||||
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||
private static final int PASSWORD_LENGTH = 16;
|
||||
|
||||
private final String registrarId;
|
||||
@@ -76,6 +79,7 @@ public class ConsoleUsersAction extends ConsoleApiAction {
|
||||
private final StringGenerator passwordGenerator;
|
||||
private final Optional<UserData> userData;
|
||||
private final Optional<String> maybeGroupEmailAddress;
|
||||
private final Optional<String> consoleIapServiceId;
|
||||
private final IamClient iamClient;
|
||||
private final String gSuiteDomainName;
|
||||
|
||||
@@ -86,6 +90,7 @@ public class ConsoleUsersAction extends ConsoleApiAction {
|
||||
IamClient iamClient,
|
||||
@Config("gSuiteDomainName") String gSuiteDomainName,
|
||||
@Config("gSuiteConsoleUserGroupEmailAddress") Optional<String> maybeGroupEmailAddress,
|
||||
@Config("consoleIapServiceId") Optional<String> consoleIapServiceId,
|
||||
@Named("base58StringGenerator") StringGenerator passwordGenerator,
|
||||
@Parameter("userData") Optional<UserData> userData,
|
||||
@Parameter("registrarId") String registrarId) {
|
||||
@@ -95,6 +100,7 @@ public class ConsoleUsersAction extends ConsoleApiAction {
|
||||
this.passwordGenerator = passwordGenerator;
|
||||
this.userData = userData;
|
||||
this.maybeGroupEmailAddress = maybeGroupEmailAddress;
|
||||
this.consoleIapServiceId = consoleIapServiceId;
|
||||
this.iamClient = iamClient;
|
||||
this.gSuiteDomainName = gSuiteDomainName;
|
||||
}
|
||||
@@ -184,11 +190,20 @@ public class ConsoleUsersAction extends ConsoleApiAction {
|
||||
|
||||
// User has no registrars assigned
|
||||
if (updatedUser.getUserRoles().getRegistrarRoles().isEmpty()) {
|
||||
try {
|
||||
directory.users().delete(email).execute();
|
||||
} catch (IOException e) {
|
||||
setFailedResponse("Failed to delete the user workspace account", SC_INTERNAL_SERVER_ERROR);
|
||||
throw e;
|
||||
// Only delete the Workspace account if runCreate() provably minted it. Addresses that
|
||||
// reached this row via CLI create_user, OteAccountBuilder, or other means must survive
|
||||
if (isConsoleMintedAddress(email)) {
|
||||
try {
|
||||
directory.users().delete(email).execute();
|
||||
} catch (IOException e) {
|
||||
setFailedResponse(
|
||||
"Failed to delete the user workspace account", SC_INTERNAL_SERVER_ERROR);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
logger.atInfo().log(
|
||||
"Skipping Workspace deletion for %s because the console did not mint the account",
|
||||
email);
|
||||
}
|
||||
|
||||
VKey<User> key = VKey.create(User.class, email);
|
||||
@@ -244,7 +259,8 @@ public class ConsoleUsersAction extends ConsoleApiAction {
|
||||
|
||||
User.Builder builder = new User.Builder().setUserRoles(userRoles).setEmailAddress(newEmail);
|
||||
tm().put(builder.build());
|
||||
User.grantIapPermission(newEmail, maybeGroupEmailAddress, cloudTasksUtils, null, iamClient);
|
||||
User.grantIapPermission(
|
||||
newEmail, maybeGroupEmailAddress, consoleIapServiceId, cloudTasksUtils, null, iamClient);
|
||||
sendConfirmationEmail(registrarId, newEmail, "Created user");
|
||||
consoleApiParams.response().setStatus(SC_CREATED);
|
||||
consoleApiParams
|
||||
@@ -368,6 +384,22 @@ public class ConsoleUsersAction extends ConsoleApiAction {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff {@code email} matches the exact shape produced by {@link #runCreate()} for the current
|
||||
* {@code registrarId}: {@code <3 alnum>.<registrarId>@<gSuiteDomainName>}.
|
||||
*
|
||||
* <p>Addresses of any other shape were provisioned outside this action and should not be passed
|
||||
* to {@code directory.users().delete()}.
|
||||
*/
|
||||
private boolean isConsoleMintedAddress(String email) {
|
||||
return email.endsWith("@" + gSuiteDomainName)
|
||||
&& Pattern.matches(
|
||||
String.format(
|
||||
"^[a-zA-Z0-9]{3}\\.%s@%s$",
|
||||
Pattern.quote(registrarId), Pattern.quote(gSuiteDomainName)),
|
||||
email);
|
||||
}
|
||||
|
||||
public record UserData(
|
||||
@Expose String emailAddress,
|
||||
@Expose String registryLockEmailAddress,
|
||||
|
||||
@@ -228,17 +228,23 @@ public class ContactAction extends ConsoleApiAction {
|
||||
|
||||
enforcePrimaryContactRestrictions(oldContactsByType, newContactsByType);
|
||||
ensurePhoneNumberNotRemovedForContactTypes(oldContactsByType, newContactsByType, Type.TECH);
|
||||
Optional<RegistrarPoc> domainRdapAbuseContact =
|
||||
getDomainRdapVisibleAbuseContact(updatedContacts);
|
||||
// If the new set has a domain RDAP abuse contact, it must have a phone number.
|
||||
if (domainRdapAbuseContact.isPresent()
|
||||
&& domainRdapAbuseContact.get().getPhoneNumber() == null) {
|
||||
throw new ContactRequirementException(
|
||||
"The abuse contact visible in domain RDAP query must have a phone number");
|
||||
}
|
||||
|
||||
ImmutableSet<RegistrarPoc> abusePocs =
|
||||
updatedContacts.stream()
|
||||
.filter(RegistrarPoc::getVisibleInDomainRdapAsAbuse)
|
||||
.collect(toImmutableSet());
|
||||
|
||||
// All abuse POCs must have a phone number attached
|
||||
abusePocs.forEach(
|
||||
poc -> {
|
||||
if (poc.getPhoneNumber() == null) {
|
||||
throw new ContactRequirementException(
|
||||
"The abuse contact visible in domain RDAP query must have a phone number");
|
||||
}
|
||||
});
|
||||
// If there was a domain RDAP abuse contact in the old set, the new set must have one.
|
||||
if (getDomainRdapVisibleAbuseContact(existingContacts).isPresent()
|
||||
&& domainRdapAbuseContact.isEmpty()) {
|
||||
if (existingContacts.stream().anyMatch(RegistrarPoc::getVisibleInDomainRdapAsAbuse)
|
||||
&& abusePocs.isEmpty()) {
|
||||
throw new ContactRequirementException(
|
||||
"An abuse contact visible in domain RDAP query must be designated");
|
||||
}
|
||||
@@ -255,26 +261,14 @@ public class ContactAction extends ConsoleApiAction {
|
||||
newContactsByType.get(Type.ADMIN).stream()
|
||||
.map(RegistrarPoc::getEmailAddress)
|
||||
.collect(toImmutableSet());
|
||||
if (!newAdminEmails.containsAll(oldAdminEmails)) {
|
||||
throw new ContactRequirementException(
|
||||
"Cannot remove or change the email address of primary contacts");
|
||||
// ADMIN (primary) PoC emails are used for EPP password resets and potentially other
|
||||
// security-sensitive tasks. A user with only EDIT_REGISTRAR_DETAILS should not be able to
|
||||
// change this.
|
||||
if (!newAdminEmails.equals(oldAdminEmails)) {
|
||||
throw new ContactRequirementException("Cannot alter the set of primary contacts");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the registrar contact whose phone number and email address is visible in domain RDAP
|
||||
* query as abuse contact (if any).
|
||||
*
|
||||
* <p>Frontend processing ensures that only one contact can be set as abuse contact in domain RDAP
|
||||
* record.
|
||||
*
|
||||
* <p>Therefore, it is possible to return inside the loop once one such contact is found.
|
||||
*/
|
||||
private static Optional<RegistrarPoc> getDomainRdapVisibleAbuseContact(
|
||||
Set<RegistrarPoc> contacts) {
|
||||
return contacts.stream().filter(RegistrarPoc::getVisibleInDomainRdapAsAbuse).findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that for each given registrar type, a phone number is present after update, if there was
|
||||
* one before.
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
// 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.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<String, String> headers = new HashMap<>();
|
||||
private final Map<String, Object> attributes = new HashMap<>();
|
||||
private String method = "POST";
|
||||
private String requestUri = "/_dr/epp";
|
||||
private final String serverName = "localhost";
|
||||
private byte[] body = new byte[0];
|
||||
private final 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<String> getHeaders(String name) {
|
||||
String value = headers.get(name);
|
||||
return Collections.enumeration(
|
||||
value == null ? Collections.emptyList() : Collections.singletonList(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> 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<Part> getParts() throws IOException, ServletException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Part getPart(String name) throws IOException, ServletException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends HttpUpgradeHandler> T upgrade(Class<T> handlerClass)
|
||||
throws IOException, ServletException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getAttribute(String name) {
|
||||
return attributes.get(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> 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<String> getParameterNames() {
|
||||
return Collections.emptyEnumeration();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getParameterValues(String name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String[]> 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<Locale> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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.util;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import com.google.common.collect.ArrayListMultimap;
|
||||
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.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Simple fake {@link HttpServletResponse} for internal use. */
|
||||
public class FakeHttpServletResponse implements HttpServletResponse {
|
||||
|
||||
private int status = SC_OK;
|
||||
private String contentType;
|
||||
private final ArrayListMultimap<String, String> headers = ArrayListMultimap.create();
|
||||
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.removeAll(name);
|
||||
headers.put(name, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addHeader(String name, String value) {
|
||||
headers.put(name, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHeader(String name) {
|
||||
List<String> values = headers.get(name);
|
||||
return values.isEmpty() ? null : values.get(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> getHeaders(String name) {
|
||||
return headers.get(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> 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() {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
|
||||
<hello/>
|
||||
</epp>
|
||||
@@ -21,15 +21,12 @@
|
||||
-- "tld":"","tlds":["a.how"],"icannActivityReportField":"srs-dom-check"}
|
||||
|
||||
SELECT
|
||||
-- Remove quotation marks from tld fields.
|
||||
REGEXP_EXTRACT(tld, '^"(.*)"$') AS tld,
|
||||
tld,
|
||||
activityReportField AS metricName,
|
||||
COUNT(*) AS count
|
||||
FROM (
|
||||
SELECT
|
||||
-- TODO(b/32486667): Replace with JSON.parse() UDF when available for views
|
||||
SPLIT(
|
||||
REGEXP_EXTRACT(JSON_EXTRACT(json, '$.tlds'), r'^\[(.*)\]$')) AS tlds,
|
||||
JSON_EXTRACT_STRING_ARRAY(json, '$.tlds') AS tlds,
|
||||
JSON_EXTRACT_SCALAR(json,
|
||||
'$.resourceType') AS resourceType,
|
||||
JSON_EXTRACT_SCALAR(json,
|
||||
@@ -43,10 +40,10 @@ FROM (
|
||||
WHERE
|
||||
STARTS_WITH(jsonPayload.message, "FLOW-LOG-SIGNATURE-METADATA")
|
||||
AND _TABLE_SUFFIX BETWEEN '%FIRST_DAY_OF_MONTH%' AND '%LAST_DAY_OF_MONTH%')
|
||||
) AS regexes
|
||||
) AS json_parsed
|
||||
JOIN
|
||||
-- Unnest the JSON-parsed tlds.
|
||||
UNNEST(regexes.tlds) AS tld
|
||||
UNNEST(json_parsed.tlds) AS tld
|
||||
-- Exclude cases that can't be tabulated correctly, where activityReportField
|
||||
-- is null/empty, or TLD is null/empty despite being a domain flow.
|
||||
WHERE
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// 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<String> idTokenSupplier;
|
||||
@Mock private ChannelHandlerContext ctx;
|
||||
@Mock private Channel channel;
|
||||
@Mock private RequestHandler<?> requestHandler;
|
||||
|
||||
@Mock private Attribute<Promise<X509Certificate>> certPromiseAttr;
|
||||
@Mock private Attribute<String> remoteAddressAttr;
|
||||
@Mock private Attribute<String> certHashAttr;
|
||||
@Mock private X509Certificate certificate;
|
||||
|
||||
private EppServiceHandler handler;
|
||||
private DefaultPromise<X509Certificate> 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);
|
||||
when(ctx.executor()).thenReturn(ImmediateEventExecutor.INSTANCE);
|
||||
}
|
||||
|
||||
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("<epp><greeting/></epp>");
|
||||
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 <greeting> frame is sent back to the client
|
||||
verify(ctx)
|
||||
.writeAndFlush(
|
||||
argThat(
|
||||
(ByteBuf buf) -> {
|
||||
String xml = buf.toString(UTF_8);
|
||||
return xml.equals("<epp><greeting/></epp>");
|
||||
}));
|
||||
}
|
||||
|
||||
@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 = "<epp><command><login><clID>RegistrarA</clID></login></command></epp>";
|
||||
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("<epp><response>success</response></epp>");
|
||||
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("<epp><response>success</response></epp>");
|
||||
}));
|
||||
|
||||
// On the next request, the cookie should be injected
|
||||
String eppCommandXml = "<epp><command><check></check></command></epp>";
|
||||
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 = "<epp><command><check></check></command></epp>";
|
||||
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 = "<epp><command><logout/></command></epp>";
|
||||
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("<epp><response>logout success</response></epp>");
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -184,16 +184,22 @@ class FlowReporterTest {
|
||||
|
||||
@Test
|
||||
void testRecordToLogs_metadata_invalidDomainName_stillGuessesTld() throws Exception {
|
||||
var maxLenTld = "a".repeat(63);
|
||||
var domainMaxLenTld = "a." + maxLenTld;
|
||||
var domainTldTooLong = "a." + maxLenTld + "b";
|
||||
when(flowReporter.eppInput.isDomainType()).thenReturn(true);
|
||||
when(flowReporter.eppInput.getSingleTargetId()).thenReturn(Optional.of("<foo@bar.com>"));
|
||||
when(flowReporter.eppInput.getTargetIds()).thenReturn(ImmutableList.of("<foo@bar.com>"));
|
||||
when(flowReporter.eppInput.getTargetIds())
|
||||
.thenReturn(ImmutableList.of("<foo@bar.com>", domainMaxLenTld, domainTldTooLong));
|
||||
flowReporter.recordToLogs();
|
||||
Map<String, Object> json =
|
||||
parseJsonMap(findFirstLogMessageByPrefix(handler, "FLOW-LOG-SIGNATURE-METADATA: "));
|
||||
assertThat(json).containsEntry("targetId", "<foo@bar.com>");
|
||||
assertThat(json).containsEntry("targetIds", ImmutableList.of("<foo@bar.com>"));
|
||||
assertThat(json).containsEntry("tld", "com>");
|
||||
assertThat(json).containsEntry("tlds", ImmutableList.of("com>"));
|
||||
assertThat(json)
|
||||
.containsEntry(
|
||||
"targetIds", ImmutableList.of("<foo@bar.com>", domainMaxLenTld, domainTldTooLong));
|
||||
assertThat(json).containsEntry("tld", "com-");
|
||||
assertThat(json).containsEntry("tlds", ImmutableList.of("com-", maxLenTld, maxLenTld + "..."));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -49,6 +49,8 @@ import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.ImmutableSortedMap;
|
||||
import com.google.common.collect.Ordering;
|
||||
import google.registry.config.RegistryConfig;
|
||||
import google.registry.config.RegistryConfigSettings;
|
||||
import google.registry.flows.EppException;
|
||||
import google.registry.flows.FlowUtils.GenericXmlSyntaxErrorException;
|
||||
import google.registry.flows.FlowUtils.NotLoggedInException;
|
||||
@@ -95,6 +97,7 @@ import google.registry.model.tld.Tld.TldState;
|
||||
import google.registry.model.tld.label.ReservedList;
|
||||
import google.registry.testing.DatabaseHelper;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.joda.money.Money;
|
||||
@@ -2447,6 +2450,283 @@ class DomainCheckFlowTest extends ResourceCheckFlowTestCase<DomainCheckFlow, Dom
|
||||
runFlowAssertResponse(outputXml);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFeeExtension_xapLabel_std_v1() throws Exception {
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(true)
|
||||
.build());
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
|
||||
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
|
||||
BigDecimal originalInitialFee =
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
|
||||
BigDecimal originalFinalFee =
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
|
||||
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
|
||||
ImmutableMap.of("USD", new BigDecimal("110.00"));
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
|
||||
ImmutableMap.of("USD", new BigDecimal("110.00"));
|
||||
|
||||
try {
|
||||
clock.setTo(Instant.parse("2009-01-06T00:00:00Z"));
|
||||
Instant deletionTime = Instant.parse("2009-01-01T00:00:00Z");
|
||||
persistDeletedDomain("example1.tld", deletionTime);
|
||||
|
||||
setEppInput("domain_check_fee_stdv1.xml");
|
||||
runFlowAssertResponse(loadFile("domain_check_fee_xap_response.xml"));
|
||||
} finally {
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
|
||||
ImmutableMap.of("USD", originalInitialFee);
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
|
||||
ImmutableMap.of("USD", originalFinalFee);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheck_xapLabel_notOptedIn_returnsReserved() throws Exception {
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
|
||||
persistDeletedDomain("example1.tld", deletionTime);
|
||||
setEppInput("domain_check_one_tld.xml");
|
||||
doCheckTest(
|
||||
create(false, "example1.tld", "Reserved"),
|
||||
create(true, "example2.tld", null),
|
||||
create(true, "example3.tld", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheck_recentlyDeletedDomain_afterXapEnds_notOptedIn_returnsAvailable() throws Exception {
|
||||
// Once the 10-day XAP window expires after deletion (e.g. day 11), checking the domain should
|
||||
// return available (avail="1") without XAP fees or reservation blocks for non-opted-in
|
||||
// registrars.
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
|
||||
persistDeletedDomain("example1.tld", deletionTime);
|
||||
setEppInput("domain_check_one_tld.xml");
|
||||
doCheckTest(
|
||||
create(true, "example1.tld", null),
|
||||
create(true, "example2.tld", null),
|
||||
create(true, "example3.tld", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheck_recentlyDeletedDomain_afterXapEnds_optedIn_returnsAvailable() throws Exception {
|
||||
// Once the 10-day XAP window expires after deletion (e.g. day 11), checking the domain should
|
||||
// return available (avail="1") without XAP fees for opted-in registrars.
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(true)
|
||||
.build());
|
||||
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
|
||||
persistDeletedDomain("example1.tld", deletionTime);
|
||||
setEppInput("domain_check_one_tld.xml");
|
||||
doCheckTest(
|
||||
create(true, "example1.tld", null),
|
||||
create(true, "example2.tld", null),
|
||||
create(true, "example3.tld", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheck_recentlyDeletedDomain_beforeXapStarts_notOptedIn_returnsAvailable()
|
||||
throws Exception {
|
||||
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, checking the
|
||||
// domain should return available (avail="1") without XAP fees or reservation blocks for
|
||||
// non-opted-in registrars.
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
|
||||
persistDeletedDomain("example1.tld", deletionTime);
|
||||
setEppInput("domain_check_one_tld.xml");
|
||||
doCheckTest(
|
||||
create(true, "example1.tld", null),
|
||||
create(true, "example2.tld", null),
|
||||
create(true, "example3.tld", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheck_recentlyDeletedDomain_beforeXapStarts_optedIn_returnsAvailable() throws Exception {
|
||||
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, checking the
|
||||
// domain should return available (avail="1") without XAP fees for opted-in registrars.
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(true)
|
||||
.build());
|
||||
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
|
||||
persistDeletedDomain("example1.tld", deletionTime);
|
||||
setEppInput("domain_check_one_tld.xml");
|
||||
doCheckTest(
|
||||
create(true, "example1.tld", null),
|
||||
create(true, "example2.tld", null),
|
||||
create(true, "example3.tld", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheck_agpDeletedRegularDomain_duringXap_returnsAvailable() throws Exception {
|
||||
// A regular domain (registered without an XAP fee) deleted during its Add Grace Period (AGP) is
|
||||
// exempt from XAP, returning available (avail="1") without reservation blocks or XAP fees when
|
||||
// checked during active XAP on the TLD.
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(
|
||||
START_INSTANT,
|
||||
Tld.ExpiryAccessPeriodMode.DISABLED,
|
||||
clock.now().minus(Duration.ofHours(1)),
|
||||
Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
Instant creationTime = clock.now().minus(Duration.ofDays(2));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
DatabaseHelper.persistDomainAsDeleted(
|
||||
DatabaseHelper.newDomain("example1.tld")
|
||||
.asBuilder()
|
||||
.setCreationTimeForTest(creationTime)
|
||||
.build(),
|
||||
deletionTime);
|
||||
setEppInput("domain_check_one_tld.xml");
|
||||
doCheckTest(
|
||||
create(true, "example1.tld", null),
|
||||
create(true, "example2.tld", null),
|
||||
create(true, "example3.tld", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheck_agpDeletedXapDomain_duringXap_returnsAvailable() throws Exception {
|
||||
// A domain originally registered with an XAP fee deleted during its Add Grace Period (AGP) is
|
||||
// exempt from XAP, returning available (avail="1") without reservation blocks or XAP fees when
|
||||
// checked during active XAP on the TLD.
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
Instant creationTime = clock.now().minus(Duration.ofHours(2));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
DatabaseHelper.persistDomainAsDeleted(
|
||||
DatabaseHelper.newDomain("example1.tld")
|
||||
.asBuilder()
|
||||
.setCreationTimeForTest(creationTime)
|
||||
.build(),
|
||||
deletionTime);
|
||||
setEppInput("domain_check_one_tld.xml");
|
||||
doCheckTest(
|
||||
create(true, "example1.tld", null),
|
||||
create(true, "example2.tld", null),
|
||||
create(true, "example3.tld", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCheck_anchorTenantDeletedAfterStandardAgp_duringXap_returnsReserved() throws Exception {
|
||||
// An anchor tenant domain deleted after standard AGP (5 days), e.g. on day 10 of its 30-day
|
||||
// anchor tenant AGP, is subject to XAP fees and reservation blocks (not exempt as an AGP
|
||||
// delete).
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setAddGracePeriodLength(Duration.ofDays(5))
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
Instant creationTime = clock.now().minus(Duration.ofDays(10));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
DatabaseHelper.persistDomainAsDeleted(
|
||||
DatabaseHelper.newDomain("example1.tld")
|
||||
.asBuilder()
|
||||
.setCreationTimeForTest(creationTime)
|
||||
.build(),
|
||||
deletionTime);
|
||||
setEppInput("domain_check_one_tld.xml");
|
||||
doCheckTest(
|
||||
create(false, "example1.tld", "Reserved"),
|
||||
create(true, "example2.tld", null),
|
||||
create(true, "example3.tld", null));
|
||||
}
|
||||
|
||||
static AllocationToken setUpDefaultToken() {
|
||||
return setUpDefaultToken("TheRegistrar");
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ import com.google.common.collect.ImmutableSortedMap;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.Ordering;
|
||||
import google.registry.config.RegistryConfig;
|
||||
import google.registry.config.RegistryConfigSettings;
|
||||
import google.registry.flows.EppException;
|
||||
import google.registry.flows.EppException.UnimplementedExtensionException;
|
||||
import google.registry.flows.EppRequestSource;
|
||||
@@ -113,6 +114,7 @@ import google.registry.flows.domain.DomainFlowUtils.FeeDescriptionMultipleMatche
|
||||
import google.registry.flows.domain.DomainFlowUtils.FeeDescriptionParseException;
|
||||
import google.registry.flows.domain.DomainFlowUtils.FeesMismatchException;
|
||||
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredDuringEarlyAccessProgramException;
|
||||
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredDuringExpiryAccessPeriodException;
|
||||
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredForPremiumNameException;
|
||||
import google.registry.flows.domain.DomainFlowUtils.InvalidDsRecordException;
|
||||
import google.registry.flows.domain.DomainFlowUtils.InvalidIdnDomainLabelException;
|
||||
@@ -2688,6 +2690,427 @@ class DomainCreateFlowTest extends ResourceFlowTestCase<DomainCreateFlow, Domain
|
||||
+ "during the Early Access Program. The EAP fee is: USD 100.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_domainInXap_withoutFeeAck_throwsException() throws Exception {
|
||||
// Creating a domain during an active XAP tier without acknowledging the XAP fee in the EPP fee
|
||||
// extension should fail with FeesRequiredDuringExpiryAccessPeriodException.
|
||||
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
|
||||
BigDecimal originalInitialFee =
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
|
||||
BigDecimal originalFinalFee =
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
|
||||
ImmutableMap.of("USD", new BigDecimal("100.00"));
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
|
||||
ImmutableMap.of("USD", new BigDecimal("100.00"));
|
||||
try {
|
||||
persistHosts();
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
setXapForTld("tld", deletionTime);
|
||||
Exception e =
|
||||
assertThrows(FeesRequiredDuringExpiryAccessPeriodException.class, this::runFlow);
|
||||
assertThat(e)
|
||||
.hasMessageThat()
|
||||
.isEqualTo(
|
||||
"Fees must be explicitly acknowledged when creating domains "
|
||||
+ "during the Expiry Access Period. The XAP fee is: USD 100.00");
|
||||
} finally {
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
|
||||
ImmutableMap.of("USD", originalInitialFee);
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
|
||||
ImmutableMap.of("USD", originalFinalFee);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_domainInXap_registrarNotOptedIn_throwsDomainReservedException()
|
||||
throws Exception {
|
||||
// When XAP is enabled on the TLD and the domain is in its XAP window, a registrar that has not
|
||||
// opted into XAP cannot register the domain and should receive a DomainReservedException.
|
||||
persistHosts();
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
persistResource(
|
||||
new Domain.Builder()
|
||||
.setDomainName(getUniqueIdFromCommand())
|
||||
.setDeletionTime(deletionTime)
|
||||
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
|
||||
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("9999-EXAMPLE")
|
||||
.build());
|
||||
Exception e = assertThrows(DomainReservedException.class, this::runFlow);
|
||||
assertThat(e).hasMessageThat().isEqualTo("example.tld is a reserved domain");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_domainInXap_optedIn_withFeeAck_chargesXapFee() throws Exception {
|
||||
// When an opted-in registrar acknowledges the XAP fee during an active XAP tier, domain
|
||||
// creation should succeed and persist a one-time XAP fee (Reason.FEE_EXPIRY_ACCESS) in
|
||||
// addition to the standard domain create fee (Reason.CREATE) and autorenew recurrence
|
||||
// (Reason.RENEW).
|
||||
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
|
||||
BigDecimal originalInitialFee =
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
|
||||
BigDecimal originalFinalFee =
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
|
||||
ImmutableMap.of("USD", new BigDecimal("100.00"));
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
|
||||
ImmutableMap.of("USD", new BigDecimal("100.00"));
|
||||
try {
|
||||
persistHosts();
|
||||
Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z");
|
||||
setXapForTld("tld", deletionTime);
|
||||
setEppInput(
|
||||
"domain_create_eap_fee.xml",
|
||||
new ImmutableMap.Builder<String, String>()
|
||||
.putAll(FEE_STD_1_0_MAP)
|
||||
.put("DESCRIPTION_1", "create")
|
||||
.put("DESCRIPTION_2", "Expiry Access Period")
|
||||
.build());
|
||||
runFlowAssertResponse(loadFile("domain_create_response_xap_fee.xml"));
|
||||
|
||||
Domain domain = reloadResourceByForeignKey();
|
||||
DomainHistory historyEntry = getHistoryEntries(domain, DomainHistory.class).get(0);
|
||||
assertBillingEvents(
|
||||
new BillingEvent.Builder()
|
||||
.setReason(Reason.CREATE)
|
||||
.setTargetId("example.tld")
|
||||
.setRegistrarId("TheRegistrar")
|
||||
.setPeriodYears(2)
|
||||
.setCost(Money.of(USD, 24))
|
||||
.setEventTime(clock.now())
|
||||
.setBillingTime(clock.now().plus(Tld.get("tld").getAddGracePeriodLength()))
|
||||
.setDomainHistory(historyEntry)
|
||||
.build(),
|
||||
new BillingEvent.Builder()
|
||||
.setReason(Reason.FEE_EXPIRY_ACCESS)
|
||||
.setTargetId("example.tld")
|
||||
.setRegistrarId("TheRegistrar")
|
||||
.setPeriodYears(1)
|
||||
.setCost(Money.of(USD, 100))
|
||||
.setEventTime(clock.now())
|
||||
.setBillingTime(clock.now().plus(Tld.get("tld").getAddGracePeriodLength()))
|
||||
.setDomainHistory(historyEntry)
|
||||
.build(),
|
||||
new BillingRecurrence.Builder()
|
||||
.setReason(Reason.RENEW)
|
||||
.setFlags(ImmutableSet.of(Flag.AUTO_RENEW))
|
||||
.setTargetId("example.tld")
|
||||
.setRegistrarId("TheRegistrar")
|
||||
.setEventTime(domain.getRegistrationExpirationTime())
|
||||
.setRecurrenceEndTime(END_INSTANT)
|
||||
.setDomainHistory(historyEntry)
|
||||
.build());
|
||||
} finally {
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
|
||||
ImmutableMap.of("USD", originalInitialFee);
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
|
||||
ImmutableMap.of("USD", originalFinalFee);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_premiumDomainInXap_optedIn_withFeeAck_chargesPremiumAndXapFees()
|
||||
throws Exception {
|
||||
// Creating a premium domain during active XAP should stack both fees, charging the premium
|
||||
// domain create fee (Reason.CREATE) plus the one-time XAP tier fee (Reason.FEE_EXPIRY_ACCESS).
|
||||
createTld("example");
|
||||
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
|
||||
BigDecimal originalInitialFee =
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
|
||||
BigDecimal originalFinalFee =
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
|
||||
ImmutableMap.of("USD", new BigDecimal("100.00"));
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
|
||||
ImmutableMap.of("USD", new BigDecimal("100.00"));
|
||||
try {
|
||||
persistHosts();
|
||||
setEppInput("domain_create_premium_xap.xml");
|
||||
Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z");
|
||||
setXapForTld("example", deletionTime);
|
||||
runFlowAssertResponse(loadFile("domain_create_response_premium_xap.xml"));
|
||||
|
||||
Domain domain = reloadResourceByForeignKey();
|
||||
DomainHistory historyEntry = getHistoryEntries(domain, DomainHistory.class).get(0);
|
||||
assertBillingEvents(
|
||||
new BillingEvent.Builder()
|
||||
.setReason(Reason.CREATE)
|
||||
.setTargetId("rich.example")
|
||||
.setRegistrarId("TheRegistrar")
|
||||
.setPeriodYears(2)
|
||||
.setCost(Money.of(USD, 200))
|
||||
.setEventTime(clock.now())
|
||||
.setBillingTime(clock.now().plus(Tld.get("example").getAddGracePeriodLength()))
|
||||
.setDomainHistory(historyEntry)
|
||||
.build(),
|
||||
new BillingEvent.Builder()
|
||||
.setReason(Reason.FEE_EXPIRY_ACCESS)
|
||||
.setTargetId("rich.example")
|
||||
.setRegistrarId("TheRegistrar")
|
||||
.setPeriodYears(1)
|
||||
.setCost(Money.of(USD, 100))
|
||||
.setEventTime(clock.now())
|
||||
.setBillingTime(clock.now().plus(Tld.get("example").getAddGracePeriodLength()))
|
||||
.setDomainHistory(historyEntry)
|
||||
.build(),
|
||||
new BillingRecurrence.Builder()
|
||||
.setReason(Reason.RENEW)
|
||||
.setFlags(ImmutableSet.of(Flag.AUTO_RENEW))
|
||||
.setTargetId("rich.example")
|
||||
.setRegistrarId("TheRegistrar")
|
||||
.setEventTime(domain.getRegistrationExpirationTime())
|
||||
.setRecurrenceEndTime(END_INSTANT)
|
||||
.setDomainHistory(historyEntry)
|
||||
.build());
|
||||
} finally {
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
|
||||
ImmutableMap.of("USD", originalInitialFee);
|
||||
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
|
||||
ImmutableMap.of("USD", originalFinalFee);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_recentlyDeletedDomain_afterXapEnds_notOptedIn_succeedsWithoutXapFee()
|
||||
throws Exception {
|
||||
// Once the 10-day XAP window expires after deletion (e.g. day 11), the domain returns to
|
||||
// standard pricing and can be registered without XAP fees or reservation blocks by
|
||||
// non-opted-in registrars.
|
||||
persistHosts();
|
||||
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
persistResource(
|
||||
new Domain.Builder()
|
||||
.setDomainName(getUniqueIdFromCommand())
|
||||
.setDeletionTime(deletionTime)
|
||||
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
|
||||
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("9999-EXAMPLE")
|
||||
.build());
|
||||
doSuccessfulTest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_recentlyDeletedDomain_afterXapEnds_optedIn_succeedsWithoutXapFee()
|
||||
throws Exception {
|
||||
// Once the 10-day XAP window expires after deletion (e.g. day 11), the domain returns to
|
||||
// standard pricing and can be registered without XAP fees by opted-in registrars.
|
||||
persistHosts();
|
||||
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(true)
|
||||
.build());
|
||||
persistResource(
|
||||
new Domain.Builder()
|
||||
.setDomainName(getUniqueIdFromCommand())
|
||||
.setDeletionTime(deletionTime)
|
||||
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
|
||||
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("9999-EXAMPLE")
|
||||
.build());
|
||||
doSuccessfulTest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_recentlyDeletedDomain_beforeXapStarts_notOptedIn_succeedsWithoutXapFee()
|
||||
throws Exception {
|
||||
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, the domain
|
||||
// can be registered at standard pricing without XAP fees or reservation blocks by
|
||||
// non-opted-in registrars.
|
||||
persistHosts();
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
persistResource(
|
||||
new Domain.Builder()
|
||||
.setDomainName(getUniqueIdFromCommand())
|
||||
.setDeletionTime(deletionTime)
|
||||
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
|
||||
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("9999-EXAMPLE")
|
||||
.build());
|
||||
doSuccessfulTest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_recentlyDeletedDomain_beforeXapStarts_optedIn_succeedsWithoutXapFee()
|
||||
throws Exception {
|
||||
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, the domain
|
||||
// can be registered at standard pricing without XAP fees by opted-in registrars.
|
||||
persistHosts();
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(true)
|
||||
.build());
|
||||
persistResource(
|
||||
new Domain.Builder()
|
||||
.setDomainName(getUniqueIdFromCommand())
|
||||
.setDeletionTime(deletionTime)
|
||||
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
|
||||
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("9999-EXAMPLE")
|
||||
.build());
|
||||
doSuccessfulTest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_agpDeletedRegularDomain_duringXap_succeedsWithoutXapFee() throws Exception {
|
||||
// A regular domain (registered without an XAP fee) that was deleted during its Add Grace
|
||||
// Period (AGP) is exempt from XAP, succeeding at standard pricing without reservation blocks
|
||||
// or XAP fees when re-registered during active XAP on the TLD.
|
||||
persistHosts();
|
||||
Instant creationTime = clock.now().minus(Duration.ofDays(2));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(
|
||||
START_INSTANT,
|
||||
Tld.ExpiryAccessPeriodMode.DISABLED,
|
||||
clock.now().minus(Duration.ofHours(1)),
|
||||
Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
persistResource(
|
||||
new Domain.Builder()
|
||||
.setDomainName(getUniqueIdFromCommand())
|
||||
.setCreationTime(creationTime)
|
||||
.setDeletionTime(deletionTime)
|
||||
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("9999-EXAMPLE")
|
||||
.build());
|
||||
doSuccessfulTest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_agpDeletedXapDomain_duringXap_succeedsWithoutXapFee() throws Exception {
|
||||
// A domain originally registered with an XAP fee that was deleted during its Add Grace
|
||||
// Period (AGP) is exempt from XAP upon subsequent re-registration, succeeding at standard
|
||||
// pricing without reservation blocks or XAP fees when re-registered during active XAP on the
|
||||
// TLD.
|
||||
persistHosts();
|
||||
Instant creationTime = clock.now().minus(Duration.ofHours(2));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
persistResource(
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(false)
|
||||
.build());
|
||||
persistResource(
|
||||
new Domain.Builder()
|
||||
.setDomainName(getUniqueIdFromCommand())
|
||||
.setCreationTime(creationTime)
|
||||
.setDeletionTime(deletionTime)
|
||||
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("9999-EXAMPLE")
|
||||
.build());
|
||||
doSuccessfulTest();
|
||||
}
|
||||
|
||||
private void setXapForTld(String tldStr, Instant deletionTime) throws Exception {
|
||||
persistResource(
|
||||
Registrar.loadByRegistrarId("TheRegistrar")
|
||||
.get()
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodEnabled(true)
|
||||
.build());
|
||||
persistResource(
|
||||
Tld.get(tldStr)
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
persistResource(
|
||||
new Domain.Builder()
|
||||
.setDomainName(getUniqueIdFromCommand())
|
||||
.setDeletionTime(deletionTime)
|
||||
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
|
||||
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("9999-EXAMPLE")
|
||||
.build());
|
||||
}
|
||||
|
||||
private void setEapForTld(String tld) {
|
||||
persistResource(
|
||||
Tld.get(tld)
|
||||
|
||||
@@ -402,6 +402,36 @@ class DomainDeleteFlowTest extends ResourceFlowTestCase<DomainDeleteFlow, Domain
|
||||
GracePeriodStatus.ADD, "domain_delete_response_fee.xml", FEE_STD_1_0_MAP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_addGracePeriodDelete_doesNotRefundXapFee() throws Exception {
|
||||
setUpSuccessfulTest();
|
||||
BillingEvent createBillingEvent =
|
||||
persistResource(createBillingEvent(Reason.CREATE, Money.of(USD, 123)));
|
||||
BillingEvent xapBillingEvent =
|
||||
persistResource(createBillingEvent(Reason.FEE_EXPIRY_ACCESS, Money.of(USD, 100)));
|
||||
setUpGracePeriods(
|
||||
GracePeriod.forBillingEvent(GracePeriodStatus.ADD, domain.getRepoId(), createBillingEvent));
|
||||
assertPollMessages(createAutorenewPollMessage("TheRegistrar").build());
|
||||
clock.advanceOneMilli();
|
||||
runFlowAssertResponse(loadFile("domain_delete_response_fee.xml", FEE_STD_1_0_MAP));
|
||||
assertThat(reloadResourceByForeignKey()).isNull();
|
||||
DomainHistory historyEntryDomainDelete =
|
||||
getOnlyHistoryEntryOfType(domain, DOMAIN_DELETE, DomainHistory.class);
|
||||
assertBillingEvents(
|
||||
createAutorenewBillingEvent("TheRegistrar").setRecurrenceEndTime(clock.now()).build(),
|
||||
createBillingEvent,
|
||||
xapBillingEvent,
|
||||
new BillingCancellation.Builder()
|
||||
.setReason(Reason.CREATE)
|
||||
.setTargetId("example.tld")
|
||||
.setRegistrarId("TheRegistrar")
|
||||
.setEventTime(clock.now())
|
||||
.setBillingTime(plusDays(TIME_BEFORE_FLOW, 1))
|
||||
.setBillingEvent(createBillingEvent.createVKey())
|
||||
.setDomainHistory(historyEntryDomainDelete)
|
||||
.build());
|
||||
}
|
||||
|
||||
private void doSuccessfulTest_noAddGracePeriod(String responseFilename) throws Exception {
|
||||
doSuccessfulTest_noAddGracePeriod(responseFilename, ImmutableMap.of());
|
||||
}
|
||||
|
||||
@@ -30,12 +30,15 @@ import static google.registry.util.DateTimeUtils.END_INSTANT;
|
||||
import static google.registry.util.DateTimeUtils.START_INSTANT;
|
||||
import static google.registry.util.DateTimeUtils.minusHours;
|
||||
import static google.registry.util.DateTimeUtils.plusHours;
|
||||
import static org.joda.money.CurrencyUnit.JPY;
|
||||
import static org.joda.money.CurrencyUnit.USD;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.ImmutableSortedMap;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.Range;
|
||||
import google.registry.flows.EppException;
|
||||
import google.registry.flows.HttpSessionMetadata;
|
||||
import google.registry.flows.SessionMetadata;
|
||||
@@ -58,8 +61,10 @@ import google.registry.testing.FakeClock;
|
||||
import google.registry.testing.FakeHttpSession;
|
||||
import google.registry.util.Clock;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.joda.money.CurrencyUnit;
|
||||
import org.joda.money.Money;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -85,7 +90,12 @@ public class DomainPricingLogicTest {
|
||||
createTld("example");
|
||||
sessionMetadata = new HttpSessionMetadata(new FakeHttpSession());
|
||||
domainPricingLogic =
|
||||
new DomainPricingLogic(new DomainPricingCustomLogic(eppInput, sessionMetadata, null));
|
||||
new DomainPricingLogic(
|
||||
new DomainPricingCustomLogic(eppInput, sessionMetadata, null),
|
||||
Duration.ofDays(10),
|
||||
Duration.ofHours(1),
|
||||
ImmutableMap.of(USD, new BigDecimal("100000.00"), JPY, new BigDecimal("10000000")),
|
||||
ImmutableMap.of(USD, new BigDecimal("10.00"), JPY, new BigDecimal("1000")));
|
||||
tld =
|
||||
persistResource(
|
||||
Tld.get("example")
|
||||
@@ -146,7 +156,14 @@ public class DomainPricingLogicTest {
|
||||
persistResource(Tld.get("sunrise").asBuilder().setTldStateTransitions(transitions).build());
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
sunriseTld, "domain.sunrise", clock.now(), 2, false, true, Optional.empty()))
|
||||
sunriseTld,
|
||||
"domain.sunrise",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
2,
|
||||
false,
|
||||
true,
|
||||
Optional.empty()))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -170,7 +187,14 @@ public class DomainPricingLogicTest {
|
||||
.build());
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "default.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"default.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -195,7 +219,14 @@ public class DomainPricingLogicTest {
|
||||
// 3 year create should be 5 (discount price) + 10*2 (regular price) = 25.
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "default.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"default.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
3,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -218,7 +249,14 @@ public class DomainPricingLogicTest {
|
||||
.build());
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "default.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"default.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -1006,7 +1044,14 @@ public class DomainPricingLogicTest {
|
||||
.build());
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "premium.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"premium.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -1015,7 +1060,14 @@ public class DomainPricingLogicTest {
|
||||
// Two-year create should be 13 (standard price) + 100 (premium price), and it's premium
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"premium.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
2,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -1051,7 +1103,14 @@ public class DomainPricingLogicTest {
|
||||
// are standard
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"premium.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
2,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -1060,7 +1119,14 @@ public class DomainPricingLogicTest {
|
||||
// Similarly, 3 years should be 13 + 10 + 10
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "premium.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"premium.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
3,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -1081,7 +1147,14 @@ public class DomainPricingLogicTest {
|
||||
// Two-year create should be 100 (premium 1st year) plus 10 (nonpremium 2nd year)
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"premium.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
2,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -1090,7 +1163,14 @@ public class DomainPricingLogicTest {
|
||||
// Similarly, 3 years should be 100 + 10 + 10
|
||||
assertThat(
|
||||
domainPricingLogic.getCreatePrice(
|
||||
tld, "premium.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
|
||||
tld,
|
||||
"premium.example",
|
||||
clock.now(),
|
||||
Optional.empty(),
|
||||
3,
|
||||
false,
|
||||
false,
|
||||
Optional.of(allocationToken)))
|
||||
.isEqualTo(
|
||||
new FeesAndCredits.Builder()
|
||||
.setCurrency(USD)
|
||||
@@ -1135,4 +1215,291 @@ public class DomainPricingLogicTest {
|
||||
.getRenewCost())
|
||||
.isEqualTo(Money.of(USD, 25));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetXapFeeFor_tier0_startOfXap() {
|
||||
Instant deletionTime = clock.now();
|
||||
Instant checkTime = deletionTime.plus(Duration.ofMinutes(30));
|
||||
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
|
||||
assertThat(xapFee)
|
||||
.hasValue(
|
||||
Fee.create(
|
||||
new BigDecimal("100000.00"),
|
||||
Fee.FeeType.XAP,
|
||||
false,
|
||||
Range.closedOpen(deletionTime, deletionTime.plus(Duration.ofHours(1))),
|
||||
deletionTime.plus(Duration.ofHours(1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetXapFeeFor_tier1() {
|
||||
Instant deletionTime = clock.now();
|
||||
Instant checkTime = deletionTime.plus(Duration.ofHours(1)).plus(Duration.ofMinutes(15));
|
||||
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
|
||||
assertThat(xapFee)
|
||||
.hasValue(
|
||||
Fee.create(
|
||||
new BigDecimal("96219.61"),
|
||||
Fee.FeeType.XAP,
|
||||
false,
|
||||
Range.closedOpen(
|
||||
deletionTime.plus(Duration.ofHours(1)), deletionTime.plus(Duration.ofHours(2))),
|
||||
deletionTime.plus(Duration.ofHours(2))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetXapFeeFor_tier239_finalTier() {
|
||||
Instant deletionTime = clock.now();
|
||||
Instant checkTime = deletionTime.plus(Duration.ofHours(239)).plus(Duration.ofMinutes(30));
|
||||
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
|
||||
assertThat(xapFee)
|
||||
.hasValue(
|
||||
Fee.create(
|
||||
new BigDecimal("10.00"),
|
||||
Fee.FeeType.XAP,
|
||||
false,
|
||||
Range.closedOpen(
|
||||
deletionTime.plus(Duration.ofHours(239)),
|
||||
deletionTime.plus(Duration.ofHours(240))),
|
||||
deletionTime.plus(Duration.ofHours(240))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetXapFeeFor_unconfiguredCurrency() {
|
||||
Instant deletionTime = clock.now();
|
||||
Instant checkTime = deletionTime.plus(Duration.ofMinutes(30));
|
||||
Optional<Fee> xapFee =
|
||||
domainPricingLogic.getXapFeeFor(checkTime, deletionTime, CurrencyUnit.EUR);
|
||||
assertThat(xapFee).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCreatePrice_xapEnabled_includesXapFeeAndRequiresExtension() throws Exception {
|
||||
Tld xapTld =
|
||||
persistResource(
|
||||
tld.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
Domain deletedDomain =
|
||||
new Domain.Builder()
|
||||
.setDomainName("deleted.example")
|
||||
.setDeletionTime(deletionTime)
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("2-EXAMPLE")
|
||||
.build();
|
||||
FeesAndCredits feesAndCredits =
|
||||
domainPricingLogic.getCreatePrice(
|
||||
xapTld,
|
||||
"deleted.example",
|
||||
clock.now(),
|
||||
Optional.of(deletedDomain),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.empty());
|
||||
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
|
||||
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
|
||||
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("96232.61")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetXapFeeFor_afterXapEnds_returnsEmpty() {
|
||||
Instant deletionTime = clock.now();
|
||||
Instant checkTime = deletionTime.plus(Duration.ofDays(10)).plus(Duration.ofMinutes(1));
|
||||
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
|
||||
assertThat(xapFee).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCreatePrice_agpDeletedRegularDomain_duringXap_noXapFee() throws Exception {
|
||||
// A regular domain deleted during AGP is exempt from XAP fee evaluation upon subsequent
|
||||
// creation during active XAP.
|
||||
Tld xapTld =
|
||||
persistResource(
|
||||
tld.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(
|
||||
START_INSTANT,
|
||||
Tld.ExpiryAccessPeriodMode.DISABLED,
|
||||
clock.now().minus(Duration.ofHours(1)),
|
||||
Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
Instant creationTime = clock.now().minus(Duration.ofDays(2));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
Domain deletedDomain =
|
||||
new Domain.Builder()
|
||||
.setDomainName("deleted.example")
|
||||
.setCreationTime(creationTime)
|
||||
.setDeletionTime(deletionTime)
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("2-EXAMPLE")
|
||||
.build();
|
||||
FeesAndCredits feesAndCredits =
|
||||
domainPricingLogic.getCreatePrice(
|
||||
xapTld,
|
||||
"deleted.example",
|
||||
clock.now(),
|
||||
Optional.of(deletedDomain),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.empty());
|
||||
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
|
||||
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
|
||||
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCreatePrice_agpDeletedXapDomain_duringXap_noXapFee() throws Exception {
|
||||
// A domain originally registered with an XAP fee deleted during AGP is exempt from XAP fee
|
||||
// evaluation upon subsequent creation during active XAP.
|
||||
Tld xapTld =
|
||||
persistResource(
|
||||
tld.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
Instant creationTime = clock.now().minus(Duration.ofHours(2));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
Domain deletedDomain =
|
||||
new Domain.Builder()
|
||||
.setDomainName("deleted.example")
|
||||
.setCreationTime(creationTime)
|
||||
.setDeletionTime(deletionTime)
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("2-EXAMPLE")
|
||||
.build();
|
||||
FeesAndCredits feesAndCredits =
|
||||
domainPricingLogic.getCreatePrice(
|
||||
xapTld,
|
||||
"deleted.example",
|
||||
clock.now(),
|
||||
Optional.of(deletedDomain),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.empty());
|
||||
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
|
||||
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
|
||||
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCreatePrice_anchorTenantDeletedAfterStandardAgp_duringXap_chargesXapFee()
|
||||
throws Exception {
|
||||
// An anchor tenant domain deleted after standard AGP (5 days), e.g. on day 10 of its 30-day
|
||||
// anchor tenant AGP, is subject to XAP fees upon re-registration (not exempt as an AGP delete).
|
||||
Tld xapTld =
|
||||
persistResource(
|
||||
tld.asBuilder()
|
||||
.setAddGracePeriodLength(Duration.ofDays(5))
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
Instant creationTime = clock.now().minus(Duration.ofDays(10));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
Domain deletedDomain =
|
||||
new Domain.Builder()
|
||||
.setDomainName("deleted.example")
|
||||
.setCreationTime(creationTime)
|
||||
.setDeletionTime(deletionTime)
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("2-EXAMPLE")
|
||||
.build();
|
||||
FeesAndCredits feesAndCredits =
|
||||
domainPricingLogic.getCreatePrice(
|
||||
xapTld,
|
||||
"deleted.example",
|
||||
clock.now(),
|
||||
Optional.of(deletedDomain),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.empty());
|
||||
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
|
||||
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCreatePrice_premiumDomainInXap_chargesPremiumAndXapFees() throws Exception {
|
||||
// Calculating create price for a recently deleted premium domain during active XAP should sum
|
||||
// both the premium create fee and the one-time XAP tier fee.
|
||||
Tld xapTld =
|
||||
persistResource(
|
||||
tld.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
Domain deletedDomain =
|
||||
new Domain.Builder()
|
||||
.setDomainName("premium.example")
|
||||
.setDeletionTime(deletionTime)
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("2-EXAMPLE")
|
||||
.build();
|
||||
FeesAndCredits feesAndCredits =
|
||||
domainPricingLogic.getCreatePrice(
|
||||
xapTld,
|
||||
"premium.example",
|
||||
clock.now(),
|
||||
Optional.of(deletedDomain),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.empty());
|
||||
assertThat(feesAndCredits.hasAnyPremiumFees()).isTrue();
|
||||
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
|
||||
assertThat(feesAndCredits.getCreateCost()).isEqualTo(Money.of(USD, new BigDecimal("100.00")));
|
||||
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
|
||||
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("96319.61")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetCreatePrice_zeroXapFee_doesNotRequireExtension() throws Exception {
|
||||
// When an XAP tier fee is $0.00, it is included in the fee items but does not require a fee
|
||||
// extension acknowledgment from the registrar.
|
||||
Tld xapTld =
|
||||
persistResource(
|
||||
tld.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
|
||||
.build());
|
||||
DomainPricingLogic zeroFeePricingLogic =
|
||||
new DomainPricingLogic(
|
||||
new DomainPricingCustomLogic(eppInput, sessionMetadata, null),
|
||||
Duration.ofDays(10),
|
||||
Duration.ofHours(1),
|
||||
ImmutableMap.of(USD, BigDecimal.ZERO),
|
||||
ImmutableMap.of(USD, BigDecimal.ZERO));
|
||||
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
|
||||
Domain deletedDomain =
|
||||
new Domain.Builder()
|
||||
.setDomainName("deleted.example")
|
||||
.setDeletionTime(deletionTime)
|
||||
.setCreationRegistrarId("TheRegistrar")
|
||||
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
|
||||
.setRepoId("2-EXAMPLE")
|
||||
.build();
|
||||
FeesAndCredits feesAndCredits =
|
||||
zeroFeePricingLogic.getCreatePrice(
|
||||
xapTld,
|
||||
"deleted.example",
|
||||
clock.now(),
|
||||
Optional.of(deletedDomain),
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
Optional.empty());
|
||||
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
|
||||
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
|
||||
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
|
||||
}
|
||||
}
|
||||
|
||||
+94
-44
@@ -22,6 +22,7 @@ import static google.registry.model.domain.token.AllocationToken.TokenStatus.VAL
|
||||
import static google.registry.model.domain.token.AllocationToken.TokenType.DEFAULT_PROMO;
|
||||
import static google.registry.model.domain.token.AllocationToken.TokenType.SINGLE_USE;
|
||||
import static google.registry.model.domain.token.AllocationToken.TokenType.UNLIMITED_USE;
|
||||
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
|
||||
import static google.registry.testing.DatabaseHelper.createTld;
|
||||
import static google.registry.testing.DatabaseHelper.persistResource;
|
||||
import static google.registry.testing.EppExceptionSubject.assertAboutEppExceptions;
|
||||
@@ -36,6 +37,7 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import com.google.common.collect.ImmutableSortedMap;
|
||||
import com.google.common.net.InternetDomainName;
|
||||
@@ -44,6 +46,7 @@ import google.registry.flows.custom.DomainPricingCustomLogic;
|
||||
import google.registry.flows.domain.DomainPricingLogic;
|
||||
import google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTokenNotInPromotionException;
|
||||
import google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTokenNotValidForRegistrarException;
|
||||
import google.registry.flows.domain.token.AllocationTokenFlowUtils.AlreadyRedeemedAllocationTokenException;
|
||||
import google.registry.flows.domain.token.AllocationTokenFlowUtils.NonexistentAllocationTokenException;
|
||||
import google.registry.model.domain.fee.FeeQueryCommandExtensionItem.CommandName;
|
||||
import google.registry.model.domain.token.AllocationToken;
|
||||
@@ -54,6 +57,8 @@ import google.registry.model.tld.Tld;
|
||||
import google.registry.persistence.transaction.JpaTestExtensions;
|
||||
import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension;
|
||||
import google.registry.testing.FakeClock;
|
||||
import google.registry.testing.TestCacheExtension;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -69,11 +74,20 @@ class AllocationTokenFlowUtilsTest {
|
||||
final JpaIntegrationTestExtension jpa =
|
||||
new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension();
|
||||
|
||||
@RegisterExtension
|
||||
final TestCacheExtension testCacheExtension =
|
||||
new TestCacheExtension.Builder().withAllocationTokenCache(Duration.ofMinutes(10)).build();
|
||||
|
||||
private final AllocationTokenExtension allocationTokenExtension =
|
||||
mock(AllocationTokenExtension.class);
|
||||
|
||||
private final DomainPricingLogic domainPricingLogic =
|
||||
new DomainPricingLogic(new DomainPricingCustomLogic(null, null, null));
|
||||
new DomainPricingLogic(
|
||||
new DomainPricingCustomLogic(null, null, null),
|
||||
Duration.ofDays(30),
|
||||
Duration.ofDays(1),
|
||||
ImmutableMap.of(),
|
||||
ImmutableMap.of());
|
||||
|
||||
private Tld tld;
|
||||
|
||||
@@ -124,8 +138,13 @@ class AllocationTokenFlowUtilsTest {
|
||||
.build());
|
||||
when(allocationTokenExtension.getAllocationToken()).thenReturn("tokeN");
|
||||
assertThat(
|
||||
AllocationTokenFlowUtils.loadAllocationTokenFromExtension(
|
||||
"TheRegistrar", "example.tld", clock.now(), Optional.of(allocationTokenExtension)))
|
||||
tm().transact(
|
||||
() ->
|
||||
AllocationTokenFlowUtils.loadAllocationTokenFromExtension(
|
||||
"TheRegistrar",
|
||||
"example.tld",
|
||||
clock.now(),
|
||||
Optional.of(allocationTokenExtension))))
|
||||
.hasValue(token);
|
||||
}
|
||||
|
||||
@@ -141,15 +160,17 @@ class AllocationTokenFlowUtilsTest {
|
||||
.build());
|
||||
when(allocationTokenExtension.getAllocationToken()).thenReturn("tokeN");
|
||||
assertThat(
|
||||
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
|
||||
"TheRegistrar",
|
||||
clock.now(),
|
||||
Optional.of(allocationTokenExtension),
|
||||
tld,
|
||||
"example.tld",
|
||||
CommandName.CREATE,
|
||||
Optional.of(1),
|
||||
domainPricingLogic))
|
||||
tm().transact(
|
||||
() ->
|
||||
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
|
||||
"TheRegistrar",
|
||||
clock.now(),
|
||||
Optional.of(allocationTokenExtension),
|
||||
tld,
|
||||
"example.tld",
|
||||
CommandName.CREATE,
|
||||
Optional.of(1),
|
||||
domainPricingLogic)))
|
||||
.hasValue(token);
|
||||
}
|
||||
|
||||
@@ -181,15 +202,17 @@ class AllocationTokenFlowUtilsTest {
|
||||
.build());
|
||||
when(allocationTokenExtension.getAllocationToken()).thenReturn("tokeN");
|
||||
assertThat(
|
||||
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
|
||||
"TheRegistrar",
|
||||
clock.now(),
|
||||
Optional.of(allocationTokenExtension),
|
||||
tld,
|
||||
"example.tld",
|
||||
CommandName.CREATE,
|
||||
Optional.of(1),
|
||||
domainPricingLogic))
|
||||
tm().transact(
|
||||
() ->
|
||||
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
|
||||
"TheRegistrar",
|
||||
clock.now(),
|
||||
Optional.of(allocationTokenExtension),
|
||||
tld,
|
||||
"example.tld",
|
||||
CommandName.CREATE,
|
||||
Optional.of(1),
|
||||
domainPricingLogic)))
|
||||
.hasValue(defaultToken);
|
||||
}
|
||||
|
||||
@@ -261,6 +284,29 @@ class AllocationTokenFlowUtilsTest {
|
||||
assertLoadTokenFromExtensionThrowsException(NonexistentAllocationTokenException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_loadFromExtension_alreadyRedeemedToken() {
|
||||
persistResource(
|
||||
singleUseTokenBuilder().setRedemptionHistoryId(new HistoryEntryId("repoId", 1L)).build());
|
||||
assertLoadTokenFromExtensionThrowsException(AlreadyRedeemedAllocationTokenException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_loadFromExtension_singleUseTokenStaleCacheReloadsFromDatabase() {
|
||||
AllocationToken token = persistResource(singleUseTokenBuilder().build());
|
||||
assertThat(AllocationToken.get(token.createVKey())).hasValue(token);
|
||||
tm().transact(
|
||||
() ->
|
||||
tm().put(
|
||||
token
|
||||
.asBuilder()
|
||||
.setRedemptionHistoryId(new HistoryEntryId("repoId", 1L))
|
||||
.build()));
|
||||
// cache still returns the old un-redeemed token
|
||||
assertThat(AllocationToken.get(token.createVKey()).get().isRedeemed()).isFalse();
|
||||
assertLoadTokenFromExtensionThrowsException(AlreadyRedeemedAllocationTokenException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_tokenInvalidForRegistrar() {
|
||||
persistResource(
|
||||
@@ -334,18 +380,20 @@ class AllocationTokenFlowUtilsTest {
|
||||
// Tokens tied to a domain should throw a catastrophic exception if used for a different domain
|
||||
persistResource(singleUseTokenBuilder().setDomainName("someotherdomain.tld").build());
|
||||
when(allocationTokenExtension.getAllocationToken()).thenReturn("tokeN");
|
||||
assertThrows(
|
||||
AllocationTokenFlowUtils.AllocationTokenNotValidForDomainException.class,
|
||||
() ->
|
||||
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
|
||||
"TheRegistrar",
|
||||
clock.now(),
|
||||
Optional.of(allocationTokenExtension),
|
||||
tld,
|
||||
"example.tld",
|
||||
CommandName.CREATE,
|
||||
Optional.of(1),
|
||||
domainPricingLogic));
|
||||
tm().transact(
|
||||
() ->
|
||||
assertThrows(
|
||||
AllocationTokenFlowUtils.AllocationTokenNotValidForDomainException.class,
|
||||
() ->
|
||||
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
|
||||
"TheRegistrar",
|
||||
clock.now(),
|
||||
Optional.of(allocationTokenExtension),
|
||||
tld,
|
||||
"example.tld",
|
||||
CommandName.CREATE,
|
||||
Optional.of(1),
|
||||
domainPricingLogic)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -451,17 +499,19 @@ class AllocationTokenFlowUtilsTest {
|
||||
}
|
||||
|
||||
private void assertLoadTokenFromExtensionThrowsException(Class<? extends EppException> clazz) {
|
||||
assertAboutEppExceptions()
|
||||
.that(
|
||||
assertThrows(
|
||||
clazz,
|
||||
() ->
|
||||
AllocationTokenFlowUtils.loadAllocationTokenFromExtension(
|
||||
"TheRegistrar",
|
||||
"example.tld",
|
||||
clock.now(),
|
||||
Optional.of(allocationTokenExtension))))
|
||||
.marshalsToXml();
|
||||
tm().transact(
|
||||
() ->
|
||||
assertAboutEppExceptions()
|
||||
.that(
|
||||
assertThrows(
|
||||
clazz,
|
||||
() ->
|
||||
AllocationTokenFlowUtils.loadAllocationTokenFromExtension(
|
||||
"TheRegistrar",
|
||||
"example.tld",
|
||||
tm().getTxTime(),
|
||||
Optional.of(allocationTokenExtension))))
|
||||
.marshalsToXml());
|
||||
}
|
||||
|
||||
private AllocationToken.Builder singleUseTokenBuilder() {
|
||||
|
||||
@@ -51,6 +51,7 @@ import google.registry.flows.host.HostFlowUtils.InvalidHostNameException;
|
||||
import google.registry.flows.host.HostFlowUtils.IpAddressNotRoutableException;
|
||||
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainDoesNotExistException;
|
||||
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainInPendingDeleteException;
|
||||
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainServerUpdateProhibitedException;
|
||||
import google.registry.model.ForeignKeyUtils;
|
||||
import google.registry.model.domain.Domain;
|
||||
import google.registry.model.eppcommon.StatusValue;
|
||||
@@ -413,6 +414,22 @@ class HostCreateFlowTest extends ResourceFlowTestCase<HostCreateFlow, Host> {
|
||||
.marshalsToXml();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_superordinateDomainIsServerUpdateProhibited() {
|
||||
createTld("tld");
|
||||
persistResource(
|
||||
DatabaseHelper.newDomain("example.tld")
|
||||
.asBuilder()
|
||||
.setStatusValues(ImmutableSet.of(StatusValue.SERVER_UPDATE_PROHIBITED))
|
||||
.build());
|
||||
setEppHostCreateInputWithIps("ns1.example.tld");
|
||||
SuperordinateDomainServerUpdateProhibitedException thrown =
|
||||
assertThrows(SuperordinateDomainServerUpdateProhibitedException.class, this::runFlow);
|
||||
assertThat(thrown)
|
||||
.hasMessageThat()
|
||||
.contains("Superordinate domain for this hostname has serverUpdateProhibited status");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIcannActivityReportField_getsLogged() throws Exception {
|
||||
runFlow();
|
||||
|
||||
@@ -42,6 +42,7 @@ import google.registry.flows.exceptions.ResourceToDeleteIsReferencedException;
|
||||
import google.registry.flows.host.HostFlowUtils.HostNameNotLowerCaseException;
|
||||
import google.registry.flows.host.HostFlowUtils.HostNameNotNormalizedException;
|
||||
import google.registry.flows.host.HostFlowUtils.HostNameNotPunyCodedException;
|
||||
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainServerUpdateProhibitedException;
|
||||
import google.registry.model.domain.Domain;
|
||||
import google.registry.model.domain.GracePeriod;
|
||||
import google.registry.model.domain.rgp.GracePeriodStatus;
|
||||
@@ -360,6 +361,25 @@ class HostDeleteFlowTest extends ResourceFlowTestCase<HostDeleteFlow, Host> {
|
||||
assertAboutEppExceptions().that(thrown).marshalsToXml();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_superordinateDomainIsServerUpdateProhibited() {
|
||||
createTld("tld");
|
||||
Domain domain =
|
||||
persistResource(
|
||||
DatabaseHelper.newDomain("example.tld")
|
||||
.asBuilder()
|
||||
.setStatusValues(ImmutableSet.of(StatusValue.SERVER_UPDATE_PROHIBITED))
|
||||
.build());
|
||||
persistResource(
|
||||
newHost("ns1.example.tld").asBuilder().setSuperordinateDomain(domain.createVKey()).build());
|
||||
clock.advanceOneMilli();
|
||||
SuperordinateDomainServerUpdateProhibitedException thrown =
|
||||
assertThrows(SuperordinateDomainServerUpdateProhibitedException.class, this::runFlow);
|
||||
assertThat(thrown)
|
||||
.hasMessageThat()
|
||||
.contains("Superordinate domain for this hostname has serverUpdateProhibited status");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIcannActivityReportField_getsLogged() throws Exception {
|
||||
persistActiveHost("ns1.example.tld");
|
||||
|
||||
@@ -68,6 +68,7 @@ import google.registry.flows.host.HostFlowUtils.HostNameTooShallowException;
|
||||
import google.registry.flows.host.HostFlowUtils.IpAddressNotRoutableException;
|
||||
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainDoesNotExistException;
|
||||
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainInPendingDeleteException;
|
||||
import google.registry.flows.host.HostFlowUtils.SuperordinateDomainServerUpdateProhibitedException;
|
||||
import google.registry.flows.host.HostUpdateFlow.CannotAddIpToExternalHostException;
|
||||
import google.registry.flows.host.HostUpdateFlow.CannotRemoveSubordinateHostLastIpException;
|
||||
import google.registry.flows.host.HostUpdateFlow.CannotRenameExternalHostException;
|
||||
@@ -889,6 +890,30 @@ class HostUpdateFlowTest extends ResourceFlowTestCase<HostUpdateFlow, Host> {
|
||||
.contains("Superordinate domain for this hostname is in pending delete");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_superordinateDomainIsServerUpdateProhibited() throws Exception {
|
||||
setEppHostUpdateInput(
|
||||
"ns1.example.tld",
|
||||
"ns2.example.tld",
|
||||
"<host:addr ip=\"v4\">192.0.2.22</host:addr>",
|
||||
"<host:addr ip=\"v6\">1080:0:0:0:8:800:200C:417A</host:addr>");
|
||||
createTld("tld");
|
||||
Domain domain =
|
||||
persistResource(
|
||||
DatabaseHelper.newDomain("example.tld")
|
||||
.asBuilder()
|
||||
.setSubordinateHosts(ImmutableSet.of(oldHostName()))
|
||||
.setStatusValues(ImmutableSet.of(StatusValue.SERVER_UPDATE_PROHIBITED))
|
||||
.build());
|
||||
persistActiveSubordinateHost(oldHostName(), domain);
|
||||
clock.advanceOneMilli();
|
||||
SuperordinateDomainServerUpdateProhibitedException thrown =
|
||||
assertThrows(SuperordinateDomainServerUpdateProhibitedException.class, this::runFlow);
|
||||
assertThat(thrown)
|
||||
.hasMessageThat()
|
||||
.contains("Superordinate domain for this hostname has serverUpdateProhibited status");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_neverExisted() throws Exception {
|
||||
ResourceDoesNotExistException thrown =
|
||||
|
||||
@@ -144,4 +144,17 @@ class ForeignKeyUtilsTest {
|
||||
fakeClock.now()))
|
||||
.containsExactlyEntriesIn(ImmutableMap.of("ns1.example.com", host1.createVKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_loadResourcesByCache_skipsDeletedAndNonexistent() {
|
||||
Host host1 = persistActiveHost("ns1.example.com");
|
||||
Host host2 = persistActiveHost("ns2.example.com");
|
||||
persistResource(host2.asBuilder().setDeletionTime(minusDays(fakeClock.now(), 1)).build());
|
||||
assertThat(
|
||||
ForeignKeyUtils.loadResourcesByCache(
|
||||
Host.class,
|
||||
ImmutableList.of("ns1.example.com", "ns2.example.com", "ns3.example.com"),
|
||||
fakeClock.now()))
|
||||
.containsExactlyEntriesIn(ImmutableMap.of("ns1.example.com", host1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ public final class OteAccountBuilderTest {
|
||||
public static void verifyIapPermission(
|
||||
@Nullable String emailAddress,
|
||||
Optional<String> maybeGroupEmailAddress,
|
||||
Optional<String> consoleIapServiceId,
|
||||
CloudTasksHelper cloudTasksHelper,
|
||||
IamClient iamClient) {
|
||||
if (emailAddress == null) {
|
||||
@@ -125,7 +126,8 @@ public final class OteAccountBuilderTest {
|
||||
String groupEmailAddress = maybeGroupEmailAddress.orElse(null);
|
||||
if (groupEmailAddress == null) {
|
||||
cloudTasksHelper.assertNoTasksEnqueued("console-user-group-update");
|
||||
verify(iamClient).addBinding(emailAddress, IAP_SECURED_WEB_APP_USER_ROLE);
|
||||
verify(iamClient)
|
||||
.addBinding(emailAddress, IAP_SECURED_WEB_APP_USER_ROLE, consoleIapServiceId.get());
|
||||
} else {
|
||||
cloudTasksHelper.assertTasksEnqueued(
|
||||
"console-user-group-update",
|
||||
@@ -146,9 +148,32 @@ public final class OteAccountBuilderTest {
|
||||
CloudTasksUtils cloudTasksUtils = cloudTasksHelper.getTestCloudTasksUtils();
|
||||
OteAccountBuilder.forRegistrarId("myclientid")
|
||||
.addUser("email@example.com")
|
||||
.grantIapPermission(Optional.of("console@example.com"), cloudTasksUtils, iamClient);
|
||||
.grantIapPermission(
|
||||
Optional.of("console@example.com"),
|
||||
Optional.of("consoleIapServiceId"),
|
||||
cloudTasksUtils,
|
||||
iamClient);
|
||||
verifyIapPermission(
|
||||
"email@example.com", Optional.of("console@example.com"), cloudTasksHelper, iamClient);
|
||||
"email@example.com",
|
||||
Optional.of("console@example.com"),
|
||||
Optional.of("consoleIapServiceId"),
|
||||
cloudTasksHelper,
|
||||
iamClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateUserGroup_worksWithoutIapServiceId() {
|
||||
CloudTasksUtils cloudTasksUtils = cloudTasksHelper.getTestCloudTasksUtils();
|
||||
OteAccountBuilder.forRegistrarId("myclientid")
|
||||
.addUser("email@example.com")
|
||||
.grantIapPermission(
|
||||
Optional.of("console@example.com"), Optional.empty(), cloudTasksUtils, iamClient);
|
||||
verifyIapPermission(
|
||||
"email@example.com",
|
||||
Optional.of("console@example.com"),
|
||||
Optional.empty(),
|
||||
cloudTasksHelper,
|
||||
iamClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -156,8 +181,14 @@ public final class OteAccountBuilderTest {
|
||||
CloudTasksUtils cloudTasksUtils = cloudTasksHelper.getTestCloudTasksUtils();
|
||||
OteAccountBuilder.forRegistrarId("myclientid")
|
||||
.addUser("email@example.com")
|
||||
.grantIapPermission(Optional.empty(), cloudTasksUtils, iamClient);
|
||||
verifyIapPermission("email@example.com", Optional.empty(), cloudTasksHelper, iamClient);
|
||||
.grantIapPermission(
|
||||
Optional.empty(), Optional.of("consoleIapServiceId"), cloudTasksUtils, iamClient);
|
||||
verifyIapPermission(
|
||||
"email@example.com",
|
||||
Optional.empty(),
|
||||
Optional.of("consoleIapServiceId"),
|
||||
cloudTasksHelper,
|
||||
iamClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -66,13 +66,21 @@ public class UserTest extends EntityTestCase {
|
||||
void testFailure_asyncAndSyncModeConflict() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> User.grantIapPermission("email@example.com", Optional.empty(), null, null, null));
|
||||
() ->
|
||||
User.grantIapPermission(
|
||||
"email@example.com",
|
||||
Optional.empty(),
|
||||
Optional.of("consoleIapServiceId"),
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
User.grantIapPermission(
|
||||
"email@example.com",
|
||||
Optional.empty(),
|
||||
Optional.of("consoleIapServiceId"),
|
||||
mock(CloudTasksUtils.class),
|
||||
mock(ServiceConnection.class),
|
||||
null));
|
||||
@@ -90,6 +98,24 @@ public class UserTest extends EntityTestCase {
|
||||
null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGrantIapPermission_neitherGroupNorIapServiceId_fails() {
|
||||
IllegalArgumentException e =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
User.grantIapPermission(
|
||||
"email@example.com",
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
mock(CloudTasksUtils.class),
|
||||
null,
|
||||
mock(IamClient.class)));
|
||||
assertThat(e)
|
||||
.hasMessageThat()
|
||||
.isEqualTo("At least one of groupEmailAddress or consoleIapServiceId must be present");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_badInputs() {
|
||||
User.Builder builder = new User.Builder();
|
||||
@@ -156,13 +182,33 @@ public class UserTest extends EntityTestCase {
|
||||
|
||||
// Individual permission.
|
||||
User.grantIapPermission(
|
||||
"email@example.com", Optional.empty(), cloudTasksUtils, null, iamClient);
|
||||
"email@example.com",
|
||||
Optional.empty(),
|
||||
Optional.of("consoleIapServiceId"),
|
||||
cloudTasksUtils,
|
||||
null,
|
||||
iamClient);
|
||||
cloudTasksHelper.assertNoTasksEnqueued();
|
||||
verify(iamClient).addBinding("email@example.com", IAP_SECURED_WEB_APP_USER_ROLE);
|
||||
verify(iamClient)
|
||||
.addBinding("email@example.com", IAP_SECURED_WEB_APP_USER_ROLE, "consoleIapServiceId");
|
||||
|
||||
// Group membership.
|
||||
User.grantIapPermission(
|
||||
"email@example.com", Optional.of("console@example.com"), cloudTasksUtils, null, iamClient);
|
||||
"email@example.com",
|
||||
Optional.of("console@example.com"),
|
||||
Optional.empty(),
|
||||
cloudTasksUtils,
|
||||
null,
|
||||
iamClient);
|
||||
// Group membership takes precedence over individual membership
|
||||
User.grantIapPermission(
|
||||
"groupemail@example.com",
|
||||
Optional.of("console@example.com"),
|
||||
Optional.of("consoleIapServiceId"),
|
||||
cloudTasksUtils,
|
||||
null,
|
||||
iamClient);
|
||||
|
||||
cloudTasksHelper.assertTasksEnqueued(
|
||||
"console-user-group-update",
|
||||
new TaskMatcher()
|
||||
@@ -171,6 +217,13 @@ public class UserTest extends EntityTestCase {
|
||||
.path("/_dr/admin/updateUserGroup")
|
||||
.param("userEmailAddress", "email@example.com")
|
||||
.param("groupEmailAddress", "console@example.com")
|
||||
.param("groupUpdateMode", "ADD"),
|
||||
new TaskMatcher()
|
||||
.service("BACKEND")
|
||||
.method(HttpMethod.POST)
|
||||
.path("/_dr/admin/updateUserGroup")
|
||||
.param("userEmailAddress", "groupemail@example.com")
|
||||
.param("groupEmailAddress", "console@example.com")
|
||||
.param("groupUpdateMode", "ADD"));
|
||||
verifyNoMoreInteractions(iamClient);
|
||||
}
|
||||
@@ -181,13 +234,25 @@ public class UserTest extends EntityTestCase {
|
||||
IamClient iamClient = mock(IamClient.class);
|
||||
|
||||
// Individual permission.
|
||||
User.grantIapPermission("email@example.com", Optional.empty(), null, connection, iamClient);
|
||||
User.grantIapPermission(
|
||||
"email@example.com",
|
||||
Optional.empty(),
|
||||
Optional.of("consoleIapServiceId"),
|
||||
null,
|
||||
connection,
|
||||
iamClient);
|
||||
verifyNoInteractions(connection);
|
||||
verify(iamClient).addBinding("email@example.com", IAP_SECURED_WEB_APP_USER_ROLE);
|
||||
verify(iamClient)
|
||||
.addBinding("email@example.com", IAP_SECURED_WEB_APP_USER_ROLE, "consoleIapServiceId");
|
||||
|
||||
// Group membership.
|
||||
User.grantIapPermission(
|
||||
"email@example.com", Optional.of("console@example.com"), null, connection, iamClient);
|
||||
"email@example.com",
|
||||
Optional.of("console@example.com"),
|
||||
Optional.empty(),
|
||||
null,
|
||||
connection,
|
||||
iamClient);
|
||||
verify(connection)
|
||||
.sendPostRequest(
|
||||
UpdateUserGroupAction.PATH,
|
||||
|
||||
@@ -25,6 +25,7 @@ import google.registry.model.ImmutableObject;
|
||||
import google.registry.model.eppcommon.AddressTest.TestEntity.TestAddress;
|
||||
import google.registry.persistence.transaction.JpaTestExtensions;
|
||||
import google.registry.persistence.transaction.JpaTestExtensions.JpaUnitTestExtension;
|
||||
import google.registry.tools.GsonUtils;
|
||||
import jakarta.persistence.Embeddable;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
@@ -108,6 +109,32 @@ class AddressTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> createAddress("line1", "", "line3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_streetLineTooLong() {
|
||||
assertThrows(IllegalArgumentException.class, () -> createAddress("a".repeat(256)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_cityTooLong() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> createAddress("line1").asBuilder().setCity("a".repeat(256)).build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_stateTooLong() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> createAddress("line1").asBuilder().setState("a".repeat(256)).build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_zipTooLong() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> createAddress("line1").asBuilder().setZip("12345678901234567").build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_pojoToAndFromXml() throws Exception {
|
||||
JAXBContext jaxbContext = JAXBContext.newInstance(TestEntity.class);
|
||||
@@ -124,6 +151,38 @@ class AddressTest {
|
||||
assertAddress(unmarshalledEntity.address, "123 W 14th St", "8th Fl", "Rm 8");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_gsonInstantiation() {
|
||||
String json = "{\"street\":[\"123 W 14th St\",\"8th Fl\"],\"countryCode\":\"US\"}";
|
||||
TestAddress address = GsonUtils.provideGson().fromJson(json, TestAddress.class);
|
||||
assertThat(address.getStreet()).containsExactly("123 W 14th St", "8th Fl");
|
||||
assertThat(address.getCountryCode()).isEqualTo("US");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_gsonInstantiation_tooManyStreetLines() {
|
||||
String json = "{\"street\":[\"123 W 14th St\",\"8th Fl\",\"Rm 8\",\"4th Line\"]}";
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GsonUtils.provideGson().fromJson(json, TestAddress.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_gsonInstantiation_emptyStreetLine() {
|
||||
String json = "{\"street\":[\"123 W 14th St\",\"\"]}";
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GsonUtils.provideGson().fromJson(json, TestAddress.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_gsonInstantiation_invalidCountryCode() {
|
||||
String json = "{\"countryCode\":\"USA\"}";
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GsonUtils.provideGson().fromJson(json, TestAddress.class));
|
||||
}
|
||||
|
||||
private static TestAddress createAddress(String... streetList) {
|
||||
return new TestAddress.Builder()
|
||||
.setStreet(ImmutableList.copyOf(streetList))
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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.model.registrar;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import google.registry.tools.GsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Unit tests for {@link RegistrarAddress}. */
|
||||
class RegistrarAddressTest {
|
||||
|
||||
@Test
|
||||
void testSuccess_gsonInstantiation() {
|
||||
String json = "{\"street\":[\"123 W 14th St\"],\"city\":\"New York\",\"countryCode\":\"US\"}";
|
||||
RegistrarAddress address = GsonUtils.provideGson().fromJson(json, RegistrarAddress.class);
|
||||
assertThat(address.getStreet()).containsExactly("123 W 14th St");
|
||||
assertThat(address.getCity()).isEqualTo("New York");
|
||||
assertThat(address.getCountryCode()).isEqualTo("US");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_gsonInstantiation_missingStreet() {
|
||||
String json = "{\"city\":\"New York\",\"countryCode\":\"US\"}";
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
() -> GsonUtils.provideGson().fromJson(json, RegistrarAddress.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_gsonInstantiation_missingCity() {
|
||||
String json = "{\"street\":[\"123 W 14th St\"],\"countryCode\":\"US\"}";
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
() -> GsonUtils.provideGson().fromJson(json, RegistrarAddress.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_gsonInstantiation_missingCountryCode() {
|
||||
String json = "{\"street\":[\"123 W 14th St\"],\"city\":\"New York\"}";
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
() -> GsonUtils.provideGson().fromJson(json, RegistrarAddress.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_gsonInstantiation_invalidCountryCode() {
|
||||
String json = "{\"street\":[\"123 W 14th St\"],\"city\":\"New York\",\"countryCode\":\"USA\"}";
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GsonUtils.provideGson().fromJson(json, RegistrarAddress.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_gsonInstantiation_tooManyStreetLines() {
|
||||
String json =
|
||||
"{\"street\":[\"1\",\"2\",\"3\",\"4\"],\"city\":\"New York\",\"countryCode\":\"US\"}";
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GsonUtils.provideGson().fromJson(json, RegistrarAddress.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_builder() {
|
||||
RegistrarAddress address =
|
||||
new RegistrarAddress.Builder()
|
||||
.setStreet(ImmutableList.of("123 W 14th St"))
|
||||
.setCity("New York")
|
||||
.setCountryCode("US")
|
||||
.build();
|
||||
assertThat(address.getStreet()).containsExactly("123 W 14th St");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_builder_missingStreet() {
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
() -> new RegistrarAddress.Builder().setCity("New York").setCountryCode("US").build());
|
||||
}
|
||||
}
|
||||
@@ -773,4 +773,20 @@ class RegistrarTest extends EntityTestCase {
|
||||
assertThat(Registrar.loadByRegistrarId("registrar").toString())
|
||||
.contains("allowedTlds=[bar, baz, foo, gon, tri]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_expiryAccessPeriodEnabled() {
|
||||
assertThat(registrar.getExpiryAccessPeriodEnabled()).isFalse();
|
||||
persistResource(registrar.asBuilder().setExpiryAccessPeriodEnabled(true).build());
|
||||
assertThat(
|
||||
Registrar.loadByRegistrarId("registrar").orElseThrow().getExpiryAccessPeriodEnabled())
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess_toJsonMap_includesExpiryAccessPeriodEnabled() {
|
||||
assertThat(registrar.toJsonMap()).containsEntry("expiryAccessPeriodEnabled", false);
|
||||
Registrar modified = registrar.asBuilder().setExpiryAccessPeriodEnabled(true).build();
|
||||
assertThat(modified.toJsonMap()).containsEntry("expiryAccessPeriodEnabled", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,6 +746,37 @@ public final class TldTest extends EntityTestCase {
|
||||
.isEqualTo(new BigDecimal("50.00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExpiryAccessPeriodMode_undefined() {
|
||||
assertThat(Tld.get("tld").getExpiryAccessPeriodModeAt(fakeClock.now()))
|
||||
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExpiryAccessPeriodMode_specified() {
|
||||
Instant a = minusDays(fakeClock.now(), 1);
|
||||
Instant b = plusDays(fakeClock.now(), 1);
|
||||
Tld tld =
|
||||
Tld.get("tld")
|
||||
.asBuilder()
|
||||
.setExpiryAccessPeriodTransitions(
|
||||
ImmutableSortedMap.of(
|
||||
START_INSTANT,
|
||||
Tld.ExpiryAccessPeriodMode.DISABLED,
|
||||
a,
|
||||
Tld.ExpiryAccessPeriodMode.ENABLED,
|
||||
b,
|
||||
Tld.ExpiryAccessPeriodMode.DISABLED))
|
||||
.build();
|
||||
|
||||
assertThat(tld.getExpiryAccessPeriodModeAt(fakeClock.now()))
|
||||
.isEqualTo(Tld.ExpiryAccessPeriodMode.ENABLED);
|
||||
assertThat(tld.getExpiryAccessPeriodModeAt(minusDays(fakeClock.now(), 2)))
|
||||
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
|
||||
assertThat(tld.getExpiryAccessPeriodModeAt(plusDays(fakeClock.now(), 2)))
|
||||
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFailure_eapFee_wrongCurrency() {
|
||||
IllegalArgumentException thrown =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user