From ad529187f58a23c7b64d3d17e3c2b326b8bfa701 Mon Sep 17 00:00:00 2001 From: Sighery Date: Mon, 4 May 2020 23:35:17 +0200 Subject: [PATCH] Initial commit --- .dependabot/config.yml | 6 + .github/workflows/main.yml | 16 ++ .gitignore | 18 ++ LICENSE | 21 +++ README.md | 54 ++++++ domains.go | 58 +++++++ domains_test.go | 163 ++++++++++++++++++ go.mod | 5 + go.sum | 11 ++ mocks/mocks.go | 18 ++ provider.go | 86 ++++++++++ records.go | 111 ++++++++++++ records_test.go | 342 +++++++++++++++++++++++++++++++++++++ 13 files changed, 909 insertions(+) create mode 100644 .dependabot/config.yml create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 domains.go create mode 100644 domains_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 mocks/mocks.go create mode 100644 provider.go create mode 100644 records.go create mode 100644 records_test.go diff --git a/.dependabot/config.yml b/.dependabot/config.yml new file mode 100644 index 0000000..7f4626c --- /dev/null +++ b/.dependabot/config.yml @@ -0,0 +1,6 @@ +version: 1 + +update_configs: + - package_manager: "go:modules" + directory: "/" + update_schedule: "daily" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..b989807 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,16 @@ +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Install dependencies + run: go get + + - name: Run tests + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52dcd72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Editors related +.vscode + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7aa11e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Sighery + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d7d27c --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Unofficial Golang library for the Njalla API + +[Njalla][] is a privacy-oriented domain name registration service. Recently +they released their [official API][]. + +This Golang library covers _some_ methods of that API. For the moment, those +are: +* `list-domains` +* `get-domain` +* `list-records` +* `add-record` +* `edit-record` +* `remove-record` + +**TO NOTE**: Even though `record` methods are implemented, I'm fairly certain +they'll fail (silently or not) in some cases. I deal mostly with `TXT`, `MX`, +`A` and `AAAA` DNS records. Some records have different/more variables, and +since I don't use them I decided against implementing them. Chances are the +methods will fail when trying to deal with those types of records (like `SSH` +records). + +The code is fairly simple, and all the methods are tested by using mocks on +the API request. The mocked returned data is based on the same data the API +returns. + +These methods cover my needs, but feel free to send in a PR to add more (or to +cover all types of DNS records), as long as they're all tested and documented. + +### Usage + +```golang +package main + +import ( + "fmt" + + "github.com/Sighery/gonjalla" +) + +func main() { + token := "api-token" + domain := "your-domain" + + records, err := ListRecords(token, domain) + if err != nil { + fmt.Println(err) + } + + fmt.Println(records) +} +``` + +[Njalla]: https://njal.la +[official API]: https://njal.la/api/ diff --git a/domains.go b/domains.go new file mode 100644 index 0000000..ba63e37 --- /dev/null +++ b/domains.go @@ -0,0 +1,58 @@ +package gonjalla + +import ( + "encoding/json" + "time" +) + +// Domain struct contains data returned by `list-domains` and `get-domains` +type Domain struct { + Name string `json:"name"` + Status string `json:"status"` + Expiry time.Time `json:"expiry"` + Locked *bool `json:"locked,omitempty"` + Mailforwarding *bool `json:"mailforwarding,omitempty"` + MaxNameservers *int `json:"max_nameservers,omitempty"` +} + +// ListDomains returns a listing of domains with minimal data +func ListDomains(token string) ([]Domain, error) { + params := map[string]interface{}{} + + data, err := Request(token, "list-domains", params) + if err != nil { + return nil, err + } + + type Response struct { + Domains []Domain `json:"domains"` + } + + var response Response + err = json.Unmarshal(data, &response) + if err != nil { + return nil, err + } + + return response.Domains, nil +} + +// GetDomain returns detailed information for each domain +func GetDomain(token string, domain string) (Domain, error) { + params := map[string]interface{}{ + "domain": domain, + } + + data, err := Request(token, "get-domain", params) + if err != nil { + return Domain{}, err + } + + var domainStruct Domain + err = json.Unmarshal(data, &domainStruct) + if err != nil { + return Domain{}, err + } + + return domainStruct, nil +} diff --git a/domains_test.go b/domains_test.go new file mode 100644 index 0000000..b3ba41a --- /dev/null +++ b/domains_test.go @@ -0,0 +1,163 @@ +package gonjalla + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/Sighery/gonjalla/mocks" +) + +func TestListDomainsExpected(t *testing.T) { + token := "test-token" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "result": { + "domains": [ + { + "name": "testing1.com", + "status": "active", + "expiry": "2021-02-20T19:38:48Z" + }, + { + "name": "testing2.com", + "status": "inactive", + "expiry": "2021-02-20T19:38:48Z" + } + ] + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + domains, err := ListDomains(token) + if err != nil { + t.Error(err) + } + + expectedTime, _ := time.Parse(time.RFC3339, "2021-02-20T19:38:48Z") + + expected := []Domain{ + { + Name: "testing1.com", + Status: "active", + Expiry: expectedTime, + }, + { + Name: "testing2.com", + Status: "inactive", + Expiry: expectedTime, + }, + } + + assert.Equal(t, domains, expected) +} + +func TestListDomainsError(t *testing.T) { + token := "test-token" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "error": { + "code": 0, + "message": "Testing error" + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + domains, err := ListDomains(token) + assert.Nil(t, domains) + assert.Error(t, err) +} + +func TestGetDomainExpected(t *testing.T) { + token := "test-token" + domain := "testing.com" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "result": { + "name": "testing.com", + "status": "active", + "expiry": "2021-02-20T19:38:48Z", + "locked": true, + "mailforwarding": false, + "max_nameservers": 10 + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + result, err := GetDomain(token, domain) + if err != nil { + t.Error(err) + } + + expectedTime, _ := time.Parse(time.RFC3339, "2021-02-20T19:38:48Z") + locked := true + mailforwarding := false + maxNameservers := 10 + + expected := Domain{ + Name: domain, + Status: "active", + Expiry: expectedTime, + Locked: &locked, + Mailforwarding: &mailforwarding, + MaxNameservers: &maxNameservers, + } + + assert.Equal(t, result, expected) +} + +func TestGetDomainError(t *testing.T) { + token := "test-token" + domain := "testing.com" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "error": { + "code": 0, + "message": "Testing error" + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + _, err := GetDomain(token, domain) + assert.Error(t, err) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f69daad --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/Sighery/gonjalla + +go 1.14 + +require github.com/stretchr/testify v1.5.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..331fa69 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/mocks/mocks.go b/mocks/mocks.go new file mode 100644 index 0000000..d1cb0de --- /dev/null +++ b/mocks/mocks.go @@ -0,0 +1,18 @@ +package mocks + +import "net/http" + +// MockClient is the mock HTTP client used for tests +type MockClient struct { + DoFunc func(req *http.Request) (*http.Response, error) +} + +var ( + // GetDoFunc fetches the mock client's `Do` func + GetDoFunc func(req *http.Request) (*http.Response, error) +) + +// Do is the mock client's `Do` func +func (m *MockClient) Do(req *http.Request) (*http.Response, error) { + return GetDoFunc(req) +} diff --git a/provider.go b/provider.go new file mode 100644 index 0000000..5d6ea53 --- /dev/null +++ b/provider.go @@ -0,0 +1,86 @@ +package gonjalla + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +type request struct { + Method string `json:"method"` + Params map[string]interface{} `json:"params"` +} + +const endpoint string = "https://njal.la/api/1/" + +// HTTPClient interface. Useful for mocked unit tests later on. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +var ( + // Client used in all requests by Request. Can be overwritten for tests. + Client HTTPClient +) + +func init() { + Client = &http.Client{} +} + +// Request common function for all of Njalla's API. +// Njalla's API uses JSON-RPC, and contains just one endpoint. +// The endpoint is POST only, and takes in a JSON in the body, with two +// arguments, check the `request` struct for more info. +// The `params` argument is variable. Some methods require no parameters, +// (like `list-domains`), while other methods require parameters (like +// `get-domain` which requires `domain: string`). +func Request( + token string, method string, params map[string]interface{}, +) ([]byte, error) { + token = fmt.Sprintf("Njalla %s", token) + + body, err := json.Marshal( + request{Method: method, Params: params}, + ) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", token) + + resp, err := Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + jsonData, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + data := make(map[string]interface{}) + + err = json.Unmarshal(jsonData, &data) + if err != nil { + return nil, err + } + + result, ok := data["result"] + if !ok { + return nil, fmt.Errorf("Missing result %s", data) + } + + unwrapped, err := json.Marshal(result) + if err != nil { + return nil, err + } + + return unwrapped, nil +} diff --git a/records.go b/records.go new file mode 100644 index 0000000..97be2e4 --- /dev/null +++ b/records.go @@ -0,0 +1,111 @@ +package gonjalla + +import "encoding/json" + +// Record struct contains data returned by `list-records` +type Record struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` + Priority *int `json:"prio,omitempty"` +} + +// ListRecords returns a listing of all records for a given domain +func ListRecords(token string, domain string) ([]Record, error) { + params := map[string]interface{}{ + "domain": domain, + } + data, err := Request(token, "list-records", params) + if err != nil { + return nil, err + } + + type Response struct { + Records []Record `json:"records"` + } + + var response Response + err = json.Unmarshal(data, &response) + if err != nil { + return nil, err + } + + return response.Records, nil +} + +// AddRecord adds a given record to a given domain. +// Returns a new Record struct, containing the response from the API if +// successful. This response will have some fields like ID (which can only +// be known after the execution) filled. +func AddRecord(token string, domain string, record Record) (Record, error) { + marshal, err := json.Marshal(record) + if err != nil { + return Record{}, err + } + + params := map[string]interface{}{ + "domain": domain, + } + err = json.Unmarshal(marshal, ¶ms) + if err != nil { + return Record{}, err + } + + data, err := Request(token, "add-record", params) + if err != nil { + return Record{}, err + } + + var response Record + err = json.Unmarshal(data, &response) + if err != nil { + return Record{}, err + } + + return response, nil +} + +// RemoveRecord removes a given record from a given domain. +// If there are no errors it will return `nil`. +func RemoveRecord(token string, domain string, id int) error { + params := map[string]interface{}{ + "domain": domain, + "id": id, + } + + _, err := Request(token, "remove-record", params) + if err != nil { + return err + } + + return nil +} + +// EditRecord edits a record for a given domain. +// This function is fairly dumb. It takes in a `Record` struct, and uses all +// its filled fields to send to Njalla. +// So, if you want to only change a given field, get the `Record` object from +// say ListRecords, change the one field you want, and then pass that here. +func EditRecord(token string, domain string, record Record) error { + marshal, err := json.Marshal(record) + if err != nil { + return err + } + + params := map[string]interface{}{ + "domain": domain, + } + err = json.Unmarshal(marshal, ¶ms) + if err != nil { + return err + } + + _, err = Request(token, "edit-record", params) + if err != nil { + return err + } + + return nil +} diff --git a/records_test.go b/records_test.go new file mode 100644 index 0000000..2daf1c8 --- /dev/null +++ b/records_test.go @@ -0,0 +1,342 @@ +package gonjalla + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Sighery/gonjalla/mocks" +) + +func TestListRecordsExpected(t *testing.T) { + token := "test-token" + domain := "testing.com" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "result": { + "records": [ + { + "id": 1337, + "name": "_acme-challenge", + "type": "TXT", + "content": "long-string", + "ttl": 10800 + }, + { + "id": 1338, + "name": "@", + "type": "A", + "content": "1.2.3.4", + "ttl": 3600 + }, + { + "id": 1339, + "name": "@", + "type": "AAAA", + "content": "2001:0DB8:0000:0000:0000:8A2E:0370:7334", + "ttl": 900 + }, + { + "id": 1340, + "name": "@", + "type": "MX", + "content": "mail.protonmail.ch", + "ttl": 300, + "prio": 10 + } + ] + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + records, err := ListRecords(token, domain) + if err != nil { + t.Error(err) + } + + priority := 10 + + expected := []Record{ + { + ID: 1337, + Name: "_acme-challenge", + Type: "TXT", + Content: "long-string", + TTL: 10800, + }, + { + ID: 1338, + Name: "@", + Type: "A", + Content: "1.2.3.4", + TTL: 3600, + }, + { + ID: 1339, + Name: "@", + Type: "AAAA", + Content: "2001:0DB8:0000:0000:0000:8A2E:0370:7334", + TTL: 900, + }, + { + ID: 1340, + Name: "@", + Type: "MX", + Content: "mail.protonmail.ch", + TTL: 300, + Priority: &priority, + }, + } + + assert.Equal(t, records, expected) +} + +func TestListRecordsError(t *testing.T) { + token := "test-token" + domain := "testing.com" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "error": { + "code": 0, + "message": "Testing error" + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + records, err := ListRecords(token, domain) + if records != nil { + t.Error("records isn't nil") + } + + assert.Error(t, err) +} + +func TestAddRecordExpected(t *testing.T) { + token := "test-token" + domain := "testing.com" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "result": { + "id": 1337, + "name": "@", + "type": "MX", + "content": "testing.com", + "ttl": 10800, + "prio": 10 + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + priority := 10 + + adding := Record{ + Name: "@", + Type: "MX", + Content: "testing.com", + TTL: 10800, + Priority: &priority, + } + + record, err := AddRecord(token, domain, adding) + if err != nil { + t.Error(err) + } + + expected := Record{ + ID: 1337, + Name: "@", + Type: "MX", + Content: "testing.com", + TTL: 10800, + Priority: &priority, + } + + assert.Equal(t, record, expected) +} + +func TestAddRecordError(t *testing.T) { + token := "test-token" + domain := "testing.com" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "error": { + "code": 0, + "message": "Testing error" + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + priority := 10 + adding := Record{ + Name: "@", + Type: "MX", + Content: "testing.com", + TTL: 10800, + Priority: &priority, + } + + _, err := AddRecord(token, domain, adding) + assert.Error(t, err) +} + +func TestRemoveRecordExpected(t *testing.T) { + token := "test-token" + domain := "testing.com" + id := 1337 + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "result": {} + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + err := RemoveRecord(token, domain, id) + assert.Nil(t, err) +} + +func TestRemoveRecordError(t *testing.T) { + token := "test-token" + domain := "testing.com" + id := 1337 + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "error": { + "code": 0, + "message": "Testing error" + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + err := RemoveRecord(token, domain, id) + assert.Error(t, err) +} + +func TestEditRecordExpected(t *testing.T) { + token := "test-token" + domain := "testing.com" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "result": { + "id": 1337, + "name": "@", + "type": "MX", + "content": "testing.com", + "ttl": 10800, + "prio": 10 + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + priority := 10 + editing := Record{ + ID: 1337, + Name: "@", + Type: "MX", + Content: "testing.com", + TTL: 10800, + Priority: &priority, + } + + err := EditRecord(token, domain, editing) + assert.Nil(t, err) +} + +func TestEditRecordError(t *testing.T) { + token := "test-token" + domain := "testing.com" + Client = &mocks.MockClient{} + + testData := `{ + "jsonrpc": "2.0", + "error": { + "code": 0, + "message": "Testing error" + } + }` + r := ioutil.NopCloser(bytes.NewReader([]byte(testData))) + + mocks.GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + } + + priority := 10 + editing := Record{ + ID: 1337, + Name: "@", + Type: "MX", + Content: "testing.com", + TTL: 10800, + Priority: &priority, + } + + err := EditRecord(token, domain, editing) + assert.Error(t, err) +}