Skip to content

Commit

Permalink
Resolve comments
Browse files Browse the repository at this point in the history
  • Loading branch information
amroessam committed Jan 11, 2025
1 parent c278547 commit b4c51d7
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 77 deletions.
13 changes: 7 additions & 6 deletions docs/spaceship.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## Configuration

Docs can be found for the spaceship API [here](https://docs.spaceship.dev/).
Docs can be found for the [spaceship API](https://docs.spaceship.dev/).

### Example

```json
Expand All @@ -12,8 +13,8 @@ Docs can be found for the spaceship API [here](https://docs.spaceship.dev/).
"provider": "spaceship",
"domain": "example.com",
"host": "subdomain",
"apikey": "YOUR_API_KEY",
"apisecret": "YOUR_API_SECRET",
"api_key": "YOUR_API_KEY",
"api_secret": "YOUR_API_SECRET",
"ip_version": "ipv4"
}
]
Expand All @@ -22,9 +23,9 @@ Docs can be found for the spaceship API [here](https://docs.spaceship.dev/).

### Compulsory parameters

- `"domain"` is the domain to update. It can be a root domain (i.e. `example.com`) or a subdomain (i.e. `subdomain.example.com`).
- `"apikey"` is your API key which can be obtained from [API Manager](https://www.spaceship.com/application/api-manager/).
- `"apisecret"` is your API secret which is provided along with your API key in the API Manager.
- `"domain"` is the domain to update. It can be a root domain (i.e. `example.com`) or a subdomain (i.e. `subdomain.example.com`), or a wildcard (i.e. `*.example.com`). In case of a wildcard, it only works if there is no existing wildcard records of any record type.
- `"api_key"` is your API key which can be obtained from [API Manager](https://www.spaceship.com/application/api-manager/).
- `"api_secret"` is your API secret which is provided along with your API key in the API Manager.

### Optional parameters

Expand Down
1 change: 1 addition & 0 deletions internal/provider/errors/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var (
ErrIPReceivedMismatch = errors.New("mismatching IP address received")
ErrIPSentMalformed = errors.New("malformed IP address sent")
ErrNoService = errors.New("no service")
ErrRateLimit = errors.New("rate limit exceeded")
ErrPrivateIPSent = errors.New("private IP cannot be routed")
ErrReceivedNoIP = errors.New("received no IP address in response")
ErrReceivedNoResult = errors.New("received no result in response")
Expand Down
50 changes: 39 additions & 11 deletions internal/provider/providers/spaceship/createrecord.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import (
"net/url"

"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/utils"
)

func (p *Provider) createRecord(ctx context.Context, client *http.Client,
recordType, address string) error {

const defaultTTL = 3600

u := url.URL{
Scheme: "https",
Host: "spaceship.dev",
Expand All @@ -26,29 +28,28 @@ func (p *Provider) createRecord(ctx context.Context, client *http.Client,
Type string `json:"type"`
Name string `json:"name"`
Address string `json:"address"`
TTL int `json:"ttl"`
TTL uint32 `json:"ttl"`
} `json:"items"`
}{
Force: true,
Items: []struct {
Type string `json:"type"`
Name string `json:"name"`
Address string `json:"address"`
TTL int `json:"ttl"`
TTL uint32 `json:"ttl"`
}{{
Type: recordType,
Name: p.owner,
Address: address,
TTL: 3600,
TTL: defaultTTL,
}},
}

var requestBody bytes.Buffer
if err := json.NewEncoder(&requestBody).Encode(createData); err != nil {
requestBody := bytes.NewBuffer(nil)
if err := json.NewEncoder(requestBody).Encode(createData); err != nil {
return fmt.Errorf("encoding request body: %w", err)
}

request, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), &requestBody)
request, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), requestBody)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
Expand All @@ -61,9 +62,36 @@ func (p *Provider) createRecord(ctx context.Context, client *http.Client,
defer response.Body.Close()

if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode,
utils.BodyToSingleLine(response.Body))
var apiError APIError
if err := json.NewDecoder(response.Body).Decode(&apiError); err != nil {
return fmt.Errorf("%w: %d", errors.ErrHTTPStatusNotValid, response.StatusCode)
}

switch response.StatusCode {
case http.StatusUnauthorized:
return fmt.Errorf("%w: invalid API credentials", errors.ErrAuth)
case http.StatusNotFound:
if apiError.Detail == "SOA record for domain "+p.domain+" not found." {
return fmt.Errorf("%w: domain %s must be configured in Spaceship first",
errors.ErrDomainNotFound, p.domain)
}
return fmt.Errorf("%w: %s", errors.ErrDomainNotFound, apiError.Detail)
case http.StatusBadRequest:
var details string
for _, d := range apiError.Data {
if d.Field != "" {
details += fmt.Sprintf(" %s: %s;", d.Field, d.Details)
} else {
details += fmt.Sprintf(" %s;", d.Details)
}
}
return fmt.Errorf("%w:%s", errors.ErrBadRequest, details)
case http.StatusTooManyRequests:
return fmt.Errorf("%w: rate limit exceeded", errors.ErrRateLimit)
default:
return fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, apiError.Detail)
}
}

return nil
Expand Down
48 changes: 48 additions & 0 deletions internal/provider/providers/spaceship/deleterecord.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package spaceship

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"

"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/utils"
)

func (p *Provider) deleteRecord(ctx context.Context, client *http.Client, record Record) error {
u := url.URL{
Scheme: "https",
Host: "spaceship.dev",
Path: fmt.Sprintf("/api/v1/dns/records/%s", p.domain),
}

deleteData := []Record{record}

var requestBody bytes.Buffer
if err := json.NewEncoder(&requestBody).Encode(deleteData); err != nil {
return fmt.Errorf("encoding request body: %w", err)
}

request, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), &requestBody)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
p.setHeaders(request)

response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()

if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode,
utils.BodyToSingleLine(response.Body))
}

return nil
}
42 changes: 36 additions & 6 deletions internal/provider/providers/spaceship/getrecord.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,9 @@ import (
"fmt"
"net/http"
"net/url"
)

type Record struct {
Type string `json:"type"`
Name string `json:"name"`
Address string `json:"address"`
}
"github.com/qdm12/ddns-updater/internal/provider/errors"
)

func (p *Provider) getRecords(ctx context.Context, client *http.Client) (
records []Record, err error) {
Expand All @@ -23,6 +19,7 @@ func (p *Provider) getRecords(ctx context.Context, client *http.Client) (
}

values := url.Values{}
// pagination values, mandatory for the API
values.Set("take", "100")
values.Set("skip", "0")
u.RawQuery = values.Encode()
Expand All @@ -39,6 +36,39 @@ func (p *Provider) getRecords(ctx context.Context, client *http.Client) (
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
var apiError APIError
if err := json.NewDecoder(response.Body).Decode(&apiError); err != nil {
return nil, fmt.Errorf("%w: %d", errors.ErrHTTPStatusNotValid, response.StatusCode)
}

switch response.StatusCode {
case http.StatusUnauthorized:
return nil, fmt.Errorf("%w: invalid API credentials", errors.ErrAuth)
case http.StatusNotFound:
if apiError.Detail == "SOA record for domain "+p.domain+" not found." {
return nil, fmt.Errorf("%w: domain %s must be configured in Spaceship first",
errors.ErrDomainNotFound, p.domain)
}
return nil, fmt.Errorf("%w: %s", errors.ErrRecordResourceSetNotFound, apiError.Detail)
case http.StatusBadRequest:
var details string
for _, d := range apiError.Data {
if d.Field != "" {
details += fmt.Sprintf(" %s: %s;", d.Field, d.Details)
} else {
details += fmt.Sprintf(" %s;", d.Details)
}
}
return nil, fmt.Errorf("%w:%s", errors.ErrBadRequest, details)
case http.StatusTooManyRequests:
return nil, fmt.Errorf("%w: rate limit exceeded", errors.ErrRateLimit)
default:
return nil, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, apiError.Detail)
}
}

var recordsResponse struct {
Items []Record `json:"items"`
}
Expand Down
4 changes: 2 additions & 2 deletions internal/provider/providers/spaceship/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ func New(data json.RawMessage, domain, owner string,
p *Provider, err error,
) {
extraSettings := struct {
APIKey string `json:"apikey"`
APISecret string `json:"apisecret"`
APIKey string `json:"api_key"`
APISecret string `json:"api_secret"`
}{}
err = json.Unmarshal(data, &extraSettings)
if err != nil {
Expand Down
17 changes: 17 additions & 0 deletions internal/provider/providers/spaceship/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package spaceship

// APIError represents the Spaceship API error response
type APIError struct {
Detail string `json:"detail"`
Data []struct {
Field string `json:"field"`
Details string `json:"details"`
} `json:"data"`
}

// Record represents a DNS record
type Record struct {
Type string `json:"type"`
Name string `json:"name"`
Address string `json:"address"`
}
67 changes: 15 additions & 52 deletions internal/provider/providers/spaceship/updaterecord.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
package spaceship

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"net/url"

"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/utils"
)

func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
Expand All @@ -25,73 +20,41 @@ func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Add
return netip.Addr{}, fmt.Errorf("getting records: %w", err)
}

var existingRecord *Record
var existingRecord Record

for _, record := range records {
if record.Type == recordType && record.Name == p.owner {
recordCopy := record
existingRecord = &recordCopy
existingRecord = record
break
}
}

if existingRecord == nil {
if err := p.createRecord(ctx, client, recordType, ip.String()); err != nil {
if existingRecord.Name == "" {
err := p.createRecord(ctx, client, recordType, ip.String())
if err != nil {
return netip.Addr{}, fmt.Errorf("creating record: %w", err)
}
return ip, nil
}

currentIP, err := netip.ParseAddr(existingRecord.Address)
if err == nil && currentIP.Compare(ip) == 0 {
return ip, nil // IP is already up to date
}

if err := p.deleteRecord(ctx, client, existingRecord); err != nil {
return netip.Addr{}, fmt.Errorf("deleting record: %w", err)
}

if err := p.createRecord(ctx, client, recordType, ip.String()); err != nil {
return netip.Addr{}, fmt.Errorf("creating record: %w", err)
}

return ip, nil
}

func (p *Provider) deleteRecord(ctx context.Context, client *http.Client, record *Record) error {
u := url.URL{
Scheme: "https",
Host: "spaceship.dev",
Path: fmt.Sprintf("/api/v1/dns/records/%s", p.domain),
if err != nil {
return netip.Addr{}, fmt.Errorf("parsing existing IP address: %w", err)
}

deleteData := []Record{{
Type: record.Type,
Name: record.Name,
Address: record.Address,
}}

var requestBody bytes.Buffer
if err := json.NewEncoder(&requestBody).Encode(deleteData); err != nil {
return fmt.Errorf("encoding request body: %w", err)
if currentIP.Compare(ip) == 0 {
return ip, nil // IP is already up to date
}

request, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), &requestBody)
err = p.deleteRecord(ctx, client, existingRecord)
if err != nil {
return fmt.Errorf("creating request: %w", err)
return netip.Addr{}, fmt.Errorf("deleting record: %w", err)
}
p.setHeaders(request)

response, err := client.Do(request)
err = p.createRecord(ctx, client, recordType, ip.String())
if err != nil {
return err
}
defer response.Body.Close()

if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode,
utils.BodyToSingleLine(response.Body))
return netip.Addr{}, fmt.Errorf("creating record: %w", err)
}

return nil
return ip, nil
}

0 comments on commit b4c51d7

Please sign in to comment.