Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(provider): add support for Spaceship #907

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Added spaceship
  • Loading branch information
Amr Essam committed Jan 11, 2025
commit 7eb185dd7dbf2cec1f8b7da12c400bb25bceb3db
32 changes: 32 additions & 0 deletions docs/spaceship.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Spaceship

## Configuration

### Example

```json
{
"settings": [
{
"provider": "spaceship",
"domain": "example.com",
"host": "subdomain",
"apikey": "YOUR_API_KEY",
"apisecret": "YOUR_API_SECRET",
"ip_version": "ipv4"
}
]
}
```

### 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.

### Optional parameters

- `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
- `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.
- `"ttl"` is the record TTL which defaults to 3600 seconds.
2 changes: 2 additions & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const (
Route53 models.Provider = "route53"
SelfhostDe models.Provider = "selfhost.de"
Servercow models.Provider = "servercow"
Spaceship models.Provider = "spaceship"
Spdyn models.Provider = "spdyn"
Strato models.Provider = "strato"
Variomedia models.Provider = "variomedia"
Expand Down Expand Up @@ -104,6 +105,7 @@ func ProviderChoices() []models.Provider {
Porkbun,
Route53,
SelfhostDe,
Spaceship,
Spdyn,
Strato,
Variomedia,
Expand Down
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/route53"
"github.com/qdm12/ddns-updater/internal/provider/providers/selfhostde"
"github.com/qdm12/ddns-updater/internal/provider/providers/servercow"
"github.com/qdm12/ddns-updater/internal/provider/providers/spaceship"
"github.com/qdm12/ddns-updater/internal/provider/providers/spdyn"
"github.com/qdm12/ddns-updater/internal/provider/providers/strato"
"github.com/qdm12/ddns-updater/internal/provider/providers/variomedia"
Expand Down Expand Up @@ -178,6 +179,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return selfhostde.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Servercow:
return servercow.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Spaceship:
return spaceship.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Spdyn:
return spdyn.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Strato:
Expand Down
70 changes: 70 additions & 0 deletions internal/provider/providers/spaceship/createrecord.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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) createRecord(ctx context.Context, client *http.Client,
recordType, address string) error {
u := url.URL{
Scheme: "https",
Host: "spaceship.dev",
Path: fmt.Sprintf("/api/v1/dns/records/%s", p.domain),
}

createData := struct {
Force bool `json:"force"`
Items []struct {
Type string `json:"type"`
Name string `json:"name"`
Address string `json:"address"`
TTL int `json:"ttl"`
} `json:"items"`
}{
Force: true,
Items: []struct {
Type string `json:"type"`
Name string `json:"name"`
Address string `json:"address"`
TTL int `json:"ttl"`
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make this uint32 since it cannot be higher than that nor negative.

Suggested change
TTL int `json:"ttl"`
TTL uint32 `json:"ttl"`

}{{
Type: recordType,
Name: p.owner,
Address: address,
TTL: 3600,
}},
}

var requestBody bytes.Buffer
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)
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
}
51 changes: 51 additions & 0 deletions internal/provider/providers/spaceship/getrecord.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package spaceship

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

type Record struct {
Type string `json:"type"`
Name string `json:"name"`
Address string `json:"address"`
}

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

values := url.Values{}
values.Set("take", "100")
values.Set("skip", "0")
u.RawQuery = values.Encode()

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

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

var recordsResponse struct {
Items []Record `json:"items"`
}

if err := json.NewDecoder(response.Body).Decode(&recordsResponse); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}

return recordsResponse.Items, nil
}
112 changes: 112 additions & 0 deletions internal/provider/providers/spaceship/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package spaceship

import (
"encoding/json"
"fmt"
"net/http"
"net/netip"

"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/headers"
"github.com/qdm12/ddns-updater/internal/provider/utils"
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
)

type Provider struct {
domain string
owner string
ipVersion ipversion.IPVersion
ipv6Suffix netip.Prefix
apiKey string
apiSecret string
}

func New(data json.RawMessage, domain, owner string,
ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
p *Provider, err error,
) {
extraSettings := struct {
APIKey string `json:"apikey"`
APISecret string `json:"apisecret"`
}{}
err = json.Unmarshal(data, &extraSettings)
if err != nil {
return nil, err
}

err = validateSettings(domain, extraSettings.APIKey, extraSettings.APISecret)
if err != nil {
return nil, fmt.Errorf("validating provider specific settings: %w", err)
}

return &Provider{
domain: domain,
owner: owner,
ipVersion: ipVersion,
ipv6Suffix: ipv6Suffix,
apiKey: extraSettings.APIKey,
apiSecret: extraSettings.APISecret,
}, nil
}

func validateSettings(domain, apiKey, apiSecret string) (err error) {
err = utils.CheckDomain(domain)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err)
}

switch {
case apiKey == "":
return fmt.Errorf("%w", errors.ErrAPIKeyNotSet)
case apiSecret == "":
return fmt.Errorf("%w", errors.ErrAPISecretNotSet)
}
return nil
}

func (p *Provider) String() string {
return utils.ToString(p.domain, p.owner, constants.Spaceship, p.ipVersion)
}

func (p *Provider) Domain() string {
return p.domain
}

func (p *Provider) Owner() string {
return p.owner
}

func (p *Provider) IPVersion() ipversion.IPVersion {
return p.ipVersion
}

func (p *Provider) IPv6Suffix() netip.Prefix {
return p.ipv6Suffix
}

func (p *Provider) Proxied() bool {
return false
}

func (p *Provider) BuildDomainName() string {
return utils.BuildDomainName(p.owner, p.domain)
}

func (p *Provider) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()),
Owner: p.Owner(),
Provider: fmt.Sprintf("<a href=\"https://www.spaceship.com/application/advanced-dns-application/manage/%s\">Spaceship</a>", p.domain),
IPVersion: p.ipVersion.String(),
}
}

func (p *Provider) setHeaders(request *http.Request) {
headers.SetUserAgent(request)
headers.SetContentType(request, "application/json")
headers.SetAccept(request, "application/json")
request.Header.Set("X-Api-Key", p.apiKey)
request.Header.Set("X-Api-Secret", p.apiSecret)
}
97 changes: 97 additions & 0 deletions internal/provider/providers/spaceship/updaterecord.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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) {
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}

records, err := p.getRecords(ctx, client)
if err != nil {
return netip.Addr{}, fmt.Errorf("getting records: %w", err)
}

var existingRecord *Record
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit no need for a pointer, you can just check the Name field is empty or not I think?

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

if existingRecord == nil {
if err := p.createRecord(ctx, client, recordType, ip.String()); 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),
}

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)
}

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
}