Skip to content

Commit ce77bc8

Browse files
committed
Add DNS provider for No-IP
1 parent ac10927 commit ce77bc8

7 files changed

Lines changed: 657 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package internal
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"time"
12+
13+
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
14+
"github.com/go-acme/lego/v4/providers/dns/internal/useragent"
15+
"golang.org/x/oauth2"
16+
)
17+
18+
const defaultBaseURL = "https://api.noip.com"
19+
20+
// Client the No-IP API client.
21+
type Client struct {
22+
BaseURL *url.URL
23+
httpClient *http.Client
24+
}
25+
26+
// NewClient creates a new Client.
27+
func NewClient(hc *http.Client) (*Client, error) {
28+
baseURL, _ := url.Parse(defaultBaseURL)
29+
30+
if hc == nil {
31+
hc = &http.Client{Timeout: 10 * time.Second}
32+
}
33+
34+
return &Client{
35+
BaseURL: baseURL,
36+
httpClient: hc,
37+
}, nil
38+
}
39+
40+
// CreateRData creates a record.
41+
// https://developer.noip.com/reference/v1-dns-records-create-rdata
42+
func (c *Client) CreateRData(ctx context.Context, zone, name, dnsType string, data RData) error {
43+
endpoint := c.BaseURL.JoinPath("v1", "dns", "records", zone, name, "rrsets", dnsType, "rdata")
44+
45+
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, []RData{data})
46+
if err != nil {
47+
return err
48+
}
49+
50+
return c.do(req, nil)
51+
}
52+
53+
// DeleteRDataByLabel deletes a record by label.
54+
// https://developer.noip.com/reference/v1-dns-records-delete-rdata-by-label
55+
func (c *Client) DeleteRDataByLabel(ctx context.Context, zone, name, dnsType, label string) error {
56+
endpoint := c.BaseURL.JoinPath("v1", "dns", "records", zone, name, "rrsets", dnsType, "rdata", label)
57+
58+
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
59+
if err != nil {
60+
return err
61+
}
62+
63+
return c.do(req, nil)
64+
}
65+
66+
func (c *Client) do(req *http.Request, result any) error {
67+
useragent.SetHeader(req.Header)
68+
69+
resp, err := c.httpClient.Do(req)
70+
if err != nil {
71+
return errutils.NewHTTPDoError(req, err)
72+
}
73+
74+
defer func() { _ = resp.Body.Close() }()
75+
76+
if resp.StatusCode/100 != 2 {
77+
return parseError(req, resp)
78+
}
79+
80+
if result == nil {
81+
return nil
82+
}
83+
84+
raw, err := io.ReadAll(resp.Body)
85+
if err != nil {
86+
return errutils.NewReadResponseError(req, resp.StatusCode, err)
87+
}
88+
89+
err = json.Unmarshal(raw, result)
90+
if err != nil {
91+
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
92+
}
93+
94+
return nil
95+
}
96+
97+
func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
98+
buf := new(bytes.Buffer)
99+
100+
if payload != nil {
101+
err := json.NewEncoder(buf).Encode(payload)
102+
if err != nil {
103+
return nil, fmt.Errorf("failed to create request JSON body: %w", err)
104+
}
105+
}
106+
107+
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
108+
if err != nil {
109+
return nil, fmt.Errorf("unable to create request: %w", err)
110+
}
111+
112+
req.Header.Set("Accept", "application/json")
113+
114+
if payload != nil {
115+
req.Header.Set("Content-Type", "application/json")
116+
}
117+
118+
return req, nil
119+
}
120+
121+
func parseError(req *http.Request, resp *http.Response) error {
122+
raw, _ := io.ReadAll(resp.Body)
123+
124+
var apiResp APIResponse[any]
125+
126+
err := json.Unmarshal(raw, &apiResp)
127+
if err != nil {
128+
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
129+
}
130+
131+
return fmt.Errorf("[status code %d] %w", resp.StatusCode, apiResp.Errors)
132+
}
133+
134+
func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client {
135+
if client == nil {
136+
client = &http.Client{Timeout: 5 * time.Second}
137+
}
138+
139+
client.Transport = &oauth2.Transport{
140+
Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}),
141+
Base: client.Transport,
142+
}
143+
144+
return client
145+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package internal
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"net/url"
7+
"testing"
8+
9+
"github.com/go-acme/lego/v4/platform/tester/servermock"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func mockBuilder() *servermock.Builder[*Client] {
14+
return servermock.NewBuilder[*Client](
15+
func(server *httptest.Server) (*Client, error) {
16+
client, err := NewClient(OAuthStaticAccessToken(server.Client(), "secret"))
17+
if err != nil {
18+
return nil, err
19+
}
20+
21+
client.BaseURL, _ = url.Parse(server.URL)
22+
23+
return client, nil
24+
},
25+
servermock.CheckHeader().
26+
WithJSONHeaders().
27+
WithAuthorization("Bearer secret"),
28+
)
29+
}
30+
31+
func TestClient_CreateRData(t *testing.T) {
32+
client := mockBuilder().
33+
Route("POST /v1/dns/records/example.com/foo/rrsets/TXT/rdata",
34+
servermock.Noop(),
35+
servermock.CheckRequestJSONBody(`[{"value":"txtTXTtxt","label":"mylabel"}]`)).
36+
Build(t)
37+
38+
data := RData{
39+
Value: "txtTXTtxt",
40+
Label: "mylabel",
41+
}
42+
43+
err := client.CreateRData(t.Context(), "example.com", "foo", "TXT", data)
44+
require.NoError(t, err)
45+
}
46+
47+
func TestClient_CreateRData_error(t *testing.T) {
48+
client := mockBuilder().
49+
Route("POST /v1/dns/records/example.com/foo/rrsets/TXT/rdata",
50+
servermock.ResponseFromFixture("full.json").
51+
WithStatusCode(http.StatusUnauthorized)).
52+
Build(t)
53+
54+
data := RData{
55+
Value: "txtTXTtxt",
56+
Label: "mylabel",
57+
}
58+
59+
err := client.CreateRData(t.Context(), "example.com", "foo", "TXT", data)
60+
require.EqualError(t, err, "[status code 401] id: err_eXgLfYUj, code: 2451, title: invalid request query, detail: unknown variant `summary`, expected `detail` or `name_only`, location: query; id: err_oWfehpGt, code: 3809, title: invalid url encoded request body")
61+
}
62+
63+
func TestClient_DeleteRDataByLabel(t *testing.T) {
64+
client := mockBuilder().
65+
Route("DELETE /v1/dns/records/example.com/foo/rrsets/TXT/rdata/mylabel",
66+
servermock.Noop()).
67+
Build(t)
68+
69+
err := client.DeleteRDataByLabel(t.Context(), "example.com", "foo", "TXT", "mylabel")
70+
require.NoError(t, err)
71+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"data": [
3+
{
4+
"name": "example.com",
5+
"created_at": "2019-08-24T14:15:22Z",
6+
"updated_at": "2024-08-24T14:15:22Z"
7+
}
8+
],
9+
"page": {
10+
"limit": 0,
11+
"offset": 0,
12+
"total": 10
13+
},
14+
"errors": [
15+
{
16+
"id": "err_eXgLfYUj",
17+
"code": "2451",
18+
"title": "invalid request query",
19+
"detail": "unknown variant `summary`, expected `detail` or `name_only`",
20+
"location": "query"
21+
},
22+
{
23+
"id": "err_oWfehpGt",
24+
"code": "3809",
25+
"title": "invalid url encoded request body"
26+
}
27+
],
28+
"warnings": [
29+
{
30+
"title": "Resource expiring soon",
31+
"code": "W1001",
32+
"detail": "The related resource will expire soon"
33+
}
34+
]
35+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package internal
2+
3+
import "strings"
4+
5+
type APIResponse[T any] struct {
6+
Data T `json:"data,omitempty"`
7+
Page *Page `json:"page,omitempty"`
8+
Errors Errors `json:"errors,omitempty"`
9+
Warnings []Warning `json:"warnings,omitempty"`
10+
}
11+
12+
type Errors []Error
13+
14+
func (e Errors) Error() string {
15+
if e == nil {
16+
return "API error"
17+
}
18+
19+
var allMsg []string
20+
21+
for _, item := range e {
22+
var msg []string
23+
24+
if item.ID != "" {
25+
msg = append(msg, "id: "+item.ID)
26+
}
27+
28+
if item.Code != "" {
29+
msg = append(msg, "code: "+item.Code)
30+
}
31+
32+
if item.Title != "" {
33+
msg = append(msg, "title: "+item.Title)
34+
}
35+
36+
if item.Detail != "" {
37+
msg = append(msg, "detail: "+item.Detail)
38+
}
39+
40+
if item.Location != "" {
41+
msg = append(msg, "location: "+item.Location)
42+
}
43+
44+
if item.Pointer != "" {
45+
msg = append(msg, "pointer: "+item.Pointer)
46+
}
47+
48+
allMsg = append(allMsg, strings.Join(msg, ", "))
49+
}
50+
51+
return strings.Join(allMsg, "; ")
52+
}
53+
54+
type Error struct {
55+
ID string `json:"id,omitempty"`
56+
Code string `json:"code,omitempty"`
57+
Title string `json:"title,omitempty"`
58+
Detail string `json:"detail,omitempty"`
59+
Location string `json:"location,omitempty"`
60+
Pointer string `json:"pointer,omitempty"`
61+
}
62+
63+
type Warning struct {
64+
Title string `json:"title,omitempty"`
65+
Code string `json:"code,omitempty"`
66+
Detail string `json:"detail,omitempty"`
67+
}
68+
69+
type Page struct {
70+
Limit int `json:"limit,omitempty"`
71+
Offset int `json:"offset,omitempty"`
72+
Total int `json:"total,omitempty"`
73+
}
74+
75+
type RData struct {
76+
Value string `json:"value,omitempty"`
77+
Label string `json:"label,omitempty"`
78+
}

0 commit comments

Comments
 (0)