diff --git a/CHANGELOG.md b/CHANGELOG.md
index a09bc6d..2e97dd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,4 +5,32 @@
FEATURES:
* **New Resource:** `quicknode_endpoint`
+* **New Resource:** `quicknode_endpoint_ip`
+* **New Resource:** `quicknode_endpoint_domain_mask`
+* **New Resource:** `quicknode_endpoint_referrer`
+* **New Resource:** `quicknode_endpoint_jwt`
+* **New Resource:** `quicknode_endpoint_request_filter`
+* **New Resource:** `quicknode_endpoint_token`
+* **New Resource:** `quicknode_endpoint_rate_limits`
+* **New Resource:** `quicknode_endpoint_method_rate_limit`
* **New Data Source:** `quicknode_chains`
+* **New Data Source:** `quicknode_endpoint`
+* **New Data Source:** `quicknode_endpoints`
+
+NOTES:
+
+* Endpoint URLs with the credential removed are named `safe_http_url` and
+ `safe_wss_url`, and carry the literal `TOKEN` where the credential belongs
+ rather than having it cut out. The real URL's shape survives, including any
+ path suffix the chain appends after the token, so
+ `replace(..., "TOKEN", token)` reproduces a working address on every chain.
+
+* `quicknode_endpoint` gains `security_options`, which decides what the endpoint
+ enforces, and `ip_custom_header` for endpoints behind a proxy. A toggle left
+ out of the configuration keeps whatever value the endpoint already has.
+* Allowlist entries are imported by value rather than by the id the API
+ assigned, so `terraform import quicknode_endpoint_ip.office 652052/203.0.113.7`
+ needs nothing looked up first.
+* Adding an allowlist entry while its toggle is disabled warns rather than
+ fails. Building an allowlist before enabling enforcement is the safe order for
+ an endpoint already serving traffic.
diff --git a/README.md b/README.md
index 0db2937..58c3aa4 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Terraform Provider for Quicknode
-The official [Terraform](https://developer.hashicorp.com/terraform) provider for
-[Quicknode](https://www.quicknode.com). Manage RPC endpoints, security rules and
-rate limits with `terraform plan` and `terraform apply`.
+The official [Terraform](https://developer.hashicorp.com/terraform)-compatible
+provider for [Quicknode](https://www.quicknode.com). Manage RPC endpoints,
+security rules and rate limits with `terraform plan` and `terraform apply`.
- [Provider documentation](./docs) — also published to the Terraform Registry
- [Contributing](./CONTRIBUTING.md)
@@ -34,9 +34,18 @@ resource "quicknode_endpoint" "payments" {
}
```
-Authentication uses a Quicknode [Admin API](https://www.quicknode.com/docs/admin-api)
-key, available on paid plans. Set `QUICKNODE_API_KEY` in the environment rather
-than writing it into a configuration file.
+Authentication uses a Quicknode [API key](https://www.quicknode.com/docs/admin-api),
+available on paid plans. Set `QUICKNODE_API_KEY` in the environment rather than
+writing it into a configuration file.
+
+The provider covers endpoints, the security mechanisms they enforce and who is
+allowed past them, RPC method filtering, and rate limits both endpoint-wide and
+per method. Endpoints created elsewhere are readable through
+`data.quicknode_endpoint` and `data.quicknode_endpoints`.
+
+A security mechanism is enabled on the endpoint and the entries it applies to
+are separate resources, so an entry added outside Terraform is left alone rather
+than deleted on the next apply.
Full resource and attribute reference lives in [`docs/`](./docs).
diff --git a/api/admin/admin.gen.go b/api/admin/admin.gen.go
index 7de3ec8..3e2bfd7 100644
--- a/api/admin/admin.gen.go
+++ b/api/admin/admin.gen.go
@@ -34,13 +34,13 @@ type GetV0EndpointsParams struct {
SortDirection *string `form:"sort_direction,omitempty" json:"sort_direction,omitempty"`
// Networks Filter by network name(s)
- Networks *[]interface{} `form:"networks,omitempty" json:"networks,omitempty"`
+ Networks *[]string `form:"networks,omitempty" json:"networks,omitempty"`
// Statuses Filter by endpoint status. Accepted values: active, paused
- Statuses *[]interface{} `form:"statuses,omitempty" json:"statuses,omitempty"`
+ Statuses *[]string `form:"statuses,omitempty" json:"statuses,omitempty"`
// Labels Filter by endpoint label(s)
- Labels *[]interface{} `form:"labels,omitempty" json:"labels,omitempty"`
+ Labels *[]string `form:"labels,omitempty" json:"labels,omitempty"`
// Dedicated Filter for dedicated endpoints only
Dedicated *bool `form:"dedicated,omitempty" json:"dedicated,omitempty"`
@@ -148,7 +148,7 @@ type PostV0EndpointsByIdMethodRateLimitsJSONBody struct {
// PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdJSONBody defines parameters for PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitId.
type PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdJSONBody struct {
// Methods An array of method names to which the rate limiter applies
- Methods []interface{} `json:"methods"`
+ Methods []string `json:"methods"`
// Rate Specifies the maximum number of requests allowed for the specified methods within the defined interval
Rate int `json:"rate"`
@@ -223,7 +223,7 @@ type PostV0EndpointsByIdSecurityRequestFiltersJSONBody struct {
// PutV0EndpointsByIdSecurityRequestFiltersByRequestFilterIdJSONBody defines parameters for PutV0EndpointsByIdSecurityRequestFiltersByRequestFilterId.
type PutV0EndpointsByIdSecurityRequestFiltersByRequestFilterIdJSONBody struct {
// Method An array of method names to whitelist
- Method *[]interface{} `json:"method,omitempty"`
+ Method *[]string `json:"method,omitempty"`
}
// PatchV0EndpointsByIdSecurityOptionsJSONBody defines parameters for PatchV0EndpointsByIdSecurityOptions.
@@ -7176,10 +7176,10 @@ type PostV0EndpointsResponse struct {
Hosts *bool `json:"hosts,omitempty"`
// Hsts Indicates whether HTTP Strict Transport Security (HSTS) is enforced
- Hsts *bool `json:"hsts,omitempty"`
-
- // IpCustomHeader Indicates whether a custom IP header is applied
- IpCustomHeader *bool `json:"ipCustomHeader,omitempty"`
+ Hsts *bool `json:"hsts,omitempty"`
+ IpCustomHeader *struct {
+ Value *string `json:"value,omitempty"`
+ } `json:"ipCustomHeader,omitempty"`
// Ips Indicates whether IP-based security is enabled
Ips *bool `json:"ips,omitempty"`
@@ -7318,10 +7318,10 @@ func (r PostV0EndpointsResponse) GetJSON200() *struct {
Hosts *bool `json:"hosts,omitempty"`
// Hsts Indicates whether HTTP Strict Transport Security (HSTS) is enforced
- Hsts *bool `json:"hsts,omitempty"`
-
- // IpCustomHeader Indicates whether a custom IP header is applied
- IpCustomHeader *bool `json:"ipCustomHeader,omitempty"`
+ Hsts *bool `json:"hsts,omitempty"`
+ IpCustomHeader *struct {
+ Value *string `json:"value,omitempty"`
+ } `json:"ipCustomHeader,omitempty"`
// Ips Indicates whether IP-based security is enabled
Ips *bool `json:"ips,omitempty"`
@@ -8648,7 +8648,7 @@ type GetV0EndpointsByIdMethodRateLimitsResponse struct {
Interval *string `json:"interval,omitempty"`
// Methods A list of method names to which the rate limiter applies
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The number of allowed requests within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -8679,7 +8679,7 @@ func (r GetV0EndpointsByIdMethodRateLimitsResponse) GetJSON200() *struct {
Interval *string `json:"interval,omitempty"`
// Methods A list of method names to which the rate limiter applies
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The number of allowed requests within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -8741,7 +8741,7 @@ type PostV0EndpointsByIdMethodRateLimitsResponse struct {
Interval *string `json:"interval,omitempty"`
// Methods A list of methods the rate limiter applies to
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The maximum number of requests allowed within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -8769,7 +8769,7 @@ func (r PostV0EndpointsByIdMethodRateLimitsResponse) GetJSON200() *struct {
Interval *string `json:"interval,omitempty"`
// Methods A list of methods the rate limiter applies to
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The maximum number of requests allowed within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -8883,7 +8883,7 @@ type PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdResponse struct {
Interval *string `json:"interval,omitempty"`
// Methods A list of methods the rate limiter applies to
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The maximum number of requests allowed within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -8911,7 +8911,7 @@ func (r PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdResponse) GetJSON
Interval *string `json:"interval,omitempty"`
// Methods A list of methods the rate limiter applies to
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The maximum number of requests allowed within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -9247,13 +9247,24 @@ type GetV0EndpointsByIdSecurityResponse struct {
// Data Contains the security state of the endpoint
Data *struct {
// DomainMasks An array of domain mask entries; null if none configured
- DomainMasks *[]interface{} `json:"domain_masks,omitempty"`
+ DomainMasks *[]struct {
+ DomainMask *string `json:"domain_mask,omitempty"`
+ Id *string `json:"id,omitempty"`
+ } `json:"domain_masks,omitempty"`
// Ips An array of allowed IP entries; null if none configured
- Ips *[]interface{} `json:"ips,omitempty"`
+ Ips *[]struct {
+ Id *string `json:"id,omitempty"`
+ Ip *string `json:"ip,omitempty"`
+ } `json:"ips,omitempty"`
// Jwts An array of JWT configuration objects; null if none configured
- Jwts *[]interface{} `json:"jwts,omitempty"`
+ Jwts *[]struct {
+ Id *string `json:"id,omitempty"`
+ Kid *string `json:"kid,omitempty"`
+ Name *string `json:"name,omitempty"`
+ PublicKey *string `json:"public_key,omitempty"`
+ } `json:"jwts,omitempty"`
// Options Security feature toggles for the endpoint
Options *struct {
@@ -9292,7 +9303,10 @@ type GetV0EndpointsByIdSecurityResponse struct {
} `json:"options,omitempty"`
// Referrers An array of allowed referrer entries; null if none configured
- Referrers *[]interface{} `json:"referrers,omitempty"`
+ Referrers *[]struct {
+ Id *string `json:"id,omitempty"`
+ Referrer *string `json:"referrer,omitempty"`
+ } `json:"referrers,omitempty"`
// RequestFilters An array of request filter objects; null if none configured
RequestFilters *[]struct {
@@ -9300,7 +9314,7 @@ type GetV0EndpointsByIdSecurityResponse struct {
Id *string `json:"id,omitempty"`
// Method An array of whitelisted method names
- Method *[]interface{} `json:"method,omitempty"`
+ Method *[]string `json:"method,omitempty"`
// Params Parameter constraints for the filter
Params *map[string]interface{} `json:"params,omitempty"`
@@ -9326,13 +9340,24 @@ func (r GetV0EndpointsByIdSecurityResponse) GetJSON200() *struct {
// Data Contains the security state of the endpoint
Data *struct {
// DomainMasks An array of domain mask entries; null if none configured
- DomainMasks *[]interface{} `json:"domain_masks,omitempty"`
+ DomainMasks *[]struct {
+ DomainMask *string `json:"domain_mask,omitempty"`
+ Id *string `json:"id,omitempty"`
+ } `json:"domain_masks,omitempty"`
// Ips An array of allowed IP entries; null if none configured
- Ips *[]interface{} `json:"ips,omitempty"`
+ Ips *[]struct {
+ Id *string `json:"id,omitempty"`
+ Ip *string `json:"ip,omitempty"`
+ } `json:"ips,omitempty"`
// Jwts An array of JWT configuration objects; null if none configured
- Jwts *[]interface{} `json:"jwts,omitempty"`
+ Jwts *[]struct {
+ Id *string `json:"id,omitempty"`
+ Kid *string `json:"kid,omitempty"`
+ Name *string `json:"name,omitempty"`
+ PublicKey *string `json:"public_key,omitempty"`
+ } `json:"jwts,omitempty"`
// Options Security feature toggles for the endpoint
Options *struct {
@@ -9371,7 +9396,10 @@ func (r GetV0EndpointsByIdSecurityResponse) GetJSON200() *struct {
} `json:"options,omitempty"`
// Referrers An array of allowed referrer entries; null if none configured
- Referrers *[]interface{} `json:"referrers,omitempty"`
+ Referrers *[]struct {
+ Id *string `json:"id,omitempty"`
+ Referrer *string `json:"referrer,omitempty"`
+ } `json:"referrers,omitempty"`
// RequestFilters An array of request filter objects; null if none configured
RequestFilters *[]struct {
@@ -9379,7 +9407,7 @@ func (r GetV0EndpointsByIdSecurityResponse) GetJSON200() *struct {
Id *string `json:"id,omitempty"`
// Method An array of whitelisted method names
- Method *[]interface{} `json:"method,omitempty"`
+ Method *[]string `json:"method,omitempty"`
// Params Parameter constraints for the filter
Params *map[string]interface{} `json:"params,omitempty"`
@@ -10229,12 +10257,8 @@ type PatchV0EndpointsByIdSecurityOptionsResponse struct {
HTTPResponse *http.Response
// JSON200 the response for an HTTP 200 `application/json` response
JSON200 *struct {
- // Data The data object which contains the following fields:
- Data *struct {
- // Option Represents the security options
+ Data *[]struct {
Option *string `json:"option,omitempty"`
-
- // Status Indicates the status of the option. Possible values: enabled, disabled
Status *string `json:"status,omitempty"`
} `json:"data,omitempty"`
@@ -10245,12 +10269,8 @@ type PatchV0EndpointsByIdSecurityOptionsResponse struct {
// GetJSON200 returns the response for an HTTP 200 `application/json` response
func (r PatchV0EndpointsByIdSecurityOptionsResponse) GetJSON200() *struct {
- // Data The data object which contains the following fields:
- Data *struct {
- // Option Represents the security options
+ Data *[]struct {
Option *string `json:"option,omitempty"`
-
- // Status Indicates the status of the option. Possible values: enabled, disabled
Status *string `json:"status,omitempty"`
} `json:"data,omitempty"`
@@ -13984,10 +14004,10 @@ func ParsePostV0EndpointsResponse(rsp *http.Response) (*PostV0EndpointsResponse,
Hosts *bool `json:"hosts,omitempty"`
// Hsts Indicates whether HTTP Strict Transport Security (HSTS) is enforced
- Hsts *bool `json:"hsts,omitempty"`
-
- // IpCustomHeader Indicates whether a custom IP header is applied
- IpCustomHeader *bool `json:"ipCustomHeader,omitempty"`
+ Hsts *bool `json:"hsts,omitempty"`
+ IpCustomHeader *struct {
+ Value *string `json:"value,omitempty"`
+ } `json:"ipCustomHeader,omitempty"`
// Ips Indicates whether IP-based security is enabled
Ips *bool `json:"ips,omitempty"`
@@ -14771,7 +14791,7 @@ func ParseGetV0EndpointsByIdMethodRateLimitsResponse(rsp *http.Response) (*GetV0
Interval *string `json:"interval,omitempty"`
// Methods A list of method names to which the rate limiter applies
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The number of allowed requests within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -14822,7 +14842,7 @@ func ParsePostV0EndpointsByIdMethodRateLimitsResponse(rsp *http.Response) (*Post
Interval *string `json:"interval,omitempty"`
// Methods A list of methods the rate limiter applies to
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The maximum number of requests allowed within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -14904,7 +14924,7 @@ func ParsePatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdResponse(rsp *h
Interval *string `json:"interval,omitempty"`
// Methods A list of methods the rate limiter applies to
- Methods *[]interface{} `json:"methods,omitempty"`
+ Methods *[]string `json:"methods,omitempty"`
// Rate The maximum number of requests allowed within the specified interval
Rate *int `json:"rate,omitempty"`
@@ -15109,13 +15129,24 @@ func ParseGetV0EndpointsByIdSecurityResponse(rsp *http.Response) (*GetV0Endpoint
// Data Contains the security state of the endpoint
Data *struct {
// DomainMasks An array of domain mask entries; null if none configured
- DomainMasks *[]interface{} `json:"domain_masks,omitempty"`
+ DomainMasks *[]struct {
+ DomainMask *string `json:"domain_mask,omitempty"`
+ Id *string `json:"id,omitempty"`
+ } `json:"domain_masks,omitempty"`
// Ips An array of allowed IP entries; null if none configured
- Ips *[]interface{} `json:"ips,omitempty"`
+ Ips *[]struct {
+ Id *string `json:"id,omitempty"`
+ Ip *string `json:"ip,omitempty"`
+ } `json:"ips,omitempty"`
// Jwts An array of JWT configuration objects; null if none configured
- Jwts *[]interface{} `json:"jwts,omitempty"`
+ Jwts *[]struct {
+ Id *string `json:"id,omitempty"`
+ Kid *string `json:"kid,omitempty"`
+ Name *string `json:"name,omitempty"`
+ PublicKey *string `json:"public_key,omitempty"`
+ } `json:"jwts,omitempty"`
// Options Security feature toggles for the endpoint
Options *struct {
@@ -15154,7 +15185,10 @@ func ParseGetV0EndpointsByIdSecurityResponse(rsp *http.Response) (*GetV0Endpoint
} `json:"options,omitempty"`
// Referrers An array of allowed referrer entries; null if none configured
- Referrers *[]interface{} `json:"referrers,omitempty"`
+ Referrers *[]struct {
+ Id *string `json:"id,omitempty"`
+ Referrer *string `json:"referrer,omitempty"`
+ } `json:"referrers,omitempty"`
// RequestFilters An array of request filter objects; null if none configured
RequestFilters *[]struct {
@@ -15162,7 +15196,7 @@ func ParseGetV0EndpointsByIdSecurityResponse(rsp *http.Response) (*GetV0Endpoint
Id *string `json:"id,omitempty"`
// Method An array of whitelisted method names
- Method *[]interface{} `json:"method,omitempty"`
+ Method *[]string `json:"method,omitempty"`
// Params Parameter constraints for the filter
Params *map[string]interface{} `json:"params,omitempty"`
@@ -15668,12 +15702,8 @@ func ParsePatchV0EndpointsByIdSecurityOptionsResponse(rsp *http.Response) (*Patc
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
- // Data The data object which contains the following fields:
- Data *struct {
- // Option Represents the security options
+ Data *[]struct {
Option *string `json:"option,omitempty"`
-
- // Status Indicates the status of the option. Possible values: enabled, disabled
Status *string `json:"status,omitempty"`
} `json:"data,omitempty"`
diff --git a/api/admin/openapi.json b/api/admin/openapi.json
index 2946610..5c32d00 100644
--- a/api/admin/openapi.json
+++ b/api/admin/openapi.json
@@ -443,7 +443,9 @@
"name": "networks",
"schema": {
"description": "Filter by network name(s)",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
}
},
@@ -453,7 +455,9 @@
"name": "statuses",
"schema": {
"description": "Filter by endpoint status. Accepted values: active, paused",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
}
},
@@ -463,7 +467,9 @@
"name": "labels",
"schema": {
"description": "Filter by endpoint label(s)",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
}
},
@@ -794,8 +800,15 @@
"type": "boolean"
},
"ipCustomHeader": {
- "description": "Indicates whether a custom IP header is applied",
- "type": "boolean"
+ "properties": {
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
},
"ips": {
"description": "Indicates whether IP-based security is enabled",
@@ -2184,7 +2197,9 @@
},
"methods": {
"description": "A list of method names to which the rate limiter applies ",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
},
"rate": {
@@ -2293,7 +2308,9 @@
},
"methods": {
"description": "A list of methods the rate limiter applies to",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
},
"rate": {
@@ -2416,7 +2433,9 @@
"properties": {
"methods": {
"description": "An array of method names to which the rate limiter applies",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
},
"rate": {
@@ -2462,7 +2481,9 @@
},
"methods": {
"description": "A list of methods the rate limiter applies to",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
},
"rate": {
@@ -2852,17 +2873,53 @@
"properties": {
"domain_masks": {
"description": "An array of domain mask entries; null if none configured",
- "items": {},
+ "items": {
+ "properties": {
+ "domain_mask": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"type": "array"
},
"ips": {
"description": "An array of allowed IP entries; null if none configured",
- "items": {},
+ "items": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "ip": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"type": "array"
},
"jwts": {
"description": "An array of JWT configuration objects; null if none configured",
- "items": {},
+ "items": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "kid": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "public_key": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"type": "array"
},
"options": {
@@ -2919,7 +2976,17 @@
},
"referrers": {
"description": "An array of allowed referrer entries; null if none configured",
- "items": {},
+ "items": {
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "referrer": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"type": "array"
},
"request_filters": {
@@ -2932,7 +2999,9 @@
},
"method": {
"description": "An array of whitelisted method names",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
},
"params": {
@@ -3673,7 +3742,9 @@
"properties": {
"method": {
"description": "An array of method names to whitelist",
- "items": {},
+ "items": {
+ "type": "string"
+ },
"type": "array"
}
},
@@ -3935,18 +4006,18 @@
"schema": {
"properties": {
"data": {
- "description": "The data object which contains the following fields:",
- "properties": {
- "option": {
- "description": "Represents the security options",
- "type": "string"
+ "items": {
+ "properties": {
+ "option": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
},
- "status": {
- "description": "Indicates the status of the option. Possible values: enabled, disabled",
- "type": "string"
- }
+ "type": "object"
},
- "type": "object"
+ "type": "array"
},
"error": {
"description": "A field containing an error message if any issue occurs",
diff --git a/api/admin/patches.json b/api/admin/patches.json
index 383c203..130b8bb 100644
--- a/api/admin/patches.json
+++ b/api/admin/patches.json
@@ -169,5 +169,167 @@
}
}
}
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1security/get/responses/200/content/application~1json/schema/properties/data/properties/ips/items",
+ "value": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "ip": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1security/get/responses/200/content/application~1json/schema/properties/data/properties/domain_masks/items",
+ "value": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "domain_mask": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1security/get/responses/200/content/application~1json/schema/properties/data/properties/referrers/items",
+ "value": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "referrer": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1security/get/responses/200/content/application~1json/schema/properties/data/properties/jwts/items",
+ "value": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "kid": {
+ "type": "string"
+ },
+ "public_key": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1security/get/responses/200/content/application~1json/schema/properties/data/properties/request_filters/items/properties/method/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1security~1request_filters~1{request_filter_id}/put/requestBody/content/application~1json/schema/properties/method/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1method-rate-limits/get/responses/200/content/application~1json/schema/properties/data/properties/rate_limiters/items/properties/methods/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1method-rate-limits~1{method_rate_limit_id}/patch/requestBody/content/application~1json/schema/properties/methods/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1method-rate-limits/post/responses/200/content/application~1json/schema/properties/data/properties/methods/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1method-rate-limits~1{method_rate_limit_id}/patch/responses/200/content/application~1json/schema/properties/data/properties/methods/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints/get/parameters/5/schema/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints/get/parameters/6/schema/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints/get/parameters/7/schema/items",
+ "value": {
+ "type": "string"
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints/post/responses/200/content/application~1json/schema/properties/data/properties/security/properties/options/properties/ipCustomHeader",
+ "value": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1security_options/patch/responses/200/content/application~1json/schema/properties/data",
+ "value": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "option": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
+ }
+ }
+ }
}
]
diff --git a/docs/data-sources/endpoint.md b/docs/data-sources/endpoint.md
new file mode 100644
index 0000000..b44cf3c
--- /dev/null
+++ b/docs/data-sources/endpoint.md
@@ -0,0 +1,81 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint Data Source - quicknode"
+subcategory: ""
+description: |-
+ One endpoint that already exists on the account, looked up by id or by label. Use it to wire a Terraform configuration into an endpoint created elsewhere without importing it.
+ Labels are not unique, so a label matching more than one endpoint is an error rather than an arbitrary pick.
+---
+
+# quicknode_endpoint (Data Source)
+
+One endpoint that already exists on the account, looked up by `id` or by `label`. Use it to wire a Terraform configuration into an endpoint created elsewhere without importing it.
+
+Labels are not unique, so a label matching more than one endpoint is an error rather than an arbitrary pick.
+
+## Example Usage
+
+```terraform
+# Look up an endpoint created outside Terraform, by id or by label.
+data "quicknode_endpoint" "payments" {
+ label = "payments-prod"
+}
+
+output "payments_rpc_url" {
+ value = data.quicknode_endpoint.payments.http_url_with_token
+ sensitive = true
+}
+
+# Attach an allowlist entry to an endpoint this configuration does not own.
+resource "quicknode_endpoint_ip" "office" {
+ endpoint_id = data.quicknode_endpoint.payments.id
+ ip = "203.0.113.7"
+}
+```
+
+
+## Schema
+
+### Optional
+
+- `id` (String) Endpoint id. Set this or `label`, not both.
+- `label` (String) Endpoint label. Set this or `id`, not both.
+
+### Read-Only
+
+- `chain` (String) Chain slug.
+- `http_url_with_token` (String, Sensitive) The working HTTPS endpoint, exactly as the Admin API returns it.
+- `ip_custom_header` (String) Header the endpoint reads the caller's IP address from, or null if none is set.
+- `multichain` (Boolean) Whether the endpoint serves more than one network.
+- `network` (String) Network slug.
+- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display.
+- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.
+- `security_options` (Attributes) Which security mechanisms the endpoint enforces. (see [below for nested schema](#nestedatt--security_options))
+- `status` (String) `active` or `paused`.
+- `tags` (List of String) Tag labels applied to the endpoint.
+- `tokens` (Attributes List) Auth tokens for the endpoint. Values land in Terraform state, so keep state encrypted and remote. (see [below for nested schema](#nestedatt--tokens))
+- `wss_url_with_token` (String, Sensitive) The working WebSocket endpoint, or null on chains without WebSocket support.
+
+
+### Nested Schema for `security_options`
+
+Read-Only:
+
+- `cors` (Boolean) Whether a Cross-Origin Resource Sharing policy is applied.
+- `domain_masks` (Boolean) Whether the endpoint is served from an approved custom domain.
+- `hsts` (Boolean) Whether the HTTP Strict Transport Security header is sent.
+- `ips` (Boolean) Whether IP restrictions are applied.
+- `jwts` (Boolean) Whether a signed JWT is required.
+- `referrers` (Boolean) Whether referrer restrictions are applied.
+- `request_filters` (Boolean) Whether RPC method filtering is applied.
+- `response_logging` (Boolean) Whether responses are logged for the endpoint.
+- `tokens` (Boolean) Whether an auth token is required.
+
+
+
+### Nested Schema for `tokens`
+
+Read-Only:
+
+- `id` (String) Token id.
+- `token` (String, Sensitive) Token value.
diff --git a/docs/data-sources/endpoints.md b/docs/data-sources/endpoints.md
new file mode 100644
index 0000000..57d3828
--- /dev/null
+++ b/docs/data-sources/endpoints.md
@@ -0,0 +1,71 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoints Data Source - quicknode"
+subcategory: ""
+description: |-
+ Endpoints on the account, optionally filtered. Several values in one filter match any of them, and several filters must all match.
+ Rows carry what the list route returns, which is less than data.quicknode_endpoint: no tokens, no security settings and no rate limits. Every page is walked, so the result is the whole account rather than the first screen.
+---
+
+# quicknode_endpoints (Data Source)
+
+Endpoints on the account, optionally filtered. Several values in one filter match any of them, and several filters must all match.
+
+Rows carry what the list route returns, which is less than `data.quicknode_endpoint`: no tokens, no security settings and no rate limits. Every page is walked, so the result is the whole account rather than the first screen.
+
+## Example Usage
+
+```terraform
+# Several values in one filter match any of them; several filters must all
+# match. Every page is walked, so this is the whole account.
+data "quicknode_endpoints" "production" {
+ tag_labels = ["prod"]
+ statuses = ["active"]
+}
+
+# Apply the same rate limit across every matching endpoint.
+resource "quicknode_endpoint_rate_limits" "production" {
+ for_each = toset(data.quicknode_endpoints.production.ids)
+
+ endpoint_id = each.value
+ rps = 50
+}
+
+output "production_networks" {
+ value = [for endpoint in data.quicknode_endpoints.production.endpoints : endpoint.network]
+}
+```
+
+
+## Schema
+
+### Optional
+
+- `labels` (List of String) Keep only endpoints carrying these labels.
+- `networks` (List of String) Keep only endpoints on these networks, for example `mainnet` or `base-sepolia`.
+- `search` (String) Match against the endpoint's subdomain or label.
+- `statuses` (List of String) Keep only endpoints with these statuses: `active` or `paused`.
+- `tag_labels` (List of String) Keep only endpoints carrying these tags.
+
+### Read-Only
+
+- `endpoints` (Attributes List) The matching endpoints. (see [below for nested schema](#nestedatt--endpoints))
+- `ids` (List of String) Ids of the matching endpoints, in the same order as `endpoints`. Convenient for `for_each` over another resource.
+
+
+### Nested Schema for `endpoints`
+
+Read-Only:
+
+- `chain` (String) Chain slug.
+- `dedicated` (Boolean) Whether the endpoint runs on dedicated infrastructure.
+- `flat_rate` (Boolean) Whether the endpoint is billed at a flat rate.
+- `id` (String) Endpoint id.
+- `label` (String) Descriptive label, or null if the endpoint has none.
+- `multichain` (Boolean) Whether the endpoint serves more than one network.
+- `name` (String) Endpoint subdomain.
+- `network` (String) Network slug.
+- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display. The list route carries no usable token, so read `data.quicknode_endpoint` for a working URL.
+- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.
+- `status` (String) `active` or `paused`.
+- `tags` (List of String) Tag labels applied to the endpoint.
diff --git a/docs/index.md b/docs/index.md
index 661893d..ba9e7f8 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -11,8 +11,8 @@ rules, and rate limits, through `terraform plan` and `terraform apply`.
## Authentication
-The provider authenticates with a Quicknode [Admin API](https://www.quicknode.com/docs/admin-api)
-key, available on paid plans. Set it in the environment rather than in a
+The provider authenticates with a Quicknode [API key](https://www.quicknode.com/docs/admin-api),
+available on paid plans. Set it in the environment rather than in a
configuration file:
```sh
@@ -35,20 +35,49 @@ terraform {
provider "quicknode" {}
```
+## What you can manage
+
+| | |
+|---|---|
+| `quicknode_endpoint` | the endpoint itself, its label, status, tags and which security mechanisms it enforces |
+| `quicknode_endpoint_ip`, `_domain_mask`, `_referrer` | who is allowed to call it |
+| `quicknode_endpoint_jwt` | signing keys, for authenticating without a token in the URL |
+| `quicknode_endpoint_token` | additional auth tokens, so one consumer can be revoked without disturbing the rest |
+| `quicknode_endpoint_request_filter` | which RPC methods it accepts |
+| `quicknode_endpoint_rate_limits`, `_method_rate_limit` | how much traffic it accepts, overall and per method |
+| `data.quicknode_endpoint`, `data.quicknode_endpoints` | endpoints created elsewhere |
+| `data.quicknode_chains` | every chain and network slug, for validating configuration at plan time |
+
+A security mechanism is enabled on the endpoint and the entries it applies to
+are separate resources, so an entry added outside Terraform is left alone rather
+than deleted on the next apply.
+
## Endpoint URLs
-The Admin API returns endpoint URLs with the auth token embedded. Endpoints
-expose both forms:
+The Admin API returns endpoint URLs with the auth token embedded in the path.
+Endpoints expose both forms:
| Attribute | Sensitive | Use it for |
|---|---|---|
| `http_url_with_token`, `wss_url_with_token` | yes | anything that makes RPC calls |
-| `http_url`, `wss_url` | no | logging, display, anything that must not hold a credential |
+| `safe_http_url`, `safe_wss_url` | no | logging, display, anything that must not hold a credential |
+
+The safe form carries the literal `TOKEN` where the credential belongs:
+
+```
+https://polished-damp-grass.hype-testnet.quiknode.pro/TOKEN/evm
+```
+
+It keeps the real URL's shape, so substituting a token reproduces a working
+address on every chain:
+
+```hcl
+replace(quicknode_endpoint.api.safe_http_url, "TOKEN", var.token)
+```
-Do not rebuild a URL by joining `http_url` to a token. The token is not always
-the last path segment — some chains append a suffix, as in
-`https:////evm` — so a hand-assembled URL works on Ethereum and
-breaks elsewhere.
+Do not assemble a URL from parts instead. The token is not always the last path
+segment — some chains append a suffix after it, as above — so a hand-built URL
+works on Ethereum and breaks elsewhere.
Token values are written to Terraform state. Use
[encrypted remote state](https://developer.hashicorp.com/terraform/language/state/sensitive-data).
@@ -58,6 +87,6 @@ Token values are written to Terraform state. Use
### Optional
-- `api_key` (String, Sensitive) Quicknode Admin API key. Defaults to the `QUICKNODE_API_KEY` environment variable. Prefer the environment variable so the key stays out of configuration and state.
-- `base_url` (String) Admin API base URL. Defaults to `https://api.quicknode.com`.
-- `requests_per_second` (Number) Throttle applied to Admin API calls. A large workspace bursts many calls during one apply, so the provider paces itself.
+- `api_key` (String, Sensitive) Quicknode API key. Defaults to the `QUICKNODE_API_KEY` environment variable. Prefer the environment variable so the key stays out of configuration and state.
+- `base_url` (String) Quicknode API base URL. Defaults to `https://api.quicknode.com`.
+- `requests_per_second` (Number) Throttle applied to Quicknode API calls. A large workspace bursts many calls during one apply, so the provider paces itself.
diff --git a/docs/resources/endpoint.md b/docs/resources/endpoint.md
index a918f8e..7834f02 100644
--- a/docs/resources/endpoint.md
+++ b/docs/resources/endpoint.md
@@ -4,14 +4,14 @@ page_title: "quicknode_endpoint Resource - quicknode"
subcategory: ""
description: |-
A Quicknode RPC endpoint on a chain and network.
- Pass http_url_with_token to anything that needs to make RPC calls. http_url and wss_url have the credential removed and are safe to log or expose, but they are not usable endpoints: the token does not sit at the end of the path on every chain, so rebuilding a URL by joining them to a token produces a broken address on chains that append a suffix.
+ Pass http_url_with_token to anything that needs to make RPC calls. safe_http_url and safe_wss_url carry the literal TOKEN where the credential belongs, so they are safe to log or display while keeping the real URL's shape, including any path suffix the chain appends. Substitute a token into one rather than assembling a URL from parts.
---
# quicknode_endpoint (Resource)
A Quicknode RPC endpoint on a chain and network.
-Pass `http_url_with_token` to anything that needs to make RPC calls. `http_url` and `wss_url` have the credential removed and are safe to log or expose, but they are not usable endpoints: the token does not sit at the end of the path on every chain, so rebuilding a URL by joining them to a token produces a broken address on chains that append a suffix.
+Pass `http_url_with_token` to anything that needs to make RPC calls. `safe_http_url` and `safe_wss_url` carry the literal `TOKEN` where the credential belongs, so they are safe to log or display while keeping the real URL's shape, including any path suffix the chain appends. Substitute a token into one rather than assembling a URL from parts.
## Example Usage
@@ -22,6 +22,19 @@ resource "quicknode_endpoint" "payments" {
label = "payments-prod"
status = "active"
tags = ["prod", "payments"]
+
+ # Each toggle decides whether a mechanism is enforced. The entries it applies
+ # to are separate resources, such as quicknode_endpoint_ip. A toggle left out
+ # keeps whatever value the endpoint already has.
+ security_options = {
+ tokens = true
+ ips = true
+ cors = false
+ }
+
+ # Read the caller's address from this header when calls arrive through a
+ # proxy, so IP restrictions match the original caller.
+ ip_custom_header = "X-Real-IP"
}
# Pass the credentialed URL to whatever makes RPC calls.
@@ -30,9 +43,11 @@ output "payments_rpc_url" {
sensitive = true
}
-# The same endpoint without the credential, safe to log or display.
-output "payments_rpc_host" {
- value = quicknode_endpoint.payments.http_url
+# The same URL with the credential replaced by the literal TOKEN. Safe to log
+# or display, and it keeps the real URL's shape, so substituting a token
+# reproduces a working address on every chain.
+output "payments_rpc_url_redacted" {
+ value = quicknode_endpoint.payments.safe_http_url
}
```
@@ -46,20 +61,41 @@ output "payments_rpc_host" {
### Optional
+- `ip_custom_header` (String) Name of the header the endpoint reads the caller's IP address from, for example `X-Real-IP`. Set it when calls arrive through a proxy, so IP restrictions match the original caller rather than the proxy.
- `label` (String) Descriptive label for the endpoint. Labels are not unique and are not used to identify the endpoint.
- `multichain` (Boolean) Whether the endpoint serves more than one network.
+- `security_options` (Attributes) Which security mechanisms the endpoint enforces. Each toggle only decides whether a mechanism is applied; the entries it applies to are separate resources, such as `quicknode_endpoint_ip`. A toggle left out of the configuration keeps whatever value the endpoint already has. (see [below for nested schema](#nestedatt--security_options))
- `status` (String) `active` or `paused`.
- `tags` (Set of String) Tag labels applied to the endpoint. Omitting the attribute removes every tag the provider finds on the endpoint.
### Read-Only
-- `http_url` (String) HTTPS URL with the auth token removed. Safe to expose, but not a working endpoint.
- `http_url_with_token` (String, Sensitive) The working HTTPS endpoint, exactly as the Admin API returns it. Pass this to whatever makes RPC calls.
- `id` (String) Endpoint id.
+- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display. Substitute a real token to make it usable: `replace(self.safe_http_url, "TOKEN", self.tokens[0].token)`.
+- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.
- `tokens` (Attributes List) Auth tokens for the endpoint. An endpoint can carry several. Token values are stored in Terraform state, so keep state encrypted and remote. (see [below for nested schema](#nestedatt--tokens))
-- `wss_url` (String) WebSocket URL with the auth token removed, or null on chains without WebSocket support.
- `wss_url_with_token` (String, Sensitive) The working WebSocket endpoint, or null on chains without WebSocket support.
+
+### Nested Schema for `security_options`
+
+Optional:
+
+- `cors` (Boolean) Apply Cross-Origin Resource Sharing policy. New endpoints have this enabled.
+- `domain_masks` (Boolean) Serve the endpoint from an approved custom domain. Add them with `quicknode_endpoint_domain_mask`.
+- `hsts` (Boolean) Send the HTTP Strict Transport Security header.
+- `ips` (Boolean) Restrict calls to the approved IP addresses. Add them with `quicknode_endpoint_ip`.
+- `jwts` (Boolean) Require a signed JWT. Register signing keys with `quicknode_endpoint_jwt`.
+- `referrers` (Boolean) Restrict calls to the approved referrers. Add them with `quicknode_endpoint_referrer`.
+- `tokens` (Boolean) Require one of the endpoint's auth tokens. New endpoints have this enabled.
+
+Read-Only:
+
+- `request_filters` (Boolean) Whether RPC method filtering is applied. Read-only: the Admin API turns this on when a `quicknode_endpoint_request_filter` exists and off when the last one is removed.
+- `response_logging` (Boolean) Whether responses are logged for the endpoint. Read-only: it is set by the account's plan rather than per endpoint.
+
+
### Nested Schema for `tokens`
diff --git a/docs/resources/endpoint_domain_mask.md b/docs/resources/endpoint_domain_mask.md
new file mode 100644
index 0000000..a424fc5
--- /dev/null
+++ b/docs/resources/endpoint_domain_mask.md
@@ -0,0 +1,55 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_domain_mask Resource - quicknode"
+subcategory: ""
+description: |-
+ A custom domain that serves a Quicknode endpoint, so callers reach it without the Quicknode hostname.
+ The entry only takes effect once security_options.domain_masks is enabled on the endpoint. Entries can be added before the toggle is turned on, which is the safe order for an endpoint already serving traffic.
+---
+
+# quicknode_endpoint_domain_mask (Resource)
+
+A custom domain that serves a Quicknode endpoint, so callers reach it without the Quicknode hostname.
+
+The entry only takes effect once `security_options.domain_masks` is enabled on the endpoint. Entries can be added before the toggle is turned on, which is the safe order for an endpoint already serving traffic.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ domain_masks = true
+ }
+}
+
+resource "quicknode_endpoint_domain_mask" "rpc" {
+ endpoint_id = quicknode_endpoint.api.id
+ domain = "rpc.example.com"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `domain` (String) Domain that serves the endpoint, for example `rpc.example.com`. Changing it replaces the entry, because the Admin API has no route to edit one in place.
+- `endpoint_id` (String) Endpoint the entry belongs to.
+
+### Read-Only
+
+- `id` (String) Entry id assigned by Quicknode.
+
+## Import
+
+Import is supported using the following syntax:
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+```shell
+# Import by the domain itself, as "/".
+terraform import quicknode_endpoint_domain_mask.rpc 652052/rpc.example.com
+```
diff --git a/docs/resources/endpoint_ip.md b/docs/resources/endpoint_ip.md
new file mode 100644
index 0000000..06a2db2
--- /dev/null
+++ b/docs/resources/endpoint_ip.md
@@ -0,0 +1,63 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_ip Resource - quicknode"
+subcategory: ""
+description: |-
+ An IP address allowed to call a Quicknode endpoint.
+ The entry only takes effect once security_options.ips is enabled on the endpoint. Entries can be added before the toggle is turned on, which is the safe order for an endpoint already serving traffic.
+---
+
+# quicknode_endpoint_ip (Resource)
+
+An IP address allowed to call a Quicknode endpoint.
+
+The entry only takes effect once `security_options.ips` is enabled on the endpoint. Entries can be added before the toggle is turned on, which is the safe order for an endpoint already serving traffic.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ ips = true
+ }
+}
+
+resource "quicknode_endpoint_ip" "office" {
+ endpoint_id = quicknode_endpoint.api.id
+ ip = "203.0.113.7"
+}
+
+# Entries may be added before the toggle is enabled, which is the safe order for
+# an endpoint already serving traffic. Adding one while security_options.ips is
+# false produces a warning rather than an error.
+resource "quicknode_endpoint_ip" "vpn" {
+ endpoint_id = quicknode_endpoint.api.id
+ ip = "198.51.100.0/24"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `endpoint_id` (String) Endpoint the entry belongs to.
+- `ip` (String) IP address or CIDR range allowed to call the endpoint. Changing it replaces the entry, because the Admin API has no route to edit one in place.
+
+### Read-Only
+
+- `id` (String) Entry id assigned by Quicknode.
+
+## Import
+
+Import is supported using the following syntax:
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+```shell
+# Import by the address itself, as "/".
+terraform import quicknode_endpoint_ip.office 652052/203.0.113.7
+```
diff --git a/docs/resources/endpoint_jwt.md b/docs/resources/endpoint_jwt.md
new file mode 100644
index 0000000..1c4f161
--- /dev/null
+++ b/docs/resources/endpoint_jwt.md
@@ -0,0 +1,65 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_jwt Resource - quicknode"
+subcategory: ""
+description: |-
+ A JWT signing key registered on a Quicknode endpoint. Callers then authenticate with a token signed by the matching private key, which keeps a long-lived credential out of the URL.
+ The key only takes effect once security_options.jwts is enabled on the endpoint.
+---
+
+# quicknode_endpoint_jwt (Resource)
+
+A JWT signing key registered on a Quicknode endpoint. Callers then authenticate with a token signed by the matching private key, which keeps a long-lived credential out of the URL.
+
+The key only takes effect once `security_options.jwts` is enabled on the endpoint.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ jwts = true
+ tokens = false
+ }
+}
+
+resource "quicknode_endpoint_jwt" "signer" {
+ endpoint_id = quicknode_endpoint.api.id
+ name = "signer"
+ public_key = file("${path.module}/signer.pub.pem")
+}
+
+# Put the generated kid in the header of the tokens signed with the matching
+# private key.
+output "jwt_kid" {
+ value = quicknode_endpoint_jwt.signer.kid
+}
+```
+
+
+## Schema
+
+### Required
+
+- `endpoint_id` (String) Endpoint the signing key belongs to.
+- `name` (String) Name for the key, used to tell several keys apart on one endpoint.
+- `public_key` (String) PEM-encoded public key that signed tokens are verified against. Changing it replaces the entry, because the Admin API has no route to edit one in place.
+
+### Read-Only
+
+- `id` (String) Entry id assigned by Quicknode.
+- `kid` (String) Key id Quicknode assigns. Put it in the `kid` header of the tokens signed with the matching private key.
+
+## Import
+
+Import is supported using the following syntax:
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+```shell
+# Import by the key's name, as "/".
+terraform import quicknode_endpoint_jwt.signer 652052/signer
+```
diff --git a/docs/resources/endpoint_method_rate_limit.md b/docs/resources/endpoint_method_rate_limit.md
new file mode 100644
index 0000000..efd941e
--- /dev/null
+++ b/docs/resources/endpoint_method_rate_limit.md
@@ -0,0 +1,73 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_method_rate_limit Resource - quicknode"
+subcategory: ""
+description: |-
+ A rate limit on a named set of RPC methods, applied on top of the endpoint-wide limits in quicknode_endpoint_rate_limits.
+ Use it to keep a handful of expensive calls, such as eth_getLogs over wide block ranges, from consuming the endpoint's whole budget.
+---
+
+# quicknode_endpoint_method_rate_limit (Resource)
+
+A rate limit on a named set of RPC methods, applied on top of the endpoint-wide limits in `quicknode_endpoint_rate_limits`.
+
+Use it to keep a handful of expensive calls, such as `eth_getLogs` over wide block ranges, from consuming the endpoint's whole budget.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+}
+
+# Keep a few expensive calls from consuming the endpoint's whole budget.
+resource "quicknode_endpoint_method_rate_limit" "heavy_reads" {
+ endpoint_id = quicknode_endpoint.api.id
+
+ methods = ["eth_getLogs", "debug_traceTransaction"]
+ rate = 5
+ interval = "second"
+}
+
+# Disabling keeps the definition in place, which suits turning a limit off
+# during an incident without losing it.
+resource "quicknode_endpoint_method_rate_limit" "trace_block" {
+ endpoint_id = quicknode_endpoint.api.id
+
+ methods = ["trace_block"]
+ rate = 100
+ interval = "minute"
+ enabled = false
+}
+```
+
+
+## Schema
+
+### Required
+
+- `endpoint_id` (String) Endpoint the limiter applies to.
+- `interval` (String) Window the rate applies to: `second`, `minute` or `hour`. Changing it replaces the limiter, because the Admin API's update route does not accept an interval.
+- `methods` (Set of String) RPC methods the limit counts, for example `eth_getLogs`.
+- `rate` (Number) Requests allowed across those methods per interval.
+
+### Optional
+
+- `enabled` (Boolean) Whether the limiter is applied. Disabling keeps the definition in place, which suits turning a limit off during an incident without losing it.
+
+### Read-Only
+
+- `id` (String) Rate limiter id assigned by Quicknode.
+
+## Import
+
+Import is supported using the following syntax:
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+```shell
+# A limiter has no natural name, so it is imported by id, as
+# "/".
+terraform import quicknode_endpoint_method_rate_limit.heavy_reads 652052/a1b2c3d4-5e6f-7890-abcd-ef1234567890
+```
diff --git a/docs/resources/endpoint_rate_limits.md b/docs/resources/endpoint_rate_limits.md
new file mode 100644
index 0000000..4bcc436
--- /dev/null
+++ b/docs/resources/endpoint_rate_limits.md
@@ -0,0 +1,74 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_rate_limits Resource - quicknode"
+subcategory: ""
+description: |-
+ Endpoint-wide request rate limits, one resource per endpoint.
+ Each bucket the Quicknode plan sets is reported under plan_default. A bucket set here overrides the plan default; a bucket left out keeps the plan default, and removing one that was set returns that bucket to the plan default rather than leaving the override in place.
+---
+
+# quicknode_endpoint_rate_limits (Resource)
+
+Endpoint-wide request rate limits, one resource per endpoint.
+
+Each bucket the Quicknode plan sets is reported under `plan_default`. A bucket set here overrides the plan default; a bucket left out keeps the plan default, and removing one that was set returns that bucket to the plan default rather than leaving the override in place.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+}
+
+# A bucket set here overrides the plan default. A bucket left out keeps it, and
+# removing one that was set returns that bucket to the plan default.
+resource "quicknode_endpoint_rate_limits" "api" {
+ endpoint_id = quicknode_endpoint.api.id
+
+ rps = 25
+ rpd = 1000000
+}
+
+output "plan_allows_per_second" {
+ value = quicknode_endpoint_rate_limits.api.plan_default.rps
+}
+```
+
+
+## Schema
+
+### Required
+
+- `endpoint_id` (String) Endpoint the limits apply to.
+
+### Optional
+
+- `rpd` (Number) Maximum requests per day. Omit to keep the plan default.
+- `rpm` (Number) Maximum requests per minute. Omit to keep the plan default.
+- `rps` (Number) Maximum requests per second. Omit to keep the plan default.
+
+### Read-Only
+
+- `id` (String) Same as `endpoint_id`. Rate limits are a property of the endpoint rather than a separate object.
+- `plan_default` (Attributes) What the account's Quicknode plan allows, before any override set here. A bucket the plan does not limit is reported as `-1`. (see [below for nested schema](#nestedatt--plan_default))
+
+
+### Nested Schema for `plan_default`
+
+Read-Only:
+
+- `rpd` (Number) Plan limit on requests per day.
+- `rpm` (Number) Plan limit on requests per minute.
+- `rps` (Number) Plan limit on requests per second.
+
+## Import
+
+Import is supported using the following syntax:
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+```shell
+# Rate limits belong to the endpoint, so the address is just the endpoint id.
+terraform import quicknode_endpoint_rate_limits.api 652052
+```
diff --git a/docs/resources/endpoint_referrer.md b/docs/resources/endpoint_referrer.md
new file mode 100644
index 0000000..1341e57
--- /dev/null
+++ b/docs/resources/endpoint_referrer.md
@@ -0,0 +1,55 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_referrer Resource - quicknode"
+subcategory: ""
+description: |-
+ A referrer allowed to call a Quicknode endpoint. Referrer checks suit browser traffic, where the header is set by the browser rather than by the caller.
+ The entry only takes effect once security_options.referrers is enabled on the endpoint. Entries can be added before the toggle is turned on, which is the safe order for an endpoint already serving traffic.
+---
+
+# quicknode_endpoint_referrer (Resource)
+
+A referrer allowed to call a Quicknode endpoint. Referrer checks suit browser traffic, where the header is set by the browser rather than by the caller.
+
+The entry only takes effect once `security_options.referrers` is enabled on the endpoint. Entries can be added before the toggle is turned on, which is the safe order for an endpoint already serving traffic.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ referrers = true
+ }
+}
+
+resource "quicknode_endpoint_referrer" "app" {
+ endpoint_id = quicknode_endpoint.api.id
+ referrer = "https://app.example.com"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `endpoint_id` (String) Endpoint the entry belongs to.
+- `referrer` (String) Referrer URL allowed to call the endpoint, for example `https://app.example.com`. Changing it replaces the entry, because the Admin API has no route to edit one in place.
+
+### Read-Only
+
+- `id` (String) Entry id assigned by Quicknode.
+
+## Import
+
+Import is supported using the following syntax:
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+```shell
+# Import by the referrer itself, as "/".
+terraform import quicknode_endpoint_referrer.app 652052/https://app.example.com
+```
diff --git a/docs/resources/endpoint_request_filter.md b/docs/resources/endpoint_request_filter.md
new file mode 100644
index 0000000..e5142bf
--- /dev/null
+++ b/docs/resources/endpoint_request_filter.md
@@ -0,0 +1,61 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_request_filter Resource - quicknode"
+subcategory: ""
+description: |-
+ The set of RPC methods a Quicknode endpoint accepts. Anything outside the set is rejected, which keeps an endpoint handed to a browser or a third party from reaching methods it has no reason to call.
+ security_options.request_filters on the endpoint reports whether filtering is applied. It is read-only: the Admin API turns it on when a filter exists and off when the last one is removed.
+---
+
+# quicknode_endpoint_request_filter (Resource)
+
+The set of RPC methods a Quicknode endpoint accepts. Anything outside the set is rejected, which keeps an endpoint handed to a browser or a third party from reaching methods it has no reason to call.
+
+`security_options.request_filters` on the endpoint reports whether filtering is applied. It is read-only: the Admin API turns it on when a filter exists and off when the last one is removed.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+}
+
+# Anything outside the set is rejected. security_options.request_filters on the
+# endpoint flips to true on its own once a filter exists.
+resource "quicknode_endpoint_request_filter" "read_only" {
+ endpoint_id = quicknode_endpoint.api.id
+
+ methods = [
+ "eth_blockNumber",
+ "eth_call",
+ "eth_getBalance",
+ "eth_getLogs",
+ ]
+}
+```
+
+
+## Schema
+
+### Required
+
+- `endpoint_id` (String) Endpoint the filter belongs to.
+- `methods` (Set of String) RPC methods the endpoint accepts, for example `eth_call` and `eth_getLogs`. Editing the set updates the filter in place.
+
+### Read-Only
+
+- `id` (String) Filter id assigned by Quicknode.
+
+## Import
+
+Import is supported using the following syntax:
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+```shell
+# A filter has no natural name, so it is imported by id, as
+# "/". Read the ids from the endpoint's security
+# settings in the Quicknode dashboard.
+terraform import quicknode_endpoint_request_filter.read_only 652052/f1e2d3c4-5b6a-7890-abcd-ef1234567890
+```
diff --git a/docs/resources/endpoint_token.md b/docs/resources/endpoint_token.md
new file mode 100644
index 0000000..b5622de
--- /dev/null
+++ b/docs/resources/endpoint_token.md
@@ -0,0 +1,62 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_token Resource - quicknode"
+subcategory: ""
+description: |-
+ An additional auth token on a Quicknode endpoint. Every endpoint is created with one token already; this resource adds further tokens, so a credential can be handed to one consumer and later revoked without disturbing the others.
+ Quicknode generates the value, so the resource takes no input beyond the endpoint. To rotate a token, add the replacement, move consumers across, then remove the old resource.
+---
+
+# quicknode_endpoint_token (Resource)
+
+An additional auth token on a Quicknode endpoint. Every endpoint is created with one token already; this resource adds further tokens, so a credential can be handed to one consumer and later revoked without disturbing the others.
+
+Quicknode generates the value, so the resource takes no input beyond the endpoint. To rotate a token, add the replacement, move consumers across, then remove the old resource.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ tokens = true
+ }
+}
+
+# Every endpoint is created with one token. This adds a second, so a credential
+# can be handed to one consumer and revoked later without disturbing the rest.
+resource "quicknode_endpoint_token" "indexer" {
+ endpoint_id = quicknode_endpoint.api.id
+}
+
+output "indexer_token" {
+ value = quicknode_endpoint_token.indexer.token
+ sensitive = true
+}
+```
+
+
+## Schema
+
+### Required
+
+- `endpoint_id` (String) Endpoint the token belongs to.
+
+### Read-Only
+
+- `id` (String) Token id assigned by Quicknode.
+- `token` (String, Sensitive) The token value. It is stored in Terraform state, so keep state encrypted and remote.
+
+## Import
+
+Import is supported using the following syntax:
+
+The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example:
+
+```shell
+# Tokens are imported by id rather than by value, as "/",
+# so the credential stays out of shell history.
+terraform import quicknode_endpoint_token.indexer 652052/d3312bd2-c1a2-4d89-865f-11c99fa3863a
+```
diff --git a/examples/data-sources/quicknode_endpoint/data-source.tf b/examples/data-sources/quicknode_endpoint/data-source.tf
new file mode 100644
index 0000000..30dba6e
--- /dev/null
+++ b/examples/data-sources/quicknode_endpoint/data-source.tf
@@ -0,0 +1,15 @@
+# Look up an endpoint created outside Terraform, by id or by label.
+data "quicknode_endpoint" "payments" {
+ label = "payments-prod"
+}
+
+output "payments_rpc_url" {
+ value = data.quicknode_endpoint.payments.http_url_with_token
+ sensitive = true
+}
+
+# Attach an allowlist entry to an endpoint this configuration does not own.
+resource "quicknode_endpoint_ip" "office" {
+ endpoint_id = data.quicknode_endpoint.payments.id
+ ip = "203.0.113.7"
+}
diff --git a/examples/data-sources/quicknode_endpoints/data-source.tf b/examples/data-sources/quicknode_endpoints/data-source.tf
new file mode 100644
index 0000000..a878ff1
--- /dev/null
+++ b/examples/data-sources/quicknode_endpoints/data-source.tf
@@ -0,0 +1,18 @@
+# Several values in one filter match any of them; several filters must all
+# match. Every page is walked, so this is the whole account.
+data "quicknode_endpoints" "production" {
+ tag_labels = ["prod"]
+ statuses = ["active"]
+}
+
+# Apply the same rate limit across every matching endpoint.
+resource "quicknode_endpoint_rate_limits" "production" {
+ for_each = toset(data.quicknode_endpoints.production.ids)
+
+ endpoint_id = each.value
+ rps = 50
+}
+
+output "production_networks" {
+ value = [for endpoint in data.quicknode_endpoints.production.endpoints : endpoint.network]
+}
diff --git a/examples/resources/quicknode_endpoint/resource.tf b/examples/resources/quicknode_endpoint/resource.tf
index e25f0c1..158dcd0 100644
--- a/examples/resources/quicknode_endpoint/resource.tf
+++ b/examples/resources/quicknode_endpoint/resource.tf
@@ -4,6 +4,19 @@ resource "quicknode_endpoint" "payments" {
label = "payments-prod"
status = "active"
tags = ["prod", "payments"]
+
+ # Each toggle decides whether a mechanism is enforced. The entries it applies
+ # to are separate resources, such as quicknode_endpoint_ip. A toggle left out
+ # keeps whatever value the endpoint already has.
+ security_options = {
+ tokens = true
+ ips = true
+ cors = false
+ }
+
+ # Read the caller's address from this header when calls arrive through a
+ # proxy, so IP restrictions match the original caller.
+ ip_custom_header = "X-Real-IP"
}
# Pass the credentialed URL to whatever makes RPC calls.
@@ -12,7 +25,9 @@ output "payments_rpc_url" {
sensitive = true
}
-# The same endpoint without the credential, safe to log or display.
-output "payments_rpc_host" {
- value = quicknode_endpoint.payments.http_url
+# The same URL with the credential replaced by the literal TOKEN. Safe to log
+# or display, and it keeps the real URL's shape, so substituting a token
+# reproduces a working address on every chain.
+output "payments_rpc_url_redacted" {
+ value = quicknode_endpoint.payments.safe_http_url
}
diff --git a/examples/resources/quicknode_endpoint_domain_mask/import.sh b/examples/resources/quicknode_endpoint_domain_mask/import.sh
new file mode 100644
index 0000000..8f50da9
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_domain_mask/import.sh
@@ -0,0 +1,2 @@
+# Import by the domain itself, as "/".
+terraform import quicknode_endpoint_domain_mask.rpc 652052/rpc.example.com
diff --git a/examples/resources/quicknode_endpoint_domain_mask/resource.tf b/examples/resources/quicknode_endpoint_domain_mask/resource.tf
new file mode 100644
index 0000000..8c491aa
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_domain_mask/resource.tf
@@ -0,0 +1,13 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ domain_masks = true
+ }
+}
+
+resource "quicknode_endpoint_domain_mask" "rpc" {
+ endpoint_id = quicknode_endpoint.api.id
+ domain = "rpc.example.com"
+}
diff --git a/examples/resources/quicknode_endpoint_ip/import.sh b/examples/resources/quicknode_endpoint_ip/import.sh
new file mode 100644
index 0000000..345268b
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_ip/import.sh
@@ -0,0 +1,2 @@
+# Import by the address itself, as "/".
+terraform import quicknode_endpoint_ip.office 652052/203.0.113.7
diff --git a/examples/resources/quicknode_endpoint_ip/resource.tf b/examples/resources/quicknode_endpoint_ip/resource.tf
new file mode 100644
index 0000000..e31e4af
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_ip/resource.tf
@@ -0,0 +1,21 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ ips = true
+ }
+}
+
+resource "quicknode_endpoint_ip" "office" {
+ endpoint_id = quicknode_endpoint.api.id
+ ip = "203.0.113.7"
+}
+
+# Entries may be added before the toggle is enabled, which is the safe order for
+# an endpoint already serving traffic. Adding one while security_options.ips is
+# false produces a warning rather than an error.
+resource "quicknode_endpoint_ip" "vpn" {
+ endpoint_id = quicknode_endpoint.api.id
+ ip = "198.51.100.0/24"
+}
diff --git a/examples/resources/quicknode_endpoint_jwt/import.sh b/examples/resources/quicknode_endpoint_jwt/import.sh
new file mode 100644
index 0000000..5db104b
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_jwt/import.sh
@@ -0,0 +1,2 @@
+# Import by the key's name, as "/".
+terraform import quicknode_endpoint_jwt.signer 652052/signer
diff --git a/examples/resources/quicknode_endpoint_jwt/resource.tf b/examples/resources/quicknode_endpoint_jwt/resource.tf
new file mode 100644
index 0000000..f402cfe
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_jwt/resource.tf
@@ -0,0 +1,21 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ jwts = true
+ tokens = false
+ }
+}
+
+resource "quicknode_endpoint_jwt" "signer" {
+ endpoint_id = quicknode_endpoint.api.id
+ name = "signer"
+ public_key = file("${path.module}/signer.pub.pem")
+}
+
+# Put the generated kid in the header of the tokens signed with the matching
+# private key.
+output "jwt_kid" {
+ value = quicknode_endpoint_jwt.signer.kid
+}
diff --git a/examples/resources/quicknode_endpoint_method_rate_limit/import.sh b/examples/resources/quicknode_endpoint_method_rate_limit/import.sh
new file mode 100644
index 0000000..9f593ab
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_method_rate_limit/import.sh
@@ -0,0 +1,3 @@
+# A limiter has no natural name, so it is imported by id, as
+# "/".
+terraform import quicknode_endpoint_method_rate_limit.heavy_reads 652052/a1b2c3d4-5e6f-7890-abcd-ef1234567890
diff --git a/examples/resources/quicknode_endpoint_method_rate_limit/resource.tf b/examples/resources/quicknode_endpoint_method_rate_limit/resource.tf
new file mode 100644
index 0000000..2d2760b
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_method_rate_limit/resource.tf
@@ -0,0 +1,24 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+}
+
+# Keep a few expensive calls from consuming the endpoint's whole budget.
+resource "quicknode_endpoint_method_rate_limit" "heavy_reads" {
+ endpoint_id = quicknode_endpoint.api.id
+
+ methods = ["eth_getLogs", "debug_traceTransaction"]
+ rate = 5
+ interval = "second"
+}
+
+# Disabling keeps the definition in place, which suits turning a limit off
+# during an incident without losing it.
+resource "quicknode_endpoint_method_rate_limit" "trace_block" {
+ endpoint_id = quicknode_endpoint.api.id
+
+ methods = ["trace_block"]
+ rate = 100
+ interval = "minute"
+ enabled = false
+}
diff --git a/examples/resources/quicknode_endpoint_rate_limits/import.sh b/examples/resources/quicknode_endpoint_rate_limits/import.sh
new file mode 100644
index 0000000..a4d5f8c
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_rate_limits/import.sh
@@ -0,0 +1,2 @@
+# Rate limits belong to the endpoint, so the address is just the endpoint id.
+terraform import quicknode_endpoint_rate_limits.api 652052
diff --git a/examples/resources/quicknode_endpoint_rate_limits/resource.tf b/examples/resources/quicknode_endpoint_rate_limits/resource.tf
new file mode 100644
index 0000000..60b568b
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_rate_limits/resource.tf
@@ -0,0 +1,17 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+}
+
+# A bucket set here overrides the plan default. A bucket left out keeps it, and
+# removing one that was set returns that bucket to the plan default.
+resource "quicknode_endpoint_rate_limits" "api" {
+ endpoint_id = quicknode_endpoint.api.id
+
+ rps = 25
+ rpd = 1000000
+}
+
+output "plan_allows_per_second" {
+ value = quicknode_endpoint_rate_limits.api.plan_default.rps
+}
diff --git a/examples/resources/quicknode_endpoint_referrer/import.sh b/examples/resources/quicknode_endpoint_referrer/import.sh
new file mode 100644
index 0000000..32e5057
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_referrer/import.sh
@@ -0,0 +1,2 @@
+# Import by the referrer itself, as "/".
+terraform import quicknode_endpoint_referrer.app 652052/https://app.example.com
diff --git a/examples/resources/quicknode_endpoint_referrer/resource.tf b/examples/resources/quicknode_endpoint_referrer/resource.tf
new file mode 100644
index 0000000..635197d
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_referrer/resource.tf
@@ -0,0 +1,13 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ referrers = true
+ }
+}
+
+resource "quicknode_endpoint_referrer" "app" {
+ endpoint_id = quicknode_endpoint.api.id
+ referrer = "https://app.example.com"
+}
diff --git a/examples/resources/quicknode_endpoint_request_filter/import.sh b/examples/resources/quicknode_endpoint_request_filter/import.sh
new file mode 100644
index 0000000..5f18eea
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_request_filter/import.sh
@@ -0,0 +1,4 @@
+# A filter has no natural name, so it is imported by id, as
+# "/". Read the ids from the endpoint's security
+# settings in the Quicknode dashboard.
+terraform import quicknode_endpoint_request_filter.read_only 652052/f1e2d3c4-5b6a-7890-abcd-ef1234567890
diff --git a/examples/resources/quicknode_endpoint_request_filter/resource.tf b/examples/resources/quicknode_endpoint_request_filter/resource.tf
new file mode 100644
index 0000000..63bc620
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_request_filter/resource.tf
@@ -0,0 +1,17 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+}
+
+# Anything outside the set is rejected. security_options.request_filters on the
+# endpoint flips to true on its own once a filter exists.
+resource "quicknode_endpoint_request_filter" "read_only" {
+ endpoint_id = quicknode_endpoint.api.id
+
+ methods = [
+ "eth_blockNumber",
+ "eth_call",
+ "eth_getBalance",
+ "eth_getLogs",
+ ]
+}
diff --git a/examples/resources/quicknode_endpoint_token/import.sh b/examples/resources/quicknode_endpoint_token/import.sh
new file mode 100644
index 0000000..3ca2f41
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_token/import.sh
@@ -0,0 +1,3 @@
+# Tokens are imported by id rather than by value, as "/",
+# so the credential stays out of shell history.
+terraform import quicknode_endpoint_token.indexer 652052/d3312bd2-c1a2-4d89-865f-11c99fa3863a
diff --git a/examples/resources/quicknode_endpoint_token/resource.tf b/examples/resources/quicknode_endpoint_token/resource.tf
new file mode 100644
index 0000000..3914349
--- /dev/null
+++ b/examples/resources/quicknode_endpoint_token/resource.tf
@@ -0,0 +1,19 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+
+ security_options = {
+ tokens = true
+ }
+}
+
+# Every endpoint is created with one token. This adds a second, so a credential
+# can be handed to one consumer and revoked later without disturbing the rest.
+resource "quicknode_endpoint_token" "indexer" {
+ endpoint_id = quicknode_endpoint.api.id
+}
+
+output "indexer_token" {
+ value = quicknode_endpoint_token.indexer.token
+ sensitive = true
+}
diff --git a/go.mod b/go.mod
index eb7b73c..77139f9 100644
--- a/go.mod
+++ b/go.mod
@@ -5,33 +5,63 @@ go 1.26.3
require (
github.com/hashicorp/terraform-plugin-framework v1.19.0
github.com/hashicorp/terraform-plugin-framework-validators v0.19.0
+ github.com/hashicorp/terraform-plugin-go v0.31.0
+ github.com/hashicorp/terraform-plugin-testing v1.16.0
github.com/oapi-codegen/runtime v1.7.0
+ golang.org/x/time v0.16.0
)
require (
+ github.com/ProtonMail/go-crypto v1.4.1 // indirect
+ github.com/agext/levenshtein v1.2.3 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
+ github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
+ github.com/cloudflare/circl v1.6.3 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-checkpoint v0.5.0 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-cty v1.5.0 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-plugin v1.7.0 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
- github.com/hashicorp/terraform-plugin-go v0.31.0 // indirect
+ github.com/hashicorp/go-version v1.9.0 // indirect
+ github.com/hashicorp/hc-install v0.9.4 // indirect
+ github.com/hashicorp/hcl/v2 v2.24.0 // indirect
+ github.com/hashicorp/logutils v1.0.0 // indirect
+ github.com/hashicorp/terraform-exec v0.25.1 // indirect
+ github.com/hashicorp/terraform-json v0.27.2 // indirect
github.com/hashicorp/terraform-plugin-log v0.11.0 // indirect
+ github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 // indirect
github.com/hashicorp/terraform-registry-address v0.4.0 // indirect
- github.com/hashicorp/terraform-svchost v0.1.1 // indirect
+ github.com/hashicorp/terraform-svchost v0.2.1 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
- github.com/oklog/run v1.1.0 // indirect
+ github.com/mitchellh/go-wordwrap v1.0.1 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/mitchellh/reflectwalk v1.0.2 // indirect
+ github.com/oklog/run v1.2.0 // indirect
+ github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
- golang.org/x/net v0.48.0 // indirect
- golang.org/x/sys v0.39.0 // indirect
- golang.org/x/text v0.32.0 // indirect
- golang.org/x/time v0.16.0 // indirect
+ github.com/zclconf/go-cty v1.18.1 // indirect
+ golang.org/x/crypto v0.50.0 // indirect
+ golang.org/x/mod v0.35.0 // indirect
+ golang.org/x/net v0.52.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
+ google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
- google.golang.org/grpc v1.79.2 // indirect
+ google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
diff --git a/go.sum b/go.sum
index 174ce70..f328dc9 100644
--- a/go.sum
+++ b/go.sum
@@ -1,34 +1,93 @@
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
+github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
+github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
+github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec=
+github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
+github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
+github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
+github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
+github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM=
+github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
+github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
+github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU=
+github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg=
+github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0=
+github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
+github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
+github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
+github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/hc-install v0.9.4 h1:KKWOpUG0EqIV63Qk2GGFrZ0s275NVs5lKf9N5vjBNoc=
+github.com/hashicorp/hc-install v0.9.4/go.mod h1:4LRYeEN2bMIFfIv57ldMWt9awfuZhvpbRt0vWmv51WU=
+github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
+github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
+github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
+github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
+github.com/hashicorp/terraform-exec v0.25.1 h1:PRutYRGM8pixV3B8812NYoBK5O+yuf3qcB/70KFKGiU=
+github.com/hashicorp/terraform-exec v0.25.1/go.mod h1:+izOYrs9sKMQK4OYvGDnrSSJHY/pm4e4eXFqSL2Q5mA=
+github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU=
+github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE=
github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA=
github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc=
github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow=
@@ -37,15 +96,29 @@ github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKU
github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8=
github.com/hashicorp/terraform-plugin-log v0.11.0 h1:WjhcpZIVqP8YRe83+dIZXncwSgtu4vh27i23G33PUQY=
github.com/hashicorp/terraform-plugin-log v0.11.0/go.mod h1:XygBz8+m5kgwTb73MMyrnUjeNQeVWECEfg+h2opMsj0=
+github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0 h1:MKS/2URqeJRwJdbOfcbdsZCq/IRrNkqJNN0GtVIsuGs=
+github.com/hashicorp/terraform-plugin-sdk/v2 v2.40.0/go.mod h1:PuG4P97Ju3QXW6c6vRkRadWJbvnEu2Xh+oOuqcYOqX4=
+github.com/hashicorp/terraform-plugin-testing v1.16.0 h1:GB97nGnJ1hESpDrCjqZig38RodSF0gdRzxlDupLXP38=
+github.com/hashicorp/terraform-plugin-testing v1.16.0/go.mod h1:eQPYAy9xFMV7xtIFX8Y+wJGtUB++HBl329zCF6PBMZk=
github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk=
github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE=
-github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ=
-github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc=
+github.com/hashicorp/terraform-svchost v0.2.1 h1:ubvrTFw3Q7CsoEaX7V06PtCTKG3wu7GyyobAoN4eF3Q=
+github.com/hashicorp/terraform-svchost v0.2.1/go.mod h1:zDMheBLvNzu7Q6o9TBvPqiZToJcSuCLXjAXxBslSky4=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
+github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -54,27 +127,53 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k=
github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
-github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
-github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
+github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
+github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
+github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
+github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
+github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
+github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
+github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/zclconf/go-cty v1.18.1 h1:yEGE8M4iIZlyKQURZNb2SnEyZlZHUcBCnx6KF81KuwM=
+github.com/zclconf/go-cty v1.18.1/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
@@ -87,28 +186,72 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
-golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
-golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
+google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
-google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
-google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/client/client.go b/internal/client/client.go
index 9376bb8..ef56856 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -14,6 +14,12 @@ import (
const DefaultBaseURL = "https://api.quicknode.com"
+// URLTokenPlaceholder stands in for the auth token in SafeHTTPURL and
+// SafeWSSURL. It keeps the shape of the real URL, including any path suffix the
+// chain appends, so the token's position stays visible and a caller can
+// substitute one rather than guess where it goes.
+const URLTokenPlaceholder = "TOKEN"
+
type Client struct {
api *admin.ClientWithResponses
}
@@ -106,20 +112,18 @@ type Endpoint struct {
Label string
Status string
- // HTTPURL and WSSURL have the auth token removed. HTTPURLWithToken and
- // WSSURLWithToken are what the API returned, credential included. The
- // token does not sit at a fixed position in the path — some chains append
- // a suffix after it, as in https:////evm — so a caller that
- // needs a working URL must use the WithToken form rather than rebuilding
- // one from the parts.
- HTTPURL string
- WSSURL string
+ // SafeHTTPURL and SafeWSSURL carry URLTokenPlaceholder where the token
+ // belongs, so they can be logged or displayed. HTTPURLWithToken and
+ // WSSURLWithToken are what the API returned, credential included.
+ SafeHTTPURL string
+ SafeWSSURL string
HTTPURLWithToken string
WSSURLWithToken string
Multichain bool
Tokens []EndpointToken
Tags []Tag
+ Security SecurityOptions
}
// EndpointToken is one of an endpoint's auth tokens. An endpoint can carry
@@ -238,6 +242,23 @@ func (c *Client) GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
Multichain: deref(data.IsMultichain),
}
endpoint.setURLs(deref(data.HttpUrl), deref(data.WssUrl))
+ if data.Security != nil && data.Security.Options != nil {
+ options := data.Security.Options
+ endpoint.Security = SecurityOptions{
+ Tokens: deref(options.Tokens),
+ Referrers: deref(options.Referrers),
+ JWTs: deref(options.Jwts),
+ IPs: deref(options.Ips),
+ DomainMasks: deref(options.DomainMasks),
+ HSTS: deref(options.Hsts),
+ Cors: deref(options.Cors),
+ RequestFilters: deref(options.RequestFilters),
+ ResponseLogging: deref(options.ResponseLogging),
+ }
+ if options.IpCustomHeader != nil {
+ endpoint.Security.IPCustomHeader = deref(options.IpCustomHeader.Value)
+ }
+ }
if data.Security != nil && data.Security.Tokens != nil {
for _, rawToken := range *data.Security.Tokens {
endpoint.Tokens = append(endpoint.Tokens, EndpointToken{
@@ -369,32 +390,34 @@ func (c *Client) RemoveEndpointTag(ctx context.Context, id string, tagID int64)
func (e *Endpoint) setURLs(httpURL, wssURL string) {
e.HTTPURLWithToken = httpURL
e.WSSURLWithToken = wssURL
- e.HTTPURL, _ = splitEndpointURL(httpURL)
- e.WSSURL, _ = splitEndpointURL(wssURL)
+ e.SafeHTTPURL = RedactEndpointURL(httpURL)
+ e.SafeWSSURL = RedactEndpointURL(wssURL)
}
-// splitEndpointURL separates the credential from an endpoint URL. The Admin API
-// returns URLs shaped https://.quiknode.pro/[/], and
-// the provider keeps the token-free prefix and suffix apart from the token so a
-// configuration can avoid writing the credential to Terraform state.
-func splitEndpointURL(raw string) (base, token string) {
+// RedactEndpointURL replaces the credential in an endpoint URL with
+// URLTokenPlaceholder. The Admin API returns URLs shaped
+// https://.quiknode.pro/[/], and the suffix differs by
+// chain, so the placeholder is substituted in position rather than the token
+// being cut out. The result keeps the real URL's shape and is safe to log.
+func RedactEndpointURL(raw string) string {
if raw == "" {
- return "", ""
+ return ""
}
scheme, rest, found := strings.Cut(raw, "://")
if !found {
- return raw, ""
+ return raw
}
host, path, found := strings.Cut(rest, "/")
if !found || path == "" {
- return raw, ""
+ return raw
}
- token, suffix, _ := strings.Cut(path, "/")
- base = scheme + "://" + host
- if suffix != "" {
- base += "/" + suffix
+
+ _, suffix, hadSuffix := strings.Cut(path, "/")
+ redacted := scheme + "://" + host + "/" + URLTokenPlaceholder
+ if hadSuffix {
+ redacted += "/" + suffix
}
- return base, token
+ return redacted
}
func deref[T any](value *T) T {
diff --git a/internal/client/client_test.go b/internal/client/client_test.go
index 1b175ff..d8f6df0 100644
--- a/internal/client/client_test.go
+++ b/internal/client/client_test.go
@@ -1,55 +1,74 @@
package client
-import "testing"
+import (
+ "strings"
+ "testing"
+)
-func TestSplitEndpointURL(t *testing.T) {
+func TestRedactEndpointURL(t *testing.T) {
cases := []struct {
- name string
- raw string
- wantBase string
- wantToken string
+ name string
+ raw string
+ want string
}{
{
- name: "token followed by a chain suffix",
- raw: "https://polished-damp-grass.hype-testnet.quiknode.pro/abc123/evm",
- wantBase: "https://polished-damp-grass.hype-testnet.quiknode.pro/evm",
- wantToken: "abc123",
+ name: "token followed by a chain suffix",
+ raw: "https://polished-damp-grass.hype-testnet.quiknode.pro/abc123/evm",
+ want: "https://polished-damp-grass.hype-testnet.quiknode.pro/TOKEN/evm",
},
{
- name: "token with no suffix",
- raw: "https://example-name.quiknode.pro/abc123",
- wantBase: "https://example-name.quiknode.pro",
- wantToken: "abc123",
+ name: "token with no suffix",
+ raw: "https://example-name.quiknode.pro/abc123",
+ want: "https://example-name.quiknode.pro/TOKEN",
},
{
- name: "websocket scheme",
- raw: "wss://example-name.quiknode.pro/abc123/evm",
- wantBase: "wss://example-name.quiknode.pro/evm",
- wantToken: "abc123",
+ name: "trailing slash after the token",
+ raw: "https://frosty-capable-pallet.btc.quiknode.pro/abc123/",
+ want: "https://frosty-capable-pallet.btc.quiknode.pro/TOKEN/",
},
{
- name: "no path at all",
- raw: "https://example-name.quiknode.pro",
- wantBase: "https://example-name.quiknode.pro",
- wantToken: "",
+ name: "websocket scheme",
+ raw: "wss://example-name.quiknode.pro/abc123/evm",
+ want: "wss://example-name.quiknode.pro/TOKEN/evm",
},
{
- name: "empty",
- raw: "",
- wantBase: "",
- wantToken: "",
+ name: "no path at all",
+ raw: "https://example-name.quiknode.pro",
+ want: "https://example-name.quiknode.pro",
+ },
+ {
+ name: "empty",
+ raw: "",
+ want: "",
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
- base, token := splitEndpointURL(testCase.raw)
- if base != testCase.wantBase {
- t.Errorf("base = %q, want %q", base, testCase.wantBase)
- }
- if token != testCase.wantToken {
- t.Errorf("token = %q, want %q", token, testCase.wantToken)
+ if got := RedactEndpointURL(testCase.raw); got != testCase.want {
+ t.Errorf("RedactEndpointURL(%q) = %q, want %q", testCase.raw, got, testCase.want)
}
})
}
}
+
+// TestRedactedURLKeepsItsShape is the point of the placeholder: substituting a
+// token has to reproduce the original URL exactly, including a suffix the
+// chain appends after the token.
+func TestRedactedURLKeepsItsShape(t *testing.T) {
+ for _, raw := range []string{
+ "https://polished-damp-grass.hype-testnet.quiknode.pro/abc123/evm",
+ "https://frosty-capable-pallet.btc.quiknode.pro/abc123/",
+ "wss://example-name.quiknode.pro/abc123",
+ } {
+ redacted := RedactEndpointURL(raw)
+ if redacted == raw {
+ t.Errorf("RedactEndpointURL(%q) left the credential in place", raw)
+ continue
+ }
+ restored := strings.Replace(redacted, URLTokenPlaceholder, "abc123", 1)
+ if restored != raw {
+ t.Errorf("substituting the token gave %q, want %q", restored, raw)
+ }
+ }
+}
diff --git a/internal/client/endpoint_test.go b/internal/client/endpoint_test.go
index 5e7ba50..bf93f21 100644
--- a/internal/client/endpoint_test.go
+++ b/internal/client/endpoint_test.go
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
)
@@ -58,14 +59,19 @@ func TestGetEndpointWithPathSuffix(t *testing.T) {
if endpoint.WSSURLWithToken != "wss://polished-damp-grass.hype-testnet.quiknode.pro/TOKENVALUE/evm" {
t.Errorf("WSSURLWithToken = %q", endpoint.WSSURLWithToken)
}
+ if endpoint.SafeWSSURL != "wss://polished-damp-grass.hype-testnet.quiknode.pro/TOKEN/evm" {
+ t.Errorf("SafeWSSURL = %q", endpoint.SafeWSSURL)
+ }
- // The token-free URL is not a working address on this chain, which is the
- // reason HTTPURLWithToken exists.
- if endpoint.HTTPURL == wantWorking {
- t.Error("HTTPURL still carries the token")
+ // The redacted URL keeps the real one's shape, so substituting a token
+ // reproduces it exactly. That is what the placeholder buys over cutting
+ // the token out: this chain puts a suffix after it.
+ const wantRedacted = "https://polished-damp-grass.hype-testnet.quiknode.pro/TOKEN/evm"
+ if endpoint.SafeHTTPURL != wantRedacted {
+ t.Errorf("SafeHTTPURL = %q, want %q", endpoint.SafeHTTPURL, wantRedacted)
}
- if endpoint.HTTPURL+"/TOKENVALUE" == wantWorking {
- t.Error("joining HTTPURL to the token happens to work here; the test no longer guards the bug it was written for")
+ if strings.Contains(endpoint.SafeHTTPURL, "TOKENVALUE") {
+ t.Error("SafeHTTPURL still carries the token")
}
if len(endpoint.Tokens) != 1 || endpoint.Tokens[0].Value != "TOKENVALUE" {
@@ -85,14 +91,14 @@ func TestGetEndpointWithoutWebsocket(t *testing.T) {
t.Fatalf("GetEndpoint: %v", err)
}
- if endpoint.WSSURL != "" || endpoint.WSSURLWithToken != "" {
- t.Errorf("WSSURL = %q, WSSURLWithToken = %q, want both empty", endpoint.WSSURL, endpoint.WSSURLWithToken)
+ if endpoint.SafeWSSURL != "" || endpoint.WSSURLWithToken != "" {
+ t.Errorf("SafeWSSURL = %q, WSSURLWithToken = %q, want both empty", endpoint.SafeWSSURL, endpoint.WSSURLWithToken)
}
if endpoint.HTTPURLWithToken != "https://frosty-capable-pallet.btc.quiknode.pro/TOKENVALUE/" {
t.Errorf("HTTPURLWithToken = %q", endpoint.HTTPURLWithToken)
}
- if endpoint.HTTPURL != "https://frosty-capable-pallet.btc.quiknode.pro" {
- t.Errorf("HTTPURL = %q", endpoint.HTTPURL)
+ if endpoint.SafeHTTPURL != "https://frosty-capable-pallet.btc.quiknode.pro/TOKEN/" {
+ t.Errorf("SafeHTTPURL = %q", endpoint.SafeHTTPURL)
}
if endpoint.Status != "paused" || endpoint.Label != "ledger" {
t.Errorf("Status = %q, Label = %q", endpoint.Status, endpoint.Label)
@@ -106,3 +112,27 @@ func TestGetEndpointRejectsEnvelopeError(t *testing.T) {
t.Fatal("expected an error from a 200 response carrying an error field")
}
}
+
+// liveEndpointBody is a verbatim GET /v0/endpoints/{id} response with the token
+// replaced. It carries ipCustomHeader and responseLogging, which the published
+// spec either mistypes or omits.
+const liveEndpointBody = `{"data":{"id":"652052","label":null,"chain":"hype","network":"hype-testnet",` +
+ `"http_url":"https://polished-damp-grass.hype-testnet.quiknode.pro/TOKENVALUE/evm",` +
+ `"wss_url":"wss://polished-damp-grass.hype-testnet.quiknode.pro/TOKENVALUE/evm",` +
+ `"security":{"options":{"tokens":true,"referrers":false,"jwts":false,"ips":false,` +
+ `"domainMasks":false,"hsts":false,"cors":true,"responseLogging":true,` +
+ `"requestFilters":false,"ipCustomHeader":{"value":null}},` +
+ `"tokens":[{"id":"d3312bd2-c1a2-4d89-865f-11c99fa3863a","token":"TOKENVALUE"}],` +
+ `"jwts":null,"referrers":null,"domain_masks":null,"ips":null,"request_filters":null},` +
+ `"status":"active","rate_limits":{"rate_limit_by_ip":false,"account":-1,"rps":-1,"rpd":-1,"rpm":-1},` +
+ `"tags":[],"is_multichain":false}}`
+
+func TestGetEndpointDecodesLiveBody(t *testing.T) {
+ endpoint, err := newTestClient(t, liveEndpointBody).GetEndpoint(context.Background(), "652052")
+ if err != nil {
+ t.Fatalf("GetEndpoint on a verbatim live body: %v", err)
+ }
+ if len(endpoint.Tokens) != 1 {
+ t.Errorf("Tokens = %+v", endpoint.Tokens)
+ }
+}
diff --git a/internal/client/list.go b/internal/client/list.go
new file mode 100644
index 0000000..759ccb1
--- /dev/null
+++ b/internal/client/list.go
@@ -0,0 +1,146 @@
+package client
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/quicknode/terraform-provider-quicknode/api/admin"
+)
+
+// listPageSize is the largest page the list route accepts. Paging at the
+// maximum keeps a large account down to few round trips.
+const listPageSize = 250
+
+// EndpointFilter narrows a listing. Empty fields are left off the query, and
+// the API treats several values in one field as "any of".
+type EndpointFilter struct {
+ Search string
+ Networks []string
+ Statuses []string
+ Labels []string
+ TagLabels []string
+}
+
+// EndpointSummary is one row of the list route. It carries less than a full
+// endpoint read: no tokens, no security and no rate limits. The list route
+// returns credentialed URLs, so the URLs here are redacted on the way in and the
+// working form is only available from a full endpoint read.
+type EndpointSummary struct {
+ ID string
+ Name string
+ Label string
+ Chain string
+ Network string
+ Status string
+ SafeHTTPURL string
+ SafeWSSURL string
+ Dedicated bool
+ FlatRate bool
+ Multichain bool
+ Tags []Tag
+}
+
+// ListEndpoints walks every page, so a caller gets the whole account rather
+// than the first twenty rows.
+func (c *Client) ListEndpoints(ctx context.Context, filter EndpointFilter) ([]EndpointSummary, error) {
+ const operation = "list endpoints"
+
+ limit := listPageSize
+ offset := 0
+ var endpoints []EndpointSummary
+
+ for {
+ params := &admin.GetV0EndpointsParams{Limit: &limit, Offset: &offset}
+ if filter.Search != "" {
+ params.Search = &filter.Search
+ }
+ if len(filter.Networks) > 0 {
+ params.Networks = &filter.Networks
+ }
+ if len(filter.Statuses) > 0 {
+ params.Statuses = &filter.Statuses
+ }
+ if len(filter.Labels) > 0 {
+ params.Labels = &filter.Labels
+ }
+ if len(filter.TagLabels) > 0 {
+ params.TagLabels = &filter.TagLabels
+ }
+
+ resp, err := c.api.GetV0EndpointsWithResponse(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil {
+ break
+ }
+
+ page := *resp.JSON200.Data
+ for _, raw := range page {
+ endpoint := EndpointSummary{
+ ID: deref(raw.Id),
+ Name: deref(raw.Name),
+ Label: deref(raw.Label),
+ Chain: deref(raw.Chain),
+ Network: deref(raw.Network),
+ Status: deref(raw.Status),
+ SafeHTTPURL: RedactEndpointURL(deref(raw.HttpUrl)),
+ SafeWSSURL: RedactEndpointURL(deref(raw.WssUrl)),
+ Dedicated: deref(raw.IsDedicated),
+ FlatRate: deref(raw.IsFlatRate),
+ Multichain: deref(raw.IsMultichain),
+ }
+ if raw.Tags != nil {
+ for _, rawTag := range *raw.Tags {
+ tag := Tag{Label: deref(rawTag.Label)}
+ if rawTag.TagId != nil {
+ tag.ID = int64(*rawTag.TagId)
+ }
+ endpoint.Tags = append(endpoint.Tags, tag)
+ }
+ }
+ endpoints = append(endpoints, endpoint)
+ }
+
+ if len(page) < limit {
+ break
+ }
+ offset += len(page)
+ }
+ return endpoints, nil
+}
+
+// FindEndpointByLabel resolves a label to a single endpoint. Labels are not
+// unique, so more than one match is an error rather than an arbitrary pick.
+func (c *Client) FindEndpointByLabel(ctx context.Context, label string) (*EndpointSummary, error) {
+ const operation = "find endpoint by label"
+
+ endpoints, err := c.ListEndpoints(ctx, EndpointFilter{Labels: []string{label}})
+ if err != nil {
+ return nil, err
+ }
+
+ matches := make([]EndpointSummary, 0, 1)
+ for _, endpoint := range endpoints {
+ if endpoint.Label == label {
+ matches = append(matches, endpoint)
+ }
+ }
+ switch len(matches) {
+ case 0:
+ return nil, &Error{Operation: operation, Status: http.StatusNotFound, Message: "no endpoint carries the label " + label}
+ case 1:
+ return &matches[0], nil
+ }
+ return nil, &Error{
+ Operation: operation,
+ Status: http.StatusConflict,
+ Message: "more than one endpoint carries the label " + label + "; labels are not unique, so look the endpoint up by id instead",
+ }
+}
diff --git a/internal/client/ratelimits.go b/internal/client/ratelimits.go
new file mode 100644
index 0000000..3c00d67
--- /dev/null
+++ b/internal/client/ratelimits.go
@@ -0,0 +1,248 @@
+package client
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/quicknode/terraform-provider-quicknode/api/admin"
+)
+
+const (
+ // RateLimitUnset is what the Admin API reports for a bucket with no
+ // endpoint-level override.
+ RateLimitUnset = -1
+
+ SourcePlanDefault = "plan_default"
+ SourceUserOverride = "user_override"
+
+ BucketRPS = "rps"
+ BucketRPM = "rpm"
+ BucketRPD = "rpd"
+)
+
+// RateLimit is one bucket as the API reports it. A plan_default carries no id,
+// because only an override can be deleted.
+type RateLimit struct {
+ Bucket string
+ Value int
+ Source string
+ ID string
+}
+
+// RateLimitOverrides names the buckets a write can set. A nil field is left
+// alone, so a configuration that manages only one bucket does not disturb the
+// others.
+type RateLimitOverrides struct {
+ RPS *int
+ RPM *int
+ RPD *int
+}
+
+// MethodRateLimit throttles a named set of RPC methods independently of the
+// endpoint-wide buckets.
+type MethodRateLimit struct {
+ ID string
+ Methods []string
+ Rate int
+ Interval string
+ Status string
+ Created string
+}
+
+func (c *Client) GetRateLimits(ctx context.Context, endpointID string) ([]RateLimit, error) {
+ const operation = "read endpoint rate limits"
+
+ resp, err := c.api.GetV0EndpointsByIdRateLimitsWithResponse(ctx, endpointID)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil, &Error{Operation: operation, Status: http.StatusNotFound, Message: "endpoint not found"}
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.RateLimits == nil {
+ return nil, nil
+ }
+
+ limits := make([]RateLimit, 0, len(*resp.JSON200.Data.RateLimits))
+ for _, raw := range *resp.JSON200.Data.RateLimits {
+ limit := RateLimit{
+ Bucket: deref(raw.Bucket),
+ Source: deref(raw.Source),
+ ID: deref(raw.Id),
+ Value: RateLimitUnset,
+ }
+ if raw.RateLimit != nil {
+ limit.Value = *raw.RateLimit
+ }
+ limits = append(limits, limit)
+ }
+ return limits, nil
+}
+
+// SetRateLimits writes the buckets the caller manages. Managing none is a no-op
+// rather than an empty write.
+func (c *Client) SetRateLimits(ctx context.Context, endpointID string, overrides RateLimitOverrides) error {
+ const operation = "update endpoint rate limits"
+
+ if overrides.RPS == nil && overrides.RPM == nil && overrides.RPD == nil {
+ return nil
+ }
+
+ body := admin.PatchV0EndpointsByIdRateLimitsJSONRequestBody{}
+ body.RateLimits.Rps = overrides.RPS
+ body.RateLimits.Rpm = overrides.RPM
+ body.RateLimits.Rpd = overrides.RPD
+
+ resp, err := c.api.PatchV0EndpointsByIdRateLimitsWithResponse(ctx, endpointID, body)
+ if err != nil {
+ return err
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return envelopeError(operation, resp.StatusCode(), resp.JSON200.Error)
+}
+
+// DeleteRateLimitOverride drops one bucket back to the plan default. A 404 is
+// success, since an absent override is the state the caller asked for.
+func (c *Client) DeleteRateLimitOverride(ctx context.Context, endpointID, overrideID string) error {
+ const operation = "remove endpoint rate limit override"
+
+ resp, err := c.api.DeleteV0EndpointsByIdRateLimitsByOverrideIdWithResponse(ctx, endpointID, overrideID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return err
+ }
+ if resp.JSON200.Data != nil && resp.JSON200.Data.Success != nil && !*resp.JSON200.Data.Success {
+ return &Error{Operation: operation, Status: resp.StatusCode(), Message: "the API reported the delete did not succeed"}
+ }
+ return nil
+}
+
+func (c *Client) ListMethodRateLimits(ctx context.Context, endpointID string) ([]MethodRateLimit, error) {
+ const operation = "list endpoint method rate limits"
+
+ resp, err := c.api.GetV0EndpointsByIdMethodRateLimitsWithResponse(ctx, endpointID)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil, &Error{Operation: operation, Status: http.StatusNotFound, Message: "endpoint not found"}
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.RateLimiters == nil {
+ return nil, nil
+ }
+
+ limiters := make([]MethodRateLimit, 0, len(*resp.JSON200.Data.RateLimiters))
+ for _, raw := range *resp.JSON200.Data.RateLimiters {
+ limiter := MethodRateLimit{
+ ID: deref(raw.Id),
+ Interval: deref(raw.Interval),
+ Status: deref(raw.Status),
+ Created: deref(raw.Created),
+ }
+ if raw.Rate != nil {
+ limiter.Rate = *raw.Rate
+ }
+ if raw.Methods != nil {
+ limiter.Methods = *raw.Methods
+ }
+ limiters = append(limiters, limiter)
+ }
+ return limiters, nil
+}
+
+func (c *Client) AddMethodRateLimit(ctx context.Context, endpointID string, limiter MethodRateLimit) (*MethodRateLimit, error) {
+ const operation = "create endpoint method rate limit"
+
+ resp, err := c.api.PostV0EndpointsByIdMethodRateLimitsWithResponse(ctx, endpointID,
+ admin.PostV0EndpointsByIdMethodRateLimitsJSONRequestBody{
+ Interval: limiter.Interval,
+ Methods: limiter.Methods,
+ Rate: limiter.Rate,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.Id == nil {
+ return nil, &Error{Operation: operation, Status: resp.StatusCode(), Message: "response carried no rate limiter id"}
+ }
+
+ data := resp.JSON200.Data
+ created := &MethodRateLimit{
+ ID: *data.Id,
+ Interval: deref(data.Interval),
+ Status: deref(data.Status),
+ Created: deref(data.Created),
+ }
+ if data.Rate != nil {
+ created.Rate = *data.Rate
+ }
+ if data.Methods != nil {
+ created.Methods = *data.Methods
+ }
+ return created, nil
+}
+
+// UpdateMethodRateLimit edits a limiter in place. The route takes the whole
+// object rather than a delta, and it does not accept interval, so a changed
+// interval has to replace the limiter.
+func (c *Client) UpdateMethodRateLimit(ctx context.Context, endpointID, limiterID string, limiter MethodRateLimit) error {
+ const operation = "update endpoint method rate limit"
+
+ resp, err := c.api.PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdWithResponse(ctx, endpointID, limiterID,
+ admin.PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdJSONRequestBody{
+ Methods: limiter.Methods,
+ Rate: limiter.Rate,
+ Status: limiter.Status,
+ })
+ if err != nil {
+ return err
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return envelopeError(operation, resp.StatusCode(), resp.JSON200.Error)
+}
+
+func (c *Client) RemoveMethodRateLimit(ctx context.Context, endpointID, limiterID string) error {
+ const operation = "remove endpoint method rate limit"
+
+ resp, err := c.api.DeleteV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdWithResponse(ctx, endpointID, limiterID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return envelopeError(operation, resp.StatusCode(), resp.JSON200.Error)
+}
diff --git a/internal/client/ratelimits_test.go b/internal/client/ratelimits_test.go
new file mode 100644
index 0000000..df78ee0
--- /dev/null
+++ b/internal/client/ratelimits_test.go
@@ -0,0 +1,171 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// rateLimitsBody mixes the two sources the route reports. Only a user_override
+// carries an id, which is what makes it deletable.
+const rateLimitsBody = `{"data":{"rate_limits":[
+{"bucket":"rps","rate_limit":25,"source":"plan_default"},
+{"bucket":"rpm","rate_limit":500,"source":"plan_default"},
+{"bucket":"rpd","rate_limit":-1,"source":"plan_default"},
+{"bucket":"rps","rate_limit":10,"source":"user_override","id":"ovr-1"}]}}`
+
+func TestGetRateLimitsKeepsSourceAndID(t *testing.T) {
+ limits, err := newTestClient(t, rateLimitsBody).GetRateLimits(context.Background(), "1")
+ if err != nil {
+ t.Fatalf("GetRateLimits: %v", err)
+ }
+ if len(limits) != 4 {
+ t.Fatalf("got %d limits, want 4", len(limits))
+ }
+
+ var override *RateLimit
+ for index, limit := range limits {
+ if limit.Source == SourceUserOverride {
+ override = &limits[index]
+ }
+ }
+ if override == nil {
+ t.Fatal("the user override was lost")
+ }
+ if override.Bucket != BucketRPS || override.Value != 10 || override.ID != "ovr-1" {
+ t.Errorf("override = %+v", *override)
+ }
+
+ for _, limit := range limits {
+ if limit.Source == SourcePlanDefault && limit.ID != "" {
+ t.Errorf("a plan default carried an id: %+v", limit)
+ }
+ }
+ if limits[2].Value != RateLimitUnset {
+ t.Errorf("an unlimited bucket should stay at %d, got %d", RateLimitUnset, limits[2].Value)
+ }
+}
+
+// TestSetRateLimitsOmitsUnmanagedBuckets guards the same hazard the security
+// toggles have: a bucket the configuration does not set must stay out of the
+// body rather than being sent as a zero.
+func TestSetRateLimitsOmitsUnmanagedBuckets(t *testing.T) {
+ var captured map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &captured)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{"limits":{"rps":10,"rpm":-1,"rpd":-1}}}`))
+ }))
+ t.Cleanup(server.Close)
+
+ quicknode, err := New("test-key", WithBaseURL(server.URL))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ rps := 10
+ if err := quicknode.SetRateLimits(context.Background(), "1", RateLimitOverrides{RPS: &rps}); err != nil {
+ t.Fatalf("SetRateLimits: %v", err)
+ }
+
+ limits, ok := captured["rate_limits"].(map[string]any)
+ if !ok {
+ t.Fatalf("request carried no rate_limits object: %v", captured)
+ }
+ if limits[BucketRPS] != float64(10) {
+ t.Errorf("rps = %v, want 10", limits[BucketRPS])
+ }
+ for _, unmanaged := range []string{BucketRPM, BucketRPD} {
+ if _, present := limits[unmanaged]; present {
+ t.Errorf("%s was sent although the caller left it unset", unmanaged)
+ }
+ }
+}
+
+func TestDeleteRateLimitOverrideRejectsFalseSuccess(t *testing.T) {
+ quicknode := newTestClient(t, `{"data":{"success":false}}`)
+
+ if err := quicknode.DeleteRateLimitOverride(context.Background(), "1", "ovr-1"); err == nil {
+ t.Fatal("a success:false delete has to be an error, not a silent no-op")
+ }
+}
+
+func TestRateLimitDeletesTolerateAbsence(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ t.Cleanup(server.Close)
+
+ quicknode, err := New("test-key", WithBaseURL(server.URL))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ ctx := context.Background()
+ if err := quicknode.DeleteRateLimitOverride(ctx, "1", "ovr-1"); err != nil {
+ t.Errorf("removing an absent override should succeed, got %v", err)
+ }
+ if err := quicknode.RemoveMethodRateLimit(ctx, "1", "lim-1"); err != nil {
+ t.Errorf("removing an absent method rate limit should succeed, got %v", err)
+ }
+}
+
+func TestListMethodRateLimits(t *testing.T) {
+ const body = `{"data":{"rate_limiters":[{"id":"lim-1","interval":"second","rate":5,
+"status":"enabled","created":"1742400000","methods":["eth_getLogs","eth_call"]}]}}`
+
+ limiters, err := newTestClient(t, body).ListMethodRateLimits(context.Background(), "1")
+ if err != nil {
+ t.Fatalf("ListMethodRateLimits: %v", err)
+ }
+ if len(limiters) != 1 {
+ t.Fatalf("got %d limiters, want 1", len(limiters))
+ }
+ limiter := limiters[0]
+ if limiter.ID != "lim-1" || limiter.Rate != 5 || limiter.Interval != "second" || limiter.Status != "enabled" {
+ t.Errorf("limiter = %+v", limiter)
+ }
+ if len(limiter.Methods) != 2 || limiter.Methods[0] != "eth_getLogs" {
+ t.Errorf("Methods = %v", limiter.Methods)
+ }
+}
+
+// TestUpdateMethodRateLimitSendsWholeObject records that the update route
+// replaces the limiter rather than merging a delta, and that it takes no
+// interval.
+func TestUpdateMethodRateLimitSendsWholeObject(t *testing.T) {
+ var captured map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &captured)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{"id":"lim-1","rate":9,"status":"disabled","methods":["eth_call"]}}`))
+ }))
+ t.Cleanup(server.Close)
+
+ quicknode, err := New("test-key", WithBaseURL(server.URL))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ err = quicknode.UpdateMethodRateLimit(context.Background(), "1", "lim-1", MethodRateLimit{
+ Methods: []string{"eth_call"},
+ Rate: 9,
+ Status: "disabled",
+ })
+ if err != nil {
+ t.Fatalf("UpdateMethodRateLimit: %v", err)
+ }
+ if captured["rate"] != float64(9) || captured["status"] != "disabled" {
+ t.Errorf("body = %v", captured)
+ }
+ if _, present := captured["interval"]; present {
+ t.Error("the update route does not accept an interval, so one must not be sent")
+ }
+}
diff --git a/internal/client/security.go b/internal/client/security.go
new file mode 100644
index 0000000..c7553a6
--- /dev/null
+++ b/internal/client/security.go
@@ -0,0 +1,500 @@
+package client
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/quicknode/terraform-provider-quicknode/api/admin"
+)
+
+const (
+ optionEnabled = "enabled"
+ optionDisabled = "disabled"
+)
+
+// SecurityOptions carries the toggles the Admin API reports for an endpoint.
+// Tokens through Cors are settable. RequestFilters and ResponseLogging are
+// reported but cannot be written, so the provider surfaces them read-only.
+type SecurityOptions struct {
+ Tokens bool
+ Referrers bool
+ JWTs bool
+ IPs bool
+ DomainMasks bool
+ HSTS bool
+ Cors bool
+
+ RequestFilters bool
+ ResponseLogging bool
+
+ IPCustomHeader string
+}
+
+// SecurityOptionsPatch names the settable toggles. A nil field is left alone,
+// which keeps a write from clobbering a toggle the configuration does not
+// manage.
+type SecurityOptionsPatch struct {
+ Tokens *bool
+ Referrers *bool
+ JWTs *bool
+ IPs *bool
+ DomainMasks *bool
+ HSTS *bool
+ Cors *bool
+}
+
+type SecurityEntry struct {
+ ID string
+ Value string
+}
+
+type JWT struct {
+ ID string
+ Name string
+ KID string
+ PublicKey string
+}
+
+type RequestFilter struct {
+ ID string
+ Methods []string
+}
+
+type EndpointSecurity struct {
+ Options SecurityOptions
+ IPs []SecurityEntry
+ DomainMasks []SecurityEntry
+ Referrers []SecurityEntry
+ JWTs []JWT
+ RequestFilters []RequestFilter
+ Tokens []EndpointToken
+}
+
+func (c *Client) GetEndpointSecurity(ctx context.Context, endpointID string) (*EndpointSecurity, error) {
+ const operation = "read endpoint security"
+
+ resp, err := c.api.GetV0EndpointsByIdSecurityWithResponse(ctx, endpointID)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil, &Error{Operation: operation, Status: http.StatusNotFound, Message: "endpoint not found"}
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil {
+ return nil, &Error{Operation: operation, Status: http.StatusNotFound, Message: "endpoint not found"}
+ }
+
+ data := resp.JSON200.Data
+ security := &EndpointSecurity{}
+
+ if data.Options != nil {
+ security.Options = SecurityOptions{
+ Tokens: deref(data.Options.Tokens),
+ Referrers: deref(data.Options.Referrers),
+ JWTs: deref(data.Options.Jwts),
+ IPs: deref(data.Options.Ips),
+ DomainMasks: deref(data.Options.DomainMasks),
+ HSTS: deref(data.Options.Hsts),
+ Cors: deref(data.Options.Cors),
+ RequestFilters: deref(data.Options.RequestFilters),
+ ResponseLogging: deref(data.Options.ResponseLogging),
+ }
+ if data.Options.IpCustomHeader != nil {
+ security.Options.IPCustomHeader = deref(data.Options.IpCustomHeader.Value)
+ }
+ }
+
+ if data.Ips != nil {
+ for _, raw := range *data.Ips {
+ security.IPs = append(security.IPs, SecurityEntry{ID: deref(raw.Id), Value: deref(raw.Ip)})
+ }
+ }
+ if data.DomainMasks != nil {
+ for _, raw := range *data.DomainMasks {
+ security.DomainMasks = append(security.DomainMasks, SecurityEntry{ID: deref(raw.Id), Value: deref(raw.DomainMask)})
+ }
+ }
+ if data.Referrers != nil {
+ for _, raw := range *data.Referrers {
+ security.Referrers = append(security.Referrers, SecurityEntry{ID: deref(raw.Id), Value: deref(raw.Referrer)})
+ }
+ }
+ if data.Jwts != nil {
+ for _, raw := range *data.Jwts {
+ security.JWTs = append(security.JWTs, JWT{
+ ID: deref(raw.Id),
+ Name: deref(raw.Name),
+ KID: deref(raw.Kid),
+ PublicKey: deref(raw.PublicKey),
+ })
+ }
+ }
+ if data.RequestFilters != nil {
+ for _, raw := range *data.RequestFilters {
+ filter := RequestFilter{ID: deref(raw.Id)}
+ if raw.Method != nil {
+ filter.Methods = append(filter.Methods, *raw.Method...)
+ }
+ security.RequestFilters = append(security.RequestFilters, filter)
+ }
+ }
+ if data.Tokens != nil {
+ for _, raw := range *data.Tokens {
+ security.Tokens = append(security.Tokens, EndpointToken{ID: deref(raw.Id), Value: deref(raw.Token)})
+ }
+ }
+
+ return security, nil
+}
+
+// SetSecurityOptions writes the settable toggles. The read path reports them as
+// booleans and the write path takes the strings "enabled" and "disabled", so
+// the conversion happens here rather than in every caller. A patch that manages
+// nothing is a no-op rather than an empty write.
+func (c *Client) SetSecurityOptions(ctx context.Context, endpointID string, patch SecurityOptionsPatch) error {
+ const operation = "update endpoint security options"
+
+ if patch.Tokens == nil && patch.Referrers == nil && patch.JWTs == nil && patch.IPs == nil &&
+ patch.DomainMasks == nil && patch.HSTS == nil && patch.Cors == nil {
+ return nil
+ }
+
+ body := admin.PatchV0EndpointsByIdSecurityOptionsJSONRequestBody{}
+ body.Options = &struct {
+ Cors *string `json:"cors,omitempty"`
+ DomainMasks *string `json:"domainMasks,omitempty"`
+ Hsts *string `json:"hsts,omitempty"`
+ Ips *string `json:"ips,omitempty"`
+ Jwts *string `json:"jwts,omitempty"`
+ Referrers *string `json:"referrers,omitempty"`
+ Tokens *string `json:"tokens,omitempty"`
+ }{
+ Tokens: toggle(patch.Tokens),
+ Referrers: toggle(patch.Referrers),
+ Jwts: toggle(patch.JWTs),
+ Ips: toggle(patch.IPs),
+ DomainMasks: toggle(patch.DomainMasks),
+ Hsts: toggle(patch.HSTS),
+ Cors: toggle(patch.Cors),
+ }
+
+ resp, err := c.api.PatchV0EndpointsByIdSecurityOptionsWithResponse(ctx, endpointID, body)
+ if err != nil {
+ return err
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return envelopeError(operation, resp.StatusCode(), resp.JSON200.Error)
+}
+
+func toggle(value *bool) *string {
+ if value == nil {
+ return nil
+ }
+ setting := optionDisabled
+ if *value {
+ setting = optionEnabled
+ }
+ return &setting
+}
+
+func (c *Client) SetIPCustomHeader(ctx context.Context, endpointID, headerName string) error {
+ const operation = "set endpoint ip custom header"
+
+ resp, err := c.api.PatchV0EndpointsByIdIpCustomHeaderWithResponse(ctx, endpointID,
+ admin.PatchV0EndpointsByIdIpCustomHeaderJSONRequestBody{HeaderName: headerName})
+ if err != nil {
+ return err
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return envelopeError(operation, resp.StatusCode(), resp.JSON200.Error)
+}
+
+func (c *Client) DeleteIPCustomHeader(ctx context.Context, endpointID string) error {
+ const operation = "clear endpoint ip custom header"
+
+ resp, err := c.api.DeleteV0EndpointsByIdIpCustomHeaderWithResponse(ctx, endpointID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return envelopeError(operation, resp.StatusCode(), resp.JSON200.Error)
+}
+
+func (c *Client) AddEndpointIP(ctx context.Context, endpointID, ip string) (*SecurityEntry, error) {
+ const operation = "add endpoint ip"
+
+ resp, err := c.api.PostV0EndpointsByIdSecurityIpsWithResponse(ctx, endpointID,
+ admin.PostV0EndpointsByIdSecurityIpsJSONRequestBody{Ip: ip})
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.Id == nil {
+ return nil, &Error{Operation: operation, Status: resp.StatusCode(), Message: "response carried no id"}
+ }
+ return &SecurityEntry{ID: *resp.JSON200.Data.Id, Value: deref(resp.JSON200.Data.Ip)}, nil
+}
+
+func (c *Client) RemoveEndpointIP(ctx context.Context, endpointID, ipID string) error {
+ const operation = "remove endpoint ip"
+
+ resp, err := c.api.DeleteV0EndpointsByIdSecurityIpsByIpIdWithResponse(ctx, endpointID, ipID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return deleteResult(operation, resp.StatusCode(), resp.JSON200.Data, resp.JSON200.Error)
+}
+
+func (c *Client) AddDomainMask(ctx context.Context, endpointID, domainMask string) (*SecurityEntry, error) {
+ const operation = "add endpoint domain mask"
+
+ resp, err := c.api.PostV0EndpointsByIdSecurityDomainMasksWithResponse(ctx, endpointID,
+ admin.PostV0EndpointsByIdSecurityDomainMasksJSONRequestBody{DomainMask: domainMask})
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.Id == nil {
+ return nil, &Error{Operation: operation, Status: resp.StatusCode(), Message: "response carried no id"}
+ }
+ return &SecurityEntry{ID: *resp.JSON200.Data.Id, Value: deref(resp.JSON200.Data.DomainMask)}, nil
+}
+
+func (c *Client) RemoveDomainMask(ctx context.Context, endpointID, domainMaskID string) error {
+ const operation = "remove endpoint domain mask"
+
+ resp, err := c.api.DeleteV0EndpointsByIdSecurityDomainMasksByDomainMaskIdWithResponse(ctx, endpointID, domainMaskID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return deleteResult(operation, resp.StatusCode(), resp.JSON200.Data, resp.JSON200.Error)
+}
+
+func (c *Client) AddReferrer(ctx context.Context, endpointID, referrer string) (*SecurityEntry, error) {
+ const operation = "add endpoint referrer"
+
+ resp, err := c.api.PostV0EndpointsByIdSecurityReferrersWithResponse(ctx, endpointID,
+ admin.PostV0EndpointsByIdSecurityReferrersJSONRequestBody{Referrer: referrer})
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.Id == nil {
+ return nil, &Error{Operation: operation, Status: resp.StatusCode(), Message: "response carried no id"}
+ }
+ return &SecurityEntry{ID: *resp.JSON200.Data.Id, Value: deref(resp.JSON200.Data.Referrer)}, nil
+}
+
+func (c *Client) RemoveReferrer(ctx context.Context, endpointID, referrerID string) error {
+ const operation = "remove endpoint referrer"
+
+ resp, err := c.api.DeleteV0EndpointsByIdSecurityReferrersByReferrerIdWithResponse(ctx, endpointID, referrerID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return deleteResult(operation, resp.StatusCode(), resp.JSON200.Data, resp.JSON200.Error)
+}
+
+func (c *Client) AddJWT(ctx context.Context, endpointID string, jwt JWT) (*JWT, error) {
+ const operation = "add endpoint jwt"
+
+ body := admin.PostV0EndpointsByIdSecurityJwtsJSONRequestBody{}
+ if jwt.Name != "" {
+ body.Name = &jwt.Name
+ }
+ if jwt.KID != "" {
+ body.Kid = &jwt.KID
+ }
+ if jwt.PublicKey != "" {
+ body.PublicKey = &jwt.PublicKey
+ }
+
+ resp, err := c.api.PostV0EndpointsByIdSecurityJwtsWithResponse(ctx, endpointID, body)
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.Id == nil {
+ return nil, &Error{Operation: operation, Status: resp.StatusCode(), Message: "response carried no id"}
+ }
+
+ data := resp.JSON200.Data
+ return &JWT{
+ ID: *data.Id,
+ Name: deref(data.Name),
+ KID: deref(data.Kid),
+ PublicKey: deref(data.PublicKey),
+ }, nil
+}
+
+func (c *Client) RemoveJWT(ctx context.Context, endpointID, jwtID string) error {
+ const operation = "remove endpoint jwt"
+
+ resp, err := c.api.DeleteV0EndpointsByIdSecurityJwtsByJwtIdWithResponse(ctx, endpointID, jwtID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return deleteResult(operation, resp.StatusCode(), resp.JSON200.Data, resp.JSON200.Error)
+}
+
+func (c *Client) AddRequestFilter(ctx context.Context, endpointID string, methods []string) (*RequestFilter, error) {
+ const operation = "add endpoint request filter"
+
+ body := admin.PostV0EndpointsByIdSecurityRequestFiltersJSONRequestBody{Method: &methods}
+
+ resp, err := c.api.PostV0EndpointsByIdSecurityRequestFiltersWithResponse(ctx, endpointID, body)
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.Id == nil {
+ return nil, &Error{Operation: operation, Status: resp.StatusCode(), Message: "response carried no id"}
+ }
+ return &RequestFilter{ID: *resp.JSON200.Data.Id, Methods: methods}, nil
+}
+
+func (c *Client) UpdateRequestFilter(ctx context.Context, endpointID, filterID string, methods []string) error {
+ const operation = "update endpoint request filter"
+
+ body := admin.PutV0EndpointsByIdSecurityRequestFiltersByRequestFilterIdJSONRequestBody{Method: &methods}
+
+ resp, err := c.api.PutV0EndpointsByIdSecurityRequestFiltersByRequestFilterIdWithResponse(ctx, endpointID, filterID, body)
+ if err != nil {
+ return err
+ }
+ return noContentResult(operation, resp.StatusCode(), resp.Body)
+}
+
+func (c *Client) RemoveRequestFilter(ctx context.Context, endpointID, filterID string) error {
+ const operation = "remove endpoint request filter"
+
+ resp, err := c.api.DeleteV0EndpointsByIdSecurityRequestFiltersByRequestFilterIdWithResponse(ctx, endpointID, filterID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ return noContentResult(operation, resp.StatusCode(), resp.Body)
+}
+
+func (c *Client) AddEndpointToken(ctx context.Context, endpointID string) (*EndpointToken, error) {
+ const operation = "add endpoint token"
+
+ resp, err := c.api.PostV0EndpointsByIdSecurityTokensWithResponse(ctx, endpointID)
+ if err != nil {
+ return nil, err
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil || resp.JSON200.Data.Id == nil {
+ return nil, &Error{Operation: operation, Status: resp.StatusCode(), Message: "response carried no id"}
+ }
+ return &EndpointToken{ID: *resp.JSON200.Data.Id, Value: deref(resp.JSON200.Data.Token)}, nil
+}
+
+func (c *Client) RemoveEndpointToken(ctx context.Context, endpointID, tokenID string) error {
+ const operation = "remove endpoint token"
+
+ resp, err := c.api.DeleteV0EndpointsByIdSecurityTokensByTokenIdWithResponse(ctx, endpointID, tokenID)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil
+ }
+ if resp.JSON200 == nil {
+ return statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ return deleteResult(operation, resp.StatusCode(), resp.JSON200.Data, resp.JSON200.Error)
+}
+
+// deleteResult reads the {data: bool, error: string} envelope the security
+// delete routes return. A 404 is treated as success by the callers, since a
+// removed entry is the state the caller asked for.
+func deleteResult(operation string, status int, data *bool, apiError *string) error {
+ if err := envelopeError(operation, status, apiError); err != nil {
+ return err
+ }
+ if data != nil && !*data {
+ return &Error{Operation: operation, Status: status, Message: "the API reported the delete did not succeed"}
+ }
+ return nil
+}
+
+// noContentResult handles the routes that answer 204 with an empty body rather
+// than the JSON envelope the rest of the Admin API uses.
+func noContentResult(operation string, status int, body []byte) error {
+ if status < 200 || status > 299 {
+ return statusError(operation, status, body)
+ }
+ return nil
+}
diff --git a/internal/client/security_test.go b/internal/client/security_test.go
new file mode 100644
index 0000000..672ac8a
--- /dev/null
+++ b/internal/client/security_test.go
@@ -0,0 +1,233 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// securityBody mirrors a live GET /v0/endpoints/{id}/security response. The
+// list entries are the shapes the patched spec declares, which the published
+// spec leaves as untyped arrays.
+const securityBody = `{"data":{
+"options":{"tokens":true,"referrers":false,"jwts":false,"ips":true,"domainMasks":false,
+"hsts":true,"cors":false,"requestFilters":true,"responseLogging":false,
+"ipCustomHeader":{"value":"X-Real-IP"}},
+"ips":[{"id":"ip-1","ip":"203.0.113.7"}],
+"domain_masks":[{"id":"dm-1","domain_mask":"rpc.example.com"}],
+"referrers":[{"id":"rf-1","referrer":"https://example.com"}],
+"jwts":[{"id":"jwt-1","name":"signer","kid":"kid-1","public_key":"-----BEGIN PUBLIC KEY-----"}],
+"request_filters":[{"id":"filter-1","method":["eth_call","eth_getLogs"]}],
+"tokens":[{"id":"tok-1","token":"TOKENVALUE"}]}}`
+
+func TestGetEndpointSecurity(t *testing.T) {
+ client := newTestClient(t, securityBody)
+
+ security, err := client.GetEndpointSecurity(context.Background(), "1")
+ if err != nil {
+ t.Fatalf("GetEndpointSecurity: %v", err)
+ }
+
+ if !security.Options.Tokens || !security.Options.IPs || !security.Options.HSTS {
+ t.Errorf("enabled toggles did not survive: %+v", security.Options)
+ }
+ if security.Options.Referrers || security.Options.Cors || security.Options.ResponseLogging {
+ t.Errorf("disabled toggles did not survive: %+v", security.Options)
+ }
+ if !security.Options.RequestFilters {
+ t.Error("requestFilters is read-only but still has to be reported")
+ }
+ if security.Options.IPCustomHeader != "X-Real-IP" {
+ t.Errorf("IPCustomHeader = %q, want X-Real-IP", security.Options.IPCustomHeader)
+ }
+
+ if len(security.IPs) != 1 || security.IPs[0].ID != "ip-1" || security.IPs[0].Value != "203.0.113.7" {
+ t.Errorf("IPs = %+v", security.IPs)
+ }
+ if len(security.DomainMasks) != 1 || security.DomainMasks[0].Value != "rpc.example.com" {
+ t.Errorf("DomainMasks = %+v", security.DomainMasks)
+ }
+ if len(security.Referrers) != 1 || security.Referrers[0].Value != "https://example.com" {
+ t.Errorf("Referrers = %+v", security.Referrers)
+ }
+ if len(security.JWTs) != 1 || security.JWTs[0].KID != "kid-1" || security.JWTs[0].Name != "signer" {
+ t.Errorf("JWTs = %+v", security.JWTs)
+ }
+ if len(security.RequestFilters) != 1 || len(security.RequestFilters[0].Methods) != 2 {
+ t.Fatalf("RequestFilters = %+v", security.RequestFilters)
+ }
+ if security.RequestFilters[0].Methods[0] != "eth_call" {
+ t.Errorf("first filtered method = %q", security.RequestFilters[0].Methods[0])
+ }
+ if len(security.Tokens) != 1 || security.Tokens[0].Value != "TOKENVALUE" {
+ t.Errorf("Tokens = %+v", security.Tokens)
+ }
+}
+
+// TestSetSecurityOptionsSendsStrings guards the asymmetry between the two
+// halves of the API: reads report the toggles as booleans and the write takes
+// the strings "enabled" and "disabled". A toggle the configuration does not
+// manage has to stay out of the body entirely rather than being sent as false.
+func TestSetSecurityOptionsSendsStrings(t *testing.T) {
+ var captured map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &captured)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"option":"tokens","status":"enabled"},{"option":"cors","status":"disabled"}]}`))
+ }))
+ t.Cleanup(server.Close)
+
+ client, err := New("test-key", WithBaseURL(server.URL))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ enabled, disabled := true, false
+ err = client.SetSecurityOptions(context.Background(), "1", SecurityOptionsPatch{
+ Tokens: &enabled,
+ Cors: &disabled,
+ })
+ if err != nil {
+ t.Fatalf("SetSecurityOptions: %v", err)
+ }
+
+ options, ok := captured["options"].(map[string]any)
+ if !ok {
+ t.Fatalf("request carried no options object: %v", captured)
+ }
+ if options["tokens"] != "enabled" {
+ t.Errorf("tokens = %v, want the string \"enabled\"", options["tokens"])
+ }
+ if options["cors"] != "disabled" {
+ t.Errorf("cors = %v, want the string \"disabled\"", options["cors"])
+ }
+ for _, unmanaged := range []string{"jwts", "ips", "hsts", "referrers", "domainMasks"} {
+ if _, present := options[unmanaged]; present {
+ t.Errorf("%s was sent although the patch left it unset", unmanaged)
+ }
+ }
+}
+
+func TestRemoveEndpointIPRejectsFalseResult(t *testing.T) {
+ client := newTestClient(t, `{"data":false}`)
+
+ err := client.RemoveEndpointIP(context.Background(), "1", "ip-1")
+ if err == nil {
+ t.Fatal("a data:false delete has to be an error, not a silent success")
+ }
+}
+
+func TestRemoveEndpointIPAcceptsTrueResult(t *testing.T) {
+ client := newTestClient(t, `{"data":true}`)
+
+ if err := client.RemoveEndpointIP(context.Background(), "1", "ip-1"); err != nil {
+ t.Fatalf("RemoveEndpointIP: %v", err)
+ }
+}
+
+// TestDeleteAbsentEntriesSucceed covers the routes a Terraform destroy hits
+// after something already removed the entry out of band. The desired state is
+// "gone", so a 404 is not a failure.
+func TestDeleteAbsentEntriesSucceed(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ t.Cleanup(server.Close)
+
+ client, err := New("test-key", WithBaseURL(server.URL))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ ctx := context.Background()
+ for name, remove := range map[string]func() error{
+ "ip": func() error { return client.RemoveEndpointIP(ctx, "1", "ip-1") },
+ "domain mask": func() error { return client.RemoveDomainMask(ctx, "1", "dm-1") },
+ "referrer": func() error { return client.RemoveReferrer(ctx, "1", "rf-1") },
+ "jwt": func() error { return client.RemoveJWT(ctx, "1", "jwt-1") },
+ "request filter": func() error { return client.RemoveRequestFilter(ctx, "1", "filter-1") },
+ "token": func() error { return client.RemoveEndpointToken(ctx, "1", "tok-1") },
+ "custom header": func() error { return client.DeleteIPCustomHeader(ctx, "1") },
+ } {
+ if err := remove(); err != nil {
+ t.Errorf("removing an absent %s should succeed, got %v", name, err)
+ }
+ }
+}
+
+// TestRequestFilterNoContentRoutes covers the two routes that answer 204 with an
+// empty body instead of the JSON envelope every other route uses.
+func TestRequestFilterNoContentRoutes(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ t.Cleanup(server.Close)
+
+ client, err := New("test-key", WithBaseURL(server.URL))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ ctx := context.Background()
+ if err := client.UpdateRequestFilter(ctx, "1", "filter-1", []string{"eth_call"}); err != nil {
+ t.Errorf("UpdateRequestFilter: %v", err)
+ }
+ if err := client.RemoveRequestFilter(ctx, "1", "filter-1"); err != nil {
+ t.Errorf("RemoveRequestFilter: %v", err)
+ }
+}
+
+func TestAddEndpointIPReturnsID(t *testing.T) {
+ client := newTestClient(t, `{"data":{"id":"ip-9","ip":"198.51.100.4"}}`)
+
+ entry, err := client.AddEndpointIP(context.Background(), "1", "198.51.100.4")
+ if err != nil {
+ t.Fatalf("AddEndpointIP: %v", err)
+ }
+ if entry.ID != "ip-9" || entry.Value != "198.51.100.4" {
+ t.Errorf("entry = %+v", entry)
+ }
+}
+
+func TestAddEndpointIPRejectsEnvelopeError(t *testing.T) {
+ client := newTestClient(t, `{"error":"that address is already allowed"}`)
+
+ if _, err := client.AddEndpointIP(context.Background(), "1", "198.51.100.4"); err == nil {
+ t.Fatal("an error inside a 200 body has to surface as an error")
+ }
+}
+
+// TestEmptyWritesAreSkipped covers the configuration that manages none of the
+// toggles or buckets. An empty body is a pointless call at best, so the client
+// makes none at all.
+func TestEmptyWritesAreSkipped(t *testing.T) {
+ var calls int
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ calls++
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{}}`))
+ }))
+ t.Cleanup(server.Close)
+
+ quicknode, err := New("test-key", WithBaseURL(server.URL))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ ctx := context.Background()
+ if err := quicknode.SetSecurityOptions(ctx, "1", SecurityOptionsPatch{}); err != nil {
+ t.Fatalf("SetSecurityOptions: %v", err)
+ }
+ if err := quicknode.SetRateLimits(ctx, "1", RateLimitOverrides{}); err != nil {
+ t.Fatalf("SetRateLimits: %v", err)
+ }
+ if calls != 0 {
+ t.Errorf("made %d requests for writes that manage nothing, want 0", calls)
+ }
+}
diff --git a/internal/provider/acceptance_test.go b/internal/provider/acceptance_test.go
new file mode 100644
index 0000000..9caf897
--- /dev/null
+++ b/internal/provider/acceptance_test.go
@@ -0,0 +1,368 @@
+package provider_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-framework/providerserver"
+ "github.com/hashicorp/terraform-plugin-go/tfprotov6"
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/hashicorp/terraform-plugin-testing/terraform"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+ "github.com/quicknode/terraform-provider-quicknode/internal/provider"
+)
+
+// Acceptance tests create real, billable Quicknode resources. They run only
+// when TF_ACC is set, and they need QUICKNODE_API_KEY for a paid account that
+// is dedicated to testing.
+//
+// The chain and network are deliberately a testnet, and every test destroys
+// what it created. Run them with `make testacc`.
+const (
+ acceptanceChain = "eth"
+ acceptanceNetwork = "ethereum-sepolia"
+)
+
+var protoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
+ "quicknode": providerserver.NewProtocol6WithError(provider.New("test")()),
+}
+
+func testAccPreCheck(t *testing.T) {
+ t.Helper()
+ if os.Getenv("QUICKNODE_API_KEY") == "" {
+ t.Fatal("QUICKNODE_API_KEY must be set for acceptance tests")
+ }
+}
+
+// testAccCheckEndpointsDestroyed asks the Admin API whether the endpoints the
+// test created are really gone. Terraform calls a destroy successful as soon as
+// the provider's Delete returns no error, so a delete the API did not honour
+// would otherwise pass and leave a billable endpoint behind. Every other
+// resource in these tests belongs to an endpoint and goes with it.
+func testAccCheckEndpointsDestroyed(state *terraform.State) error {
+ quicknode, err := client.New(os.Getenv("QUICKNODE_API_KEY"))
+ if err != nil {
+ return fmt.Errorf("could not build a client to confirm the destroy: %w", err)
+ }
+
+ for name, resourceState := range state.RootModule().Resources {
+ if resourceState.Type != "quicknode_endpoint" {
+ continue
+ }
+ id := resourceState.Primary.ID
+ endpoint, err := quicknode.GetEndpoint(context.Background(), id)
+ if client.IsNotFound(err) {
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("%s: could not confirm endpoint %s was destroyed: %w", name, id, err)
+ }
+ return fmt.Errorf("%s: endpoint %s still exists after destroy, with status %q", name, id, endpoint.Status)
+ }
+ return nil
+}
+
+func endpointConfig(label string) string {
+ return fmt.Sprintf(`
+resource "quicknode_endpoint" "test" {
+ chain = %q
+ network = %q
+ label = %q
+}
+`, acceptanceChain, acceptanceNetwork, label)
+}
+
+func TestAccEndpoint_lifecycle(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: endpointConfig("tfacc-endpoint"),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("quicknode_endpoint.test", "id"),
+ resource.TestCheckResourceAttr("quicknode_endpoint.test", "chain", acceptanceChain),
+ resource.TestCheckResourceAttr("quicknode_endpoint.test", "label", "tfacc-endpoint"),
+ resource.TestCheckResourceAttr("quicknode_endpoint.test", "status", "active"),
+ // The credentialed URL is the one that works; the stripped
+ // URL must not carry the token.
+ resource.TestCheckResourceAttrSet("quicknode_endpoint.test", "http_url_with_token"),
+ resource.TestCheckResourceAttrSet("quicknode_endpoint.test", "tokens.0.token"),
+ resource.TestCheckResourceAttrSet("quicknode_endpoint.test", "security_options.tokens"),
+ ),
+ },
+ {
+ ResourceName: "quicknode_endpoint.test",
+ ImportState: true,
+ ImportStateVerify: true,
+ },
+ {
+ Config: endpointConfig("tfacc-endpoint-renamed"),
+ Check: resource.TestCheckResourceAttr(
+ "quicknode_endpoint.test", "label", "tfacc-endpoint-renamed"),
+ },
+ },
+ })
+}
+
+// TestAccEndpoint_securityOptions checks the boolean-read, string-write
+// asymmetry end to end, and that a toggle the configuration does not manage
+// keeps its value rather than being reset.
+func TestAccEndpoint_securityOptions(t *testing.T) {
+ withOptions := func(cors bool) string {
+ return fmt.Sprintf(`
+resource "quicknode_endpoint" "test" {
+ chain = %q
+ network = %q
+ label = "tfacc-security-options"
+
+ security_options = {
+ cors = %t
+ }
+}
+`, acceptanceChain, acceptanceNetwork, cors)
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: withOptions(false),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("quicknode_endpoint.test", "security_options.cors", "false"),
+ // tokens is unmanaged here, so it has to keep the value a
+ // new endpoint is created with rather than being turned off.
+ resource.TestCheckResourceAttr("quicknode_endpoint.test", "security_options.tokens", "true"),
+ ),
+ },
+ {
+ Config: withOptions(true),
+ Check: resource.TestCheckResourceAttr("quicknode_endpoint.test", "security_options.cors", "true"),
+ },
+ },
+ })
+}
+
+func TestAccEndpointIP_importByValue(t *testing.T) {
+ const config = `
+resource "quicknode_endpoint" "test" {
+ chain = "eth"
+ network = "ethereum-sepolia"
+ label = "tfacc-ip"
+
+ security_options = {
+ ips = true
+ }
+}
+
+resource "quicknode_endpoint_ip" "test" {
+ endpoint_id = quicknode_endpoint.test.id
+ ip = "203.0.113.7"
+}
+`
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: config,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("quicknode_endpoint_ip.test", "id"),
+ resource.TestCheckResourceAttr("quicknode_endpoint_ip.test", "ip", "203.0.113.7"),
+ ),
+ },
+ {
+ ResourceName: "quicknode_endpoint_ip.test",
+ ImportState: true,
+ // The address is the value an operator already knows, not the
+ // entry id the API assigned.
+ ImportStateIdFunc: func(state *terraform.State) (string, error) {
+ endpoint := state.RootModule().Resources["quicknode_endpoint.test"]
+ if endpoint == nil {
+ return "", fmt.Errorf("the endpoint is not in state")
+ }
+ return endpoint.Primary.ID + "/203.0.113.7", nil
+ },
+ ImportStateVerify: true,
+ },
+ },
+ })
+}
+
+func TestAccRequestFilter_updatesInPlace(t *testing.T) {
+ withMethods := func(methods string) string {
+ return fmt.Sprintf(`
+resource "quicknode_endpoint" "test" {
+ chain = %q
+ network = %q
+ label = "tfacc-request-filter"
+}
+
+resource "quicknode_endpoint_request_filter" "test" {
+ endpoint_id = quicknode_endpoint.test.id
+ methods = %s
+}
+`, acceptanceChain, acceptanceNetwork, methods)
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: withMethods(`["eth_call"]`),
+ Check: resource.TestCheckResourceAttr("quicknode_endpoint_request_filter.test", "methods.#", "1"),
+ },
+ {
+ // A real PUT route backs this, so the filter must update rather
+ // than be replaced.
+ Config: withMethods(`["eth_call", "eth_getLogs"]`),
+ Check: resource.TestCheckResourceAttr("quicknode_endpoint_request_filter.test", "methods.#", "2"),
+ },
+ },
+ })
+}
+
+// TestAccRateLimits_dropReturnsPlanDefault covers the behaviour the patch route
+// cannot express: removing a bucket from the configuration has to delete the
+// override rather than leave it in place.
+func TestAccRateLimits_dropReturnsPlanDefault(t *testing.T) {
+ withBuckets := func(buckets string) string {
+ return fmt.Sprintf(`
+resource "quicknode_endpoint" "test" {
+ chain = %q
+ network = %q
+ label = "tfacc-rate-limits"
+}
+
+resource "quicknode_endpoint_rate_limits" "test" {
+ endpoint_id = quicknode_endpoint.test.id
+%s
+}
+`, acceptanceChain, acceptanceNetwork, buckets)
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: withBuckets(" rps = 25\n rpm = 500"),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "rps", "25"),
+ resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "rpm", "500"),
+ resource.TestCheckResourceAttrSet("quicknode_endpoint_rate_limits.test", "plan_default.rps"),
+ ),
+ },
+ {
+ Config: withBuckets(" rps = 25"),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "rps", "25"),
+ resource.TestCheckNoResourceAttr("quicknode_endpoint_rate_limits.test", "rpm"),
+ ),
+ },
+ },
+ })
+}
+
+func TestAccMethodRateLimit_lifecycle(t *testing.T) {
+ withRate := func(rate int, enabled bool) string {
+ return fmt.Sprintf(`
+resource "quicknode_endpoint" "test" {
+ chain = %q
+ network = %q
+ label = "tfacc-method-rate-limit"
+}
+
+resource "quicknode_endpoint_method_rate_limit" "test" {
+ endpoint_id = quicknode_endpoint.test.id
+ methods = ["eth_getLogs"]
+ rate = %d
+ interval = "second"
+ enabled = %t
+}
+`, acceptanceChain, acceptanceNetwork, rate, enabled)
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: withRate(5, true),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "rate", "5"),
+ resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "enabled", "true"),
+ ),
+ },
+ {
+ Config: withRate(9, false),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "rate", "9"),
+ resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "enabled", "false"),
+ ),
+ },
+ },
+ })
+}
+
+func TestAccEndpointDataSource_byLabel(t *testing.T) {
+ const config = `
+resource "quicknode_endpoint" "test" {
+ chain = "eth"
+ network = "ethereum-sepolia"
+ label = "tfacc-data-source"
+}
+
+data "quicknode_endpoint" "test" {
+ label = quicknode_endpoint.test.label
+ depends_on = [quicknode_endpoint.test]
+}
+
+data "quicknode_endpoints" "test" {
+ statuses = ["active"]
+ depends_on = [quicknode_endpoint.test]
+}
+`
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: config,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrPair(
+ "data.quicknode_endpoint.test", "id",
+ "quicknode_endpoint.test", "id"),
+ resource.TestCheckResourceAttrSet("data.quicknode_endpoint.test", "http_url_with_token"),
+ resource.TestCheckResourceAttrSet("data.quicknode_endpoints.test", "ids.#"),
+ ),
+ },
+ },
+ })
+}
+
+func TestAccChainsDataSource(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: `data "quicknode_chains" "all" {}`,
+ Check: resource.TestCheckResourceAttrSet("data.quicknode_chains.all", "chains.#"),
+ },
+ },
+ })
+}
diff --git a/internal/provider/endpoint_data_source.go b/internal/provider/endpoint_data_source.go
new file mode 100644
index 0000000..b94166d
--- /dev/null
+++ b/internal/provider/endpoint_data_source.go
@@ -0,0 +1,204 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+var _ datasource.DataSource = (*endpointDataSource)(nil)
+var _ datasource.DataSourceWithConfigure = (*endpointDataSource)(nil)
+var _ datasource.DataSourceWithConfigValidators = (*endpointDataSource)(nil)
+
+type endpointDataSource struct {
+ client *client.Client
+}
+
+type endpointDataSourceModel struct {
+ ID types.String `tfsdk:"id"`
+ Label types.String `tfsdk:"label"`
+ Chain types.String `tfsdk:"chain"`
+ Network types.String `tfsdk:"network"`
+ Status types.String `tfsdk:"status"`
+ Multichain types.Bool `tfsdk:"multichain"`
+ Tags []types.String `tfsdk:"tags"`
+ SafeHTTPURL types.String `tfsdk:"safe_http_url"`
+ SafeWSSURL types.String `tfsdk:"safe_wss_url"`
+ HTTPURLWithToken types.String `tfsdk:"http_url_with_token"`
+ WSSURLWithToken types.String `tfsdk:"wss_url_with_token"`
+ Tokens []tokenModel `tfsdk:"tokens"`
+ SecurityOptions types.Object `tfsdk:"security_options"`
+ IPCustomHeader types.String `tfsdk:"ip_custom_header"`
+}
+
+type tokenModel struct {
+ ID types.String `tfsdk:"id"`
+ Token types.String `tfsdk:"token"`
+}
+
+func NewEndpointDataSource() datasource.DataSource {
+ return &endpointDataSource{}
+}
+
+func (d *endpointDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_endpoint"
+}
+
+func (d *endpointDataSource) ConfigValidators(_ context.Context) []datasource.ConfigValidator {
+ return []datasource.ConfigValidator{
+ datasourcevalidator.ExactlyOneOf(path.MatchRoot("id"), path.MatchRoot("label")),
+ }
+}
+
+func (d *endpointDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ computedBool := func(description string) schema.BoolAttribute {
+ return schema.BoolAttribute{Computed: true, MarkdownDescription: description}
+ }
+ resp.Schema = schema.Schema{
+ MarkdownDescription: "One endpoint that already exists on the account, looked up by `id` or by `label`. Use it to wire a Terraform configuration into an endpoint created elsewhere without importing it.\n\n" +
+ "Labels are not unique, so a label matching more than one endpoint is an error rather than an arbitrary pick.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Optional: true,
+ Computed: true,
+ MarkdownDescription: "Endpoint id. Set this or `label`, not both.",
+ },
+ "label": schema.StringAttribute{
+ Optional: true,
+ Computed: true,
+ MarkdownDescription: "Endpoint label. Set this or `id`, not both.",
+ },
+ "chain": schema.StringAttribute{Computed: true, MarkdownDescription: "Chain slug."},
+ "network": schema.StringAttribute{Computed: true, MarkdownDescription: "Network slug."},
+ "status": schema.StringAttribute{Computed: true, MarkdownDescription: "`active` or `paused`."},
+ "multichain": computedBool("Whether the endpoint serves more than one network."),
+ "tags": schema.ListAttribute{
+ Computed: true,
+ ElementType: types.StringType,
+ MarkdownDescription: "Tag labels applied to the endpoint.",
+ },
+ "safe_http_url": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display.",
+ },
+ "safe_wss_url": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.",
+ },
+ "http_url_with_token": schema.StringAttribute{
+ Computed: true,
+ Sensitive: true,
+ MarkdownDescription: "The working HTTPS endpoint, exactly as the Admin API returns it.",
+ },
+ "wss_url_with_token": schema.StringAttribute{
+ Computed: true,
+ Sensitive: true,
+ MarkdownDescription: "The working WebSocket endpoint, or null on chains without WebSocket support.",
+ },
+ "ip_custom_header": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Header the endpoint reads the caller's IP address from, or null if none is set.",
+ },
+ "tokens": schema.ListNestedAttribute{
+ Computed: true,
+ MarkdownDescription: "Auth tokens for the endpoint. Values land in Terraform state, so keep state encrypted and remote.",
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{Computed: true, MarkdownDescription: "Token id."},
+ "token": schema.StringAttribute{Computed: true, Sensitive: true, MarkdownDescription: "Token value."},
+ },
+ },
+ },
+ "security_options": schema.SingleNestedAttribute{
+ Computed: true,
+ MarkdownDescription: "Which security mechanisms the endpoint enforces.",
+ Attributes: map[string]schema.Attribute{
+ "tokens": computedBool("Whether an auth token is required."),
+ "referrers": computedBool("Whether referrer restrictions are applied."),
+ "jwts": computedBool("Whether a signed JWT is required."),
+ "ips": computedBool("Whether IP restrictions are applied."),
+ "domain_masks": computedBool("Whether the endpoint is served from an approved custom domain."),
+ "hsts": computedBool("Whether the HTTP Strict Transport Security header is sent."),
+ "cors": computedBool("Whether a Cross-Origin Resource Sharing policy is applied."),
+ "request_filters": computedBool("Whether RPC method filtering is applied."),
+ "response_logging": computedBool("Whether responses are logged for the endpoint."),
+ },
+ },
+ },
+ }
+}
+
+func (d *endpointDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The endpoint data source expected providerData, got %T.", req.ProviderData))
+ return
+ }
+ d.client = data.Client
+}
+
+func (d *endpointDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config endpointDataSourceModel
+ resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ endpointID := config.ID.ValueString()
+ if endpointID == "" {
+ match, err := d.client.FindEndpointByLabel(ctx, config.Label.ValueString())
+ if err != nil {
+ resp.Diagnostics.AddError("Could not find an endpoint with that label", err.Error())
+ return
+ }
+ endpointID = match.ID
+ }
+
+ endpoint, err := d.client.GetEndpoint(ctx, endpointID)
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the Quicknode endpoint", err.Error())
+ return
+ }
+
+ config.ID = types.StringValue(endpoint.ID)
+ config.Label = stringOrNull(endpoint.Label)
+ config.Chain = types.StringValue(endpoint.Chain)
+ config.Network = types.StringValue(endpoint.Network)
+ config.Status = types.StringValue(endpoint.Status)
+ config.Multichain = types.BoolValue(endpoint.Multichain)
+ config.SafeHTTPURL = stringOrNull(endpoint.SafeHTTPURL)
+ config.SafeWSSURL = stringOrNull(endpoint.SafeWSSURL)
+ config.HTTPURLWithToken = stringOrNull(endpoint.HTTPURLWithToken)
+ config.WSSURLWithToken = stringOrNull(endpoint.WSSURLWithToken)
+ config.IPCustomHeader = stringOrNull(endpoint.Security.IPCustomHeader)
+
+ config.Tags = make([]types.String, 0, len(endpoint.Tags))
+ for _, tag := range endpoint.Tags {
+ config.Tags = append(config.Tags, types.StringValue(tag.Label))
+ }
+ config.Tokens = make([]tokenModel, 0, len(endpoint.Tokens))
+ for _, token := range endpoint.Tokens {
+ config.Tokens = append(config.Tokens, tokenModel{
+ ID: types.StringValue(token.ID),
+ Token: types.StringValue(token.Value),
+ })
+ }
+
+ options, diags := securityOptionsObject(endpoint.Security)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ config.SecurityOptions = options
+ resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
+}
diff --git a/internal/provider/endpoint_resource.go b/internal/provider/endpoint_resource.go
index dc655e4..abd4871 100644
--- a/internal/provider/endpoint_resource.go
+++ b/internal/provider/endpoint_resource.go
@@ -49,11 +49,13 @@ type endpointResourceModel struct {
Status types.String `tfsdk:"status"`
Multichain types.Bool `tfsdk:"multichain"`
Tags types.Set `tfsdk:"tags"`
- HTTPURL types.String `tfsdk:"http_url"`
- WSSURL types.String `tfsdk:"wss_url"`
+ SafeHTTPURL types.String `tfsdk:"safe_http_url"`
+ SafeWSSURL types.String `tfsdk:"safe_wss_url"`
HTTPURLWithToken types.String `tfsdk:"http_url_with_token"`
WSSURLWithToken types.String `tfsdk:"wss_url_with_token"`
Tokens types.List `tfsdk:"tokens"`
+ SecurityOptions types.Object `tfsdk:"security_options"`
+ IPCustomHeader types.String `tfsdk:"ip_custom_header"`
}
func NewEndpointResource() resource.Resource {
@@ -68,8 +70,8 @@ func (r *endpointResource) Schema(_ context.Context, _ resource.SchemaRequest, r
resp.Schema = schema.Schema{
MarkdownDescription: "A Quicknode RPC endpoint on a chain and network.\n\n" +
"Pass `http_url_with_token` to anything that needs to make RPC calls. " +
- "`http_url` and `wss_url` have the credential removed and are safe to log or expose, but they are not usable endpoints: " +
- "the token does not sit at the end of the path on every chain, so rebuilding a URL by joining them to a token produces a broken address on chains that append a suffix.",
+ "`safe_http_url` and `safe_wss_url` carry the literal `TOKEN` where the credential belongs, so they are safe to log or display " +
+ "while keeping the real URL's shape, including any path suffix the chain appends. Substitute a token into one rather than assembling a URL from parts.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
@@ -108,14 +110,14 @@ func (r *endpointResource) Schema(_ context.Context, _ resource.SchemaRequest, r
ElementType: types.StringType,
MarkdownDescription: "Tag labels applied to the endpoint. Omitting the attribute removes every tag the provider finds on the endpoint.",
},
- "http_url": schema.StringAttribute{
+ "safe_http_url": schema.StringAttribute{
Computed: true,
- MarkdownDescription: "HTTPS URL with the auth token removed. Safe to expose, but not a working endpoint.",
+ MarkdownDescription: "The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display. Substitute a real token to make it usable: `replace(self.safe_http_url, \"TOKEN\", self.tokens[0].token)`.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
- "wss_url": schema.StringAttribute{
+ "safe_wss_url": schema.StringAttribute{
Computed: true,
- MarkdownDescription: "WebSocket URL with the auth token removed, or null on chains without WebSocket support.",
+ MarkdownDescription: "The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"http_url_with_token": schema.StringAttribute{
@@ -130,6 +132,12 @@ func (r *endpointResource) Schema(_ context.Context, _ resource.SchemaRequest, r
MarkdownDescription: "The working WebSocket endpoint, or null on chains without WebSocket support.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
+ "security_options": securityOptionsSchema(),
+ "ip_custom_header": schema.StringAttribute{
+ Optional: true,
+ MarkdownDescription: "Name of the header the endpoint reads the caller's IP address from, for example `X-Real-IP`. Set it when calls arrive through a proxy, so IP restrictions match the original caller rather than the proxy.",
+ Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
+ },
"tokens": schema.ListNestedAttribute{
Computed: true,
MarkdownDescription: "Auth tokens for the endpoint. An endpoint can carry several. Token values are stored in Terraform state, so keep state encrypted and remote.",
@@ -231,6 +239,11 @@ func (r *endpointResource) Create(ctx context.Context, req resource.CreateReques
}
}
+ resp.Diagnostics.Append(r.applySecurity(ctx, created.ID, plan.SecurityOptions, plan.IPCustomHeader, types.StringNull())...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
wanted, diags := tagLabels(ctx, plan.Tags)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
@@ -306,6 +319,13 @@ func (r *endpointResource) Update(ctx context.Context, req resource.UpdateReques
}
}
+ if !plan.SecurityOptions.Equal(state.SecurityOptions) || !plan.IPCustomHeader.Equal(state.IPCustomHeader) {
+ resp.Diagnostics.Append(r.applySecurity(ctx, id, plan.SecurityOptions, plan.IPCustomHeader, state.IPCustomHeader)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ }
+
if !plan.Tags.Equal(state.Tags) {
resp.Diagnostics.Append(r.reconcileTags(ctx, id, plan.Tags)...)
if resp.Diagnostics.HasError() {
@@ -351,6 +371,36 @@ func (r *endpointResource) readInto(ctx context.Context, id string, model *endpo
tokens, tokenDiags := tokenList(endpoint.Tokens)
diags.Append(tokenDiags...)
model.Tokens = tokens
+
+ options, optionDiags := securityOptionsObject(endpoint.Security)
+ diags.Append(optionDiags...)
+ model.SecurityOptions = options
+}
+
+// applySecurity writes the settable toggles and the custom IP header. The
+// previous header is needed because clearing the attribute has to become a
+// delete rather than an empty write.
+func (r *endpointResource) applySecurity(ctx context.Context, id string, options types.Object, header, previousHeader types.String) diag.Diagnostics {
+ patch, diags := securityOptionsPatch(ctx, options)
+ if diags.HasError() {
+ return diags
+ }
+ if err := r.client.SetSecurityOptions(ctx, id, patch); err != nil {
+ diags.AddError("Could not update the endpoint security options", err.Error())
+ return diags
+ }
+
+ switch {
+ case !header.IsNull() && !header.IsUnknown():
+ if err := r.client.SetIPCustomHeader(ctx, id, header.ValueString()); err != nil {
+ diags.AddError("Could not set the endpoint's custom IP header", err.Error())
+ }
+ case !previousHeader.IsNull():
+ if err := r.client.DeleteIPCustomHeader(ctx, id); err != nil {
+ diags.AddError("Could not clear the endpoint's custom IP header", err.Error())
+ }
+ }
+ return diags
}
func applyEndpoint(endpoint *client.Endpoint, state *endpointResourceModel) diag.Diagnostics {
@@ -367,6 +417,11 @@ func applyEndpoint(endpoint *client.Endpoint, state *endpointResourceModel) diag
diags.Append(tokenDiags...)
state.Tokens = tokens
+ options, optionDiags := securityOptionsObject(endpoint.Security)
+ diags.Append(optionDiags...)
+ state.SecurityOptions = options
+ state.IPCustomHeader = stringOrNull(endpoint.Security.IPCustomHeader)
+
if endpoint.Label == "" {
state.Label = types.StringNull()
} else {
@@ -387,10 +442,10 @@ func applyEndpoint(endpoint *client.Endpoint, state *endpointResourceModel) diag
}
// applyEndpointURLs maps empty URLs to null so that a chain without WebSocket
-// support reports wss_url as absent rather than as an empty string.
+// support reports safe_wss_url as absent rather than as an empty string.
func applyEndpointURLs(endpoint *client.Endpoint, model *endpointResourceModel) {
- model.HTTPURL = stringOrNull(endpoint.HTTPURL)
- model.WSSURL = stringOrNull(endpoint.WSSURL)
+ model.SafeHTTPURL = stringOrNull(endpoint.SafeHTTPURL)
+ model.SafeWSSURL = stringOrNull(endpoint.SafeWSSURL)
model.HTTPURLWithToken = stringOrNull(endpoint.HTTPURLWithToken)
model.WSSURLWithToken = stringOrNull(endpoint.WSSURLWithToken)
}
diff --git a/internal/provider/endpoints_data_source.go b/internal/provider/endpoints_data_source.go
new file mode 100644
index 0000000..50c9197
--- /dev/null
+++ b/internal/provider/endpoints_data_source.go
@@ -0,0 +1,183 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+var _ datasource.DataSource = (*endpointsDataSource)(nil)
+var _ datasource.DataSourceWithConfigure = (*endpointsDataSource)(nil)
+
+type endpointsDataSource struct {
+ client *client.Client
+}
+
+type endpointsDataSourceModel struct {
+ Search types.String `tfsdk:"search"`
+ Networks []types.String `tfsdk:"networks"`
+ Statuses []types.String `tfsdk:"statuses"`
+ Labels []types.String `tfsdk:"labels"`
+ TagLabels []types.String `tfsdk:"tag_labels"`
+ Endpoints []endpointSummaryModel `tfsdk:"endpoints"`
+ IDs []types.String `tfsdk:"ids"`
+}
+
+type endpointSummaryModel struct {
+ ID types.String `tfsdk:"id"`
+ Name types.String `tfsdk:"name"`
+ Label types.String `tfsdk:"label"`
+ Chain types.String `tfsdk:"chain"`
+ Network types.String `tfsdk:"network"`
+ Status types.String `tfsdk:"status"`
+ SafeHTTPURL types.String `tfsdk:"safe_http_url"`
+ SafeWSSURL types.String `tfsdk:"safe_wss_url"`
+ Dedicated types.Bool `tfsdk:"dedicated"`
+ FlatRate types.Bool `tfsdk:"flat_rate"`
+ Multichain types.Bool `tfsdk:"multichain"`
+ Tags []types.String `tfsdk:"tags"`
+}
+
+func NewEndpointsDataSource() datasource.DataSource {
+ return &endpointsDataSource{}
+}
+
+func (d *endpointsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_endpoints"
+}
+
+func (d *endpointsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ filter := func(description string) schema.ListAttribute {
+ return schema.ListAttribute{
+ Optional: true,
+ ElementType: types.StringType,
+ MarkdownDescription: description,
+ }
+ }
+ resp.Schema = schema.Schema{
+ MarkdownDescription: "Endpoints on the account, optionally filtered. Several values in one filter match any of them, and several filters must all match.\n\n" +
+ "Rows carry what the list route returns, which is less than `data.quicknode_endpoint`: no tokens, no security settings and no rate limits. " +
+ "Every page is walked, so the result is the whole account rather than the first screen.",
+ Attributes: map[string]schema.Attribute{
+ "search": schema.StringAttribute{
+ Optional: true,
+ MarkdownDescription: "Match against the endpoint's subdomain or label.",
+ },
+ "networks": filter("Keep only endpoints on these networks, for example `mainnet` or `base-sepolia`."),
+ "statuses": filter("Keep only endpoints with these statuses: `active` or `paused`."),
+ "labels": filter("Keep only endpoints carrying these labels."),
+ "tag_labels": filter("Keep only endpoints carrying these tags."),
+ "ids": schema.ListAttribute{
+ Computed: true,
+ ElementType: types.StringType,
+ MarkdownDescription: "Ids of the matching endpoints, in the same order as `endpoints`. Convenient for `for_each` over another resource.",
+ },
+ "endpoints": schema.ListNestedAttribute{
+ Computed: true,
+ MarkdownDescription: "The matching endpoints.",
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{Computed: true, MarkdownDescription: "Endpoint id."},
+ "name": schema.StringAttribute{Computed: true, MarkdownDescription: "Endpoint subdomain."},
+ "label": schema.StringAttribute{Computed: true, MarkdownDescription: "Descriptive label, or null if the endpoint has none."},
+ "chain": schema.StringAttribute{Computed: true, MarkdownDescription: "Chain slug."},
+ "network": schema.StringAttribute{Computed: true, MarkdownDescription: "Network slug."},
+ "status": schema.StringAttribute{Computed: true, MarkdownDescription: "`active` or `paused`."},
+ "safe_http_url": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display. The list route carries no usable token, so read `data.quicknode_endpoint` for a working URL.",
+ },
+ "safe_wss_url": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.",
+ },
+ "dedicated": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the endpoint runs on dedicated infrastructure."},
+ "flat_rate": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the endpoint is billed at a flat rate."},
+ "multichain": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the endpoint serves more than one network."},
+ "tags": schema.ListAttribute{
+ Computed: true,
+ ElementType: types.StringType,
+ MarkdownDescription: "Tag labels applied to the endpoint.",
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func (d *endpointsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The endpoints data source expected providerData, got %T.", req.ProviderData))
+ return
+ }
+ d.client = data.Client
+}
+
+func (d *endpointsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config endpointsDataSourceModel
+ resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ endpoints, err := d.client.ListEndpoints(ctx, client.EndpointFilter{
+ Search: config.Search.ValueString(),
+ Networks: plainStrings(config.Networks),
+ Statuses: plainStrings(config.Statuses),
+ Labels: plainStrings(config.Labels),
+ TagLabels: plainStrings(config.TagLabels),
+ })
+ if err != nil {
+ resp.Diagnostics.AddError("Could not list the Quicknode endpoints", err.Error())
+ return
+ }
+
+ config.Endpoints = make([]endpointSummaryModel, 0, len(endpoints))
+ config.IDs = make([]types.String, 0, len(endpoints))
+ for _, endpoint := range endpoints {
+ tags := make([]types.String, 0, len(endpoint.Tags))
+ for _, tag := range endpoint.Tags {
+ tags = append(tags, types.StringValue(tag.Label))
+ }
+ config.Endpoints = append(config.Endpoints, endpointSummaryModel{
+ ID: types.StringValue(endpoint.ID),
+ Name: types.StringValue(endpoint.Name),
+ Label: stringOrNull(endpoint.Label),
+ Chain: types.StringValue(endpoint.Chain),
+ Network: types.StringValue(endpoint.Network),
+ Status: types.StringValue(endpoint.Status),
+ SafeHTTPURL: stringOrNull(endpoint.SafeHTTPURL),
+ SafeWSSURL: stringOrNull(endpoint.SafeWSSURL),
+ Dedicated: types.BoolValue(endpoint.Dedicated),
+ FlatRate: types.BoolValue(endpoint.FlatRate),
+ Multichain: types.BoolValue(endpoint.Multichain),
+ Tags: tags,
+ })
+ config.IDs = append(config.IDs, types.StringValue(endpoint.ID))
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
+}
+
+func plainStrings(values []types.String) []string {
+ if len(values) == 0 {
+ return nil
+ }
+ plain := make([]string, 0, len(values))
+ for _, value := range values {
+ if value.IsNull() || value.IsUnknown() {
+ continue
+ }
+ plain = append(plain, value.ValueString())
+ }
+ return plain
+}
diff --git a/internal/provider/jwt_resource.go b/internal/provider/jwt_resource.go
new file mode 100644
index 0000000..f7cef49
--- /dev/null
+++ b/internal/provider/jwt_resource.go
@@ -0,0 +1,206 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+var _ resource.Resource = (*jwtResource)(nil)
+var _ resource.ResourceWithConfigure = (*jwtResource)(nil)
+var _ resource.ResourceWithImportState = (*jwtResource)(nil)
+
+type jwtResource struct {
+ client *client.Client
+}
+
+type jwtResourceModel struct {
+ ID types.String `tfsdk:"id"`
+ EndpointID types.String `tfsdk:"endpoint_id"`
+ Name types.String `tfsdk:"name"`
+ PublicKey types.String `tfsdk:"public_key"`
+ KID types.String `tfsdk:"kid"`
+}
+
+func NewJWTResource() resource.Resource {
+ return &jwtResource{}
+}
+
+func (r *jwtResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_endpoint_jwt"
+}
+
+func (r *jwtResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ MarkdownDescription: "A JWT signing key registered on a Quicknode endpoint. Callers then authenticate with a token signed by the matching private key, which keeps a long-lived credential out of the URL.\n\n" +
+ "The key only takes effect once `security_options.jwts` is enabled on the endpoint.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Entry id assigned by Quicknode.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ "endpoint_id": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Endpoint the signing key belongs to.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ },
+ "name": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Name for the key, used to tell several keys apart on one endpoint.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
+ },
+ "public_key": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "PEM-encoded public key that signed tokens are verified against. Changing it replaces the entry, because the Admin API has no route to edit one in place.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
+ },
+ "kid": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Key id Quicknode assigns. Put it in the `kid` header of the tokens signed with the matching private key.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ },
+ }
+}
+
+func (r *jwtResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The JWT resource expected providerData, got %T.", req.ProviderData))
+ return
+ }
+ r.client = data.Client
+}
+
+func (r *jwtResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var plan jwtResourceModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ created, err := r.client.AddJWT(ctx, plan.EndpointID.ValueString(), client.JWT{
+ Name: plan.Name.ValueString(),
+ PublicKey: plan.PublicKey.ValueString(),
+ })
+ if err != nil {
+ resp.Diagnostics.AddError("Could not register the JWT signing key", err.Error())
+ return
+ }
+
+ plan.ID = types.StringValue(created.ID)
+ plan.KID = types.StringValue(created.KID)
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+ resp.Diagnostics.Append(warnToggleDisabled(ctx, r.client, plan.EndpointID.ValueString(), "jwts", "JWT authentication")...)
+}
+
+func (r *jwtResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var state jwtResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ security, err := r.client.GetEndpointSecurity(ctx, state.EndpointID.ValueString())
+ if client.IsNotFound(err) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's JWT signing keys", err.Error())
+ return
+ }
+
+ for _, jwt := range security.JWTs {
+ if jwt.ID != state.ID.ValueString() {
+ continue
+ }
+ state.Name = types.StringValue(jwt.Name)
+ state.KID = types.StringValue(jwt.KID)
+ if jwt.PublicKey != "" {
+ state.PublicKey = types.StringValue(jwt.PublicKey)
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+ return
+ }
+ resp.State.RemoveResource(ctx)
+}
+
+// Update exists only to satisfy the interface. Every attribute replaces the
+// resource, so Terraform never calls it.
+func (r *jwtResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) {
+}
+
+func (r *jwtResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var state jwtResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if err := r.client.RemoveJWT(ctx, state.EndpointID.ValueString(), state.ID.ValueString()); err != nil {
+ resp.Diagnostics.AddError("Could not remove the JWT signing key", err.Error())
+ }
+}
+
+// ImportState takes "/".
+func (r *jwtResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ endpointID, name, found := strings.Cut(req.ID, "/")
+ if !found || endpointID == "" || name == "" {
+ resp.Diagnostics.AddError(
+ "Unexpected import address",
+ fmt.Sprintf("Import a JWT signing key as \"/\", for example \"652052/signer\". Got %q.", req.ID),
+ )
+ return
+ }
+
+ security, err := r.client.GetEndpointSecurity(ctx, endpointID)
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's JWT signing keys", err.Error())
+ return
+ }
+
+ matches := make([]client.JWT, 0, 1)
+ for _, jwt := range security.JWTs {
+ if jwt.Name == name {
+ matches = append(matches, jwt)
+ }
+ }
+ switch len(matches) {
+ case 0:
+ resp.Diagnostics.AddError("No matching JWT signing key", fmt.Sprintf("Endpoint %s has no JWT signing key named %q.", endpointID, name))
+ return
+ case 1:
+ default:
+ resp.Diagnostics.AddError(
+ "More than one matching JWT signing key",
+ fmt.Sprintf("Endpoint %s has %d JWT signing keys named %q, so this address is ambiguous.", endpointID, len(matches), name),
+ )
+ return
+ }
+
+ state := jwtResourceModel{
+ ID: types.StringValue(matches[0].ID),
+ EndpointID: types.StringValue(endpointID),
+ Name: types.StringValue(matches[0].Name),
+ PublicKey: types.StringValue(matches[0].PublicKey),
+ KID: types.StringValue(matches[0].KID),
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+}
diff --git a/internal/provider/method_rate_limit_resource.go b/internal/provider/method_rate_limit_resource.go
new file mode 100644
index 0000000..24e680c
--- /dev/null
+++ b/internal/provider/method_rate_limit_resource.go
@@ -0,0 +1,298 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
+ "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+const (
+ limiterEnabled = "enabled"
+ limiterDisabled = "disabled"
+)
+
+var limiterIntervals = []string{"second", "minute", "hour"}
+
+var _ resource.Resource = (*methodRateLimitResource)(nil)
+var _ resource.ResourceWithConfigure = (*methodRateLimitResource)(nil)
+var _ resource.ResourceWithImportState = (*methodRateLimitResource)(nil)
+
+type methodRateLimitResource struct {
+ client *client.Client
+}
+
+type methodRateLimitResourceModel struct {
+ ID types.String `tfsdk:"id"`
+ EndpointID types.String `tfsdk:"endpoint_id"`
+ Methods types.Set `tfsdk:"methods"`
+ Rate types.Int64 `tfsdk:"rate"`
+ Interval types.String `tfsdk:"interval"`
+ Enabled types.Bool `tfsdk:"enabled"`
+}
+
+func NewMethodRateLimitResource() resource.Resource {
+ return &methodRateLimitResource{}
+}
+
+func (r *methodRateLimitResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_endpoint_method_rate_limit"
+}
+
+func (r *methodRateLimitResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ MarkdownDescription: "A rate limit on a named set of RPC methods, applied on top of the endpoint-wide limits in `quicknode_endpoint_rate_limits`.\n\n" +
+ "Use it to keep a handful of expensive calls, such as `eth_getLogs` over wide block ranges, from consuming the endpoint's whole budget.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Rate limiter id assigned by Quicknode.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ "endpoint_id": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Endpoint the limiter applies to.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ },
+ "methods": schema.SetAttribute{
+ Required: true,
+ ElementType: types.StringType,
+ MarkdownDescription: "RPC methods the limit counts, for example `eth_getLogs`.",
+ Validators: []validator.Set{setvalidator.SizeAtLeast(1)},
+ },
+ "rate": schema.Int64Attribute{
+ Required: true,
+ MarkdownDescription: "Requests allowed across those methods per interval.",
+ Validators: []validator.Int64{int64validator.AtLeast(1)},
+ },
+ "interval": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Window the rate applies to: `second`, `minute` or `hour`. Changing it replaces the limiter, because the Admin API's update route does not accept an interval.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ Validators: []validator.String{stringvalidator.OneOf(limiterIntervals...)},
+ },
+ "enabled": schema.BoolAttribute{
+ Optional: true,
+ Computed: true,
+ Default: booldefault.StaticBool(true),
+ MarkdownDescription: "Whether the limiter is applied. Disabling keeps the definition in place, which suits turning a limit off during an incident without losing it.",
+ },
+ },
+ }
+}
+
+func (r *methodRateLimitResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The method rate limit resource expected providerData, got %T.", req.ProviderData))
+ return
+ }
+ r.client = data.Client
+}
+
+func (r *methodRateLimitResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var plan methodRateLimitResourceModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ methods, diags := methodNames(ctx, plan.Methods)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ endpointID := plan.EndpointID.ValueString()
+ created, err := r.client.AddMethodRateLimit(ctx, endpointID, client.MethodRateLimit{
+ Methods: methods,
+ Rate: int(plan.Rate.ValueInt64()),
+ Interval: plan.Interval.ValueString(),
+ })
+ if err != nil {
+ resp.Diagnostics.AddError("Could not create the method rate limit", err.Error())
+ return
+ }
+
+ plan.ID = types.StringValue(created.ID)
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ // The create route takes no status, so a limiter that is meant to start
+ // disabled needs a second call.
+ if !plan.Enabled.ValueBool() {
+ if err := r.client.UpdateMethodRateLimit(ctx, endpointID, created.ID, client.MethodRateLimit{
+ Methods: methods,
+ Rate: int(plan.Rate.ValueInt64()),
+ Status: limiterDisabled,
+ }); err != nil {
+ resp.Diagnostics.AddError("Created the method rate limit but could not disable it", err.Error())
+ }
+ }
+}
+
+func (r *methodRateLimitResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var state methodRateLimitResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ limiters, err := r.client.ListMethodRateLimits(ctx, state.EndpointID.ValueString())
+ if client.IsNotFound(err) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's method rate limits", err.Error())
+ return
+ }
+
+ for _, limiter := range limiters {
+ if limiter.ID != state.ID.ValueString() {
+ continue
+ }
+ methods, diags := methodSet(methodsPreservingCase(ctx, limiter.Methods, state.Methods))
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ state.Methods = methods
+ state.Rate = types.Int64Value(int64(limiter.Rate))
+ state.Interval = types.StringValue(limiter.Interval)
+ state.Enabled = types.BoolValue(limiter.Status != limiterDisabled)
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+ return
+ }
+ resp.State.RemoveResource(ctx)
+}
+
+func (r *methodRateLimitResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+ var plan, state methodRateLimitResourceModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ methods, diags := methodNames(ctx, plan.Methods)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ plan.ID = state.ID
+ status := limiterEnabled
+ if !plan.Enabled.ValueBool() {
+ status = limiterDisabled
+ }
+ if err := r.client.UpdateMethodRateLimit(ctx, state.EndpointID.ValueString(), state.ID.ValueString(), client.MethodRateLimit{
+ Methods: methods,
+ Rate: int(plan.Rate.ValueInt64()),
+ Status: status,
+ }); err != nil {
+ resp.Diagnostics.AddError("Could not update the method rate limit", err.Error())
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *methodRateLimitResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var state methodRateLimitResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if err := r.client.RemoveMethodRateLimit(ctx, state.EndpointID.ValueString(), state.ID.ValueString()); err != nil {
+ resp.Diagnostics.AddError("Could not remove the method rate limit", err.Error())
+ }
+}
+
+// ImportState takes "/". A limiter has no natural
+// name, so it is addressed by id.
+func (r *methodRateLimitResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ endpointID, limiterID, found := strings.Cut(req.ID, "/")
+ if !found || endpointID == "" || limiterID == "" {
+ resp.Diagnostics.AddError(
+ "Unexpected import address",
+ fmt.Sprintf("Import a method rate limit as \"/\", for example \"652052/a1b2c3d4-...\". Got %q.", req.ID),
+ )
+ return
+ }
+
+ limiters, err := r.client.ListMethodRateLimits(ctx, endpointID)
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's method rate limits", err.Error())
+ return
+ }
+
+ for _, limiter := range limiters {
+ if limiter.ID != limiterID {
+ continue
+ }
+ methods, diags := methodSet(limiter.Methods)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ state := methodRateLimitResourceModel{
+ ID: types.StringValue(limiter.ID),
+ EndpointID: types.StringValue(endpointID),
+ Methods: methods,
+ Rate: types.Int64Value(int64(limiter.Rate)),
+ Interval: types.StringValue(limiter.Interval),
+ Enabled: types.BoolValue(limiter.Status != limiterDisabled),
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+ return
+ }
+ resp.Diagnostics.AddError("No matching method rate limit", fmt.Sprintf("Endpoint %s has no method rate limit with the id %q.", endpointID, limiterID))
+}
+
+// methodsPreservingCase keeps the casing the configuration wrote. This route
+// lowercases the method names it stores, so reading them back verbatim would
+// leave a diff that applying never settles. A method the prior state does not
+// hold is kept as the API returned it, so a real change is still detected.
+func methodsPreservingCase(ctx context.Context, fromAPI []string, prior types.Set) []string {
+ if prior.IsNull() || prior.IsUnknown() {
+ return fromAPI
+ }
+ priorNames, diags := methodNames(ctx, prior)
+ if diags.HasError() {
+ return fromAPI
+ }
+
+ configured := make(map[string]string, len(priorNames))
+ for _, name := range priorNames {
+ configured[strings.ToLower(name)] = name
+ }
+
+ preserved := make([]string, 0, len(fromAPI))
+ for _, name := range fromAPI {
+ if original, ok := configured[strings.ToLower(name)]; ok {
+ preserved = append(preserved, original)
+ continue
+ }
+ preserved = append(preserved, name)
+ }
+ return preserved
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 5ddc5b2..5463d73 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -52,15 +52,15 @@ func (p *quicknodeProvider) Schema(_ context.Context, _ provider.SchemaRequest,
"api_key": schema.StringAttribute{
Optional: true,
Sensitive: true,
- MarkdownDescription: "Quicknode Admin API key. Defaults to the `" + apiKeyEnvVar + "` environment variable. Prefer the environment variable so the key stays out of configuration and state.",
+ MarkdownDescription: "Quicknode API key. Defaults to the `" + apiKeyEnvVar + "` environment variable. Prefer the environment variable so the key stays out of configuration and state.",
},
"base_url": schema.StringAttribute{
Optional: true,
- MarkdownDescription: "Admin API base URL. Defaults to `" + client.DefaultBaseURL + "`.",
+ MarkdownDescription: "Quicknode API base URL. Defaults to `" + client.DefaultBaseURL + "`.",
},
"requests_per_second": schema.Int64Attribute{
Optional: true,
- MarkdownDescription: "Throttle applied to Admin API calls. A large workspace bursts many calls during one apply, so the provider paces itself.",
+ MarkdownDescription: "Throttle applied to Quicknode API calls. A large workspace bursts many calls during one apply, so the provider paces itself.",
Validators: []validator.Int64{int64validator.AtLeast(1)},
},
},
@@ -77,7 +77,7 @@ func (p *quicknodeProvider) Configure(ctx context.Context, req provider.Configur
if config.APIKey.IsUnknown() {
resp.Diagnostics.AddAttributeError(
path.Root("api_key"),
- "Admin API key is not known at plan time",
+ "API key is not known at plan time",
"The api_key value comes from another resource that has not been applied yet. Set it from a variable or from the "+apiKeyEnvVar+" environment variable instead.",
)
return
@@ -96,8 +96,8 @@ func (p *quicknodeProvider) Configure(ctx context.Context, req provider.Configur
if apiKey == "" {
resp.Diagnostics.AddAttributeError(
path.Root("api_key"),
- "Missing Admin API key",
- "Set the "+apiKeyEnvVar+" environment variable, or set api_key on the provider block. Admin API access requires a paid Quicknode plan.",
+ "Missing Quicknode API key",
+ "Set the "+apiKeyEnvVar+" environment variable, or set api_key on the provider block. API access requires a paid Quicknode plan.",
)
return
}
@@ -115,12 +115,12 @@ func (p *quicknodeProvider) Configure(ctx context.Context, req provider.Configur
if err != nil {
if client.IsUnauthorized(err) {
resp.Diagnostics.AddError(
- "Quicknode rejected the Admin API key",
- "Check that the key is valid and that the account is on a paid plan, which Admin API access requires. "+err.Error(),
+ "Quicknode rejected the API key",
+ "Check that the key is valid and that the account is on a paid plan, which API access requires. "+err.Error(),
)
return
}
- resp.Diagnostics.AddError("Could not reach the Quicknode Admin API", err.Error())
+ resp.Diagnostics.AddError("Could not reach the Quicknode API", err.Error())
return
}
@@ -130,13 +130,21 @@ func (p *quicknodeProvider) Configure(ctx context.Context, req provider.Configur
}
func (p *quicknodeProvider) Resources(_ context.Context) []func() resource.Resource {
- return []func() resource.Resource{
+ resources := []func() resource.Resource{
NewEndpointResource,
+ NewEndpointTokenResource,
+ NewJWTResource,
+ NewRequestFilterResource,
+ NewRateLimitsResource,
+ NewMethodRateLimitResource,
}
+ return append(resources, securityEntryResources()...)
}
func (p *quicknodeProvider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
NewChainsDataSource,
+ NewEndpointDataSource,
+ NewEndpointsDataSource,
}
}
diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go
index da300b6..98d95a1 100644
--- a/internal/provider/provider_test.go
+++ b/internal/provider/provider_test.go
@@ -23,11 +23,25 @@ func TestProviderSchema(t *testing.T) {
}
}
- if _, ok := schema.ResourceSchemas["quicknode_endpoint"]; !ok {
- t.Errorf("quicknode_endpoint is missing, got %v", keys(schema.ResourceSchemas))
+ for _, name := range []string{
+ "quicknode_endpoint",
+ "quicknode_endpoint_ip",
+ "quicknode_endpoint_domain_mask",
+ "quicknode_endpoint_referrer",
+ "quicknode_endpoint_jwt",
+ "quicknode_endpoint_request_filter",
+ "quicknode_endpoint_token",
+ "quicknode_endpoint_rate_limits",
+ "quicknode_endpoint_method_rate_limit",
+ } {
+ if _, ok := schema.ResourceSchemas[name]; !ok {
+ t.Errorf("%s is missing, got %v", name, keys(schema.ResourceSchemas))
+ }
}
- if _, ok := schema.DataSourceSchemas["quicknode_chains"]; !ok {
- t.Errorf("quicknode_chains is missing, got %v", keys(schema.DataSourceSchemas))
+ for _, name := range []string{"quicknode_chains", "quicknode_endpoint", "quicknode_endpoints"} {
+ if _, ok := schema.DataSourceSchemas[name]; !ok {
+ t.Errorf("%s is missing, got %v", name, keys(schema.DataSourceSchemas))
+ }
}
}
diff --git a/internal/provider/rate_limits_resource.go b/internal/provider/rate_limits_resource.go
new file mode 100644
index 0000000..bd56d18
--- /dev/null
+++ b/internal/provider/rate_limits_resource.go
@@ -0,0 +1,306 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+var planDefaultAttrTypes = map[string]attr.Type{
+ client.BucketRPS: types.Int64Type,
+ client.BucketRPM: types.Int64Type,
+ client.BucketRPD: types.Int64Type,
+}
+
+var _ resource.Resource = (*rateLimitsResource)(nil)
+var _ resource.ResourceWithConfigure = (*rateLimitsResource)(nil)
+var _ resource.ResourceWithImportState = (*rateLimitsResource)(nil)
+
+type rateLimitsResource struct {
+ client *client.Client
+}
+
+type rateLimitsResourceModel struct {
+ ID types.String `tfsdk:"id"`
+ EndpointID types.String `tfsdk:"endpoint_id"`
+ RPS types.Int64 `tfsdk:"rps"`
+ RPM types.Int64 `tfsdk:"rpm"`
+ RPD types.Int64 `tfsdk:"rpd"`
+ PlanDefault types.Object `tfsdk:"plan_default"`
+}
+
+func NewRateLimitsResource() resource.Resource {
+ return &rateLimitsResource{}
+}
+
+func (r *rateLimitsResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_endpoint_rate_limits"
+}
+
+func (r *rateLimitsResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ bucket := func(description string) schema.Int64Attribute {
+ return schema.Int64Attribute{
+ Optional: true,
+ MarkdownDescription: description,
+ Validators: []validator.Int64{int64validator.AtLeast(1)},
+ }
+ }
+ resp.Schema = schema.Schema{
+ MarkdownDescription: "Endpoint-wide request rate limits, one resource per endpoint.\n\n" +
+ "Each bucket the Quicknode plan sets is reported under `plan_default`. A bucket set here overrides the plan default; " +
+ "a bucket left out keeps the plan default, and removing one that was set returns that bucket to the plan default rather than leaving the override in place.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Same as `endpoint_id`. Rate limits are a property of the endpoint rather than a separate object.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ "endpoint_id": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Endpoint the limits apply to.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ },
+ "rps": bucket("Maximum requests per second. Omit to keep the plan default."),
+ "rpm": bucket("Maximum requests per minute. Omit to keep the plan default."),
+ "rpd": bucket("Maximum requests per day. Omit to keep the plan default."),
+ "plan_default": schema.SingleNestedAttribute{
+ Computed: true,
+ MarkdownDescription: "What the account's Quicknode plan allows, before any override set here. A bucket the plan does not limit is reported as `-1`.",
+ Attributes: map[string]schema.Attribute{
+ client.BucketRPS: schema.Int64Attribute{Computed: true, MarkdownDescription: "Plan limit on requests per second."},
+ client.BucketRPM: schema.Int64Attribute{Computed: true, MarkdownDescription: "Plan limit on requests per minute."},
+ client.BucketRPD: schema.Int64Attribute{Computed: true, MarkdownDescription: "Plan limit on requests per day."},
+ },
+ },
+ },
+ }
+}
+
+func (r *rateLimitsResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The rate limits resource expected providerData, got %T.", req.ProviderData))
+ return
+ }
+ r.client = data.Client
+}
+
+func (r *rateLimitsResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var plan rateLimitsResourceModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ endpointID := plan.EndpointID.ValueString()
+ if err := r.client.SetRateLimits(ctx, endpointID, overridesFrom(plan)); err != nil {
+ resp.Diagnostics.AddError("Could not set the endpoint rate limits", err.Error())
+ return
+ }
+
+ plan.ID = plan.EndpointID
+ resp.Diagnostics.Append(r.readPlanDefaults(ctx, endpointID, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *rateLimitsResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var state rateLimitsResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ limits, err := r.client.GetRateLimits(ctx, state.EndpointID.ValueString())
+ if client.IsNotFound(err) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint rate limits", err.Error())
+ return
+ }
+
+ overrides, defaults := splitRateLimits(limits)
+ state.ID = state.EndpointID
+ state.RPS = bucketOrNull(overrides, client.BucketRPS)
+ state.RPM = bucketOrNull(overrides, client.BucketRPM)
+ state.RPD = bucketOrNull(overrides, client.BucketRPD)
+
+ planDefault, diags := planDefaultObject(defaults)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ state.PlanDefault = planDefault
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+}
+
+func (r *rateLimitsResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+ var plan, state rateLimitsResourceModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ endpointID := state.EndpointID.ValueString()
+ plan.ID = state.ID
+
+ // A bucket dropped from the configuration has to lose its override, which
+ // is a delete rather than a write: the patch route has no way to say
+ // "return this bucket to the plan default".
+ dropped := map[string]bool{
+ client.BucketRPS: plan.RPS.IsNull() && !state.RPS.IsNull(),
+ client.BucketRPM: plan.RPM.IsNull() && !state.RPM.IsNull(),
+ client.BucketRPD: plan.RPD.IsNull() && !state.RPD.IsNull(),
+ }
+ if dropped[client.BucketRPS] || dropped[client.BucketRPM] || dropped[client.BucketRPD] {
+ resp.Diagnostics.Append(r.dropOverrides(ctx, endpointID, dropped)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ }
+
+ if overrides := overridesFrom(plan); overrides.RPS != nil || overrides.RPM != nil || overrides.RPD != nil {
+ if err := r.client.SetRateLimits(ctx, endpointID, overrides); err != nil {
+ resp.Diagnostics.AddError("Could not update the endpoint rate limits", err.Error())
+ return
+ }
+ }
+
+ resp.Diagnostics.Append(r.readPlanDefaults(ctx, endpointID, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+// Delete returns every bucket to the plan default. Nothing is torn down,
+// because the limits belong to the endpoint rather than to a separate object.
+func (r *rateLimitsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var state rateLimitsResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ all := map[string]bool{client.BucketRPS: true, client.BucketRPM: true, client.BucketRPD: true}
+ resp.Diagnostics.Append(r.dropOverrides(ctx, state.EndpointID.ValueString(), all)...)
+}
+
+func (r *rateLimitsResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(req.ID))...)
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("endpoint_id"), types.StringValue(req.ID))...)
+}
+
+func (r *rateLimitsResource) dropOverrides(ctx context.Context, endpointID string, buckets map[string]bool) diag.Diagnostics {
+ var diags diag.Diagnostics
+
+ limits, err := r.client.GetRateLimits(ctx, endpointID)
+ if client.IsNotFound(err) {
+ return diags
+ }
+ if err != nil {
+ diags.AddError("Could not read the endpoint rate limits", err.Error())
+ return diags
+ }
+
+ for _, limit := range limits {
+ if limit.Source != client.SourceUserOverride || limit.ID == "" || !buckets[limit.Bucket] {
+ continue
+ }
+ if err := r.client.DeleteRateLimitOverride(ctx, endpointID, limit.ID); err != nil {
+ diags.AddError("Could not return a rate limit bucket to the plan default", fmt.Sprintf("bucket %q: %s", limit.Bucket, err.Error()))
+ return diags
+ }
+ }
+ return diags
+}
+
+func (r *rateLimitsResource) readPlanDefaults(ctx context.Context, endpointID string, model *rateLimitsResourceModel) diag.Diagnostics {
+ limits, err := r.client.GetRateLimits(ctx, endpointID)
+ if err != nil {
+ var diags diag.Diagnostics
+ diags.AddError("Could not read the endpoint rate limits back", err.Error())
+ return diags
+ }
+ _, defaults := splitRateLimits(limits)
+
+ planDefault, diags := planDefaultObject(defaults)
+ model.PlanDefault = planDefault
+ return diags
+}
+
+func overridesFrom(model rateLimitsResourceModel) client.RateLimitOverrides {
+ var overrides client.RateLimitOverrides
+ for _, bucket := range []struct {
+ value types.Int64
+ target **int
+ }{
+ {model.RPS, &overrides.RPS},
+ {model.RPM, &overrides.RPM},
+ {model.RPD, &overrides.RPD},
+ } {
+ if bucket.value.IsNull() || bucket.value.IsUnknown() {
+ continue
+ }
+ wanted := int(bucket.value.ValueInt64())
+ *bucket.target = &wanted
+ }
+ return overrides
+}
+
+func splitRateLimits(limits []client.RateLimit) (overrides, defaults map[string]int) {
+ overrides = make(map[string]int, 3)
+ defaults = make(map[string]int, 3)
+ for _, limit := range limits {
+ switch limit.Source {
+ case client.SourceUserOverride:
+ overrides[limit.Bucket] = limit.Value
+ case client.SourcePlanDefault:
+ defaults[limit.Bucket] = limit.Value
+ }
+ }
+ return overrides, defaults
+}
+
+func bucketOrNull(buckets map[string]int, name string) types.Int64 {
+ value, ok := buckets[name]
+ if !ok || value == client.RateLimitUnset {
+ return types.Int64Null()
+ }
+ return types.Int64Value(int64(value))
+}
+
+func planDefaultObject(defaults map[string]int) (types.Object, diag.Diagnostics) {
+ value := func(name string) attr.Value {
+ limit, ok := defaults[name]
+ if !ok {
+ limit = client.RateLimitUnset
+ }
+ return types.Int64Value(int64(limit))
+ }
+ return types.ObjectValue(planDefaultAttrTypes, map[string]attr.Value{
+ client.BucketRPS: value(client.BucketRPS),
+ client.BucketRPM: value(client.BucketRPM),
+ client.BucketRPD: value(client.BucketRPD),
+ })
+}
diff --git a/internal/provider/request_filter_resource.go b/internal/provider/request_filter_resource.go
new file mode 100644
index 0000000..9afae5d
--- /dev/null
+++ b/internal/provider/request_filter_resource.go
@@ -0,0 +1,222 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+var _ resource.Resource = (*requestFilterResource)(nil)
+var _ resource.ResourceWithConfigure = (*requestFilterResource)(nil)
+var _ resource.ResourceWithImportState = (*requestFilterResource)(nil)
+
+type requestFilterResource struct {
+ client *client.Client
+}
+
+type requestFilterResourceModel struct {
+ ID types.String `tfsdk:"id"`
+ EndpointID types.String `tfsdk:"endpoint_id"`
+ Methods types.Set `tfsdk:"methods"`
+}
+
+func NewRequestFilterResource() resource.Resource {
+ return &requestFilterResource{}
+}
+
+func (r *requestFilterResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_endpoint_request_filter"
+}
+
+func (r *requestFilterResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ MarkdownDescription: "The set of RPC methods a Quicknode endpoint accepts. Anything outside the set is rejected, which keeps an endpoint handed to a browser or a third party from reaching methods it has no reason to call.\n\n" +
+ "`security_options.request_filters` on the endpoint reports whether filtering is applied. It is read-only: the Admin API turns it on when a filter exists and off when the last one is removed.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Filter id assigned by Quicknode.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ "endpoint_id": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Endpoint the filter belongs to.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ },
+ "methods": schema.SetAttribute{
+ Required: true,
+ ElementType: types.StringType,
+ MarkdownDescription: "RPC methods the endpoint accepts, for example `eth_call` and `eth_getLogs`. Editing the set updates the filter in place.",
+ Validators: []validator.Set{setvalidator.SizeAtLeast(1)},
+ },
+ },
+ }
+}
+
+func (r *requestFilterResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The request filter resource expected providerData, got %T.", req.ProviderData))
+ return
+ }
+ r.client = data.Client
+}
+
+func (r *requestFilterResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var plan requestFilterResourceModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ methods, diags := methodNames(ctx, plan.Methods)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ created, err := r.client.AddRequestFilter(ctx, plan.EndpointID.ValueString(), methods)
+ if err != nil {
+ resp.Diagnostics.AddError("Could not create the request filter", err.Error())
+ return
+ }
+
+ plan.ID = types.StringValue(created.ID)
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *requestFilterResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var state requestFilterResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ security, err := r.client.GetEndpointSecurity(ctx, state.EndpointID.ValueString())
+ if client.IsNotFound(err) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's request filters", err.Error())
+ return
+ }
+
+ for _, filter := range security.RequestFilters {
+ if filter.ID != state.ID.ValueString() {
+ continue
+ }
+ methods, diags := methodSet(filter.Methods)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ state.Methods = methods
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+ return
+ }
+ resp.State.RemoveResource(ctx)
+}
+
+func (r *requestFilterResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+ var plan, state requestFilterResourceModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ methods, diags := methodNames(ctx, plan.Methods)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ plan.ID = state.ID
+ if err := r.client.UpdateRequestFilter(ctx, state.EndpointID.ValueString(), state.ID.ValueString(), methods); err != nil {
+ resp.Diagnostics.AddError("Could not update the request filter", err.Error())
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+}
+
+func (r *requestFilterResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var state requestFilterResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if err := r.client.RemoveRequestFilter(ctx, state.EndpointID.ValueString(), state.ID.ValueString()); err != nil {
+ resp.Diagnostics.AddError("Could not remove the request filter", err.Error())
+ }
+}
+
+// ImportState takes "/". A filter has no natural name,
+// so unlike the allowlist entries it is addressed by its id. Read them from
+// the endpoint's security settings in the Quicknode dashboard or from
+// `GET /v0/endpoints/{id}/security`.
+func (r *requestFilterResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ endpointID, filterID, found := strings.Cut(req.ID, "/")
+ if !found || endpointID == "" || filterID == "" {
+ resp.Diagnostics.AddError(
+ "Unexpected import address",
+ fmt.Sprintf("Import a request filter as \"/\", for example \"652052/f1e2d3c4-...\". Got %q.", req.ID),
+ )
+ return
+ }
+
+ security, err := r.client.GetEndpointSecurity(ctx, endpointID)
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's request filters", err.Error())
+ return
+ }
+
+ for _, filter := range security.RequestFilters {
+ if filter.ID != filterID {
+ continue
+ }
+ methods, diags := methodSet(filter.Methods)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ state := requestFilterResourceModel{
+ ID: types.StringValue(filter.ID),
+ EndpointID: types.StringValue(endpointID),
+ Methods: methods,
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+ return
+ }
+ resp.Diagnostics.AddError("No matching request filter", fmt.Sprintf("Endpoint %s has no request filter with the id %q.", endpointID, filterID))
+}
+
+func methodNames(ctx context.Context, methods types.Set) ([]string, diag.Diagnostics) {
+ var names []string
+ diags := methods.ElementsAs(ctx, &names, false)
+ return names, diags
+}
+
+func methodSet(methods []string) (types.Set, diag.Diagnostics) {
+ values := make([]attr.Value, 0, len(methods))
+ for _, method := range methods {
+ values = append(values, types.StringValue(method))
+ }
+ return types.SetValue(types.StringType, values)
+}
diff --git a/internal/provider/security_entry_resource.go b/internal/provider/security_entry_resource.go
new file mode 100644
index 0000000..8cb4732
--- /dev/null
+++ b/internal/provider/security_entry_resource.go
@@ -0,0 +1,268 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+// securityEntryKind describes one of the allowlists whose entries are created
+// and deleted but never edited. The three share everything except the name of
+// the attribute holding the value and the routes behind it.
+type securityEntryKind struct {
+ typeName string
+ attribute string
+ toggle string
+ noun string
+ subject string
+ description string
+ valueDoc string
+
+ add func(*client.Client, context.Context, string, string) (*client.SecurityEntry, error)
+ remove func(*client.Client, context.Context, string, string) error
+ list func(*client.EndpointSecurity) []client.SecurityEntry
+}
+
+var securityEntryKinds = []securityEntryKind{
+ {
+ typeName: "endpoint_ip",
+ attribute: "ip",
+ toggle: "ips",
+ noun: "IP address",
+ subject: "IP address filtering",
+ description: "An IP address allowed to call a Quicknode endpoint.",
+ valueDoc: "IP address or CIDR range allowed to call the endpoint.",
+ add: func(c *client.Client, ctx context.Context, endpointID, value string) (*client.SecurityEntry, error) {
+ return c.AddEndpointIP(ctx, endpointID, value)
+ },
+ remove: func(c *client.Client, ctx context.Context, endpointID, entryID string) error {
+ return c.RemoveEndpointIP(ctx, endpointID, entryID)
+ },
+ list: func(security *client.EndpointSecurity) []client.SecurityEntry { return security.IPs },
+ },
+ {
+ typeName: "endpoint_domain_mask",
+ attribute: "domain",
+ toggle: "domain_masks",
+ noun: "domain mask",
+ subject: "Domain masking",
+ description: "A custom domain that serves a Quicknode endpoint, so callers reach it without the Quicknode hostname.",
+ valueDoc: "Domain that serves the endpoint, for example `rpc.example.com`.",
+ add: func(c *client.Client, ctx context.Context, endpointID, value string) (*client.SecurityEntry, error) {
+ return c.AddDomainMask(ctx, endpointID, value)
+ },
+ remove: func(c *client.Client, ctx context.Context, endpointID, entryID string) error {
+ return c.RemoveDomainMask(ctx, endpointID, entryID)
+ },
+ list: func(security *client.EndpointSecurity) []client.SecurityEntry { return security.DomainMasks },
+ },
+ {
+ typeName: "endpoint_referrer",
+ attribute: "referrer",
+ toggle: "referrers",
+ noun: "referrer",
+ subject: "Referrer filtering",
+ description: "A referrer allowed to call a Quicknode endpoint. Referrer checks suit browser traffic, where the header is set by the browser rather than by the caller.",
+ valueDoc: "Referrer URL allowed to call the endpoint, for example `https://app.example.com`.",
+ add: func(c *client.Client, ctx context.Context, endpointID, value string) (*client.SecurityEntry, error) {
+ return c.AddReferrer(ctx, endpointID, value)
+ },
+ remove: func(c *client.Client, ctx context.Context, endpointID, entryID string) error {
+ return c.RemoveReferrer(ctx, endpointID, entryID)
+ },
+ list: func(security *client.EndpointSecurity) []client.SecurityEntry { return security.Referrers },
+ },
+}
+
+func securityEntryResources() []func() resource.Resource {
+ constructors := make([]func() resource.Resource, 0, len(securityEntryKinds))
+ for _, kind := range securityEntryKinds {
+ constructors = append(constructors, func() resource.Resource {
+ return &securityEntryResource{kind: kind}
+ })
+ }
+ return constructors
+}
+
+var _ resource.Resource = (*securityEntryResource)(nil)
+var _ resource.ResourceWithConfigure = (*securityEntryResource)(nil)
+var _ resource.ResourceWithImportState = (*securityEntryResource)(nil)
+
+type securityEntryResource struct {
+ kind securityEntryKind
+ client *client.Client
+}
+
+func (r *securityEntryResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_" + r.kind.typeName
+}
+
+func (r *securityEntryResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ MarkdownDescription: r.kind.description + "\n\n" +
+ "The entry only takes effect once `security_options." + r.kind.toggle + "` is enabled on the endpoint. " +
+ "Entries can be added before the toggle is turned on, which is the safe order for an endpoint already serving traffic.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Entry id assigned by Quicknode.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ "endpoint_id": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Endpoint the entry belongs to.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ },
+ r.kind.attribute: schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: r.kind.valueDoc + " Changing it replaces the entry, because the Admin API has no route to edit one in place.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
+ },
+ },
+ }
+}
+
+func (r *securityEntryResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The %s resource expected providerData, got %T.", r.kind.typeName, req.ProviderData))
+ return
+ }
+ r.client = data.Client
+}
+
+func (r *securityEntryResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var endpointID, value types.String
+ resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root("endpoint_id"), &endpointID)...)
+ resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, path.Root(r.kind.attribute), &value)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ entry, err := r.kind.add(r.client, ctx, endpointID.ValueString(), value.ValueString())
+ if err != nil {
+ resp.Diagnostics.AddError("Could not add the "+r.kind.noun, err.Error())
+ return
+ }
+
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(entry.ID))...)
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("endpoint_id"), endpointID)...)
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(r.kind.attribute), value)...)
+ resp.Diagnostics.Append(warnToggleDisabled(ctx, r.client, endpointID.ValueString(), r.kind.toggle, r.kind.subject)...)
+}
+
+func (r *securityEntryResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var entryID, endpointID types.String
+ resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("id"), &entryID)...)
+ resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("endpoint_id"), &endpointID)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ security, err := r.client.GetEndpointSecurity(ctx, endpointID.ValueString())
+ if client.IsNotFound(err) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's "+r.kind.noun+" entries", err.Error())
+ return
+ }
+
+ for _, entry := range r.kind.list(security) {
+ if entry.ID != entryID.ValueString() {
+ continue
+ }
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(r.kind.attribute), types.StringValue(entry.Value))...)
+ return
+ }
+ resp.State.RemoveResource(ctx)
+}
+
+// Update exists only to satisfy the interface. Every attribute replaces the
+// resource, so Terraform never calls it.
+func (r *securityEntryResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) {
+}
+
+func (r *securityEntryResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var entryID, endpointID types.String
+ resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("id"), &entryID)...)
+ resp.Diagnostics.Append(req.State.GetAttribute(ctx, path.Root("endpoint_id"), &endpointID)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if err := r.kind.remove(r.client, ctx, endpointID.ValueString(), entryID.ValueString()); err != nil {
+ resp.Diagnostics.AddError("Could not remove the "+r.kind.noun, err.Error())
+ }
+}
+
+// ImportState takes "/", so the address is what the
+// operator already knows rather than an id that only exists in the API.
+func (r *securityEntryResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ endpointID, value, found := strings.Cut(req.ID, "/")
+ if !found || endpointID == "" || value == "" {
+ resp.Diagnostics.AddError(
+ "Unexpected import address",
+ fmt.Sprintf("Import a %s as \"/<%s>\", for example \"652052/%s\". Got %q.", r.kind.noun, r.kind.attribute, importExample(r.kind.attribute), req.ID),
+ )
+ return
+ }
+
+ security, err := r.client.GetEndpointSecurity(ctx, endpointID)
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's "+r.kind.noun+" entries", err.Error())
+ return
+ }
+
+ matches := make([]client.SecurityEntry, 0, 1)
+ for _, entry := range r.kind.list(security) {
+ if entry.Value == value {
+ matches = append(matches, entry)
+ }
+ }
+ switch len(matches) {
+ case 0:
+ resp.Diagnostics.AddError(
+ "No matching "+r.kind.noun,
+ fmt.Sprintf("Endpoint %s has no %s entry with the value %q.", endpointID, r.kind.noun, value),
+ )
+ return
+ case 1:
+ default:
+ resp.Diagnostics.AddError(
+ "More than one matching "+r.kind.noun,
+ fmt.Sprintf("Endpoint %s has %d %s entries with the value %q, so this address is ambiguous. Remove the duplicates, or import by writing the entry's id into state directly.", endpointID, len(matches), r.kind.noun, value),
+ )
+ return
+ }
+
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(matches[0].ID))...)
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("endpoint_id"), types.StringValue(endpointID))...)
+ resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(r.kind.attribute), types.StringValue(matches[0].Value))...)
+}
+
+func importExample(attribute string) string {
+ switch attribute {
+ case "ip":
+ return "203.0.113.7"
+ case "domain":
+ return "rpc.example.com"
+ }
+ return "https://app.example.com"
+}
diff --git a/internal/provider/security_options.go b/internal/provider/security_options.go
new file mode 100644
index 0000000..34fc8ee
--- /dev/null
+++ b/internal/provider/security_options.go
@@ -0,0 +1,165 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+var securityOptionsAttrTypes = map[string]attr.Type{
+ "tokens": types.BoolType,
+ "referrers": types.BoolType,
+ "jwts": types.BoolType,
+ "ips": types.BoolType,
+ "domain_masks": types.BoolType,
+ "hsts": types.BoolType,
+ "cors": types.BoolType,
+ "request_filters": types.BoolType,
+ "response_logging": types.BoolType,
+}
+
+type securityOptionsModel struct {
+ Tokens types.Bool `tfsdk:"tokens"`
+ Referrers types.Bool `tfsdk:"referrers"`
+ JWTs types.Bool `tfsdk:"jwts"`
+ IPs types.Bool `tfsdk:"ips"`
+ DomainMasks types.Bool `tfsdk:"domain_masks"`
+ HSTS types.Bool `tfsdk:"hsts"`
+ Cors types.Bool `tfsdk:"cors"`
+
+ RequestFilters types.Bool `tfsdk:"request_filters"`
+ ResponseLogging types.Bool `tfsdk:"response_logging"`
+}
+
+func securityOptionsSchema() schema.SingleNestedAttribute {
+ settable := func(description string) schema.BoolAttribute {
+ return schema.BoolAttribute{
+ Optional: true,
+ Computed: true,
+ MarkdownDescription: description,
+ }
+ }
+ return schema.SingleNestedAttribute{
+ Optional: true,
+ Computed: true,
+ MarkdownDescription: "Which security mechanisms the endpoint enforces. Each toggle only decides whether a mechanism is applied; " +
+ "the entries it applies to are separate resources, such as `quicknode_endpoint_ip`. " +
+ "A toggle left out of the configuration keeps whatever value the endpoint already has.",
+ Attributes: map[string]schema.Attribute{
+ "tokens": settable("Require one of the endpoint's auth tokens. New endpoints have this enabled."),
+ "referrers": settable("Restrict calls to the approved referrers. Add them with `quicknode_endpoint_referrer`."),
+ "jwts": settable("Require a signed JWT. Register signing keys with `quicknode_endpoint_jwt`."),
+ "ips": settable("Restrict calls to the approved IP addresses. Add them with `quicknode_endpoint_ip`."),
+ "domain_masks": settable("Serve the endpoint from an approved custom domain. Add them with `quicknode_endpoint_domain_mask`."),
+ "hsts": settable("Send the HTTP Strict Transport Security header."),
+ "cors": settable("Apply Cross-Origin Resource Sharing policy. New endpoints have this enabled."),
+ "request_filters": schema.BoolAttribute{
+ Computed: true,
+ MarkdownDescription: "Whether RPC method filtering is applied. Read-only: the Admin API turns this on when a `quicknode_endpoint_request_filter` exists and off when the last one is removed.",
+ },
+ "response_logging": schema.BoolAttribute{
+ Computed: true,
+ MarkdownDescription: "Whether responses are logged for the endpoint. Read-only: it is set by the account's plan rather than per endpoint.",
+ },
+ },
+ }
+}
+
+// securityOptionsObject renders what the API reports. Every toggle is known
+// after a read, including the two the provider cannot write.
+func securityOptionsObject(options client.SecurityOptions) (types.Object, diag.Diagnostics) {
+ return types.ObjectValue(securityOptionsAttrTypes, map[string]attr.Value{
+ "tokens": types.BoolValue(options.Tokens),
+ "referrers": types.BoolValue(options.Referrers),
+ "jwts": types.BoolValue(options.JWTs),
+ "ips": types.BoolValue(options.IPs),
+ "domain_masks": types.BoolValue(options.DomainMasks),
+ "hsts": types.BoolValue(options.HSTS),
+ "cors": types.BoolValue(options.Cors),
+ "request_filters": types.BoolValue(options.RequestFilters),
+ "response_logging": types.BoolValue(options.ResponseLogging),
+ })
+}
+
+// securityOptionsPatch collects the toggles worth writing. An unknown value is
+// one Terraform will fill from the API, and a null value is one the
+// configuration does not manage; neither belongs in the request body, because
+// sending it would overwrite a setting nobody asked to change.
+func securityOptionsPatch(ctx context.Context, planned types.Object) (client.SecurityOptionsPatch, diag.Diagnostics) {
+ var patch client.SecurityOptionsPatch
+ if planned.IsNull() || planned.IsUnknown() {
+ return patch, nil
+ }
+
+ var model securityOptionsModel
+ diags := planned.As(ctx, &model, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return patch, diags
+ }
+
+ for _, toggle := range []struct {
+ value types.Bool
+ target **bool
+ }{
+ {model.Tokens, &patch.Tokens},
+ {model.Referrers, &patch.Referrers},
+ {model.JWTs, &patch.JWTs},
+ {model.IPs, &patch.IPs},
+ {model.DomainMasks, &patch.DomainMasks},
+ {model.HSTS, &patch.HSTS},
+ {model.Cors, &patch.Cors},
+ } {
+ if toggle.value.IsNull() || toggle.value.IsUnknown() {
+ continue
+ }
+ wanted := toggle.value.ValueBool()
+ *toggle.target = &wanted
+ }
+ return patch, diags
+}
+
+// securityToggleEnabled reports one toggle by its resource-facing name, which
+// the entry resources use to warn when they add something the endpoint is not
+// enforcing.
+func securityToggleEnabled(options client.SecurityOptions, name string) bool {
+ switch name {
+ case "tokens":
+ return options.Tokens
+ case "referrers":
+ return options.Referrers
+ case "jwts":
+ return options.JWTs
+ case "ips":
+ return options.IPs
+ case "domain_masks":
+ return options.DomainMasks
+ case "request_filters":
+ return options.RequestFilters
+ }
+ return true
+}
+
+// warnToggleDisabled reports an entry the endpoint is not enforcing. The API
+// accepts it either way, and building an allowlist before enabling the toggle
+// is the safe order for an endpoint already serving traffic, so this is a
+// warning rather than an error.
+func warnToggleDisabled(ctx context.Context, quicknode *client.Client, endpointID, toggle, subject string) diag.Diagnostics {
+ var diags diag.Diagnostics
+
+ security, err := quicknode.GetEndpointSecurity(ctx, endpointID)
+ if err != nil || securityToggleEnabled(security.Options, toggle) {
+ return diags
+ }
+ diags.AddWarning(
+ subject+" is disabled on the endpoint",
+ fmt.Sprintf("Endpoint %s has security_options.%s set to false, so this entry is stored but not enforced. Set %s = true on the endpoint to apply it.", endpointID, toggle, toggle),
+ )
+ return diags
+}
diff --git a/internal/provider/token_resource.go b/internal/provider/token_resource.go
new file mode 100644
index 0000000..223abe2
--- /dev/null
+++ b/internal/provider/token_resource.go
@@ -0,0 +1,172 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+var _ resource.Resource = (*endpointTokenResource)(nil)
+var _ resource.ResourceWithConfigure = (*endpointTokenResource)(nil)
+var _ resource.ResourceWithImportState = (*endpointTokenResource)(nil)
+
+type endpointTokenResource struct {
+ client *client.Client
+}
+
+type endpointTokenResourceModel struct {
+ ID types.String `tfsdk:"id"`
+ EndpointID types.String `tfsdk:"endpoint_id"`
+ Token types.String `tfsdk:"token"`
+}
+
+func NewEndpointTokenResource() resource.Resource {
+ return &endpointTokenResource{}
+}
+
+func (r *endpointTokenResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_endpoint_token"
+}
+
+func (r *endpointTokenResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ MarkdownDescription: "An additional auth token on a Quicknode endpoint. Every endpoint is created with one token already; this resource adds further tokens, so a credential can be handed to one consumer and later revoked without disturbing the others.\n\n" +
+ "Quicknode generates the value, so the resource takes no input beyond the endpoint. To rotate a token, add the replacement, move consumers across, then remove the old resource.",
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "Token id assigned by Quicknode.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ "endpoint_id": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Endpoint the token belongs to.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
+ },
+ "token": schema.StringAttribute{
+ Computed: true,
+ Sensitive: true,
+ MarkdownDescription: "The token value. It is stored in Terraform state, so keep state encrypted and remote.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ },
+ }
+}
+
+func (r *endpointTokenResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The endpoint token resource expected providerData, got %T.", req.ProviderData))
+ return
+ }
+ r.client = data.Client
+}
+
+func (r *endpointTokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var plan endpointTokenResourceModel
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ created, err := r.client.AddEndpointToken(ctx, plan.EndpointID.ValueString())
+ if err != nil {
+ resp.Diagnostics.AddError("Could not add the endpoint token", err.Error())
+ return
+ }
+
+ plan.ID = types.StringValue(created.ID)
+ plan.Token = types.StringValue(created.Value)
+ resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
+ resp.Diagnostics.Append(warnToggleDisabled(ctx, r.client, plan.EndpointID.ValueString(), "tokens", "Token authentication")...)
+}
+
+func (r *endpointTokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var state endpointTokenResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ security, err := r.client.GetEndpointSecurity(ctx, state.EndpointID.ValueString())
+ if client.IsNotFound(err) {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's tokens", err.Error())
+ return
+ }
+
+ for _, token := range security.Tokens {
+ if token.ID != state.ID.ValueString() {
+ continue
+ }
+ state.Token = types.StringValue(token.Value)
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+ return
+ }
+ resp.State.RemoveResource(ctx)
+}
+
+// Update exists only to satisfy the interface. The endpoint replaces the
+// resource and the value is generated, so Terraform never calls it.
+func (r *endpointTokenResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) {
+}
+
+func (r *endpointTokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var state endpointTokenResourceModel
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if err := r.client.RemoveEndpointToken(ctx, state.EndpointID.ValueString(), state.ID.ValueString()); err != nil {
+ resp.Diagnostics.AddError("Could not remove the endpoint token", err.Error())
+ }
+}
+
+// ImportState takes "/". Tokens are addressed by id
+// rather than by value, so importing one does not put the credential on a
+// command line or into a shell history.
+func (r *endpointTokenResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ endpointID, tokenID, found := strings.Cut(req.ID, "/")
+ if !found || endpointID == "" || tokenID == "" {
+ resp.Diagnostics.AddError(
+ "Unexpected import address",
+ fmt.Sprintf("Import an endpoint token as \"/\", for example \"652052/d3312bd2-...\". Got %q.", req.ID),
+ )
+ return
+ }
+
+ security, err := r.client.GetEndpointSecurity(ctx, endpointID)
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's tokens", err.Error())
+ return
+ }
+
+ for _, token := range security.Tokens {
+ if token.ID != tokenID {
+ continue
+ }
+ state := endpointTokenResourceModel{
+ ID: types.StringValue(token.ID),
+ EndpointID: types.StringValue(endpointID),
+ Token: types.StringValue(token.Value),
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
+ return
+ }
+ resp.Diagnostics.AddError("No matching token", fmt.Sprintf("Endpoint %s has no token with the id %q.", endpointID, tokenID))
+}
diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl
index ee6db5c..7194dbb 100644
--- a/templates/index.md.tmpl
+++ b/templates/index.md.tmpl
@@ -11,8 +11,8 @@ rules, and rate limits, through `terraform plan` and `terraform apply`.
## Authentication
-The provider authenticates with a Quicknode [Admin API](https://www.quicknode.com/docs/admin-api)
-key, available on paid plans. Set it in the environment rather than in a
+The provider authenticates with a Quicknode [API key](https://www.quicknode.com/docs/admin-api),
+available on paid plans. Set it in the environment rather than in a
configuration file:
```sh
@@ -23,20 +23,49 @@ export QUICKNODE_API_KEY="your-api-key"
{{ tffile "examples/provider/provider.tf" }}
+## What you can manage
+
+| | |
+|---|---|
+| `quicknode_endpoint` | the endpoint itself, its label, status, tags and which security mechanisms it enforces |
+| `quicknode_endpoint_ip`, `_domain_mask`, `_referrer` | who is allowed to call it |
+| `quicknode_endpoint_jwt` | signing keys, for authenticating without a token in the URL |
+| `quicknode_endpoint_token` | additional auth tokens, so one consumer can be revoked without disturbing the rest |
+| `quicknode_endpoint_request_filter` | which RPC methods it accepts |
+| `quicknode_endpoint_rate_limits`, `_method_rate_limit` | how much traffic it accepts, overall and per method |
+| `data.quicknode_endpoint`, `data.quicknode_endpoints` | endpoints created elsewhere |
+| `data.quicknode_chains` | every chain and network slug, for validating configuration at plan time |
+
+A security mechanism is enabled on the endpoint and the entries it applies to
+are separate resources, so an entry added outside Terraform is left alone rather
+than deleted on the next apply.
+
## Endpoint URLs
-The Admin API returns endpoint URLs with the auth token embedded. Endpoints
-expose both forms:
+The Admin API returns endpoint URLs with the auth token embedded in the path.
+Endpoints expose both forms:
| Attribute | Sensitive | Use it for |
|---|---|---|
| `http_url_with_token`, `wss_url_with_token` | yes | anything that makes RPC calls |
-| `http_url`, `wss_url` | no | logging, display, anything that must not hold a credential |
+| `safe_http_url`, `safe_wss_url` | no | logging, display, anything that must not hold a credential |
+
+The safe form carries the literal `TOKEN` where the credential belongs:
+
+```
+https://polished-damp-grass.hype-testnet.quiknode.pro/TOKEN/evm
+```
+
+It keeps the real URL's shape, so substituting a token reproduces a working
+address on every chain:
+
+```hcl
+replace(quicknode_endpoint.api.safe_http_url, "TOKEN", var.token)
+```
-Do not rebuild a URL by joining `http_url` to a token. The token is not always
-the last path segment — some chains append a suffix, as in
-`https:////evm` — so a hand-assembled URL works on Ethereum and
-breaks elsewhere.
+Do not assemble a URL from parts instead. The token is not always the last path
+segment — some chains append a suffix after it, as above — so a hand-built URL
+works on Ethereum and breaks elsewhere.
Token values are written to Terraform state. Use
[encrypted remote state](https://developer.hashicorp.com/terraform/language/state/sensitive-data).