main
go 482 lines 12.6 KB
Raw
1 package hetzner
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "slices"
9 "strings"
10 "time"
11
12 "github.com/go-acme/lego/v4/challenge"
13 legohetzner "github.com/go-acme/lego/v4/providers/dns/hetzner"
14 "github.com/hetznercloud/hcloud-go/v2/hcloud"
15 "github.com/hetznercloud/hcloud-go/v2/hcloud/exp/zoneutil"
16
17 "github.com/gosuda/portal-tunnel/v2/utils"
18 )
19
20 const defaultRecordTTL = 60
21
22 type Provider struct {
23 apiToken string
24
25 zones *utils.Snapshot[map[string]string]
26 }
27
28 func New(apiToken string) *Provider {
29 return &Provider{
30 apiToken: strings.TrimSpace(apiToken),
31 zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
32 }
33 }
34
35 func (p *Provider) Name() string {
36 return "hetzner"
37 }
38
39 func (p *Provider) ChallengeProvider(context.Context) (challenge.Provider, error) {
40 if p == nil {
41 return nil, errors.New("hetzner provider is nil")
42 }
43 if p.apiToken == "" {
44 return nil, errors.New("hetzner api token is required")
45 }
46
47 cfg := legohetzner.NewDefaultConfig()
48 cfg.APIToken = p.apiToken
49
50 provider, err := legohetzner.NewDNSProviderConfig(cfg)
51 if err != nil {
52 return nil, fmt.Errorf("create hetzner lego provider: %w", err)
53 }
54 return provider, nil
55 }
56
57 func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
58 if p == nil {
59 return errors.New("hetzner provider is nil")
60 }
61 baseDomain = utils.NormalizeBaseDomain(baseDomain)
62 if baseDomain == "" {
63 return errors.New("base domain is required")
64 }
65 if err := utils.ValidateIPv4(publicIPv4); err != nil {
66 return err
67 }
68
69 client, zone, err := p.clientAndZone(ctx, baseDomain)
70 if err != nil {
71 return err
72 }
73
74 for _, recordName := range []string{baseDomain, "*." + baseDomain} {
75 if err := ensureRecord(ctx, client, zone, recordName, hcloud.ZoneRRSetTypeA, strings.TrimSpace(publicIPv4)); err != nil {
76 return fmt.Errorf("upsert hetzner A record %s: %w", recordName, err)
77 }
78 }
79 return nil
80 }
81
82 func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
83 if p == nil {
84 return errors.New("hetzner provider is nil")
85 }
86 name = utils.NormalizeHostname(name)
87 if name == "" {
88 return errors.New("record name is required")
89 }
90 if err := utils.ValidateIPv4(publicIPv4); err != nil {
91 return err
92 }
93
94 client, zone, err := p.clientAndZone(ctx, name)
95 if err != nil {
96 return err
97 }
98 if err := ensureRecord(ctx, client, zone, name, hcloud.ZoneRRSetTypeA, strings.TrimSpace(publicIPv4)); err != nil {
99 return fmt.Errorf("upsert hetzner A record %s: %w", name, err)
100 }
101 return nil
102 }
103
104 func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
105 if p == nil {
106 return errors.New("hetzner provider is nil")
107 }
108 name = utils.NormalizeHostname(name)
109 if name == "" {
110 return errors.New("record name is required")
111 }
112
113 client, zone, err := p.clientAndZone(ctx, name)
114 if err != nil {
115 return err
116 }
117 if err := deleteRRSet(ctx, client, zone, name, hcloud.ZoneRRSetTypeA); err != nil {
118 return fmt.Errorf("delete hetzner A record %s: %w", name, err)
119 }
120 return nil
121 }
122
123 func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
124 if p == nil {
125 return errors.New("hetzner provider is nil")
126 }
127 name = utils.NormalizeHostname(name)
128 if name == "" {
129 return errors.New("record name is required")
130 }
131 value = strings.TrimSpace(value)
132 if value == "" {
133 return errors.New("txt record value is required")
134 }
135
136 client, zone, err := p.clientAndZone(ctx, name)
137 if err != nil {
138 return err
139 }
140 if err := ensureTXTRecord(ctx, client, zone, name, value); err != nil {
141 return fmt.Errorf("upsert hetzner TXT record %s: %w", name, err)
142 }
143 return nil
144 }
145
146 func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
147 if p == nil {
148 return errors.New("hetzner provider is nil")
149 }
150 name = utils.NormalizeHostname(name)
151 if name == "" {
152 return errors.New("record name is required")
153 }
154 matchPrefix = strings.TrimSpace(matchPrefix)
155 if matchPrefix == "" {
156 return errors.New("txt record match prefix is required")
157 }
158
159 client, zone, err := p.clientAndZone(ctx, name)
160 if err != nil {
161 return err
162 }
163 if err := deleteTXTRecords(ctx, client, zone, name, matchPrefix); err != nil {
164 return fmt.Errorf("delete hetzner TXT records %s: %w", name, err)
165 }
166 return nil
167 }
168
169 func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, _ uint16, _, _, content string) error {
170 if p == nil {
171 return errors.New("hetzner provider is nil")
172 }
173 name = utils.NormalizeHostname(name)
174 if name == "" {
175 return errors.New("record name is required")
176 }
177 content = strings.TrimSpace(content)
178 if content == "" {
179 return errors.New("https record content is required")
180 }
181
182 client, zone, err := p.clientAndZone(ctx, name)
183 if err != nil {
184 return err
185 }
186 if err := ensureRecord(ctx, client, zone, name, hcloud.ZoneRRSetTypeHTTPS, content); err != nil {
187 return fmt.Errorf("upsert hetzner HTTPS record %s: %w", name, err)
188 }
189 return nil
190 }
191
192 func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
193 if p == nil {
194 return errors.New("hetzner provider is nil")
195 }
196 name = utils.NormalizeHostname(name)
197 if name == "" {
198 return errors.New("record name is required")
199 }
200
201 client, zone, err := p.clientAndZone(ctx, name)
202 if err != nil {
203 return err
204 }
205 if err := deleteRRSet(ctx, client, zone, name, hcloud.ZoneRRSetTypeHTTPS); err != nil {
206 return fmt.Errorf("delete hetzner HTTPS record %s: %w", name, err)
207 }
208 return nil
209 }
210
211 func (p *Provider) EnsureDNSSEC(_ context.Context, baseDomain string) (state, dsRecord, message string, err error) {
212 if p == nil {
213 return "", "", "", errors.New("hetzner provider is nil")
214 }
215 baseDomain = utils.NormalizeBaseDomain(baseDomain)
216 if baseDomain == "" {
217 return "", "", "", errors.New("base domain is required")
218 }
219 if p.apiToken == "" {
220 return "", "", "", errors.New("hetzner api token is required")
221 }
222 return "", "", "", errors.New("hetzner dns does not support provider-side dnssec signing; use a DNSSEC-capable provider for ENS gasless automation")
223 }
224
225 func (p *Provider) clientAndZone(ctx context.Context, domain string) (*hcloud.Client, *hcloud.Zone, error) {
226 client, err := p.newClient()
227 if err != nil {
228 return nil, nil, err
229 }
230 zone, err := p.findZone(ctx, client, domain)
231 if err != nil {
232 return nil, nil, err
233 }
234 return client, zone, nil
235 }
236
237 func (p *Provider) newClient() (*hcloud.Client, error) {
238 if p == nil {
239 return nil, errors.New("hetzner provider is nil")
240 }
241 if p.apiToken == "" {
242 return nil, errors.New("hetzner api token is required")
243 }
244 return hcloud.NewClient(
245 hcloud.WithToken(p.apiToken),
246 hcloud.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
247 hcloud.WithPollOpts(hcloud.PollOpts{BackoffFunc: hcloud.ConstantBackoff(2 * time.Second)}),
248 ), nil
249 }
250
251 func (p *Provider) findZone(ctx context.Context, client *hcloud.Client, domain string) (*hcloud.Zone, error) {
252 if client == nil {
253 return nil, errors.New("hetzner client is nil")
254 }
255 domain = utils.NormalizeHostname(domain)
256 candidates := utils.DomainCandidates(domain)
257
258 zones := p.zones.Load()
259 for _, candidate := range candidates {
260 if zoneName := zones[candidate]; zoneName != "" {
261 return &hcloud.Zone{Name: zoneName}, nil
262 }
263 }
264
265 for _, candidate := range candidates {
266 zone, _, err := client.Zone.GetByName(ctx, candidate)
267 if err != nil {
268 return nil, fmt.Errorf("get hetzner zone %s: %w", candidate, err)
269 }
270 if zone == nil {
271 continue
272 }
273 zoneName := utils.NormalizeBaseDomain(zone.Name)
274 if zoneName == "" {
275 continue
276 }
277 p.zones.UpdateCopy(func(zones *map[string]string) {
278 if *zones == nil {
279 *zones = make(map[string]string)
280 }
281 (*zones)[candidate] = zoneName
282 })
283 return zone, nil
284 }
285
286 return nil, fmt.Errorf("no hetzner zone found for %s", domain)
287 }
288
289 func ensureRecord(ctx context.Context, client *hcloud.Client, zone *hcloud.Zone, fqdn string, recordType hcloud.ZoneRRSetType, value string) error {
290 recordName, err := relativeRecordName(fqdn, zone)
291 if err != nil {
292 return err
293 }
294 value = strings.TrimSpace(value)
295 if value == "" {
296 return errors.New("record value is required")
297 }
298 desired := []hcloud.ZoneRRSetRecord{{Value: value}}
299
300 existing, _, err := client.Zone.GetRRSetByNameAndType(ctx, zone, recordName, recordType)
301 if err != nil {
302 return err
303 }
304 if existing == nil {
305 ttl := defaultRecordTTL
306 result, _, err := client.Zone.CreateRRSet(ctx, zone, hcloud.ZoneRRSetCreateOpts{
307 Name: recordName,
308 Type: recordType,
309 TTL: &ttl,
310 Records: desired,
311 })
312 if err != nil {
313 return err
314 }
315 return waitAction(ctx, client, result.Action)
316 }
317 if sameRecords(existing.Records, desired) {
318 return nil
319 }
320
321 action, _, err := client.Zone.SetRRSetRecords(ctx, existing, hcloud.ZoneRRSetSetRecordsOpts{Records: desired})
322 if err != nil {
323 return err
324 }
325 return waitAction(ctx, client, action)
326 }
327
328 func ensureTXTRecord(ctx context.Context, client *hcloud.Client, zone *hcloud.Zone, fqdn, value string) error {
329 recordName, err := relativeRecordName(fqdn, zone)
330 if err != nil {
331 return err
332 }
333 formatted := zoneutil.FormatTXTRecord(value)
334 desired := []hcloud.ZoneRRSetRecord{{Value: formatted}}
335
336 existing, _, err := client.Zone.GetRRSetByNameAndType(ctx, zone, recordName, hcloud.ZoneRRSetTypeTXT)
337 if err != nil {
338 return err
339 }
340 if existing == nil {
341 ttl := defaultRecordTTL
342 result, _, err := client.Zone.CreateRRSet(ctx, zone, hcloud.ZoneRRSetCreateOpts{
343 Name: recordName,
344 Type: hcloud.ZoneRRSetTypeTXT,
345 TTL: &ttl,
346 Records: desired,
347 })
348 if err != nil {
349 return err
350 }
351 return waitAction(ctx, client, result.Action)
352 }
353 for _, record := range existing.Records {
354 if txtContent(record.Value) == value {
355 return nil
356 }
357 }
358
359 ttl := defaultRecordTTL
360 action, _, err := client.Zone.AddRRSetRecords(ctx, existing, hcloud.ZoneRRSetAddRecordsOpts{
361 Records: desired,
362 TTL: &ttl,
363 })
364 if err != nil {
365 return err
366 }
367 return waitAction(ctx, client, action)
368 }
369
370 func deleteRRSet(ctx context.Context, client *hcloud.Client, zone *hcloud.Zone, fqdn string, recordType hcloud.ZoneRRSetType) error {
371 recordName, err := relativeRecordName(fqdn, zone)
372 if err != nil {
373 return err
374 }
375 existing, _, err := client.Zone.GetRRSetByNameAndType(ctx, zone, recordName, recordType)
376 if err != nil {
377 return err
378 }
379 if existing == nil {
380 return nil
381 }
382
383 result, _, err := client.Zone.DeleteRRSet(ctx, existing)
384 if err != nil {
385 return err
386 }
387 return waitAction(ctx, client, result.Action)
388 }
389
390 func deleteTXTRecords(ctx context.Context, client *hcloud.Client, zone *hcloud.Zone, fqdn, matchPrefix string) error {
391 recordName, err := relativeRecordName(fqdn, zone)
392 if err != nil {
393 return err
394 }
395 existing, _, err := client.Zone.GetRRSetByNameAndType(ctx, zone, recordName, hcloud.ZoneRRSetTypeTXT)
396 if err != nil {
397 return err
398 }
399 if existing == nil {
400 return nil
401 }
402
403 remaining := existing.Records[:0]
404 for _, record := range existing.Records {
405 if strings.HasPrefix(txtContent(record.Value), matchPrefix) {
406 continue
407 }
408 remaining = append(remaining, record)
409 }
410 if len(remaining) == len(existing.Records) {
411 return nil
412 }
413 if len(remaining) == 0 {
414 result, _, err := client.Zone.DeleteRRSet(ctx, existing)
415 if err != nil {
416 return err
417 }
418 return waitAction(ctx, client, result.Action)
419 }
420
421 action, _, err := client.Zone.SetRRSetRecords(ctx, existing, hcloud.ZoneRRSetSetRecordsOpts{Records: remaining})
422 if err != nil {
423 return err
424 }
425 return waitAction(ctx, client, action)
426 }
427
428 func relativeRecordName(fqdn string, zone *hcloud.Zone) (string, error) {
429 fqdn = utils.NormalizeHostname(fqdn)
430 if fqdn == "" {
431 return "", errors.New("record name is required")
432 }
433 if zone == nil {
434 return "", errors.New("hetzner zone is required")
435 }
436 zoneName := utils.NormalizeBaseDomain(zone.Name)
437 if zoneName == "" && zone.ID != 0 {
438 return "", errors.New("hetzner zone name is required")
439 }
440 if fqdn == zoneName {
441 return "@", nil
442 }
443 suffix := "." + zoneName
444 if !strings.HasSuffix(fqdn, suffix) {
445 return "", fmt.Errorf("hostname %q is outside hetzner zone %q", fqdn, zoneName)
446 }
447 return strings.TrimSuffix(fqdn, suffix), nil
448 }
449
450 func sameRecords(current, desired []hcloud.ZoneRRSetRecord) bool {
451 if len(current) != len(desired) {
452 return false
453 }
454 current = slices.Clone(current)
455 desired = slices.Clone(desired)
456 slices.SortFunc(current, compareRecords)
457 slices.SortFunc(desired, compareRecords)
458 for i := range current {
459 if current[i] != desired[i] {
460 return false
461 }
462 }
463 return true
464 }
465
466 func compareRecords(a, b hcloud.ZoneRRSetRecord) int {
467 if cmp := strings.Compare(a.Value, b.Value); cmp != 0 {
468 return cmp
469 }
470 return strings.Compare(a.Comment, b.Comment)
471 }
472
473 func txtContent(raw string) string {
474 return zoneutil.ParseTXTRecord(strings.TrimSpace(raw))
475 }
476
477 func waitAction(ctx context.Context, client *hcloud.Client, action *hcloud.Action) error {
478 if action == nil {
479 return nil
480 }
481 return client.Action.WaitFor(ctx, action)
482 }