main
go 573 lines 14.4 KB
Raw
1 package njalla
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/go-acme/lego/v4/challenge"
16 legonjalla "github.com/go-acme/lego/v4/providers/dns/njalla"
17
18 "github.com/gosuda/portal-tunnel/v2/utils"
19 )
20
21 const (
22 apiEndpoint = "https://njal.la/api/1/"
23 defaultRecordTTL = 60
24 defaultTimeout = 30 * time.Second
25 )
26
27 type Provider struct {
28 token string
29 zones *utils.Snapshot[map[string]string]
30 }
31
32 func New(token string) *Provider {
33 return &Provider{
34 token: strings.TrimSpace(token),
35 zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
36 }
37 }
38
39 func (p *Provider) Name() string {
40 return "njalla"
41 }
42
43 func (p *Provider) ChallengeProvider(context.Context) (challenge.Provider, error) {
44 if p == nil {
45 return nil, errors.New("njalla provider is nil")
46 }
47 if p.token == "" {
48 return nil, errors.New("njalla token is required")
49 }
50
51 cfg := legonjalla.NewDefaultConfig()
52 cfg.Token = p.token
53
54 provider, err := legonjalla.NewDNSProviderConfig(cfg)
55 if err != nil {
56 return nil, fmt.Errorf("create njalla lego provider: %w", err)
57 }
58 return provider, nil
59 }
60
61 func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
62 if p == nil {
63 return errors.New("njalla provider is nil")
64 }
65 baseDomain = utils.NormalizeBaseDomain(baseDomain)
66 if baseDomain == "" {
67 return errors.New("base domain is required")
68 }
69 if err := utils.ValidateIPv4(publicIPv4); err != nil {
70 return err
71 }
72
73 client, zone, err := p.clientAndZone(ctx, baseDomain)
74 if err != nil {
75 return err
76 }
77
78 for _, recordName := range []string{baseDomain, "*." + baseDomain} {
79 if err := ensureRecord(ctx, client, zone, recordName, "A", strings.TrimSpace(publicIPv4)); err != nil {
80 return fmt.Errorf("upsert njalla A record %s: %w", recordName, err)
81 }
82 }
83 return nil
84 }
85
86 func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
87 if p == nil {
88 return errors.New("njalla provider is nil")
89 }
90 name = utils.NormalizeHostname(name)
91 if name == "" {
92 return errors.New("record name is required")
93 }
94 if err := utils.ValidateIPv4(publicIPv4); err != nil {
95 return err
96 }
97
98 client, zone, err := p.clientAndZone(ctx, name)
99 if err != nil {
100 return err
101 }
102 if err := ensureRecord(ctx, client, zone, name, "A", strings.TrimSpace(publicIPv4)); err != nil {
103 return fmt.Errorf("upsert njalla A record %s: %w", name, err)
104 }
105 return nil
106 }
107
108 func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
109 if p == nil {
110 return errors.New("njalla provider is nil")
111 }
112 name = utils.NormalizeHostname(name)
113 if name == "" {
114 return errors.New("record name is required")
115 }
116
117 client, zone, err := p.clientAndZone(ctx, name)
118 if err != nil {
119 return err
120 }
121 if err := deleteRecords(ctx, client, zone, name, "A", ""); err != nil {
122 return fmt.Errorf("delete njalla A record %s: %w", name, err)
123 }
124 return nil
125 }
126
127 func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
128 if p == nil {
129 return errors.New("njalla provider is nil")
130 }
131 name = utils.NormalizeHostname(name)
132 if name == "" {
133 return errors.New("record name is required")
134 }
135 value = strings.TrimSpace(value)
136 if value == "" {
137 return errors.New("txt record value is required")
138 }
139
140 client, zone, err := p.clientAndZone(ctx, name)
141 if err != nil {
142 return err
143 }
144 if err := ensureTXTRecord(ctx, client, zone, name, value); err != nil {
145 return fmt.Errorf("upsert njalla TXT record %s: %w", name, err)
146 }
147 return nil
148 }
149
150 func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
151 if p == nil {
152 return errors.New("njalla provider is nil")
153 }
154 name = utils.NormalizeHostname(name)
155 if name == "" {
156 return errors.New("record name is required")
157 }
158 matchPrefix = strings.TrimSpace(matchPrefix)
159 if matchPrefix == "" {
160 return errors.New("txt record match prefix is required")
161 }
162
163 client, zone, err := p.clientAndZone(ctx, name)
164 if err != nil {
165 return err
166 }
167 if err := deleteRecords(ctx, client, zone, name, "TXT", matchPrefix); err != nil {
168 return fmt.Errorf("delete njalla TXT records %s: %w", name, err)
169 }
170 return nil
171 }
172
173 func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, _ uint16, _, _, content string) error {
174 if p == nil {
175 return errors.New("njalla provider is nil")
176 }
177 name = utils.NormalizeHostname(name)
178 if name == "" {
179 return errors.New("record name is required")
180 }
181 content = strings.TrimSpace(content)
182 if content == "" {
183 return errors.New("https record content is required")
184 }
185
186 client, zone, err := p.clientAndZone(ctx, name)
187 if err != nil {
188 return err
189 }
190 if err := ensureRecord(ctx, client, zone, name, "HTTPS", content); err != nil {
191 return fmt.Errorf("upsert njalla HTTPS record %s: %w", name, err)
192 }
193 return nil
194 }
195
196 func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
197 if p == nil {
198 return errors.New("njalla provider is nil")
199 }
200 name = utils.NormalizeHostname(name)
201 if name == "" {
202 return errors.New("record name is required")
203 }
204
205 client, zone, err := p.clientAndZone(ctx, name)
206 if err != nil {
207 return err
208 }
209 if err := deleteRecords(ctx, client, zone, name, "HTTPS", ""); err != nil {
210 return fmt.Errorf("delete njalla HTTPS record %s: %w", name, err)
211 }
212 return nil
213 }
214
215 func (p *Provider) EnsureDNSSEC(_ context.Context, baseDomain string) (state, dsRecord, message string, err error) {
216 if p == nil {
217 return "", "", "", errors.New("njalla provider is nil")
218 }
219 baseDomain = utils.NormalizeBaseDomain(baseDomain)
220 if baseDomain == "" {
221 return "", "", "", errors.New("base domain is required")
222 }
223 if p.token == "" {
224 return "", "", "", errors.New("njalla token is required")
225 }
226 return "", "", "", errors.New("njalla dnssec automation is not supported; use a DNSSEC-capable provider for ENS gasless automation")
227 }
228
229 func (p *Provider) clientAndZone(ctx context.Context, domain string) (*apiClient, string, error) {
230 client, err := p.newClient()
231 if err != nil {
232 return nil, "", err
233 }
234 zone, err := p.findZone(ctx, client, domain)
235 if err != nil {
236 return nil, "", err
237 }
238 return client, zone, nil
239 }
240
241 func (p *Provider) newClient() (*apiClient, error) {
242 if p == nil {
243 return nil, errors.New("njalla provider is nil")
244 }
245 if p.token == "" {
246 return nil, errors.New("njalla token is required")
247 }
248 return &apiClient{
249 token: p.token,
250 endpoint: apiEndpoint,
251 httpClient: utils.NewHTTPClient(utils.WithHTTPTimeout(defaultTimeout)),
252 }, nil
253 }
254
255 func (p *Provider) findZone(ctx context.Context, client *apiClient, domain string) (string, error) {
256 if client == nil {
257 return "", errors.New("njalla client is nil")
258 }
259 domain = utils.NormalizeHostname(domain)
260 candidates := utils.DomainCandidates(domain)
261
262 zones := p.zones.Load()
263 for _, candidate := range candidates {
264 if zone := zones[candidate]; zone != "" {
265 return zone, nil
266 }
267 }
268
269 var lastErr error
270 for _, candidate := range candidates {
271 if _, err := client.listRecords(ctx, candidate); err != nil {
272 lastErr = err
273 continue
274 }
275 p.zones.UpdateCopy(func(zones *map[string]string) {
276 if *zones == nil {
277 *zones = make(map[string]string)
278 }
279 (*zones)[candidate] = candidate
280 })
281 return candidate, nil
282 }
283 if lastErr != nil {
284 return "", fmt.Errorf("no njalla zone found for %s: %w", domain, lastErr)
285 }
286 return "", fmt.Errorf("no njalla zone found for %s", domain)
287 }
288
289 func ensureRecord(ctx context.Context, client *apiClient, zone, fqdn, recordType, content string) error {
290 recordName, err := relativeRecordName(fqdn, zone)
291 if err != nil {
292 return err
293 }
294 content = strings.TrimSpace(content)
295 if content == "" {
296 return errors.New("record content is required")
297 }
298
299 existing, err := listRecords(ctx, client, zone, fqdn, recordType)
300 if err != nil {
301 return err
302 }
303 needsAdd := true
304 for _, record := range existing {
305 if strings.TrimSpace(record.Content) == content {
306 needsAdd = false
307 continue
308 }
309 if err := client.removeRecord(ctx, record.ID.String(), zone); err != nil {
310 return err
311 }
312 }
313 if !needsAdd {
314 return nil
315 }
316 _, err = client.addRecord(ctx, record{
317 Domain: zone,
318 Name: recordName,
319 Type: strings.ToUpper(strings.TrimSpace(recordType)),
320 TTL: defaultRecordTTL,
321 Content: content,
322 })
323 return err
324 }
325
326 func ensureTXTRecord(ctx context.Context, client *apiClient, zone, fqdn, value string) error {
327 recordName, err := relativeRecordName(fqdn, zone)
328 if err != nil {
329 return err
330 }
331 existing, err := listRecords(ctx, client, zone, fqdn, "TXT")
332 if err != nil {
333 return err
334 }
335 for _, record := range existing {
336 if txtContent(record.Content) == value {
337 return nil
338 }
339 }
340 _, err = client.addRecord(ctx, record{
341 Domain: zone,
342 Name: recordName,
343 Type: "TXT",
344 TTL: defaultRecordTTL,
345 Content: value,
346 })
347 return err
348 }
349
350 func deleteRecords(ctx context.Context, client *apiClient, zone, fqdn, recordType, matchPrefix string) error {
351 existing, err := listRecords(ctx, client, zone, fqdn, recordType)
352 if err != nil {
353 return err
354 }
355 for _, record := range existing {
356 if matchPrefix != "" && !strings.HasPrefix(txtContent(record.Content), matchPrefix) {
357 continue
358 }
359 if err := client.removeRecord(ctx, record.ID.String(), zone); err != nil {
360 return err
361 }
362 }
363 return nil
364 }
365
366 func listRecords(ctx context.Context, client *apiClient, zone, fqdn, recordType string) ([]record, error) {
367 if client == nil {
368 return nil, errors.New("njalla client is nil")
369 }
370 recordName, err := relativeRecordName(fqdn, zone)
371 if err != nil {
372 return nil, err
373 }
374 recordType = strings.ToUpper(strings.TrimSpace(recordType))
375
376 records, err := client.listRecords(ctx, zone)
377 if err != nil {
378 return nil, err
379 }
380 filtered := make([]record, 0, len(records))
381 for _, record := range records {
382 if !strings.EqualFold(strings.TrimSpace(record.Type), recordType) || !sameRecordName(record.Name, recordName, fqdn, zone) {
383 continue
384 }
385 filtered = append(filtered, record)
386 }
387 return filtered, nil
388 }
389
390 func relativeRecordName(fqdn, zone string) (string, error) {
391 fqdn = utils.NormalizeHostname(fqdn)
392 zone = utils.NormalizeBaseDomain(zone)
393 if fqdn == "" {
394 return "", errors.New("record name is required")
395 }
396 if zone == "" {
397 return "", errors.New("njalla zone is required")
398 }
399 if fqdn == zone {
400 return "@", nil
401 }
402 suffix := "." + zone
403 if !strings.HasSuffix(fqdn, suffix) {
404 return "", fmt.Errorf("hostname %q is outside njalla zone %q", fqdn, zone)
405 }
406 return strings.TrimSuffix(fqdn, suffix), nil
407 }
408
409 func sameRecordName(recordName, expected, fqdn, zone string) bool {
410 recordName = utils.NormalizeHostname(recordName)
411 expected = strings.TrimSpace(strings.ToLower(expected))
412 fqdn = utils.NormalizeHostname(fqdn)
413 zone = utils.NormalizeBaseDomain(zone)
414
415 if recordName == expected {
416 return true
417 }
418 if expected == "@" && (recordName == "" || recordName == zone || recordName == fqdn) {
419 return true
420 }
421 return recordName == fqdn
422 }
423
424 func txtContent(raw string) string {
425 unquoted, err := strconv.Unquote(strings.TrimSpace(raw))
426 if err == nil {
427 return unquoted
428 }
429 return strings.Trim(strings.TrimSpace(raw), "\"")
430 }
431
432 type apiClient struct {
433 token string
434 endpoint string
435 httpClient *http.Client
436 }
437
438 type apiRequest struct {
439 Method string `json:"method"`
440 Params any `json:"params"`
441 }
442
443 type apiResponse struct {
444 Error *apiError `json:"error,omitempty"`
445 Result json.RawMessage `json:"result,omitempty"`
446 }
447
448 type apiError struct {
449 Code int `json:"code"`
450 Message string `json:"message"`
451 }
452
453 func (e apiError) Error() string {
454 return fmt.Sprintf("code: %d, message: %s", e.Code, e.Message)
455 }
456
457 type recordID string
458
459 func (id recordID) String() string {
460 return string(id)
461 }
462
463 func (id *recordID) UnmarshalJSON(data []byte) error {
464 var value any
465 decoder := json.NewDecoder(bytes.NewReader(data))
466 decoder.UseNumber()
467 if err := decoder.Decode(&value); err != nil {
468 return err
469 }
470 switch v := value.(type) {
471 case nil:
472 *id = ""
473 case string:
474 *id = recordID(v)
475 case json.Number:
476 *id = recordID(v.String())
477 default:
478 return fmt.Errorf("unsupported njalla record id %s", strings.TrimSpace(string(data)))
479 }
480 return nil
481 }
482
483 type record struct {
484 ID recordID `json:"id,omitempty"`
485 Content string `json:"content,omitempty"`
486 Domain string `json:"domain,omitempty"`
487 Name string `json:"name,omitempty"`
488 TTL int `json:"ttl,omitempty"`
489 Type string `json:"type,omitempty"`
490 }
491
492 type recordsResult struct {
493 Records []record `json:"records,omitempty"`
494 }
495
496 func (c *apiClient) addRecord(ctx context.Context, rec record) (*record, error) {
497 var out record
498 if err := c.do(ctx, "add-record", rec, &out); err != nil {
499 return nil, err
500 }
501 return &out, nil
502 }
503
504 func (c *apiClient) removeRecord(ctx context.Context, id, domain string) error {
505 id = strings.TrimSpace(id)
506 if id == "" {
507 return errors.New("njalla record id is required")
508 }
509 return c.do(ctx, "remove-record", record{ID: recordID(id), Domain: domain}, nil)
510 }
511
512 func (c *apiClient) listRecords(ctx context.Context, domain string) ([]record, error) {
513 var out recordsResult
514 if err := c.do(ctx, "list-records", record{Domain: domain}, &out); err != nil {
515 return nil, err
516 }
517 return out.Records, nil
518 }
519
520 func (c *apiClient) do(ctx context.Context, method string, params any, out any) error {
521 if c == nil {
522 return errors.New("njalla client is nil")
523 }
524 endpoint := strings.TrimSpace(c.endpoint)
525 if endpoint == "" {
526 endpoint = apiEndpoint
527 }
528 body, err := json.Marshal(apiRequest{Method: method, Params: params})
529 if err != nil {
530 return fmt.Errorf("marshal njalla api request: %w", err)
531 }
532
533 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
534 if err != nil {
535 return fmt.Errorf("create njalla api request: %w", err)
536 }
537 req.Header.Set("Accept", "application/json")
538 req.Header.Set("Authorization", "Njalla "+c.token)
539 req.Header.Set("Content-Type", "application/json")
540
541 client := c.httpClient
542 if client == nil {
543 client = utils.DefaultHTTPClient
544 }
545 resp, err := client.Do(req)
546 if err != nil {
547 return err
548 }
549 defer resp.Body.Close()
550
551 raw, err := io.ReadAll(resp.Body)
552 if err != nil {
553 return fmt.Errorf("read njalla api response: %w", err)
554 }
555 if resp.StatusCode != http.StatusOK {
556 return fmt.Errorf("njalla api %s failed: %s", method, resp.Status)
557 }
558
559 var envelope apiResponse
560 if err := json.Unmarshal(raw, &envelope); err != nil {
561 return fmt.Errorf("decode njalla api response: %w", err)
562 }
563 if envelope.Error != nil {
564 return envelope.Error
565 }
566 if out == nil || len(envelope.Result) == 0 {
567 return nil
568 }
569 if err := json.Unmarshal(envelope.Result, out); err != nil {
570 return fmt.Errorf("decode njalla api result: %w", err)
571 }
572 return nil
573 }