Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
jandelgado committed Aug 2, 2021
0 parents commit 2b70a9d
Show file tree
Hide file tree
Showing 8 changed files with 736 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
_gitignore/
*~
22 changes: 22 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
MIT License

Copyright (c) 2021 Jan Delgado
Copyright (c) 2020 matthiasng

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# IONOS DNS API for `libdns`

This package implements the libdns interfaces for the [IONOS DNS
API (beta)](https://developer.hosting.ionos.de/docs/dns)

## Authenticating

To authenticate you need to supply a IONOS API Key, as described on
https://developer.hosting.ionos.de/docs/getstarted

## Example

Here's a minimal example of how to get all DNS records for zone.

```go
package main

import (
"context"
"encoding/json"
"fmt"
"os"
"time"

"github.com/libdns/ionos"
)

func main() {
token := os.Getenv("LIBDNS_IONOS_TOKEN")
if token == "" {
panic("LIBDNS_IONOS_TOKEN not set")
}

zone := os.Getenv("LIBDNS_IONOS_ZONE")
if zone == "" {
panic("LIBDNS_IONOS_ZONE not set")
}

p := &ionos.Provider{
AuthAPIToken: token,
}

records, err := p.GetRecords(context.TODO(), zone)
if err != nil {
panic(err)
}

out, _ := json.MarshalIndent(records, "", " ")
fmt.Println(string(out))
}
```

## Test

The file `provisioner_test.go` contains an end-to-end test suite, using the
original IONOS API service (i.e. no test doubles - be careful). To run the
tests:

```console
$ export LIBDNS_IONOS_TEST_ZONE=mydomain.org
$ export LIBDNS_IONOS_TEST_TOKEN=aaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
$ go test -v
=== RUN Test_AppendRecords
--- PASS: Test_AppendRecords (43.01s)
=== RUN Test_DeleteRecords
--- PASS: Test_DeleteRecords (23.91s)
=== RUN Test_GetRecords
--- PASS: Test_GetRecords (30.96s)
=== RUN Test_SetRecords
--- PASS: Test_SetRecords (51.39s)
PASS
ok github.com/libdns/ionos 149.277s
```

The tests were taken from the [Hetzner libdns
module](https://github.com/libdns/hetzner) and are not modified.

## Author

Original Work (C) Copyright 2020 by matthiasng (based on https://github.com/libdns/hetzner),
this version (C) Copyright 2021 by Jan Delgado (github.com/jandelgado).

License: MIT

273 changes: 273 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
// libdns client for IONOS DNS API
package ionos

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"time"

"github.com/libdns/libdns"
)

const (
APIEndpoint = "https://api.hosting.ionos.com/dns/v1"
)

type getAllZonesResponse struct {
Zones []zoneDescriptor
}

type zoneDescriptor struct {
Name string `json:"name"`
ID string `json:"id"`
Type string `json:"type"`
}

type getZoneResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Records []zoneRecord `json:"records"`
}

type zoneRecord struct {
ID string `json:"id"`
Name string `json:"name"`
RootName string `json:"rootName"`
Type string `json:"type"`
Content string `json:"content"`
ChangeDate string `json:"changeDate"`
TTL int `json:"ttl"`
Prio int `json:"prio"`
Disabled bool `json:"disabled"`
}

type record struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL int `json:"ttl"`
Prio int `json:"prio"`
Disabled bool `json:"disabled,omitempty"` // TODO default=true
}

func doRequest(token string, request *http.Request) ([]byte, error) {
request.Header.Add("X-API-Key", token)
request.Header.Add("Content-Type", "application/json")

client := &http.Client{} // no timeout set because request is w/ context
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()

if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("%s (%d)", http.StatusText(response.StatusCode), response.StatusCode)
}
return ioutil.ReadAll(response.Body)
}

// GET /v1/zones
func getAllZones(ctx context.Context, token string) (getAllZonesResponse, error) {
uri := fmt.Sprintf("%s/zones", APIEndpoint)
req, err := http.NewRequestWithContext(ctx, "GET", uri, nil)
data, err := doRequest(token, req)

if err != nil {
return getAllZonesResponse{}, err
}

// parse top-level JSON array
zones := make([]zoneDescriptor, 0)
err = json.Unmarshal(data, &zones)
return getAllZonesResponse{zones}, err
}

// findZoneDescriptor finds the zoneDescriptor for the named zoned in all zones
func findZoneDescriptor(ctx context.Context, token string, zoneName string) (zoneDescriptor, error) {
allZones, err := getAllZones(ctx, token)
if err != nil {
return zoneDescriptor{}, err
}
for _, zone := range allZones.Zones {
if zone.Name == zoneName {
return zone, nil
}
}
return zoneDescriptor{}, fmt.Errorf("zone not found")
}

// getZone reads a zone by it's IONOS zoneID
// /v1/zones/{zoneId}
func getZone(ctx context.Context, token string, zoneID string) (getZoneResponse, error) {
uri := fmt.Sprintf("%s/zones/%s", APIEndpoint, zoneID)
req, err := http.NewRequestWithContext(ctx, "GET", uri, nil)
data, err := doRequest(token, req)
var result getZoneResponse
if err != nil {
return result, err
}

err = json.Unmarshal(data, &result)
return result, err
}

// findRecordInZone searches all records in the given zone for a record with
// the given name and type and returns this record on success
func findRecordInZone(ctx context.Context, token, zoneName, name, typ string) (zoneRecord, error) {
zoneResp, err := getZoneByName(ctx, token, zoneName)
if err != nil {
return zoneRecord{}, err
}

for _, r := range zoneResp.Records {
if r.Name == name && r.Type == typ {
return r, nil
}
}
return zoneRecord{}, fmt.Errorf("record not found")
}

// getZoneByName reads a zone by it's zone name, requiring 2 REST calls to
// the IONOS API
func getZoneByName(ctx context.Context, token, zoneName string) (getZoneResponse, error) {
zoneDes, err := findZoneDescriptor(ctx, token, zoneName)
if err != nil {
return getZoneResponse{}, err
}
return getZone(ctx, token, zoneDes.ID)
}

// getAllRecords returns all records from the given zone
func getAllRecords(ctx context.Context, token string, zoneName string) ([]libdns.Record, error) {
zoneResp, err := getZoneByName(ctx, token, zoneName)
if err != nil {
return nil, err
}
records := []libdns.Record{}
for _, r := range zoneResp.Records {
records = append(records, libdns.Record{
ID: r.ID,
Type: r.Type,
// libdns Name is partially qualified, relative to zone
Name: libdns.RelativeName(r.Name, zoneResp.Name),
Value: r.Content,
TTL: time.Duration(r.TTL) * time.Second,
})
}
return records, nil
}

// createRecord creates a DNS record in the given zone
// POST /v1/zones/{zoneId}/records
func createRecord(ctx context.Context, token string, zoneName string, r libdns.Record) (libdns.Record, error) {
zoneResp, err := getZoneByName(ctx, token, zoneName)
if err != nil {
return libdns.Record{}, err
}

reqData := []record{
{Type: r.Type,
// IONOS: Name is fully qualified
Name: libdns.AbsoluteName(r.Name, zoneName),
Content: r.Value,
TTL: int(r.TTL.Seconds()),
}}

reqBuffer, err := json.Marshal(reqData)
if err != nil {
return libdns.Record{}, err
}

uri := fmt.Sprintf("%s/zones/%s/records", APIEndpoint, zoneResp.ID)
req, err := http.NewRequestWithContext(ctx, "POST", uri, bytes.NewBuffer(reqBuffer))
_, err = doRequest(token, req) // no data is returned on success, just 201

if err != nil {
return libdns.Record{}, err
}

// re-read the record so we get it's ID. IONOS API does not return the
// ID currently in the response.
createdRec, err := findRecordInZone(ctx, token, zoneName, libdns.AbsoluteName(r.Name, zoneName), r.Type)
if err != nil {
// Thats bad, the record was created, but we can not read it ?!
// in this case we just return an empty ID
log.Printf("ERROR could not find record: %+v", err)
}

return libdns.Record{
ID: createdRec.ID,
Type: r.Type,
// always return partially qualified name, relative to zone for libdns
Name: libdns.RelativeName(unFQDN(r.Name), zoneName),
Value: r.Value,
TTL: r.TTL,
}, nil
}

// DELETE /v1/zones/{zoneId}/records/{recordId}
func deleteRecord(ctx context.Context, token, zoneName string, record libdns.Record) error {
zoneResp, err := getZoneByName(ctx, token, zoneName)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "DELETE",
fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneResp.ID, record.ID), nil)
if err != nil {
return err
}
_, err = doRequest(token, req)
return err
}

// /v1/zones/{zoneId}/records/{recordId}
func updateRecord(ctx context.Context, token string, zone string, r libdns.Record) (libdns.Record, error) {
zoneDes, err := getZoneByName(ctx, token, zone)
if err != nil {
return libdns.Record{}, err
}

reqData := record{
Type: r.Type,
Name: libdns.AbsoluteName(r.Name, zone),
Content: r.Value,
TTL: int(r.TTL.Seconds()),
}

reqBuffer, err := json.Marshal(reqData)
if err != nil {
return libdns.Record{}, err
}

req, err := http.NewRequestWithContext(ctx, "PUT",
fmt.Sprintf("%s/zones/%s/records/%s", APIEndpoint, zoneDes.ID, r.ID),
bytes.NewBuffer(reqBuffer))

if err != nil {
return libdns.Record{}, err
}

_, err = doRequest(token, req)

return libdns.Record{
ID: r.ID,
Type: r.Type,
Name: r.Name,
Value: r.Value,
TTL: time.Duration(r.TTL) * time.Second,
}, err
}

func createOrUpdateRecord(ctx context.Context, token string, zone string, r libdns.Record) (libdns.Record, error) {
if r.ID == "" {
return createRecord(ctx, token, zone, r)
}
return updateRecord(ctx, token, zone, r)
}
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/libdns/ionos

go 1.16

require github.com/libdns/libdns v0.2.1
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis=
github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
Loading

0 comments on commit 2b70a9d

Please sign in to comment.