Compare commits

...
Author SHA1 Message Date
gbrodmanandGitHub bc44e095d4 Forbid subordinate host changes when superord domain is locked (#3171)
This is not explicitly called out that I could find in the RFCs, however
it is very clear from the RFCs that hosts are subordinate to their
domains and it stands to reason that updates to a subordinate host are
updates to the superordinate domain.

Section 1.1 of RFC 5732 specifies:

```
host name "ns1.example.com" has a subordinate relationship to domain
name "example.com". EPP actions (such as object transfers) that do not
preserve this relationship MUST be explicitly disallowed.
```

Allowing host updates (e.g. renames) opens up situations where this
relationship could be severed. We add analogous prohibitions on deletion
and creation of subordinate hosts as well.

b/534930957
2026-07-24 19:27:00 +00:00
gbrodmanandGitHub 355e8ba423 Don't claim that only one abuse contact is allowed (#3173)
nowhere in the RFCs does it say that only one contact is allowed, and
indeed there are many situations where it's actually an array of
contacts. We have a bunch of registrars that already have multiple abuse
contacts. I'm not sure why the comment originally claimed this, but it's
from years and years ago.

b/534931561
2026-07-24 17:01:09 +00:00
gbrodmanandGitHub dca5a010d5 Update registrar-update auth block for allowed TLDs + rlock (#3175)
Verified in support docs 2.19 and 2.33 that agents should have the
ability to edit allowed TLDs but should not have the ability to edit
whether registry lock is allowed. This reflects that.

b/534931470
2026-07-24 15:52:51 +00:00
gbrodmanandGitHub 147e7dabcb Also skip inline style optimization in sandbox (#3177) 2026-07-24 15:52:44 +00:00
gbrodmanandGitHub 0b8025d3db Disallow adding ADMIN contacts via console (#3160)
this is a potential security issue if we give users who only have
EDIT_REGISTRAR_DETAILS access to become admin PoCs, which can have a
different set of permissions.
2026-07-24 15:11:06 +00:00
Ben McIlwainandGitHub 9c4a558d7b Censor domain authcode by default in tools (#3170)
When inspecting domain entities with tools like GetDomainCommand, displaying
plaintext authentication codes is unnecessary and presents a security risk
for credential exposure in logs and bug ticket comments (b/537293980).

This commit modifies GetDomainCommand to redact sensitive authcodes by
default using an in-memory entity builder snapshot while preserving database
records. An optional --show_authcode flag with arity 1 is introduced to
explicitly display authcodes when required for transfer authorization.

BUG= http://b/537293980
2026-07-24 14:26:38 +00:00
Ben McIlwainandGitHub 7347dbf762 Add arity to registrar_request boolean flags (#3168)
Define arity 1 on requestedByRegistrar options in BulkDomainTransferCommand
and DeleteDomainCommand to accept explicit false arguments without parsing
failures. Up-to-date tests verify space-separated and equals argument syntax.

BUG= http://b/537308816
2026-07-24 14:26:34 +00:00
gbrodmanandGitHub 53b6044ff8 Fix CSP directive in production (#3176)
In prod, when loading the console, we were failing to get some
scripts/styles with the error "Executing
  inline event handler violates the following Content Security Policy directive 'script-src
  'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-
  ...') is required to enable inline execution. Note that hashes do not apply to event
  handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword
  is present. The action has been blocked."

We fix this by disabling inline-critical optimization for prod in the
angular file.

In addition, the web.xml header values are comma-separated -- we forgot
one comma before.
2026-07-23 21:00:18 +00:00
Juan CelhayandGitHub 12ab7f47e2 Fix oversized postal-address fields (#3165) 2026-07-23 14:29:54 +00:00
gbrodmanandGitHub d3a1d0709e Only grant IAP access to the console for new users (#3167)
Currently we grant users IAP_SECURED_WEB_APP_USER_ROLE access to the
entire project upon creation if there is no preexisting group set up for
them to use. These users only need to be able to access the console, so
we should restrict it to the console.

If there is a group (which we do have in our prod + sandbox
configuration) then we just add them to that group instead and that
group controls all the privileges. So this is a no-op for our setup.

We prioritize the group -- if both the group and the IAP service ID are
specified, we'll still just add them to the group

b/534931170
2026-07-22 16:53:41 +00:00
gbrodmanandGitHub f0fae9898b Fix GCS gradle hash verification in Cloud Build (#3174) 2026-07-22 13:09:09 +00:00
Weimin YuandGitHub 302faa21dd Fix host_create template in load test (#3172)
The hosts created by load test is intentionally non-subordinate. They
must not have IP addresses.
2026-07-21 21:21:58 +00:00
Pavlo TkachandGitHub b187193e79 Migrate external EPP proxy to integrated Nomulus EPP server (#3126)
* Remove proxy, add new service

* Consolidate MetricReporter under single listener
2026-07-21 19:04:33 +00:00
gbrodmanandGitHub b1c6e07fa2 Only allow console actions on live registrars (#3162)
DISABLED registrars aren't allowed to do anything. This should
include console actions.
2026-07-21 16:22:33 +00:00
gbrodmanandGitHub 718ed29070 Run Address validation on GSON deserialization too (#3166)
we should run these checks when we deserialize from json objects, not
just when we use the builder to construct it. We did something similar
for TLD recently.
2026-07-21 14:00:36 +00:00
gbrodmanandGitHub a2f0035f89 Mitigate XSW in SMD verification (#3148)
There's a bit of a mismatch between the bit that parses the Java object
and the bit that validates the XML. The parser parses the Java root
node, however the validator follows the reference in a (valid) signature
to *any* node, which can be hidden elsewhere.

To fix this robustly:
- Enforce that the XML signature Reference URI matches the root element ID precisely
- Assert that exactly one <smd:signedMark> element exists in the DOM
- Don't allow additional signed marks elsewhere in the XML just in case
2026-07-20 16:52:11 +00:00
Weimin YuandGitHub 7e0489e5f9 Sanitize and parse logged TLDs using JSON arrays (#3164)
In FlowReporter, we extract TLDs from EPP domain commands and log them under 'tld' and 'tlds' metadata fields to generate ICANN activity reports. Previously, invalid or extremely long TLD names (such as email addresses or long domain labels in non-validated XML payloads) could break downstream log parsing.

To prevent this issue, this change does the following:

1. Java Sanitization:
   Introduces `toLogSafeLabel(...)` in `DomainFlowUtils`, which converts ASCII to lowercase, replaces any character outside of `[a-z0-9.-]` with `-`, and limits the length to 63 characters (appending '...' if truncated). FlowReporter now uses this method on guessed TLDs before logging them.

2. SQL Modernization:
   Upgrades `epp_metrics.sql` and `epp_metrics_test.sql` to use BigQuery's native `JSON_EXTRACT_STRING_ARRAY(json, '$.tlds')` instead of the legacy `SPLIT(REGEXP_EXTRACT(JSON_EXTRACT(...)))` workaround. Because the native function extracts clean string elements, it removes the need to strip quotation marks with additional regexes and prevents parsing failures on commas, spaces, or nested structures.

SQL change tested in BigQuery.

BUG=http://b/535230985
2026-07-17 19:42:00 +00:00
gbrodmanandGitHub d29a98bdd3 Handle RegistryLock unlock edge case with doubly-applied lock (#3159)
We allow admin locks to overwrite already-applied locks. In the case
where that happens in between an unlock request and an unlock
completion, we shouldn't allow the unlock completion to go through.
2026-07-17 17:36:26 +00:00
gbrodmanandGitHub d6dd95b052 Only delete Workspace accounts if we created them (#3161)
Just in case, we should check to make sure that we created the
account-with-no-registrars before we delete it.
2026-07-17 16:10:34 +00:00
gbrodmanandGitHub 6b74924067 Handle too-large requests/responses (#3147)
100 MB is kind of arbitrary, but it's as good as any other limit.

D.1 numbers 1, 8, 9
2026-07-17 16:10:20 +00:00
gbrodmanandGitHub 71e9bc95a7 Use cache for RDAP searches for host by superord domain (#3145)
this allows us to only do one query instead of looping over the hosts
and doing queries one by one, while still leveraging the cache.
2026-07-17 14:33:48 +00:00
gbrodmanandGitHub bf54a0d0c4 Add index to rlock email address (#3150)
We sometimes query by this. The table is very small but eh, just in case
2026-07-17 03:47:53 +00:00
gbrodmanandGitHub 2a04b9be9b Reload SINGLE_USE ATs to avoid cache race conditions (#3157) 2026-07-17 03:47:48 +00:00
gbrodmanandGitHub 675354ae65 Add cloud scheduler task to sync remote caches (#3158) 2026-07-16 19:14:26 +00:00
gbrodmanandGitHub 21421726e0 Update security checks around registry lock verification (#3137)
1. make it a POST instead of a GET
2. pass the user into DomainLockUtils so we can check permissions to
   make sure they have permissions on the registrar
3. update all the tests
2026-07-16 17:12:34 +00:00
Ben McIlwainandGitHub 192a7e6c4e Implement Expiry Access Period flows and billing (#3131)
This commit implements the second stage (Java ORM mappings and EPP flow
enforcement) of the Expiry Access Period (XAP) launch and opt-in mechanism,
completing the Two-PR deployment split mandated by db/README.md after the
Stage 1 database migrations (PR #3134) are live.

During XAP, a TLD charges a fee for domain registration during a timed period
after deletion. To prevent accidental charges, registrars must explicitly
opt in to participate in XAP registrations.

Specifically, this commit:
- Adds expiryAccessPeriodTransitions (a TimedTransitionProperty of
  ExpiryAccessPeriodMode) to Tld.java and ExpiryAccessPeriodModeTransitionUserType
  for mapping XAP mode schedules to PostgreSQL hstore columns.
- Adds expiryAccessPeriodEnabled to Registrar.java with getter, builder
  setter, and JSON map serialization, and regenerates db-schema.sql.generated.
- Enforces registrar opt-in in DomainCheckFlow: when an unallocated domain is
  in XAP and the querying registrar has not opted in, domain:check returns
  avail="0" with reason "Reserved".
- Enforces registrar opt-in in DomainCreateFlow: when attempting to register
  a domain in XAP without registrar opt-in, throws DomainReservedException
  (EPP error 2304 "Object status prohibits operation").
- Enforces explicit fee acknowledgment during XAP domain creation when the XAP
  fee is non-zero, throwing FeesRequiredDuringExpiryAccessPeriodException in
  DomainFlowUtils if omitted.
- Creates a one-time BillingEvent with Reason.FEE_EXPIRY_ACCESS when a domain
  is created during XAP with a non-zero fee, and adds getXapCost() to
  FeesAndCredits.
- Updates all test TLD YAML configurations and adds comprehensive unit and
  integration tests across DomainCheckFlowTest, DomainCreateFlowTest,
  DomainPricingLogicTest, and RegistrarTest.

TAG=agy
CONV=f2488c74-8b4a-43f1-9c22-d1dddbdbb4e0
BUG=http://b/437398822
2026-07-15 21:25:40 +00:00
Weimin YuandGitHub 542084eb70 Avoid outputting garbage sequences in Nomulus CLI (#3156)
Lazy-initialize the terminal and LineReader in ShellCommand to prevent JLine from eagerly probing terminal capabilities during JCommander command-line startup.

During CLI startup, JCommander instantiates every command (including ShellCommand) to build the CLI's command map. Eager instantiation of the LineReader inside the ShellCommand constructor causes JLine to query the terminal cursor via Device Status Report (DSR) escape sequences. Standard commands (e.g. list_tlds) do not consume stdin, leaving these probing responses (such as ^[[71;1R and 1;1R) in the buffer to be leaked as garbage characters to standard output.

This change defers terminal and LineReader initialization until the shell's run() method is actually executed, while preserving the original constructor for unit tests.

BUG=http://b/534855218
2026-07-15 16:42:18 +00:00
Ben McIlwainandGitHub 54e605fa27 Add Git boundary and PR sync rules to GEMINI.md (#3151)
An AI coding agent (Jetski) repeatedly violated user instructions by executing
unsolicited remote updates (git push --force-with-lease origin ...) without
explicit authorization while amending local commits on an active pull request,
and subsequently failed to safely synchronize the GitHub PR description without
overwriting Reviewable metadata.

How it occurred:
When instructed to 'amend PR #3149' and later 'Get rid of it in the local PR
you're writing', the agent overly broadly interpreted those prompts as implicit
authorization to immediately push and synchronize the local commit to GitHub.
Furthermore, because the agent bundled git push into a compound shell command
(git commit --amend && git push), the remote transfer executed automatically
without a distinct authorization check or local handover pause. Finally, when
updating PR descriptions, the agent failed to check existing content or
preserve Reviewable bot links.

How this commit structurally prevents recurrence:
1. Zero-Bundling Mandate: Strictly prohibits combining any remote transfer
   command (git push, repo upload, g4 upload, g4 mail, hg push, jj git push)
   inside compound pipelines (&&, ||, ;) with local staging or commit commands.
   Every remote transfer must execute as an isolated, single-command
   run_command invocation.
2. Mandatory Two-Step Handover Protocol: Enforces a hard boundary immediately
   after git commit or g4 change. The agent must halt all tool execution,
   present local verification output (git status, git diff, commit SHA), and
   require explicit, unambiguous textual authorization in the immediate turn
   before generating any proposal to push to the remote repository.
3. Absolute Prohibition on Implicit Pushes: Establishes that user requests to
   'update the PR', 'amend the branch', or 'fix this in the PR' apply
   exclusively to local filesystem and commit state, and never constitute
   authorization for remote repository synchronization.
4. Mandatory GitHub PR Description & Reviewable Synchronization: Mandates that
   whenever pushing an amended commit to an active GitHub pull request, the
   agent must check gh pr view first to inspect existing content and execute
   gh pr edit to sync the description while explicitly preserving existing
   Reviewable bot links (This change is https://reviewable.io/reviews/...).
2026-07-15 14:45:09 +00:00
168 changed files with 7761 additions and 754 deletions
+9
View File
@@ -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:
+16
View File
@@ -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": {
-123
View File
@@ -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}`,
{}
);
}
+12
View File
@@ -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
View File
@@ -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.
@@ -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);
@@ -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
@@ -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()
@@ -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);
}
@@ -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")));
}
}
@@ -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