feat: implement gcloud acme

Kim committed Apr 2, 2026 at 11:47 UTC 3c267fac06beaef9bc59a63586b7bcd6a8c53f61
22 files changed +1408 -53
.env.example
+9 -3
@@ -17,15 +17,19 @@ UDP_PORT_COUNT=0
17 KEYLESS_DIR=/portal-certs
18 # Leave empty to use manual fullchain.pem/privatekey.pem from KEYLESS_DIR.
19 # Set this when Portal should manage ACME DNS-01/renewal and/or ENS gasless DNSSEC/TXT automation.
20 -# Supported managed values: cloudflare, route53
21 -ACME_DNS_PROVIDER=cloudflare
20 +# Supported managed values: cloudflare, gcloud, route53
21 +ACME_DNS_PROVIDER=gcloud
22 # Optional ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
23 # for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
24 -ENS_GASLESS_ENABLED=false
24
25 # Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
26 CLOUDFLARE_TOKEN=
27
28 +# Google Cloud DNS settings.
29 +GCP_PROJECT_ID=
30 +GCP_MANAGED_ZONE=
31 +GOOGLE_APPLICATION_CREDENTIALS=
32 +
33 # Route53 settings (use static credentials or ambient AWS credentials)
34 AWS_ACCESS_KEY_ID=
35 AWS_SECRET_ACCESS_KEY=
@@ -38,6 +42,8 @@ AWS_DNSSEC_KMS_KEY_ARN=
42 # Optional Route53 key-signing key name override. Defaults to portal_ksk.
43 DNSSEC_KSK_NAME=
44
45 +ENS_GASLESS_ENABLED=false
46 +
47 # Admin/auth configuration
48 ADMIN_SECRET_KEY=
49 LANDING_PAGE_ENABLED=false
README.md
+1 -1
@@ -58,7 +58,7 @@ The Docker setup persists both the relay identity JSON and relay certificates un
58 For public domains, you can either:
59
60 - place `fullchain.pem` and `privatekey.pem` in `./.portal-certs` and leave `ACME_DNS_PROVIDER` empty, or
61 -- set `ACME_DNS_PROVIDER=cloudflare|route53` and let Portal manage DNS-01 + renewal
61 +- set `ACME_DNS_PROVIDER=cloudflare|gcloud|route53` and let Portal manage DNS-01 + renewal
62
63 If you want Portal-managed ENS TXT/DNSSEC while keeping manual certificate files, place the certs in `./.portal-certs`, set `ACME_DNS_PROVIDER`, and enable `ENS_GASLESS_ENABLED=true`.
64 For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).
ROADMAP.md new
+452
@@ -0,0 +1,452 @@
1 +# Portal Ethereum Roadmap
2 +
3 +## Goal
4 +
5 +Turn Portal from a self-hosted relay product that already uses Ethereum identity internally into an Ethereum-native service publishing layer:
6 +
7 +- `wallet` owns the app or relay
8 +- `ENS` and `DNSSEC` verify which `address` currently controls a relay or app endpoint
9 +- `Portal` continues to provide transport, routing, and end-to-end TLS
10 +- `L2` handles rewards, payouts, and paid access
11 +
12 +This roadmap is intentionally incremental. It preserves the current transport model and avoids pushing dynamic lease state fully onchain.
13 +
14 +## Current Position
15 +
16 +Portal already has strong Ethereum alignment:
17 +
18 +- Lease registration is authenticated with SIWE.
19 +- Each app or relay identity already carries an `address` owner field.
20 +- Relay and tunnel transport are already separated from identity.
21 +- DNS + ACME + wildcard TLS already solve public HTTPS delivery.
22 +
23 +What is still missing:
24 +
25 +- user-facing ENS identity and discovery
26 +- a single public identity model across UI, registry, and rewards
27 +- an Ethereum-native rewards and payments layer
28 +- a clear split between stable relay identity and temporary app lease endpoints
29 +
30 +## Core Decisions
31 +
32 +### Keep these as-is
33 +
34 +- Keep the current raw TCP reverse transport and SNI routing model.
35 +- Keep current tenant TLS passthrough and keyless signing.
36 +- Keep DNS + ACME for browser-facing HTTPS.
37 +- Keep relay-hosted wildcard domains for actual service delivery.
38 +
39 +### Add these on top
40 +
41 +- Use `Identity` as the single product identity contract.
42 +- Use `address` as the canonical owner field inside that identity.
43 +- Use ENS first for verifiable relay identity and address binding, then later for richer profiles and discovery where it adds real value.
44 +- Use `L2 mainnet` as the default L2 for rewards and payments.
45 +- Use offchain accounting with onchain settlement, not onchain event-by-event metering.
46 +- Use x402 only where Portal can actually see HTTP, not across the raw passthrough data plane.
47 +- Keep app subdomains as temporary address-authenticated leases unless persistent name ownership becomes a real product requirement.
48 +
49 +## Product Positioning
50 +
51 +Portal should be positioned as:
52 +
53 +- wallet-owned infrastructure
54 +- ENS-addressable services
55 +- permissionless app publishing for Ethereum-native services
56 +- a bridge between offchain apps and Ethereum identity, naming, and economics
57 +
58 +This is not "ngrok but web3".
59 +It is closer to "Ethereum-native app publishing and service identity".
60 +
61 +## Architecture End State
62 +
63 +### Transport
64 +
65 +- Portal remains the transport and ingress layer.
66 +- Relay routing, reverse sessions, UDP relay, and E2EE remain unchanged.
67 +
68 +### HTTPS
69 +
70 +- Public app access continues to use DNS hostnames such as `app.portal.example.com`.
71 +- ACME continues to provision and renew relay root and wildcard certificates.
72 +- ENS does not replace ACME.
73 +
74 +### Identity
75 +
76 +- `Identity` becomes the only stable product identity contract.
77 +- `Identity.Name` is the service or relay identity label used by Portal.
78 +- `Identity.Address` is the canonical owner wallet for that identity.
79 +- UI, registry, rewards, and relay operator state all key off the same `Identity`, with `address` anchoring ownership.
80 +- Human-friendly owner names come from ENS primary names and ENS profile data.
81 +
82 +### Naming
83 +
84 +- `ENSIP-17` is used for stable relay DNS names, such as `portal.example.com`.
85 +- Canonical `.eth` relay names such as `relay.eth` are optional follow-on identity surfaces, not a requirement for initial ENS support.
86 +- Temporary app subdomains such as `app.portal.example.com` remain Portal leases by default; when published with ENSIP-17 they prove current controlling `address`, not permanent name ownership.
87 +- ENS-native app names such as `app.relay.eth` are deferred until Portal has a concrete need for persistent app identity beyond temporary lease hostnames.
88 +
89 +### Economics
90 +
91 +- Reward calculation stays offchain.
92 +- Reward claiming and paid access settle onchain on L2 mainnet.
93 +- Paid HTTP access can use x402.
94 +
95 +## Roadmap
96 +
97 +### Phase 0: Identity Cleanup
98 +
99 +Objective:
100 +Make `Identity` the single real identity contract across the product.
101 +
102 +Scope:
103 +
104 +- Keep `Identity` as the canonical contract for auth, registry, policy, rewards, claims, and grouping.
105 +- Keep `Identity.Address` as the canonical owner field for wallet ownership and future ENS resolution.
106 +- Keep `metadata.owner` as the user-facing owner label in frontend pages.
107 +- Do not expose `Address` directly in frontend page UI until ENS/address display policy is finalized.
108 +- Define a single normalization policy for `Identity.Name` and EVM `Address`.
109 +
110 +Success criteria:
111 +
112 +- Every public lease, relay operator, and future reward recipient can be mapped to one `Identity`.
113 +- Every `Identity` can be mapped to one canonical owner `address`.
114 +- No duplicate identity contracts remain in the main user path.
115 +
116 +### Phase 1: ENS Profile UX
117 +
118 +Objective:
119 +Make Portal identities legible inside the product.
120 +
121 +Scope:
122 +
123 +- Resolve primary ENS names for `Identity.Address`.
124 +- Resolve ENS avatars and basic profile records where available.
125 +- Display ENS name first, checksum address second, raw metadata fallback last.
126 +- Show relay operator identity and app identity using the same rendering rules.
127 +
128 +Success criteria:
129 +
130 +- Public app cards and relay/operator views show wallet-native identities instead of ad hoc labels.
131 +- Address ownership is still verifiable even when ENS data is absent.
132 +
133 +### Phase 2: DNS-Backed Relay ENS Presence
134 +
135 +Objective:
136 +Make Portal relays verifiably address-bound in the ENS ecosystem without requiring onchain ENS registration.
137 +
138 +Scope:
139 +
140 +- Enable DNSSEC on the relay root domain.
141 +- Add `ENSIP-17` TXT records for the stable relay DNS name.
142 +- Bind the relay root domain to the relay operator `address`.
143 +- Keep relay discovery and transport state separate from relay identity: ENS proves `who`, Portal discovery continues to publish live topology.
144 +- Do not require a canonical `.eth` name or resolver-managed ENS text records in this phase.
145 +
146 +Important:
147 +
148 +- ENSIP-17 is complementary to ACME, not a replacement.
149 +- Use ENSIP-17 first for stable relay names, not short-lived lease hostnames.
150 +- The primary value of ENS in this phase is independent verification of `domain -> address`, not browse/search UX by itself.
151 +- Dynamic app subdomains may publish the currently controlling `address`, but they remain temporary leases and are cleaned up when the lease ends.
152 +
153 +Success criteria:
154 +
155 +- A relay root domain can be independently verified as currently controlled by one wallet `address`.
156 +- ENS-aware clients can resolve `relay domain -> address` without trusting relay-local UI or API claims.
157 +- Relay identity stays stable even if live ingress or overlay endpoints later change.
158 +
159 +### Phase 3: Reward Engine v1
160 +
161 +Objective:
162 +Launch rewards without token and contract complexity.
163 +
164 +Scope:
165 +
166 +- Build an offchain reward index keyed by `Identity`, with rollups by owner `address`.
167 +- Score relay operators and app publishers by selected signals:
168 + - uptime
169 + - successful traffic handling
170 + - app activity
171 + - referrals
172 + - public relay participation
173 +- Add abuse controls:
174 + - self-traffic filtering
175 + - repeated low-value traffic suppression
176 + - operator/app collusion heuristics
177 + - minimum quality thresholds
178 +
179 +Output:
180 +
181 +- point balances
182 +- epochs
183 +- auditable reward reports
184 +
185 +Success criteria:
186 +
187 +- Rewards can run for multiple epochs without token issuance.
188 +- Abuse patterns are measurable before any onchain commitment.
189 +
190 +### Phase 4: Onchain Reward Claims on L2 Mainnet
191 +
192 +Objective:
193 +Turn rewards into Ethereum-native assets without moving dynamic scoring onchain.
194 +
195 +Scope:
196 +
197 +- Use `L2 mainnet` as the default settlement chain.
198 +- Publish epoch results as Merkle roots or signed claim vouchers.
199 +- Let users claim to their wallet address.
200 +- Start with simple fungible rewards, then expand to reputation assets.
201 +
202 +Recommended asset types:
203 +
204 +- `ERC-20` for claimable rewards
205 +- `ERC-1155` for seasonal badges or tiers
206 +- non-transferable reputation badges later if needed
207 +
208 +Success criteria:
209 +
210 +- Users can claim rewards directly with the same address that owns their Portal identity.
211 +- Reward distribution cost stays low enough for recurring epochs.
212 +
213 +### Phase 5: x402 Paid Access
214 +
215 +Objective:
216 +Add payment-native access for HTTP workloads.
217 +
218 +Scope:
219 +
220 +- Support x402 where Portal has an HTTP handler boundary:
221 + - app-side x402 integration
222 + - tunnel-side `RunHTTP` / `--http-route` paywall middleware
223 + - paid relay-side HTTP services
224 +- Use L2 mainnet and supported stable assets for default payment flows.
225 +- Add facilitator-backed verification and settlement rather than requiring every seller to run chain infrastructure.
226 +
227 +Non-goal:
228 +
229 +- Do not attempt to force x402 across the full raw TLS passthrough path at relay edge.
230 +- Raw passthrough traffic is not a global HTTP middleware boundary.
231 +
232 +Success criteria:
233 +
234 +- A Portal-published HTTP API can require payment without changing the relay transport model.
235 +- Repeated access can later compose with wallet/session identity.
236 +
237 +### Phase 6: ENS-Native App Naming
238 +
239 +Objective:
240 +Add ENS-native app identity only if temporary lease hostnames become insufficient.
241 +
242 +Scope:
243 +
244 +- Introduce `CCIP-Read`-backed resolution for dynamic names such as `app.relay.eth`.
245 +- Keep actual service delivery on DNS + ACME hostnames.
246 +- Use ENS names as an optional identity surface, with Portal providing the current target state.
247 +- Map ENS app names to current Portal lease state and metadata only when persistent app identity is worth the added complexity.
248 +
249 +Why later:
250 +
251 +- Lease state is dynamic and short-lived.
252 +- Today Portal app subdomains are intentionally temporary leases, not address-owned permanent namespaces.
253 +- Dynamic app naming is a poor fit for direct onchain storage and ownership updates unless the product deliberately moves toward persistent app identities.
254 +
255 +Success criteria:
256 +
257 +- ENS names can represent live Portal apps without rewriting the transport plane.
258 +- Dynamic service identity is possible without losing current HTTPS behavior.
259 +
260 +### Phase 7: Relay Staking and Reputation
261 +
262 +Objective:
263 +Align public relay participation with durable Ethereum incentives.
264 +
265 +Scope:
266 +
267 +- Add optional staking for public relay operators.
268 +- Add slashable rules for clear policy violations or persistent low-quality operation.
269 +- Tie reward multipliers to quality and stake, not only raw traffic.
270 +- Make operator reputation portable and wallet-bound.
271 +
272 +Success criteria:
273 +
274 +- Public relay operation has credible skin in the game.
275 +- Rewards and visibility can favor reliable operators without central review of every action.
276 +
277 +### Phase 8: Governance and Network Coordination
278 +
279 +Objective:
280 +Move from a product with crypto features to a real Ethereum-aligned network.
281 +
282 +Scope:
283 +
284 +- Formalize registry policy for public relays.
285 +- Define reward allocation policy and eligibility rules.
286 +- Introduce limited governance over network-level parameters only after incentives stabilize.
287 +
288 +Non-goal:
289 +
290 +- Do not put fast-moving runtime controls onchain too early.
291 +- Governance should come after identity, discovery, and economics are already working.
292 +
293 +Success criteria:
294 +
295 +- Portal can coordinate a public relay ecosystem without abandoning product quality or operational simplicity.
296 +
297 +## Recommended Execution Order
298 +
299 +Near-term:
300 +
301 +1. Phase 0
302 +2. Phase 1
303 +3. Phase 2
304 +
305 +Mid-term:
306 +
307 +4. Phase 3
308 +5. Phase 4
309 +6. Phase 5
310 +
311 +Long-term:
312 +
313 +7. Phase 6
314 +8. Phase 7
315 +9. Phase 8
316 +
317 +## Preferred Chain Strategy
318 +
319 +Default recommendation:
320 +
321 +- `L2 mainnet` for rewards, x402 payments, and user-facing economic activity
322 +- ENS as a hybrid model:
323 + - L1 anchor for resolution flow
324 + - L2-aware primary names for user identity
325 + - `CCIP-Read` for dynamic app naming
326 +
327 +Reasoning:
328 +
329 +- L2 economics fit frequent claims and low-value payments much better than L1.
330 +- ENS identity can still remain canonical while user activity happens on L2.
331 +
332 +## What This Roadmap Does Not Change
333 +
334 +- Portal does not become an onchain transport protocol.
335 +- Lease registration, renewal, and routing do not move to smart contracts.
336 +- ACME is still required for public browser HTTPS.
337 +- App subdomains do not become permanent address-owned namespaces by default.
338 +- Ethereum integration should extend the product, not replace the networking model that already works.
339 +
340 +## Summary
341 +
342 +The intended end state is:
343 +
344 +- Portal transport remains simple, fast, and mostly offchain.
345 +- Portal `Identity` becomes the native product identity contract.
346 +- Ethereum wallets become the canonical owners of those identities.
347 +- ENS first becomes the verification layer for `domain -> address`, then later expands into richer naming and discovery only where it improves the product.
348 +- L2 mainnet becomes the rewards and payments layer.
349 +- x402 enables paid HTTP access where Portal has an HTTP boundary.
350 +
351 +If executed in this order, Portal can become Ethereum-native without sacrificing its current simplicity or transport guarantees.
352 +
353 +그 문구는 현재 코드 설명이라기보다, 가능한 다음 아키텍처를 설명한 말입니다.
354 +지금 Portal에 그대로 적용된 상태는 아닙니다.
355 +
356 +지금 구조를 코드로 보면 relay가 여전히 권한자입니다.
357 +
358 +lease 등록을 relay가 승인하고 portal/api_server.go:534
359 +hostname을 relay가 정하고 portal/api_server.go:542
360 +hostname -> lease 매핑을 relay registry가 들고 있고 portal/lease.go:72
361 +실제 ingress에서 SNI를 보고 어느 lease로 보낼지 relay가 결정합니다 portal/server.go:479
362 +ENS gasless는 지금 여기에 검증 가능한 바깥 증거를 하나 더 붙인 겁니다.
363 +
364 +등록 후 relay가 hostname -> address TXT를 publish함 portal/api_server.go:605
365 +그래서 외부인은 “이 도메인이 현재 어느 address에 묶였는지”를 독립적으로 확인할 수 있음
366 +즉 지금 달라진 것은:
367 +
368 +relay가 말하는 owner를 외부에서 검증 가능
369 +relay root와 app subdomain이 address-bound endpoint가 됨
370 +지금 안 달라진 것은:
371 +
372 +누가 붙는지
373 +실제 어느 세션으로 라우팅할지
374 +승인/차단 정책
375 +이건 여전히 relay가 결정합니다.
376 +그래서 그 문구의 뜻을 정확히 풀면 이겁니다.
377 +
378 +현재 Portal:
379 +relay가 identity + live mapping + routing을 다 가짐
380 +ENS를 더 강하게 쓰는 미래 모델:
381 +identity/ownership은 ENS나 서명된 state가 담당
382 +relay는 live transport만 담당
383 +client는 relay 주장 대신 ENS/서명된 state를 검증
384 +하지만 중요한 보정이 있습니다.
385 +현재 public HTTPS ingress 모델에서는 relay를 완전 dumb pipe로 만들 수는 없습니다.
386 +relay는 여전히:
387 +
388 +active lease가 살아 있는지
389 +ready reverse session이 있는지
390 +어느 연결로 브리지할지
391 +를 알아야 하기 때문입니다.
392 +즉 그 문구를 Portal 현실에 맞게 바꾸면:
393 +
394 +완전 authority 제거는 아님
395 +ownership/identity에 대한 authority를 relay 밖으로 뺄 수 있음
396 +live session routing authority는 아직 relay 안에 남음
397 +한 줄로 줄이면:
398 +지금 ENS가 만든 변화는 “relay를 안 믿고도 owner identity를 검증할 수 있게 된 것”이고, 아직 “relay가 routing 권한을 잃은 것”은 아닙니다.
399 +
400 +원하면 다음 답변에서
401 +
402 +현재 구조
403 +ENS 강화 구조
404 +컨트랙트 source-of-truth 구조
405 +를 3단 비교로 그려드리겠습니다.
406 +
407 +그건 지금 단계에서 붙여도 사용성에 변화가 없어?
408 +
409 +
410 +거의 없습니다. 지금 단계에서 붙여도 내부 설계 의미는 있지만, 사용자 체감 사용성 변화는 작습니다.
411 +
412 +이유는 간단합니다.
413 +지금 사용자 플로우는 여전히:
414 +
415 +relay에 등록
416 +relay가 hostname 할당
417 +relay가 route 유지
418 +사용자는 그 URL로 접속
419 +이 구조라서, ENS가 있어도 접속 방식 자체는 안 바뀝니다.
420 +
421 +지금 바로 체감되는 변화는 이 정도뿐입니다.
422 +
423 +“이 도메인이 현재 어느 address에 묶였는지” 검증 가능
424 +relay/operator/app identity를 address 기준으로 설명 가능
425 +외부에서 더 신뢰성 있게 표시 가능
426 +하지만 대부분의 일반 사용자는:
427 +
428 +ENS-aware client를 쓰지 않으면 못 느낌
429 +접속 URL도 그대로
430 +publish 방식도 그대로
431 +relay 신뢰 모델도 대부분 그대로
432 +즉 지금 붙이면 생기는 건 주로:
433 +
434 +검증성
435 +설명 가능성
436 +미래 확장성
437 +이지,
438 +즉시 UX 개선
439 +은 아닙니다.
440 +사용성 변화가 커지려면 ENS가 아래 중 하나와 연결돼야 합니다.
441 +
442 +UI에서 verified owner/ENS name 표시
443 +catalog/search에서 owner/address 기준 묶기
444 +결제/정산에서 payee identity 표시
445 +relay 선택/평판 시스템과 연결
446 +persistent name claim
447 +그래서 냉정하게 말하면:
448 +
449 +backend에만 붙이면 체감 변화 작음
450 +UI/selection/payment와 연결되면 의미 커짐
451 +한 줄로 줄이면:
452 +지금 단계에서 ENS를 더 붙여도 “맞는 방향”일 수는 있지만, 그 자체만으로 사용성이 크게 달라지진 않습니다.
\ No newline at end of file
cmd/relay-server/main.go
+7 -1
@@ -50,6 +50,8 @@ type relayServerConfig struct {
50 ACMEDNSProvider string
51 ENSGaslessEnabled bool
52 CloudflareToken string
53 + GCPProjectID string
54 + GCPManagedZone string
55 AWSAccessKeyID string
56 AWSSecretAccessKey string
57 AWSSessionToken string
@@ -80,9 +82,11 @@ func runServeCommand(args []string) error {
82
83 utils.StringFlagEnv(fs, &cfg.KeylessDir, "keyless-dir", "./.portal-certs", "directory path for relay keyless materials", "KEYLESS_DIR")
84 utils.StringFlagEnv(fs, &cfg.AdminSettingsPath, "admin-settings-path", "admin_settings.json", "admin settings file path", "ADMIN_SETTINGS_PATH")
83 - utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "ACME DNS provider for managed DNS-01/A-record sync and ENS gasless DNSSEC/TXT automation (cloudflare|route53); leave empty to use manual fullchain.pem/privatekey.pem from KEYLESS_DIR", "ACME_DNS_PROVIDER")
85 + utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "ACME DNS provider for managed DNS-01/A-record sync and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|route53); leave empty to use manual fullchain.pem/privatekey.pem from KEYLESS_DIR", "ACME_DNS_PROVIDER")
86 utils.BoolFlagEnv(fs, &cfg.ENSGaslessEnabled, "ens-gasless-enabled", false, "enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames", "ENS_GASLESS_ENABLED")
87 utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
88 + utils.StringFlagEnv(fs, &cfg.GCPProjectID, "gcp-project-id", "", "Google Cloud project id for Cloud DNS automation; auto-detected from ADC or GCE metadata when omitted", "GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GCE_PROJECT")
89 + utils.StringFlagEnv(fs, &cfg.GCPManagedZone, "gcp-managed-zone", "", "explicit Google Cloud DNS managed zone name or numeric ID override", "GCP_MANAGED_ZONE", "GCP_ZONE", "GCE_ZONE_ID")
90 utils.StringFlagEnv(fs, &cfg.AWSAccessKeyID, "aws-access-key-id", "", "AWS access key ID for Route53 static credentials; uses the default AWS credential chain when omitted", "AWS_ACCESS_KEY_ID")
91 utils.StringFlagEnv(fs, &cfg.AWSSecretAccessKey, "aws-secret-access-key", "", "AWS secret access key for Route53 static credentials", "AWS_SECRET_ACCESS_KEY")
92 utils.StringFlagEnv(fs, &cfg.AWSSessionToken, "aws-session-token", "", "AWS session token for Route53 temporary credentials", "AWS_SESSION_TOKEN")
@@ -139,6 +143,8 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
143 DNSProvider: cfg.ACMEDNSProvider,
144 ENSGaslessEnabled: cfg.ENSGaslessEnabled,
145 CloudflareToken: cfg.CloudflareToken,
146 + GCPProjectID: cfg.GCPProjectID,
147 + GCPManagedZone: cfg.GCPManagedZone,
148 AWSAccessKeyID: cfg.AWSAccessKeyID,
149 AWSSecretAccessKey: cfg.AWSSecretAccessKey,
150 AWSSessionToken: cfg.AWSSessionToken,
docker-compose.yml
+5
@@ -40,6 +40,9 @@ services:
40 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
41 ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
42 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
43 + GCP_PROJECT_ID: ${GCP_PROJECT_ID:-}
44 + GCP_MANAGED_ZONE: ${GCP_MANAGED_ZONE:-}
45 + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
46 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
47 AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
48 AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
@@ -50,4 +53,6 @@ services:
53 DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
54 volumes:
55 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
56 + # Uncomment when using a Google Cloud service account file for gcloud automation.
57 + # - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
58 restart: unless-stopped
docs/architecture.md
+3 -3
@@ -64,7 +64,7 @@ UDP client
64 ### Operational Constraints
65
66 - For non-localhost deployments, relay TLS can run from manual certificate files in `KEYLESS_DIR` or from managed ACME.
67 -- When managed ACME is enabled, supported DNS providers are only `cloudflare` and `route53`.
67 +- When managed ACME is enabled, supported DNS providers are `cloudflare`, `gcloud`, and `route53`.
68 - ENS gasless automation reuses `ACME_DNS_PROVIDER` for DNSSEC and ENS TXT sync.
69 - Relay, tunnel, and demo-app identities are persisted as JSON at `IDENTITY_PATH` / `--identity-path`. Missing files are generated automatically and stored with `name`, `address`, `public_key`, and `private_key`.
70 - Managed non-localhost ACME keeps both root and wildcard DNS A records in sync.
@@ -112,7 +112,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
112 - `transport.RelayDatagram`: per-lease raw UDP socket plus QUIC DATAGRAM bridge runtime
113 - `transport.PortAllocator`: count-based UDP port allocator with sticky name-based reservation and grace period
114 - `transport.datagramSession`: internal QUIC DATAGRAM bind/send/receive primitive shared by relay and SDK datagram runtimes
115 -- `acme`: Cloudflare/Route53-backed root/wildcard A-record sync + certificate provisioning/renewal for the relay root host and wildcard
115 +- `acme`: Cloudflare/Google Cloud DNS/Route53-backed root/wildcard A-record sync + certificate provisioning/renewal for the relay root host and wildcard
116 - `keyless`: admin/API TLS attach helpers and tenant-side signer integration
117 - `auth`: SIWE register challenge creation/verification plus lease access token issue/verify
118 - `discovery`: signed relay descriptor publication and relay-set synchronization
@@ -351,7 +351,7 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
351 - `fullchain.pem`
352 - `privatekey.pem`
353 - For non-localhost deployments, Portal can either use those files directly or manage them through ACME.
354 -- When ACME is enabled, DNS-01 currently supports `cloudflare` and `route53`, and keeps:
354 +- When ACME is enabled, DNS-01 currently supports `cloudflare`, `gcloud`, and `route53`, and keeps:
355 - root host A record
356 - wildcard host A record
357 - relay certificate renewal
docs/deployment.md
+83 -3
@@ -9,7 +9,7 @@ You need:
9 - A public domain, for example `example.com`
10 - A public Linux server with a static public IPv4
11 - Docker and Docker Compose
12 -- Optional for managed ACME DNS-01 automation or Portal-managed ENS TXT sync: a supported DNS provider account for `cloudflare` or `route53`
12 +- Optional for managed ACME DNS-01 automation or Portal-managed ENS TXT sync: a supported DNS provider account for `cloudflare`, `gcloud`, or `route53`
13 - Open inbound ports:
14 - `443/tcp`
15 - `4017/tcp`
@@ -30,7 +30,7 @@ Choose one of these modes:
30 - Set `ACME_DNS_PROVIDER`.
31 - Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for DNSSEC + ENS TXT automation.
32 - Managed ACME mode
33 - - Set `ACME_DNS_PROVIDER` to `cloudflare` or `route53`.
33 + - Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, or `route53`.
34 - Portal manages root/wildcard A records and certificate renewal.
35 - If ENS gasless is enabled, Portal also manages DNSSEC.
36
@@ -43,6 +43,7 @@ If you only need a relay and do not need Portal-managed DNS or automatic renewal
43 Set `ACME_DNS_PROVIDER` to one of:
44
45 - `cloudflare`
46 +- `gcloud`
47 - `route53`
48
49 ### 3.2 Cloudflare setup
@@ -121,7 +122,34 @@ When `ENS_GASLESS_ENABLED=true` and `ACME_DNS_PROVIDER=route53` and the hosted z
122 - `AWS_DNSSEC_KMS_KEY_ARN`
123 - optional `DNSSEC_KSK_NAME`
124
124 -### 3.4 Optional ENS Gasless Automation
125 +### 3.4 Google Cloud DNS setup
126 +
127 +Create or select a public Cloud DNS managed zone that covers your relay host.
128 +
129 +Portal uses standard Google Application Default Credentials (ADC) for both Cloud DNS API access and lego DNS-01. Examples:
130 +
131 +- `GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/gcp-dns.json` with a mounted service account JSON file
132 +- an attached service account or workload identity on GCE, GKE, or Cloud Run
133 +
134 +Optional environment variables:
135 +
136 +- `GCP_PROJECT_ID`
137 +- `GCP_MANAGED_ZONE`
138 +- `GOOGLE_APPLICATION_CREDENTIALS`
139 +
140 +Equivalent relay flags:
141 +
142 +- `--gcp-project-id`
143 +- `--gcp-managed-zone`
144 +
145 +Notes:
146 +
147 +- `GCP_PROJECT_ID` is optional when ADC or GCE metadata already exposes the project id.
148 +- `GCP_MANAGED_ZONE` is optional, but useful when the credentials can edit a specific managed zone without permission to list all zones.
149 +- `GOOGLE_APPLICATION_CREDENTIALS` should point to the in-container path when you run Portal in Docker with a mounted service account JSON file.
150 +- Portal only targets public Cloud DNS managed zones.
151 +
152 +### 3.5 Optional ENS Gasless Automation
153
154 Portal can optionally enable ENS gasless DNS import for the base domain and lease hostnames.
155
@@ -131,14 +159,39 @@ Portal can optionally enable ENS gasless DNS import for the base domain and leas
159 - Portal uses that provider for both DNSSEC automation and ENS TXT create/delete.
160 - If valid manual certificate files already exist in `KEYLESS_DIR`, Portal keeps using them and does not force ACME certificate issuance just because `ACME_DNS_PROVIDER` is set.
161 - Cloudflare can enable zone signing directly, but some registrars still require publishing the returned DS record.
162 +- Google Cloud DNS can enable zone signing directly, but the registrar may still require publishing the returned DS record.
163 - Route53 requires a compatible KMS key ARN when no active KSK already exists, and the registrar may still require the DS record.
164 - New lease hostnames such as `app.portal.example.com` are published automatically when they register and are cleaned up on unregister or expiry.
165 - ENS gasless import still depends on DNSSEC being valid for the domain.
166 - By default Portal writes `ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 <address>`.
167 - The address is derived automatically from the relay identity for the base domain and from each lease identity for lease hostnames.
168 - This enables offchain gasless DNSSEC usage in ENS-aware clients. It does not perform an onchain ENS claim transaction.
169 +- Portal can automate provider-side DNS changes, but registrar-side DS publication is not always automatable. Expect a manual registrar step unless your registrar publishes DS records automatically.
170 - Keep `ENS_GASLESS_ENABLED=false` unless you intend to use ENS gasless DNS import.
171
172 +Typical rollout:
173 +
174 +1. Set `ACME_DNS_PROVIDER` and the provider credentials.
175 +2. Set `ENS_GASLESS_ENABLED=true`.
176 +3. Start Portal and confirm the log contains both `dnssec configured` and `ens gasless dns import configured`.
177 +4. If the DNSSEC state is `pending`, publish the returned `DS` record at your registrar and wait for propagation.
178 +5. Re-check until the provider DNSSEC state becomes `active`.
179 +6. Verify external resolution with an ENS-aware client after DNSSEC is active.
180 +
181 +Registrar DS publication:
182 +
183 +- Cloudflare, Google Cloud DNS, and Route53 can sign the zone and return the DS record, but they do not control your registrar unless the domain is registered with the same provider.
184 +- If your registrar is separate, you must copy the DS values from the provider into the registrar's DNSSEC or DS configuration screen.
185 +- Example: if the domain is registered at Namecheap and delegated to Cloudflare nameservers, enable DNSSEC in Cloudflare first, then add the Cloudflare DS record in Namecheap under the domain's `Advanced DNS` DNSSEC section.
186 +- Until the registrar publishes the DS record at the parent zone, provider status typically stays `pending` and ENS gasless resolution may fail even though Portal already wrote the `ENS1 ...` TXT record.
187 +
188 +Verification checklist:
189 +
190 +- Provider DNSSEC status is `active`.
191 +- `dig +short DS example.com` returns the DS record from the parent zone.
192 +- `dig +short TXT example.com` returns the `ENS1 ...` TXT record.
193 +- ENS-aware resolution returns the expected address for the base domain and each lease hostname.
194 +
195 ## 4. Run Relay Server
196
197 ### 4.1 Create `.env` at repository root
@@ -218,6 +271,21 @@ DNSSEC_KSK_NAME=portal_ksk
271 ENS_GASLESS_ENABLED=false
272 ```
273
274 +Google Cloud DNS example:
275 +
276 +```bash
277 +IDENTITY_PATH=/portal-certs/identity.json
278 +KEYLESS_DIR=/portal-certs
279 +ACME_DNS_PROVIDER=gcloud
280 +# Optional when ADC does not expose the project id directly.
281 +GCP_PROJECT_ID=my-gcp-project
282 +# Optional override when the credentials cannot list managed zones.
283 +GCP_MANAGED_ZONE=portal-example-com
284 +# Standard ADC when using a mounted service account file.
285 +GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/gcp-dns.json
286 +ENS_GASLESS_ENABLED=false
287 +```
288 +
289 Notes:
290
291 - For non-apex deployments, set `PORTAL_URL` to the non-apex host value, for example `https://portal.example.com:8443`
@@ -248,6 +316,18 @@ chmod 755 ./.portal-certs
316
317 If you use manual certificate mode, make sure `fullchain.pem` and `privatekey.pem` already exist in `./.portal-certs` before startup.
318
319 +If you use `ACME_DNS_PROVIDER=gcloud` with a service account JSON file under Docker Compose, mount the file into the container and set `GOOGLE_APPLICATION_CREDENTIALS` to the in-container path. Example:
320 +
321 +```yaml
322 +services:
323 + portal:
324 + environment:
325 + GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/gcp-dns.json
326 + volumes:
327 + - ./.portal-certs:/portal-certs
328 + - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
329 +```
330 +
331 Then start the stack:
332
333 ```bash
docs/examples/nginx-proxy-multi-service/.env.example
+7 -1
@@ -10,7 +10,7 @@ PORTAL_URL=https://portal.example.com
10 API_PORT=4017
11 SNI_PORT=4443
12
13 -# ACME DNS provider (cloudflare or route53)
13 +# ACME DNS provider (cloudflare, gcloud, or route53)
14 ACME_DNS_PROVIDER=cloudflare
15
16 # Relay identity persistence
@@ -26,6 +26,12 @@ ADMIN_SECRET_KEY=
26 # Cloudflare API token (Zone:Read + DNS:Edit) for portal ACME cert issuance
27 CLOUDFLARE_TOKEN=
28
29 +# Google Cloud DNS settings.
30 +# Use ADC via GOOGLE_APPLICATION_CREDENTIALS, workload identity, or an attached service account.
31 +GCP_PROJECT_ID=
32 +GCP_MANAGED_ZONE=
33 +GOOGLE_APPLICATION_CREDENTIALS=
34 +
35 # Portal state directories
36 KEYLESS_DIR=/portal-certs
37
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+5
@@ -83,6 +83,9 @@ services:
83 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
84 ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
85 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
86 + GCP_PROJECT_ID: ${GCP_PROJECT_ID:-}
87 + GCP_MANAGED_ZONE: ${GCP_MANAGED_ZONE:-}
88 + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
89 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
90 AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
91 AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
@@ -93,6 +96,8 @@ services:
96 DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
97 volumes:
98 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
99 + # Uncomment when using a Google Cloud service account file for gcloud automation.
100 + # - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
101 restart: unless-stopped
102
103 # ─── App A: backend ─────────────────────────────────────────────────────────
docs/examples/nginx-proxy/.env.example
+7 -1
@@ -8,7 +8,7 @@ PORTAL_URL=https://portal.example.com
8 API_PORT=4017
9 SNI_PORT=443
10
11 -# ACME DNS provider (cloudflare or route53)
11 +# ACME DNS provider (cloudflare, gcloud, or route53)
12 ACME_DNS_PROVIDER=cloudflare
13
14 # Relay identity persistence
@@ -24,6 +24,12 @@ ADMIN_SECRET_KEY=
24 # Cloudflare API token (Zone:Read + DNS:Edit) for portal ACME cert issuance
25 CLOUDFLARE_TOKEN=
26
27 +# Google Cloud DNS settings.
28 +# Use ADC via GOOGLE_APPLICATION_CREDENTIALS, workload identity, or an attached service account.
29 +GCP_PROJECT_ID=
30 +GCP_MANAGED_ZONE=
31 +GOOGLE_APPLICATION_CREDENTIALS=
32 +
33 # Portal state directories
34 KEYLESS_DIR=/portal-certs
35
docs/examples/nginx-proxy/docker-compose.yaml
+5
@@ -81,6 +81,9 @@ services:
81 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
82 ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
83 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
84 + GCP_PROJECT_ID: ${GCP_PROJECT_ID:-}
85 + GCP_MANAGED_ZONE: ${GCP_MANAGED_ZONE:-}
86 + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-}
87 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
88 AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
89 AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
@@ -91,4 +94,6 @@ services:
94 DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
95 volumes:
96 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
97 + # Uncomment when using a Google Cloud service account file for gcloud automation.
98 + # - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
99 restart: unless-stopped
go.mod
+21
@@ -3,6 +3,7 @@ module github.com/gosuda/portal/v2
3 go 1.26.1
4
5 require (
6 + cloud.google.com/go/compute/metadata v0.9.0
7 github.com/aws/aws-sdk-go-v2 v1.41.1
8 github.com/aws/aws-sdk-go-v2/config v1.32.8
9 github.com/aws/aws-sdk-go-v2/credentials v1.19.8
@@ -16,11 +17,15 @@ require (
17 github.com/spruceid/siwe-go v0.2.1
18 golang.org/x/crypto v0.48.0
19 golang.org/x/net v0.50.0
20 + golang.org/x/oauth2 v0.35.0
21 golang.org/x/sync v0.19.0
22 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
23 + google.golang.org/api v0.267.0
24 )
25
26 require (
27 + cloud.google.com/go/auth v0.18.1 // indirect
28 + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
29 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
30 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
31 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
@@ -34,19 +39,35 @@ require (
39 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
40 github.com/aws/smithy-go v1.24.0 // indirect
41 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
42 + github.com/cespare/xxhash/v2 v2.3.0 // indirect
43 github.com/dchest/uniuri v1.2.0 // indirect
44 github.com/ethereum/go-ethereum v1.17.1 // indirect
45 + github.com/felixge/httpsnoop v1.0.4 // indirect
46 + github.com/go-logr/logr v1.4.3 // indirect
47 + github.com/go-logr/stdr v1.2.2 // indirect
48 github.com/google/btree v1.1.2 // indirect
49 + github.com/google/s2a-go v0.1.9 // indirect
50 + github.com/google/uuid v1.6.0 // indirect
51 + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
52 + github.com/googleapis/gax-go/v2 v2.17.0 // indirect
53 github.com/holiman/uint256 v1.3.2 // indirect
54 github.com/mattn/go-colorable v0.1.13 // indirect
55 github.com/mattn/go-isatty v0.0.20 // indirect
56 github.com/miekg/dns v1.1.72 // indirect
57 github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 // indirect
58 + go.opentelemetry.io/auto/sdk v1.2.1 // indirect
59 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
60 + go.opentelemetry.io/otel v1.39.0 // indirect
61 + go.opentelemetry.io/otel/metric v1.39.0 // indirect
62 + go.opentelemetry.io/otel/trace v1.39.0 // indirect
63 golang.org/x/mod v0.32.0 // indirect
64 golang.org/x/sys v0.41.0 // indirect
65 golang.org/x/text v0.34.0 // indirect
66 golang.org/x/time v0.14.0 // indirect
67 golang.org/x/tools v0.41.0 // indirect
68 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
69 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
70 + google.golang.org/grpc v1.78.0 // indirect
71 + google.golang.org/protobuf v1.36.11 // indirect
72 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
73 )
go.sum
+57
@@ -1,3 +1,9 @@
1 +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
2 +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
3 +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
4 +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
5 +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
6 +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
7 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
8 github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
9 github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
@@ -32,6 +38,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
38 github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
39 github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
40 github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
41 +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
42 +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
43 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
44 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
45 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -43,15 +51,32 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1
51 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
52 github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
53 github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I=
54 +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
55 +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
56 github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
57 github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
58 github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
59 github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
60 +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
61 +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
62 +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
63 +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
64 +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
65 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
66 +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
67 +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
68 github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
69 github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
70 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
71 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
72 +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
73 +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
74 +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
75 +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
76 +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
77 +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
78 +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
79 +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
80 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc h1:aS9LQ35x6EtrGKCmOWRj6Y9aQ2l5hP8dVva4oxB9VEg=
81 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
82 github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
@@ -78,6 +103,22 @@ github.com/spruceid/siwe-go v0.2.1 h1:BroySys6CyUzeyNppTseEOT/w56xTdOfcmECTI7rnu
103 github.com/spruceid/siwe-go v0.2.1/go.mod h1:MHpHbptGsM3lHth2L8quhZ9ipiwST8zsJH1CjWpeO1k=
104 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
105 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
106 +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
107 +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
108 +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
109 +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
110 +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
111 +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
112 +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
113 +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
114 +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
115 +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
116 +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
117 +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
118 +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
119 +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
120 +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
121 +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
122 go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
123 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
124 golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
@@ -86,6 +127,8 @@ golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
127 golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
128 golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
129 golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
130 +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
131 +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
132 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
133 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
134 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -103,6 +146,20 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
146 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
147 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
148 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
149 +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
150 +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
151 +google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
152 +google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
153 +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
154 +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
155 +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
156 +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
157 +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
158 +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
159 +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
160 +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
161 +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
162 +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
163 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
164 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
165 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
portal/acme/acme.go
+4
@@ -46,6 +46,8 @@ type Config struct {
46 ENSGaslessEnabled bool
47 ENSGaslessAddress string
48 CloudflareToken string
49 + GCPProjectID string
50 + GCPManagedZone string
51 AWSAccessKeyID string
52 AWSSecretAccessKey string
53 AWSSessionToken string
@@ -79,6 +81,8 @@ func NewManager(cfg Config) (*Manager, error) {
81 cfg.DNSProvider = strings.ToLower(strings.TrimSpace(cfg.DNSProvider))
82 cfg.ENSGaslessAddress = strings.TrimSpace(cfg.ENSGaslessAddress)
83 cfg.CloudflareToken = strings.TrimSpace(cfg.CloudflareToken)
84 + cfg.GCPProjectID = strings.TrimSpace(cfg.GCPProjectID)
85 + cfg.GCPManagedZone = strings.TrimSpace(cfg.GCPManagedZone)
86 cfg.AWSAccessKeyID = strings.TrimSpace(cfg.AWSAccessKeyID)
87 cfg.AWSSecretAccessKey = strings.TrimSpace(cfg.AWSSecretAccessKey)
88 cfg.AWSSessionToken = strings.TrimSpace(cfg.AWSSessionToken)
portal/acme/cloudflare/provider.go
+3 -3
@@ -106,10 +106,10 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
106 if p.token == "" {
107 return errors.New("cloudflare token is required")
108 }
109 - publicIPv4 = strings.TrimSpace(publicIPv4)
110 - if publicIPv4 == "" {
111 - return errors.New("public ipv4 is required")
109 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
110 + return err
111 }
112 + publicIPv4 = strings.TrimSpace(publicIPv4)
113
114 zoneID, err := findZoneID(ctx, p.token, baseDomain)
115 if err != nil {
portal/acme/gcloud/provider.go new
+625
@@ -0,0 +1,625 @@
1 +package gcloud
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 + "net/http"
8 + "strconv"
9 + "strings"
10 + "time"
11 +
12 + "cloud.google.com/go/compute/metadata"
13 + "github.com/go-acme/lego/v4/challenge"
14 + "github.com/go-acme/lego/v4/providers/dns/gcloud"
15 + "golang.org/x/oauth2"
16 + "golang.org/x/oauth2/google"
17 + "google.golang.org/api/dns/v1"
18 + "google.golang.org/api/option"
19 +
20 + "github.com/gosuda/portal/v2/types"
21 + "github.com/gosuda/portal/v2/utils"
22 +)
23 +
24 +const (
25 + defaultRecordTTL = 60
26 + defaultPollPeriod = 2 * time.Second
27 +)
28 +
29 +type Config struct {
30 + ProjectID string
31 + ManagedZone string
32 +}
33 +
34 +type Provider struct {
35 + cfg Config
36 +}
37 +
38 +type runtimeConfig struct {
39 + ProjectID string
40 + ManagedZone string
41 + HTTPClient *http.Client
42 +}
43 +
44 +func New(cfg Config) *Provider {
45 + return &Provider{
46 + cfg: Config{
47 + ProjectID: strings.TrimSpace(cfg.ProjectID),
48 + ManagedZone: strings.TrimSpace(cfg.ManagedZone),
49 + },
50 + }
51 +}
52 +
53 +func (p *Provider) Name() string {
54 + return "gcloud"
55 +}
56 +
57 +func (p *Provider) ChallengeProvider(ctx context.Context) (challenge.Provider, error) {
58 + if p == nil {
59 + return nil, errors.New("gcloud provider is nil")
60 + }
61 +
62 + runtimeCfg, err := newRuntimeConfig(ctx, p.cfg)
63 + if err != nil {
64 + return nil, err
65 + }
66 +
67 + cfg := gcloud.NewDefaultConfig()
68 + cfg.Project = runtimeCfg.ProjectID
69 + cfg.ZoneID = runtimeCfg.ManagedZone
70 + cfg.HTTPClient = runtimeCfg.HTTPClient
71 +
72 + provider, err := gcloud.NewDNSProviderConfig(cfg)
73 + if err != nil {
74 + return nil, fmt.Errorf("create gcloud lego provider: %w", err)
75 + }
76 + return provider, nil
77 +}
78 +
79 +func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
80 + if p == nil {
81 + return errors.New("gcloud provider is nil")
82 + }
83 + baseDomain = utils.NormalizeBaseDomain(baseDomain)
84 + if baseDomain == "" {
85 + return errors.New("base domain is required")
86 + }
87 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
88 + return err
89 + }
90 +
91 + service, runtimeCfg, zone, err := newService(ctx, p.cfg, baseDomain)
92 + if err != nil {
93 + return err
94 + }
95 +
96 + for _, recordName := range []string{baseDomain, "*." + baseDomain} {
97 + if err := ensureRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.ResourceRecordSet{
98 + Name: fqdn(recordName),
99 + Type: "A",
100 + Ttl: defaultRecordTTL,
101 + Rrdatas: []string{strings.TrimSpace(publicIPv4)},
102 + }); err != nil {
103 + return fmt.Errorf("upsert gcloud A record %s: %w", recordName, err)
104 + }
105 + }
106 + return nil
107 +}
108 +
109 +func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
110 + if p == nil {
111 + return errors.New("gcloud provider is nil")
112 + }
113 + name = utils.NormalizeHostname(name)
114 + if name == "" {
115 + return errors.New("record name is required")
116 + }
117 + value = strings.TrimSpace(value)
118 + if value == "" {
119 + return errors.New("txt record value is required")
120 + }
121 +
122 + service, runtimeCfg, zone, err := newService(ctx, p.cfg, name)
123 + if err != nil {
124 + return err
125 + }
126 +
127 + existing, err := listRecordSets(ctx, service, runtimeCfg.ProjectID, zone.Name, name, "TXT")
128 + if err != nil {
129 + return fmt.Errorf("list gcloud TXT records %s: %w", name, err)
130 + }
131 +
132 + values := make([]string, 0, len(existing)+1)
133 + seen := make(map[string]struct{}, len(existing)+1)
134 + for _, recordSet := range existing {
135 + for _, raw := range recordSet.Rrdatas {
136 + normalized := txtContent(raw)
137 + if normalized == "" {
138 + continue
139 + }
140 + if _, ok := seen[normalized]; ok {
141 + continue
142 + }
143 + seen[normalized] = struct{}{}
144 + values = append(values, normalized)
145 + }
146 + }
147 + if _, ok := seen[value]; ok {
148 + return nil
149 + }
150 + values = append(values, value)
151 +
152 + if err := replaceRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, existing, &dns.ResourceRecordSet{
153 + Name: fqdn(name),
154 + Type: "TXT",
155 + Ttl: recordTTL(existing),
156 + Rrdatas: values,
157 + }); err != nil {
158 + return fmt.Errorf("upsert gcloud TXT record %s: %w", name, err)
159 + }
160 + return nil
161 +}
162 +
163 +func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
164 + if p == nil {
165 + return errors.New("gcloud provider is nil")
166 + }
167 + name = utils.NormalizeHostname(name)
168 + if name == "" {
169 + return errors.New("record name is required")
170 + }
171 + matchPrefix = strings.TrimSpace(matchPrefix)
172 + if matchPrefix == "" {
173 + return errors.New("txt record match prefix is required")
174 + }
175 +
176 + service, runtimeCfg, zone, err := newService(ctx, p.cfg, name)
177 + if err != nil {
178 + return err
179 + }
180 +
181 + existing, err := listRecordSets(ctx, service, runtimeCfg.ProjectID, zone.Name, name, "TXT")
182 + if err != nil {
183 + return fmt.Errorf("list gcloud TXT records %s: %w", name, err)
184 + }
185 + if len(existing) == 0 {
186 + return nil
187 + }
188 +
189 + remaining := make([]string, 0, len(existing))
190 + seen := make(map[string]struct{}, len(existing))
191 + removed := false
192 + for _, recordSet := range existing {
193 + for _, raw := range recordSet.Rrdatas {
194 + normalized := txtContent(raw)
195 + if normalized == "" {
196 + continue
197 + }
198 + if strings.HasPrefix(normalized, matchPrefix) {
199 + removed = true
200 + continue
201 + }
202 + if _, ok := seen[normalized]; ok {
203 + continue
204 + }
205 + seen[normalized] = struct{}{}
206 + remaining = append(remaining, normalized)
207 + }
208 + }
209 + if !removed {
210 + return nil
211 + }
212 +
213 + if len(remaining) == 0 {
214 + if err := applyChange(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.Change{
215 + Deletions: existing,
216 + }); err != nil {
217 + return fmt.Errorf("delete gcloud TXT records %s: %w", name, err)
218 + }
219 + return nil
220 + }
221 +
222 + if err := replaceRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, existing, &dns.ResourceRecordSet{
223 + Name: fqdn(name),
224 + Type: "TXT",
225 + Ttl: recordTTL(existing),
226 + Rrdatas: remaining,
227 + }); err != nil {
228 + return fmt.Errorf("delete gcloud TXT records %s: %w", name, err)
229 + }
230 + return nil
231 +}
232 +
233 +func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (types.DNSSECStatus, error) {
234 + if p == nil {
235 + return types.DNSSECStatus{}, errors.New("gcloud provider is nil")
236 + }
237 + baseDomain = utils.NormalizeBaseDomain(baseDomain)
238 + if baseDomain == "" {
239 + return types.DNSSECStatus{}, errors.New("base domain is required")
240 + }
241 +
242 + service, runtimeCfg, zone, err := newService(ctx, p.cfg, baseDomain)
243 + if err != nil {
244 + return types.DNSSECStatus{}, err
245 + }
246 + managedZone := zone.Name
247 +
248 + state := strings.ToLower(strings.TrimSpace(dnssecState(zone)))
249 + if state != "on" && state != "transfer" {
250 + if err := enableDNSSEC(ctx, service, runtimeCfg.ProjectID, managedZone); err != nil {
251 + return types.DNSSECStatus{}, fmt.Errorf("enable gcloud dnssec: %w", err)
252 + }
253 + zone, err = service.ManagedZones.Get(runtimeCfg.ProjectID, managedZone).Context(ctx).Do()
254 + if err != nil {
255 + return types.DNSSECStatus{}, fmt.Errorf("refresh gcloud managed zone %s: %w", managedZone, err)
256 + }
257 + }
258 +
259 + keys, err := listDNSKeys(ctx, service, runtimeCfg.ProjectID, managedZone)
260 + if err != nil {
261 + return types.DNSSECStatus{}, fmt.Errorf("list gcloud dnssec keys: %w", err)
262 + }
263 +
264 + return dnssecStatusFromZone(zone, keys), nil
265 +}
266 +
267 +func newRuntimeConfig(ctx context.Context, cfg Config) (runtimeConfig, error) {
268 + creds, err := google.FindDefaultCredentials(ctx, dns.NdevClouddnsReadwriteScope)
269 + if err != nil {
270 + return runtimeConfig{}, fmt.Errorf("load gcloud credentials: %w", err)
271 + }
272 +
273 + projectID := strings.TrimSpace(cfg.ProjectID)
274 + if projectID == "" {
275 + projectID = strings.TrimSpace(creds.ProjectID)
276 + }
277 + if projectID == "" && metadata.OnGCE() {
278 + if detected, err := metadata.ProjectIDWithContext(ctx); err == nil {
279 + projectID = strings.TrimSpace(detected)
280 + }
281 + }
282 + if projectID == "" {
283 + return runtimeConfig{}, errors.New("gcloud project id is required")
284 + }
285 +
286 + return runtimeConfig{
287 + ProjectID: projectID,
288 + ManagedZone: strings.TrimSpace(cfg.ManagedZone),
289 + HTTPClient: oauth2.NewClient(ctx, creds.TokenSource),
290 + }, nil
291 +}
292 +
293 +func newService(ctx context.Context, cfg Config, domain string) (*dns.Service, runtimeConfig, *dns.ManagedZone, error) {
294 + runtimeCfg, err := newRuntimeConfig(ctx, cfg)
295 + if err != nil {
296 + return nil, runtimeConfig{}, nil, err
297 + }
298 +
299 + service, err := dns.NewService(ctx, option.WithHTTPClient(runtimeCfg.HTTPClient))
300 + if err != nil {
301 + return nil, runtimeConfig{}, nil, fmt.Errorf("create gcloud dns service: %w", err)
302 + }
303 +
304 + zone, err := findManagedZone(ctx, service, runtimeCfg.ProjectID, domain, runtimeCfg.ManagedZone)
305 + if err != nil {
306 + return nil, runtimeConfig{}, nil, err
307 + }
308 + return service, runtimeCfg, zone, nil
309 +}
310 +
311 +func findManagedZone(ctx context.Context, service *dns.Service, projectID, domain, explicit string) (*dns.ManagedZone, error) {
312 + if service == nil {
313 + return nil, errors.New("gcloud dns service is nil")
314 + }
315 +
316 + if explicit = strings.TrimSpace(explicit); explicit != "" {
317 + zone, err := service.ManagedZones.Get(projectID, explicit).Context(ctx).Do()
318 + if err != nil {
319 + return nil, fmt.Errorf("get gcloud managed zone %q: %w", explicit, err)
320 + }
321 + if err := validateManagedZone(zone, domain, explicit); err != nil {
322 + return nil, err
323 + }
324 + return zone, nil
325 + }
326 +
327 + for _, candidate := range utils.DomainCandidates(domain) {
328 + out, err := service.ManagedZones.List(projectID).DnsName(fqdn(candidate)).Context(ctx).Do()
329 + if err != nil {
330 + return nil, fmt.Errorf("list gcloud managed zones: %w", err)
331 + }
332 + for _, zone := range out.ManagedZones {
333 + if !isPublicZone(zone) || utils.NormalizeHostname(zone.DnsName) != candidate {
334 + continue
335 + }
336 + return zone, nil
337 + }
338 + }
339 +
340 + return nil, fmt.Errorf("no gcloud public managed zone found for %s", domain)
341 +}
342 +
343 +func validateManagedZone(zone *dns.ManagedZone, domain, explicit string) error {
344 + if zone == nil {
345 + return fmt.Errorf("gcloud managed zone %q is nil", explicit)
346 + }
347 + if !isPublicZone(zone) {
348 + return fmt.Errorf("gcloud managed zone %q is not public", explicit)
349 + }
350 + if zoneDomain := utils.NormalizeHostname(zone.DnsName); zoneDomain == "" || !utils.HostnameMatchesBaseDomain(domain, zoneDomain) {
351 + return fmt.Errorf("gcloud managed zone %q does not cover %s", explicit, domain)
352 + }
353 + return nil
354 +}
355 +
356 +func ensureRecordSet(ctx context.Context, service *dns.Service, projectID, managedZone string, desired *dns.ResourceRecordSet) error {
357 + existing, err := listRecordSets(ctx, service, projectID, managedZone, desired.Name, desired.Type)
358 + if err != nil {
359 + return err
360 + }
361 + if len(existing) == 1 && sameRecordSet(existing[0], desired) {
362 + return nil
363 + }
364 + return replaceRecordSet(ctx, service, projectID, managedZone, existing, desired)
365 +}
366 +
367 +func replaceRecordSet(ctx context.Context, service *dns.Service, projectID, managedZone string, existing []*dns.ResourceRecordSet, desired *dns.ResourceRecordSet) error {
368 + change := &dns.Change{
369 + Additions: []*dns.ResourceRecordSet{desired},
370 + }
371 + if len(existing) > 0 {
372 + change.Deletions = existing
373 + }
374 + return applyChange(ctx, service, projectID, managedZone, change)
375 +}
376 +
377 +func applyChange(ctx context.Context, service *dns.Service, projectID, managedZone string, change *dns.Change) error {
378 + if service == nil {
379 + return errors.New("gcloud dns service is nil")
380 + }
381 +
382 + result, err := service.Changes.Create(projectID, managedZone, change).Context(ctx).Do()
383 + if err != nil {
384 + return err
385 + }
386 + if strings.EqualFold(strings.TrimSpace(result.Status), "done") {
387 + return nil
388 + }
389 +
390 + for {
391 + if !utils.SleepOrDone(ctx, defaultPollPeriod) {
392 + return ctx.Err()
393 + }
394 + result, err = service.Changes.Get(projectID, managedZone, result.Id).Context(ctx).Do()
395 + if err != nil {
396 + return err
397 + }
398 + if strings.EqualFold(strings.TrimSpace(result.Status), "done") {
399 + return nil
400 + }
401 + }
402 +}
403 +
404 +func listRecordSets(ctx context.Context, service *dns.Service, projectID, managedZone, name, recordType string) ([]*dns.ResourceRecordSet, error) {
405 + if service == nil {
406 + return nil, errors.New("gcloud dns service is nil")
407 + }
408 +
409 + recordType = strings.ToUpper(strings.TrimSpace(recordType))
410 + name = fqdn(name)
411 + out, err := service.ResourceRecordSets.List(projectID, managedZone).Name(name).Type(recordType).Context(ctx).Do()
412 + if err != nil {
413 + return nil, err
414 + }
415 +
416 + filtered := make([]*dns.ResourceRecordSet, 0, len(out.Rrsets))
417 + for _, recordSet := range out.Rrsets {
418 + if !strings.EqualFold(strings.TrimSpace(recordSet.Name), name) || !strings.EqualFold(strings.TrimSpace(recordSet.Type), recordType) {
419 + continue
420 + }
421 + filtered = append(filtered, recordSet)
422 + }
423 + return filtered, nil
424 +}
425 +
426 +func enableDNSSEC(ctx context.Context, service *dns.Service, projectID, managedZone string) error {
427 + if service == nil {
428 + return errors.New("gcloud dns service is nil")
429 + }
430 +
431 + operation, err := service.ManagedZones.Patch(projectID, managedZone, &dns.ManagedZone{
432 + DnssecConfig: &dns.ManagedZoneDnsSecConfig{
433 + State: "on",
434 + },
435 + }).Context(ctx).Do()
436 + if err != nil {
437 + return err
438 + }
439 + if strings.EqualFold(strings.TrimSpace(operation.Status), "done") {
440 + return nil
441 + }
442 +
443 + for {
444 + if !utils.SleepOrDone(ctx, defaultPollPeriod) {
445 + return ctx.Err()
446 + }
447 + operation, err = service.ManagedZoneOperations.Get(projectID, managedZone, operation.Id).Context(ctx).Do()
448 + if err != nil {
449 + return err
450 + }
451 + if strings.EqualFold(strings.TrimSpace(operation.Status), "done") {
452 + return nil
453 + }
454 + }
455 +}
456 +
457 +func listDNSKeys(ctx context.Context, service *dns.Service, projectID, managedZone string) ([]*dns.DnsKey, error) {
458 + if service == nil {
459 + return nil, errors.New("gcloud dns service is nil")
460 + }
461 +
462 + keys := make([]*dns.DnsKey, 0, 2)
463 + err := service.DnsKeys.List(projectID, managedZone).DigestType("sha256,sha384,sha1").Pages(ctx, func(page *dns.DnsKeysListResponse) error {
464 + keys = append(keys, page.DnsKeys...)
465 + return nil
466 + })
467 + if err != nil {
468 + return nil, err
469 + }
470 + return keys, nil
471 +}
472 +
473 +func dnssecStatusFromZone(zone *dns.ManagedZone, keys []*dns.DnsKey) types.DNSSECStatus {
474 + status := types.DNSSECStatus{
475 + State: strings.TrimSpace(dnssecState(zone)),
476 + }
477 + if status.DSRecord = activeDSRecord(keys); status.DSRecord != "" {
478 + status.Message = "publish the DS record at the registrar after Cloud DNS zone signing is enabled"
479 + } else if strings.EqualFold(status.State, "on") || strings.EqualFold(status.State, "transfer") {
480 + status.Message = "wait for the active Cloud DNS DS record before updating the registrar"
481 + }
482 + return status
483 +}
484 +
485 +func activeDSRecord(keys []*dns.DnsKey) string {
486 + for _, key := range keys {
487 + if key == nil || !key.IsActive || !strings.EqualFold(strings.TrimSpace(key.Type), "keySigning") {
488 + continue
489 + }
490 + if ds, ok := dnsKeyDSRecord(key); ok {
491 + return ds
492 + }
493 + }
494 + return ""
495 +}
496 +
497 +func dnsKeyDSRecord(key *dns.DnsKey) (string, bool) {
498 + if key == nil {
499 + return "", false
500 + }
501 +
502 + algorithm, ok := dnssecAlgorithmCode(key.Algorithm)
503 + if !ok {
504 + return "", false
505 + }
506 + digest, ok := preferredDigest(key.Digests)
507 + if !ok {
508 + return "", false
509 + }
510 + digestType, ok := dnssecDigestTypeCode(digest.Type)
511 + if !ok {
512 + return "", false
513 + }
514 +
515 + return fmt.Sprintf("%d %d %d %s", key.KeyTag, algorithm, digestType, strings.TrimSpace(digest.Digest)), true
516 +}
517 +
518 +func preferredDigest(digests []*dns.DnsKeyDigest) (*dns.DnsKeyDigest, bool) {
519 + for _, candidate := range []string{"sha256", "sha384", "sha1"} {
520 + for _, digest := range digests {
521 + if digest == nil || !strings.EqualFold(strings.TrimSpace(digest.Type), candidate) || strings.TrimSpace(digest.Digest) == "" {
522 + continue
523 + }
524 + return digest, true
525 + }
526 + }
527 + return nil, false
528 +}
529 +
530 +func dnssecAlgorithmCode(raw string) (int, bool) {
531 + switch strings.ToLower(strings.TrimSpace(raw)) {
532 + case "rsasha1":
533 + return 5, true
534 + case "rsasha256":
535 + return 8, true
536 + case "rsasha512":
537 + return 10, true
538 + case "ecdsap256sha256":
539 + return 13, true
540 + case "ecdsap384sha384":
541 + return 14, true
542 + default:
543 + return 0, false
544 + }
545 +}
546 +
547 +func dnssecDigestTypeCode(raw string) (int, bool) {
548 + switch strings.ToLower(strings.TrimSpace(raw)) {
549 + case "sha1":
550 + return 1, true
551 + case "sha256":
552 + return 2, true
553 + case "sha384":
554 + return 4, true
555 + default:
556 + return 0, false
557 + }
558 +}
559 +
560 +func dnssecState(zone *dns.ManagedZone) string {
561 + if zone == nil || zone.DnssecConfig == nil {
562 + return ""
563 + }
564 + return zone.DnssecConfig.State
565 +}
566 +
567 +func sameRecordSet(current, desired *dns.ResourceRecordSet) bool {
568 + if current == nil || desired == nil {
569 + return false
570 + }
571 + if !strings.EqualFold(strings.TrimSpace(current.Name), strings.TrimSpace(desired.Name)) || !strings.EqualFold(strings.TrimSpace(current.Type), strings.TrimSpace(desired.Type)) || current.Ttl != desired.Ttl {
572 + return false
573 + }
574 +
575 + currentValues := make(map[string]int, len(current.Rrdatas))
576 + for _, value := range current.Rrdatas {
577 + currentValues[txtContent(value)]++
578 + }
579 + for _, value := range desired.Rrdatas {
580 + normalized := txtContent(value)
581 + if currentValues[normalized] == 0 {
582 + return false
583 + }
584 + currentValues[normalized]--
585 + }
586 + for _, remaining := range currentValues {
587 + if remaining != 0 {
588 + return false
589 + }
590 + }
591 + return true
592 +}
593 +
594 +func recordTTL(recordSets []*dns.ResourceRecordSet) int64 {
595 + for _, recordSet := range recordSets {
596 + if recordSet != nil && recordSet.Ttl > 0 {
597 + return recordSet.Ttl
598 + }
599 + }
600 + return defaultRecordTTL
601 +}
602 +
603 +func isPublicZone(zone *dns.ManagedZone) bool {
604 + if zone == nil {
605 + return false
606 + }
607 + visibility := strings.ToLower(strings.TrimSpace(zone.Visibility))
608 + return visibility == "" || visibility == "public"
609 +}
610 +
611 +func fqdn(name string) string {
612 + normalized := utils.NormalizeHostname(name)
613 + if normalized == "" {
614 + return ""
615 + }
616 + return normalized + "."
617 +}
618 +
619 +func txtContent(raw string) string {
620 + unquoted, err := strconv.Unquote(strings.TrimSpace(raw))
621 + if err == nil {
622 + return unquoted
623 + }
624 + return strings.Trim(strings.TrimSpace(raw), "\"")
625 +}
portal/acme/gcloud/provider_test.go new
+62
@@ -0,0 +1,62 @@
1 +package gcloud
2 +
3 +import (
4 + "testing"
5 +
6 + gdns "google.golang.org/api/dns/v1"
7 +)
8 +
9 +func TestDNSKeyDSRecordPrefersSHA256(t *testing.T) {
10 + t.Parallel()
11 +
12 + record, ok := dnsKeyDSRecord(&gdns.DnsKey{
13 + Algorithm: "ecdsap256sha256",
14 + KeyTag: 12345,
15 + Type: "keySigning",
16 + IsActive: true,
17 + Digests: []*gdns.DnsKeyDigest{
18 + {Type: "sha1", Digest: "AAAA"},
19 + {Type: "sha256", Digest: "BBBB"},
20 + },
21 + })
22 + if !ok {
23 + t.Fatal("dnsKeyDSRecord() = !ok, want ok")
24 + }
25 + if record != "12345 13 2 BBBB" {
26 + t.Fatalf("dnsKeyDSRecord() = %q, want %q", record, "12345 13 2 BBBB")
27 + }
28 +}
29 +
30 +func TestDNSSECStatusFromZoneUsesActiveKeySigningKey(t *testing.T) {
31 + t.Parallel()
32 +
33 + status := dnssecStatusFromZone(&gdns.ManagedZone{
34 + DnssecConfig: &gdns.ManagedZoneDnsSecConfig{State: "on"},
35 + }, []*gdns.DnsKey{
36 + {
37 + Algorithm: "rsasha256",
38 + KeyTag: 100,
39 + Type: "zoneSigning",
40 + IsActive: true,
41 + Digests: []*gdns.DnsKeyDigest{
42 + {Type: "sha256", Digest: "IGNORE"},
43 + },
44 + },
45 + {
46 + Algorithm: "rsasha256",
47 + KeyTag: 200,
48 + Type: "keySigning",
49 + IsActive: true,
50 + Digests: []*gdns.DnsKeyDigest{
51 + {Type: "sha256", Digest: "USEME"},
52 + },
53 + },
54 + })
55 +
56 + if status.State != "on" {
57 + t.Fatalf("dnssecStatusFromZone().State = %q, want %q", status.State, "on")
58 + }
59 + if status.DSRecord != "200 8 2 USEME" {
60 + t.Fatalf("dnssecStatusFromZone().DSRecord = %q, want %q", status.DSRecord, "200 8 2 USEME")
61 + }
62 +}
portal/acme/provider.go
+7
@@ -8,12 +8,14 @@ import (
8 "github.com/go-acme/lego/v4/challenge"
9
10 "github.com/gosuda/portal/v2/portal/acme/cloudflare"
11 + "github.com/gosuda/portal/v2/portal/acme/gcloud"
12 "github.com/gosuda/portal/v2/portal/acme/route53"
13 "github.com/gosuda/portal/v2/types"
14 )
15
16 const (
17 TypeCloudflare = "cloudflare"
18 + TypeGCloud = "gcloud"
19 TypeRoute53 = "route53"
20 )
21
@@ -32,6 +34,11 @@ func NewDNSProvider(providerType string, cfg Config) (DNSProvider, error) {
34 return nil, nil
35 case TypeCloudflare:
36 return cloudflare.New(cfg.CloudflareToken), nil
37 + case TypeGCloud:
38 + return gcloud.New(gcloud.Config{
39 + ProjectID: cfg.GCPProjectID,
40 + ManagedZone: cfg.GCPManagedZone,
41 + }), nil
42 case TypeRoute53:
43 return route53.New(route53.Config{
44 AccessKeyID: cfg.AWSAccessKeyID,
portal/acme/route53/provider.go
+2 -25
@@ -4,7 +4,6 @@ import (
4 "context"
5 "errors"
6 "fmt"
7 - "net"
7 "strconv"
8 "strings"
9 "time"
@@ -88,7 +87,7 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
87 if baseDomain == "" {
88 return errors.New("base domain is required")
89 }
91 - if err := validateIPv4(publicIPv4); err != nil {
90 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
91 return err
92 }
93
@@ -249,7 +248,7 @@ func findHostedZoneID(ctx context.Context, client *awsroute53.Client, domain, ex
248 return "", errors.New("route53 client is nil")
249 }
250
252 - candidates := domainCandidates(domain)
251 + candidates := utils.DomainCandidates(domain)
252 if len(candidates) == 0 {
253 return "", fmt.Errorf("invalid base domain for hosted zone lookup: %q", domain)
254 }
@@ -446,28 +445,6 @@ func deleteRecordSet(ctx context.Context, client *awsroute53.Client, hostedZoneI
445 return err
446 }
447
449 -func validateIPv4(raw string) error {
450 - ip := net.ParseIP(strings.TrimSpace(raw))
451 - if ip == nil || ip.To4() == nil {
452 - return fmt.Errorf("invalid ipv4 address: %q", raw)
453 - }
454 - return nil
455 -}
456 -
457 -func domainCandidates(domain string) []string {
458 - normalized := utils.NormalizeHostname(domain)
459 - parts := strings.Split(normalized, ".")
460 - if len(parts) < 2 {
461 - return nil
462 - }
463 -
464 - candidates := make([]string, 0, len(parts)-1)
465 - for i := range len(parts) - 1 {
466 - candidates = append(candidates, strings.Join(parts[i:], "."))
467 - }
468 - return candidates
469 -}
470 -
448 func (p *Provider) awsRegion() string {
449 if p == nil {
450 return defaultAWSRegion
portal/acme/route53/provider_test.go
-12
@@ -5,18 +5,6 @@ import (
5 "testing"
6 )
7
8 -func TestDomainCandidates(t *testing.T) {
9 - t.Parallel()
10 -
11 - got := domainCandidates("portal.example.com")
12 - if len(got) != 2 {
13 - t.Fatalf("len(domainCandidates) = %d, want 2", len(got))
14 - }
15 - if got[0] != "portal.example.com" || got[1] != "example.com" {
16 - t.Fatalf("domainCandidates() = %v, want [portal.example.com example.com]", got)
17 - }
18 -}
19 -
8 func TestFindHostedZoneIDExplicitOverride(t *testing.T) {
9 t.Parallel()
10
utils/utils.go
+22
@@ -191,6 +191,20 @@ func NormalizeBaseDomain(domain string) string {
191 return strings.TrimPrefix(NormalizeHostname(domain), "*.")
192 }
193
194 +func DomainCandidates(domain string) []string {
195 + normalized := NormalizeHostname(domain)
196 + parts := strings.Split(normalized, ".")
197 + if len(parts) < 2 {
198 + return nil
199 + }
200 +
201 + candidates := make([]string, 0, len(parts)-1)
202 + for i := range len(parts) - 1 {
203 + candidates = append(candidates, strings.Join(parts[i:], "."))
204 + }
205 + return candidates
206 +}
207 +
208 func HostnameMatchesBaseDomain(hostname, baseDomain string) bool {
209 hostname = NormalizeHostname(hostname)
210 baseDomain = NormalizeBaseDomain(baseDomain)
@@ -505,6 +519,14 @@ func AddrString(addr net.Addr) string {
519 return addr.String()
520 }
521
522 +func ValidateIPv4(raw string) error {
523 + ip := net.ParseIP(strings.TrimSpace(raw))
524 + if ip == nil || ip.To4() == nil {
525 + return fmt.Errorf("invalid ipv4 address: %q", raw)
526 + }
527 + return nil
528 +}
529 +
530 func RandomHex(size int) (string, error) {
531 buf := make([]byte, size)
532 if _, err := io.ReadFull(rand.Reader, buf); err != nil {
utils/utils_test.go
+21
@@ -135,6 +135,16 @@ func TestParseCIDRsRejectsInvalidValue(t *testing.T) {
135 }
136 }
137
138 +func TestDomainCandidates(t *testing.T) {
139 + t.Parallel()
140 +
141 + got := DomainCandidates("portal.example.com")
142 + want := []string{"portal.example.com", "example.com"}
143 + if !reflect.DeepEqual(got, want) {
144 + t.Fatalf("DomainCandidates() = %v, want %v", got, want)
145 + }
146 +}
147 +
148 func TestNormalizeTargetAddr(t *testing.T) {
149 t.Parallel()
150
@@ -147,6 +157,17 @@ func TestNormalizeTargetAddr(t *testing.T) {
157 }
158 }
159
160 +func TestValidateIPv4(t *testing.T) {
161 + t.Parallel()
162 +
163 + if err := ValidateIPv4("203.0.113.10"); err != nil {
164 + t.Fatalf("ValidateIPv4() error = %v", err)
165 + }
166 + if err := ValidateIPv4("not-an-ip"); err == nil {
167 + t.Fatal("ValidateIPv4() error = nil, want invalid ip error")
168 + }
169 +}
170 +
171 func TestNormalizeDNSLabel(t *testing.T) {
172 t.Parallel()
173