├── .github └── workflows │ ├── build.yml │ └── codeql-analysis.yml ├── .gitignore ├── LICENSE ├── README.md ├── apis ├── cache │ ├── client.go │ ├── doc.go │ ├── flush.go │ ├── interface.go │ ├── interface_test.go │ └── types.go ├── cryptokeys │ ├── client.go │ ├── cryptokey_create.go │ ├── cryptokey_create_test.go │ ├── cryptokey_delete.go │ ├── cryptokey_delete_test.go │ ├── cryptokey_get.go │ ├── cryptokey_get_test.go │ ├── cryptokey_list.go │ ├── cryptokey_list_test.go │ ├── cryptokey_toggle.go │ ├── cryptokey_toggle_test.go │ ├── doc.go │ ├── interface.go │ ├── interface_test.go │ └── types_cryptokey.go ├── search │ ├── client.go │ ├── interface.go │ ├── search.go │ ├── search_test.go │ ├── types.go │ ├── types_resultlist.go │ └── types_resultlist_test.go ├── servers │ ├── client.go │ ├── doc.go │ ├── interface.go │ ├── interface_test.go │ ├── servers_get.go │ ├── servers_list.go │ └── types.go └── zones │ ├── client.go │ ├── doc.go │ ├── interface.go │ ├── types.go │ ├── types_rrsetchangetype.go │ ├── types_rrsetchangetype_test.go │ ├── types_zone.go │ ├── types_zone_test.go │ ├── types_zonekind.go │ ├── types_zonekind_test.go │ ├── types_zonesoaedit.go │ ├── types_zonesoaedit_test.go │ ├── types_zonesoaeditapi.go │ ├── types_zonesoaeditapi_test.go │ ├── types_zonetype.go │ ├── zones_addrecordset.go │ ├── zones_create.go │ ├── zones_create_test.go │ ├── zones_delete.go │ ├── zones_export.go │ ├── zones_get.go │ ├── zones_list.go │ ├── zones_modifybasicdata.go │ ├── zones_notifyslaves.go │ ├── zones_rectify.go │ ├── zones_removerecordset.go │ ├── zones_retrievemaster.go │ └── zones_verify.go ├── client.go ├── client_test.go ├── doc.go ├── doc_test.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── interface.go ├── options.go └── pdnshttp ├── auth.go ├── auth_basic.go ├── auth_key.go ├── auth_tls.go ├── client.go ├── client_test.go ├── errors.go └── req_opt.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Compile & Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | name: Run tests 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go: [ '1.24', '1.23', '1.22' ] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v3 22 | with: 23 | go-version: ${{ matrix.go }} 24 | 25 | - name: Run static analysis 26 | run: go vet 27 | 28 | - name: Run unit tests 29 | run: go test ./... 30 | 31 | - name: Compile 32 | run: go build 33 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '42 11 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PowerDNS client library for Go 2 | 3 | [![GoDoc](https://godoc.org/github.com/mittwald/go-powerdns?status.svg)](https://godoc.org/github.com/mittwald/go-powerdns) 4 | [![Build Status](https://travis-ci.org/mittwald/go-powerdns.svg?branch=master)](https://travis-ci.org/mittwald/go-powerdns) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/aa54a869f5ff56477a2a/maintainability)](https://codeclimate.com/github/mittwald/go-powerdns/maintainability) 6 | 7 | This package contains a Go library for accessing the [PowerDNS][powerdns] Authoritative API. 8 | 9 | ## Supported features 10 | 11 | - [x] Servers 12 | - [x] Zones 13 | - [x] Cryptokeys 14 | - [ ] Metadata 15 | - [ ] TSIG Keys 16 | - [x] Searching 17 | - [ ] Statistics 18 | - [x] Cache 19 | 20 | ## Installation 21 | 22 | Install using `go get`: 23 | 24 | ```console 25 | > go get github.com/mittwald/go-powerdns 26 | ``` 27 | 28 | ## Usage 29 | 30 | First, instantiate a client using `pdns.New`: 31 | 32 | ```go 33 | client, err := pdns.New( 34 | pdns.WithBaseURL("http://localhost:8081"), 35 | pdns.WithAPIKeyAuthentication("supersecret"), 36 | ) 37 | ``` 38 | 39 | The client then offers more specialiced sub-clients, for example for managing server and zones. 40 | Have a look at this library's [documentation][godoc] for more information. 41 | 42 | ## Complete example 43 | 44 | ```go 45 | package main 46 | 47 | import "context" 48 | import "github.com/mittwald/go-powerdns" 49 | import "github.com/mittwald/go-powerdns/apis/zones" 50 | 51 | func main() { 52 | client, err := pdns.New( 53 | pdns.WithBaseURL("http://localhost:8081"), 54 | pdns.WithAPIKeyAuthentication("supersecret"), 55 | ) 56 | 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | client.Zones().CreateZone(context.Background(), "localhost", zones.Zone{ 62 | Name: "mydomain.example.", 63 | Type: zones.ZoneTypeZone, 64 | Kind: zones.ZoneKindNative, 65 | Nameservers: []string{ 66 | "ns1.example.com.", 67 | "ns2.example.com.", 68 | }, 69 | ResourceRecordSets: []zones.ResourceRecordSet{ 70 | {Name: "foo.mydomain.example.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 71 | }, 72 | }) 73 | } 74 | ``` 75 | 76 | [powerdns]: https://github.com/PowerDNS/pdns 77 | [godoc]: https://godoc.org/github.com/mittwald/go-powerdns 78 | -------------------------------------------------------------------------------- /apis/cache/client.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "github.com/mittwald/go-powerdns/pdnshttp" 4 | 5 | type client struct { 6 | httpClient *pdnshttp.Client 7 | } 8 | 9 | // New creates a new Cache client 10 | func New(hc *pdnshttp.Client) Client { 11 | return &client{ 12 | httpClient: hc, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apis/cache/doc.go: -------------------------------------------------------------------------------- 1 | // Package cache contains a specialized client for interacting with PowerDNS' "Cache" API. 2 | // 3 | // More information 4 | // 5 | // Official API documentation: https://doc.powerdns.com/authoritative/http-api/cache.html 6 | package cache 7 | -------------------------------------------------------------------------------- /apis/cache/flush.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/mittwald/go-powerdns/pdnshttp" 9 | ) 10 | 11 | func (c *client) Flush(ctx context.Context, serverID string, name string) (*FlushResult, error) { 12 | cfr := FlushResult{} 13 | path := fmt.Sprintf("/servers/%s/cache/flush", url.PathEscape(serverID)) 14 | 15 | err := c.httpClient.Put(ctx, path, &cfr, pdnshttp.WithQueryValue("domain", name)) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return &cfr, nil 21 | } 22 | -------------------------------------------------------------------------------- /apis/cache/interface.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "context" 4 | 5 | // Client defines the interface for Cache operations. 6 | type Client interface { 7 | // Flush flush a cache-entry by name 8 | Flush(ctx context.Context, serverID string, name string) (*FlushResult, error) 9 | } 10 | -------------------------------------------------------------------------------- /apis/cache/interface_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | -------------------------------------------------------------------------------- /apis/cache/types.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | // FlushResult represent the result of a cache-flush. 4 | type FlushResult struct { 5 | Count int `json:"count"` 6 | Result string `json:"result"` 7 | } 8 | -------------------------------------------------------------------------------- /apis/cryptokeys/client.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import "github.com/mittwald/go-powerdns/pdnshttp" 4 | 5 | type client struct { 6 | httpClient *pdnshttp.Client 7 | } 8 | 9 | // New returns a new HTTP API Client 10 | func New(hc *pdnshttp.Client) Client { 11 | return &client{ 12 | httpClient: hc, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_create.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mittwald/go-powerdns/pdnshttp" 7 | "net/url" 8 | ) 9 | 10 | func (c *client) CreateCryptokey(ctx context.Context, serverID, zoneID string, opts Cryptokey) (*Cryptokey, error) { 11 | cryptokey := Cryptokey{} 12 | path := fmt.Sprintf("/servers/%s/zones/%s/cryptokeys", 13 | url.PathEscape(serverID), url.PathEscape(zoneID)) 14 | 15 | err := c.httpClient.Post(ctx, path, &cryptokey, pdnshttp.WithJSONRequestBody(opts)) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return &cryptokey, nil 21 | } 22 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_create_test.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "github.com/mittwald/go-powerdns/pdnshttp" 6 | "github.com/stretchr/testify/assert" 7 | "gopkg.in/h2non/gock.v1" 8 | "io" 9 | "net/http" 10 | "testing" 11 | ) 12 | 13 | func TestClient_CreateCryptokey(t *testing.T) { 14 | gock.New("http://dns.example"). 15 | Post("/api/v1/servers/localhost/zones/pdns-test.de/cryptokeys"). 16 | Reply(http.StatusOK). 17 | SetHeader("Content-Type", "application/json"). 18 | BodyString(`{ 19 | "active": true, 20 | "algorithm": "ECDSAP256SHA256", 21 | "bits": 256, 22 | "dnskey": "257 3 13 sO2Oog47gVFc0iDl0Ubm/RUJ/bdOks/tJmfNS4KX7IPEj2lymwvHBlXqXEvnpsVa+c4CGidwdoGyo7TDMDUIQg==", 23 | "ds": [ 24 | "50747 13 1 63cdac4d2115c3ea8a8f5d311af58957c2270e32", 25 | "50747 13 2 336d41f466a29e65118a5d46c02b3680043e8194096e61d07c77931fb49269a8", 26 | "50747 13 4 03821c4f34a8d63ef80015383d3a5f12ce99e0cb8d8f5a3010b41098fac54f4127d63ea5021f7396bac8c079b6235bf3" 27 | ], 28 | "flags": 257, 29 | "id": 102, 30 | "keytype": "csk", 31 | "privatekey": "Private-key-format: v1.2\nAlgorithm: 13 (ECDSAP256SHA256)\nPrivateKey: 4Xt/Qdsasn/TBC3O/PVCIEO4c2NozvRpX50qVdEL/Ag=\n", 32 | "published": true, 33 | "type": "Cryptokey" 34 | }`) 35 | 36 | hc := &http.Client{Transport: gock.DefaultTransport} 37 | c := pdnshttp.NewClient("http://dns.example", hc, &pdnshttp.APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 38 | cc := New(c) 39 | 40 | key, err := cc.CreateCryptokey(context.Background(), "localhost", "pdns-test.de", Cryptokey{}) 41 | 42 | assert.Nil(t, err) 43 | assert.NotNil(t, key) 44 | assert.Equal(t, "ECDSAP256SHA256", key.Algorithm) 45 | assert.Equal(t, 256, key.Bits) 46 | } 47 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_delete.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | func (c *client) DeleteCryptokey(ctx context.Context, serverID, zoneID string, cryptokeyID int) error { 11 | path := fmt.Sprintf("/servers/%s/zones/%s/cryptokeys/%s", 12 | url.PathEscape(serverID), url.PathEscape(zoneID), url.PathEscape(strconv.Itoa(cryptokeyID))) 13 | 14 | return c.httpClient.Delete(ctx, path, nil) 15 | } 16 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_delete_test.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "github.com/mittwald/go-powerdns/pdnshttp" 6 | "github.com/stretchr/testify/assert" 7 | "gopkg.in/h2non/gock.v1" 8 | "io" 9 | "net/http" 10 | "testing" 11 | ) 12 | 13 | func TestClient_DeleteCryptokey(t *testing.T) { 14 | gock.New("http://dns.example"). 15 | Delete("/api/v1/servers/localhost/zones/pdns-test.de/cryptokeys/102"). 16 | Reply(http.StatusNoContent) 17 | 18 | hc := &http.Client{Transport: gock.DefaultTransport} 19 | c := pdnshttp.NewClient("http://dns.example", hc, &pdnshttp.APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 20 | cc := New(c) 21 | 22 | err := cc.DeleteCryptokey(context.Background(), "localhost", "pdns-test.de", 102) 23 | 24 | assert.Nil(t, err) 25 | } 26 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_get.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | func (c *client) GetCryptokey(ctx context.Context, serverID, zoneID string, cryptokeyID int) (*Cryptokey, error) { 11 | cryptokey := Cryptokey{} 12 | path := fmt.Sprintf("/servers/%s/zones/%s/cryptokeys/%s", 13 | url.PathEscape(serverID), url.PathEscape(zoneID), url.PathEscape(strconv.Itoa(cryptokeyID))) 14 | 15 | err := c.httpClient.Get(ctx, path, &cryptokey) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return &cryptokey, nil 21 | } 22 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_get_test.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "github.com/mittwald/go-powerdns/pdnshttp" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "gopkg.in/h2non/gock.v1" 9 | "io" 10 | "net/http" 11 | "testing" 12 | ) 13 | 14 | func TestClient_GetCryptokey(t *testing.T) { 15 | gock.New("http://dns.example"). 16 | Get("/api/v1/servers/localhost/zones/pdns-test.de/cryptokeys/102"). 17 | Reply(http.StatusOK). 18 | SetHeader("Content-Type", "application/json"). 19 | BodyString(`{ 20 | "active": true, 21 | "algorithm": "ECDSAP256SHA256", 22 | "bits": 256, 23 | "dnskey": "257 3 13 sO2Oog47gVFc0iDl0Ubm/RUJ/bdOks/tJmfNS4KX7IPEj2lymwvHBlXqXEvnpsVa+c4CGidwdoGyo7TDMDUIQg==", 24 | "ds": [ 25 | "50747 13 1 63cdac4d2115c3ea8a8f5d311af58957c2270e32", 26 | "50747 13 2 336d41f466a29e65118a5d46c02b3680043e8194096e61d07c77931fb49269a8", 27 | "50747 13 4 03821c4f34a8d63ef80015383d3a5f12ce99e0cb8d8f5a3010b41098fac54f4127d63ea5021f7396bac8c079b6235bf3" 28 | ], 29 | "flags": 257, 30 | "id": 102, 31 | "keytype": "csk", 32 | "privatekey": "Private-key-format: v1.2\nAlgorithm: 13 (ECDSAP256SHA256)\nPrivateKey: 4Xt/Qdsasn/TBC3O/PVCIEO4c2NozvRpX50qVdEL/Ag=\n", 33 | "published": true, 34 | "type": "Cryptokey" 35 | }`) 36 | 37 | hc := &http.Client{Transport: gock.DefaultTransport} 38 | c := pdnshttp.NewClient("http://dns.example", hc, &pdnshttp.APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 39 | cc := New(c) 40 | 41 | key, err := cc.GetCryptokey(context.Background(), "localhost", "pdns-test.de", 102) 42 | 43 | assert.Nil(t, err) 44 | require.NotNil(t, key) 45 | require.Len(t, key.DS, 3) 46 | assert.Equal(t, "ECDSAP256SHA256", key.Algorithm) 47 | } 48 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_list.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | func (c *client) ListCryptokeys(ctx context.Context, serverID, zoneID string) ([]Cryptokey, error) { 10 | cryptokeys := []Cryptokey{} 11 | path := fmt.Sprintf("/servers/%s/zones/%s/cryptokeys", url.PathEscape(serverID), url.PathEscape(zoneID)) 12 | 13 | err := c.httpClient.Get(ctx, path, &cryptokeys) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | return cryptokeys, nil 19 | } 20 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_list_test.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "github.com/mittwald/go-powerdns/pdnshttp" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "gopkg.in/h2non/gock.v1" 9 | "io" 10 | "net/http" 11 | "testing" 12 | ) 13 | 14 | func TestClient_ListCryptokeys(t *testing.T) { 15 | gock.New("http://dns.example"). 16 | Get("/api/v1/servers/localhost/zones/pdns-test.de/cryptokeys"). 17 | Reply(http.StatusOK). 18 | SetHeader("Content-Type", "application/json"). 19 | BodyString(`[ 20 | { 21 | "active": true, 22 | "algorithm": "ECDSAP256SHA256", 23 | "bits": 256, 24 | "dnskey": "257 3 13 sO2Oog47gVFc0iDl0Ubm/RUJ/bdOks/tJmfNS4KX7IPEj2lymwvHBlXqXEvnpsVa+c4CGidwdoGyo7TDMDUIQg==", 25 | "ds": [ 26 | "50747 13 1 63cdac4d2115c3ea8a8f5d311af58957c2270e32", 27 | "50747 13 2 336d41f466a29e65118a5d46c02b3680043e8194096e61d07c77931fb49269a8", 28 | "50747 13 4 03821c4f34a8d63ef80015383d3a5f12ce99e0cb8d8f5a3010b41098fac54f4127d63ea5021f7396bac8c079b6235bf3" 29 | ], 30 | "flags": 257, 31 | "id": 102, 32 | "keytype": "csk", 33 | "published": true, 34 | "type": "Cryptokey" 35 | } 36 | ]`) 37 | 38 | hc := &http.Client{Transport: gock.DefaultTransport} 39 | c := pdnshttp.NewClient("http://dns.example", hc, &pdnshttp.APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 40 | cc := New(c) 41 | 42 | list, err := cc.ListCryptokeys(context.Background(), "localhost", "pdns-test.de") 43 | 44 | assert.Nil(t, err) 45 | require.NotNil(t, list) 46 | require.Len(t, list, 1) 47 | assert.Equal(t, 102, list[0].ID) 48 | } 49 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_toggle.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | func (c *client) ToggleCryptokey(ctx context.Context, serverID, zoneID string, cryptokeyID int) error { 11 | path := fmt.Sprintf("/servers/%s/zones/%s/cryptokeys/%s", 12 | url.PathEscape(serverID), url.PathEscape(zoneID), url.PathEscape(strconv.Itoa(cryptokeyID))) 13 | 14 | return c.httpClient.Put(ctx, path, nil) 15 | } 16 | -------------------------------------------------------------------------------- /apis/cryptokeys/cryptokey_toggle_test.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import ( 4 | "context" 5 | "github.com/mittwald/go-powerdns/pdnshttp" 6 | "github.com/stretchr/testify/assert" 7 | "gopkg.in/h2non/gock.v1" 8 | "io" 9 | "net/http" 10 | "testing" 11 | ) 12 | 13 | func TestClient_ToggleCryptokey(t *testing.T) { 14 | gock.New("http://dns.example"). 15 | Put("/api/v1/servers/localhost/zones/pdns-test.de/cryptokeys/102"). 16 | Reply(http.StatusNoContent) 17 | 18 | hc := &http.Client{Transport: gock.DefaultTransport} 19 | c := pdnshttp.NewClient("http://dns.example", hc, &pdnshttp.APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 20 | cc := New(c) 21 | 22 | err := cc.ToggleCryptokey(context.Background(), "localhost", "pdns-test.de", 102) 23 | 24 | assert.Nil(t, err) 25 | } 26 | -------------------------------------------------------------------------------- /apis/cryptokeys/doc.go: -------------------------------------------------------------------------------- 1 | // Package cryptokeys contains a specialized client for interacting with PowerDNS' "Cryptokeys" API. 2 | // 3 | // More information 4 | // 5 | // Official API documentation: https://doc.powerdns.com/authoritative/http-api/cryptokey.html 6 | package cryptokeys 7 | -------------------------------------------------------------------------------- /apis/cryptokeys/interface.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | import "context" 4 | 5 | // Client defines method for interacting with the PowerDNS "Cryptokeys" endpoints 6 | type Client interface { 7 | 8 | // ListCryptokeys lists all CryptoKeys, except its privatekey 9 | ListCryptokeys(ctx context.Context, serverID, zoneID string) ([]Cryptokey, error) 10 | 11 | // GetCryptokey returns all data about the CryptoKey, including the privatekey. 12 | // If the server with the given "serverID" does not exist, 13 | // the error return value will contain a pdnshttp.ErrNotFound error (see example) 14 | GetCryptokey(ctx context.Context, serverID, zoneID string, cryptokeyID int) (*Cryptokey, error) 15 | 16 | // CreateCryptokey creates a new CryptoKey 17 | CreateCryptokey(ctx context.Context, serverID, zoneID string, opts Cryptokey) (*Cryptokey, error) 18 | 19 | // ToggleCryptokey (de)activates a CryptoKey for the given zone 20 | ToggleCryptokey(ctx context.Context, serverID, zoneID string, cryptokeyID int) error 21 | 22 | // DeleteCryptokey deletes a CryptoKey from the given zone 23 | DeleteCryptokey(ctx context.Context, serverID, zoneID string, cryptokeyID int) error 24 | } 25 | -------------------------------------------------------------------------------- /apis/cryptokeys/interface_test.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | -------------------------------------------------------------------------------- /apis/cryptokeys/types_cryptokey.go: -------------------------------------------------------------------------------- 1 | package cryptokeys 2 | 3 | // Cryptokey represents a Cryptokey model of the API 4 | // More information: https://doc.powerdns.com/authoritative/http-api/cryptokey.html#cryptokey 5 | type Cryptokey struct { 6 | ID int `json:"id,omitempty"` 7 | Type string `json:"type,omitempty"` 8 | KeyType string `json:"keytype,omitempty"` 9 | Active bool `json:"active,omitempty"` 10 | Published bool `json:"published,omitempty"` 11 | DNSKey string `json:"dnskey,omitempty"` 12 | DS []string `json:"ds,omitempty"` 13 | PrivateKey string `json:"privatekey,omitempty"` 14 | Algorithm string `json:"algorithm,omitempty"` 15 | Bits int `json:"bits,omitempty"` 16 | } 17 | -------------------------------------------------------------------------------- /apis/search/client.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import "github.com/mittwald/go-powerdns/pdnshttp" 4 | 5 | type client struct { 6 | httpClient *pdnshttp.Client 7 | } 8 | 9 | // New creates a new Search client 10 | func New(hc *pdnshttp.Client) Client { 11 | return &client{ 12 | httpClient: hc, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apis/search/interface.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import "context" 4 | 5 | // Client defines method for interacting with the PowerDNS "Search" endpoints 6 | type Client interface { 7 | 8 | // ListServers lists all known servers 9 | Search(ctx context.Context, serverID, query string, max int, objectType ObjectType) (ResultList, error) 10 | } 11 | -------------------------------------------------------------------------------- /apis/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mittwald/go-powerdns/pdnshttp" 7 | "net/url" 8 | ) 9 | 10 | func (c *client) Search(ctx context.Context, serverID, query string, max int, objectType ObjectType) (ResultList, error) { 11 | path := fmt.Sprintf("/servers/%s/search-data", url.PathEscape(serverID)) 12 | results := make(ResultList, 0) 13 | 14 | err := c.httpClient.Get( 15 | ctx, 16 | path, 17 | &results, 18 | pdnshttp.WithQueryValue("q", query), 19 | pdnshttp.WithQueryValue("max", fmt.Sprintf("%d", max)), 20 | pdnshttp.WithQueryValue("object_type", objectType.String()), 21 | ) 22 | 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return results, nil 28 | } 29 | -------------------------------------------------------------------------------- /apis/search/search_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mittwald/go-powerdns/pdnshttp" 7 | "github.com/stretchr/testify/assert" 8 | "gopkg.in/h2non/gock.v1" 9 | "io" 10 | "net/http" 11 | "testing" 12 | ) 13 | 14 | func TestSearchExecutesCorrectRequest(t *testing.T) { 15 | cases := []struct { 16 | query string 17 | max int 18 | objectType ObjectType 19 | expectedObjectType string 20 | }{ 21 | {"example.com", 10, ObjectTypeAll, "all"}, 22 | {"example.com", 10, ObjectTypeZone, "zone"}, 23 | {"example.com", 10, ObjectTypeRecord, "record"}, 24 | {"example.com", 10, ObjectTypeComment, "comment"}, 25 | {"example.com", 15, ObjectTypeComment, "comment"}, 26 | } 27 | 28 | for i := range cases { 29 | t.Run(fmt.Sprintf("test with %+v", cases[i]), func(t *testing.T) { 30 | gock.New("http://dns.example"). 31 | Get("/api/v1/servers/localhost/search-data"). 32 | MatchParam("object_type", cases[i].expectedObjectType). 33 | MatchParam("max", fmt.Sprintf("%d", cases[i].max)). 34 | MatchParam("q", cases[i].query). 35 | Reply(http.StatusOK). 36 | SetHeader("Content-Type", "application/json"). 37 | BodyString(exampleSearchResult) 38 | 39 | hc := &http.Client{Transport: gock.DefaultTransport} 40 | c := pdnshttp.NewClient("http://dns.example", hc, &pdnshttp.APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 41 | sc := New(c) 42 | 43 | results, err := sc.Search( 44 | context.Background(), 45 | "localhost", 46 | cases[i].query, 47 | cases[i].max, 48 | cases[i].objectType, 49 | ) 50 | 51 | assert.Nil(t, err) 52 | assert.IsType(t, ResultList{}, results) 53 | assert.Len(t, results, 5) 54 | 55 | assert.True(t, gock.IsDone()) 56 | }) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /apis/search/types.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import "fmt" 4 | 5 | // ObjectType represents the object type for which a search should be performed 6 | type ObjectType int 7 | 8 | // Possible object types; according to the PowerDNS documentation, this list is 9 | // exhaustive. 10 | const ( 11 | _ = iota 12 | ObjectTypeAll ObjectType = iota 13 | ObjectTypeZone 14 | ObjectTypeRecord 15 | ObjectTypeComment 16 | ) 17 | 18 | // String makes this type implement fmt.Stringer 19 | func (t ObjectType) String() string { 20 | switch t { 21 | case ObjectTypeAll: 22 | return "all" 23 | case ObjectTypeZone: 24 | return "zone" 25 | case ObjectTypeRecord: 26 | return "record" 27 | case ObjectTypeComment: 28 | return "comment" 29 | } 30 | 31 | return "" 32 | } 33 | 34 | // UnmarshalJSON makes this type implement json.Unmarshaler 35 | func (t *ObjectType) UnmarshalJSON(b []byte) error { 36 | switch string(b) { 37 | case `"all"`: 38 | *t = ObjectTypeAll 39 | case `"zone"`: 40 | *t = ObjectTypeZone 41 | case `"record"`: 42 | *t = ObjectTypeRecord 43 | case `"comment"`: 44 | *t = ObjectTypeComment 45 | default: 46 | return fmt.Errorf(`unknown search type: %s'`, string(b)) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // Result represents a single search result. See the documentation for more 53 | // information: https://doc.powerdns.com/authoritative/http-api/search.html#searchresult 54 | type Result struct { 55 | Content string `json:"content"` 56 | Disabled bool `json:"disabled"` 57 | Name string `json:"name"` 58 | ObjectType ObjectType `json:"object_type"` 59 | ZoneID string `json:"zone_id"` 60 | Zone string `json:"zone"` 61 | Type string `json:"type"` 62 | TTL int `json:"ttl"` 63 | } 64 | -------------------------------------------------------------------------------- /apis/search/types_resultlist.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | // ResultList represents a list of search results. The type itself offers some 4 | // advanced filtering functions for convenience. 5 | type ResultList []Result 6 | 7 | // FilterByObjectType returns all elements of a result list that are of a given 8 | // object type. 9 | func (l ResultList) FilterByObjectType(t ObjectType) ResultList { 10 | return l.FilterBy(func(r *Result) bool { 11 | return r.ObjectType == t 12 | }) 13 | } 14 | 15 | // FilterByRecordType returns all elements of a result list that are a resource 16 | // record and have a certain record type. 17 | func (l ResultList) FilterByRecordType(t string) ResultList { 18 | return l.FilterBy(func(r *Result) bool { 19 | return r.ObjectType == ObjectTypeRecord && r.Type == t 20 | }) 21 | } 22 | 23 | // FilterBy returns all elements of a result list that match a generic matcher 24 | // function. The "matcher" function will be invoked for each element in the 25 | // result list; if it returns true, the respective item will be included in the 26 | // result list. 27 | func (l ResultList) FilterBy(matcher func(*Result) bool) ResultList { 28 | out := make(ResultList, 0, len(l)) 29 | 30 | for i := range l { 31 | if matcher(&l[i]) { 32 | out = append(out, l[i]) 33 | } 34 | } 35 | 36 | return out 37 | } 38 | -------------------------------------------------------------------------------- /apis/search/types_resultlist_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | const exampleSearchResult = `[{"name": "example-search.de.", "object_type": "zone", "zone_id": "example-search.de."}, {"content": "127.0.0.1", "disabled": false, "name": "example-search.de.", "object_type": "record", "ttl": 60, "type": "A", "zone": "example-search.de.", "zone_id": "example-search.de."}, {"content": "ns1.example.com.", "disabled": false, "name": "example-search.de.", "object_type": "record", "ttl": 3600, "type": "NS", "zone": "example-search.de.", "zone_id": "example-search.de."}, {"content": "ns2.example.com.", "disabled": false, "name": "example-search.de.", "object_type": "record", "ttl": 3600, "type": "NS", "zone": "example-search.de.", "zone_id": "example-search.de."}, {"content": "a.misconfigured.powerdns.server. hostmaster.example-search.de. 2019031901 10800 3600 604800 3600", "disabled": false, "name": "example-search.de.", "object_type": "record", "ttl": 3600, "type": "SOA", "zone": "example-search.de.", "zone_id": "example-search.de."}]` 11 | 12 | func TestResultListCanBeJSONDecoded(t *testing.T) { 13 | out := make(ResultList, 0) 14 | err := json.Unmarshal([]byte(exampleSearchResult), &out) 15 | 16 | assert.Nil(t, err) 17 | assert.Len(t, out, 5) 18 | } 19 | 20 | func TestResultListFilterByObjectTypeFiltersCorrectly(t *testing.T) { 21 | out := make(ResultList, 0) 22 | err := json.Unmarshal([]byte(exampleSearchResult), &out) 23 | 24 | require.Nil(t, err) 25 | 26 | filtered := out.FilterByObjectType(ObjectTypeZone) 27 | 28 | require.NotNil(t, filtered) 29 | assert.Len(t, filtered, 1) 30 | assert.Equal(t, ObjectTypeZone, filtered[0].ObjectType) 31 | } 32 | 33 | func TestResultListFilterByRecordTypeFiltersCorrectly(t *testing.T) { 34 | out := make(ResultList, 0) 35 | err := json.Unmarshal([]byte(exampleSearchResult), &out) 36 | 37 | require.Nil(t, err) 38 | 39 | filtered := out.FilterByRecordType("NS") 40 | 41 | require.NotNil(t, filtered) 42 | require.Len(t, filtered, 2) 43 | assert.Equal(t, ObjectTypeRecord, filtered[0].ObjectType) 44 | assert.Equal(t, ObjectTypeRecord, filtered[1].ObjectType) 45 | } 46 | -------------------------------------------------------------------------------- /apis/servers/client.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import "github.com/mittwald/go-powerdns/pdnshttp" 4 | 5 | type client struct { 6 | httpClient *pdnshttp.Client 7 | } 8 | 9 | func New(hc *pdnshttp.Client) Client { 10 | return &client{ 11 | httpClient: hc, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apis/servers/doc.go: -------------------------------------------------------------------------------- 1 | // This package contains a specialized client for interacting with PowerDNS' "Servers" API. 2 | // 3 | // More information 4 | // 5 | // Official API documentation: https://doc.powerdns.com/authoritative/http-api/server.html 6 | package servers 7 | -------------------------------------------------------------------------------- /apis/servers/interface.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import "context" 4 | 5 | // Client defines method for interacting with the PowerDNS "Servers" endpoints 6 | type Client interface { 7 | 8 | // ListServers lists all known servers 9 | ListServers(ctx context.Context) ([]Server, error) 10 | 11 | // GetServer returns a specific server. If the server with the given "serverID" does 12 | // not exist, the error return value will contain a pdnshttp.ErrNotFound error (see example) 13 | GetServer(ctx context.Context, serverID string) (*Server, error) 14 | } 15 | -------------------------------------------------------------------------------- /apis/servers/interface_test.go: -------------------------------------------------------------------------------- 1 | package servers 2 | -------------------------------------------------------------------------------- /apis/servers/servers_get.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | func (c *client) GetServer(ctx context.Context, serverID string) (*Server, error) { 10 | server := Server{} 11 | err := c.httpClient.Get(ctx, fmt.Sprintf("/servers/%s", url.PathEscape(serverID)), &server) 12 | 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return &server, err 18 | } 19 | -------------------------------------------------------------------------------- /apis/servers/servers_list.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import "context" 4 | 5 | func (c *client) ListServers(ctx context.Context) ([]Server, error) { 6 | servers := make([]Server, 0) 7 | 8 | err := c.httpClient.Get(ctx, "/servers", &servers) 9 | if err != nil { 10 | return nil, err 11 | } 12 | 13 | return servers, nil 14 | } 15 | -------------------------------------------------------------------------------- /apis/servers/types.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | // Server models a PowerDNS server. 4 | // 5 | // More information: https://doc.powerdns.com/authoritative/http-api/server.html#server 6 | type Server struct { 7 | ID string `json:"id"` 8 | Type string `json:"type"` 9 | DaemonType string `json:"daemon_type"` 10 | Version string `json:"version"` 11 | URL string `json:"url,omitempty"` 12 | ConfigURL string `json:"config_url,omitempty"` 13 | ZonesURL string `json:"zones_url,omitempty"` 14 | } 15 | -------------------------------------------------------------------------------- /apis/zones/client.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import "github.com/mittwald/go-powerdns/pdnshttp" 4 | 5 | type client struct { 6 | httpClient *pdnshttp.Client 7 | } 8 | 9 | func New(hc *pdnshttp.Client) Client { 10 | return &client{ 11 | httpClient: hc, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apis/zones/doc.go: -------------------------------------------------------------------------------- 1 | // This package contains a specialized client for interacting with PowerDNS' "Zones" API. 2 | // 3 | // More information 4 | // 5 | // Official API documentation: https://doc.powerdns.com/authoritative/http-api/zone.html 6 | package zones 7 | -------------------------------------------------------------------------------- /apis/zones/interface.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import "context" 4 | 5 | // Client defines the interface for Zone operations. 6 | type Client interface { 7 | // ListZones lists known zones for a given serverID 8 | ListZones(ctx context.Context, serverID string) ([]Zone, error) 9 | 10 | // ListZone list known zone for a given serverID and zoneID 11 | ListZone(ctx context.Context, serverID string, zoneID string) ([]Zone, error) 12 | 13 | // CreateZone creates a new zone for a given server. 14 | CreateZone(ctx context.Context, serverID string, zone Zone) (*Zone, error) 15 | 16 | // GetZone returns an existing zone by ID. If not found, the first returned value 17 | // will be nil, and the error return value will be an instance of "pdnshttp.ErrNotFound". 18 | GetZone(ctx context.Context, serverID string, zoneID string, opts ...GetZoneOption) (*Zone, error) 19 | 20 | // DeleteZone deletes a zone. No shit. 21 | DeleteZone(ctx context.Context, serverID string, zoneID string) error 22 | 23 | // AddRecordSetToZone will add a new set of records to a zone. Existing record sets for 24 | // the exact name/type combination will be replaced. 25 | // 26 | // Deprecated: Superceded by AddRecordSetsToZone 27 | AddRecordSetToZone(ctx context.Context, serverID string, zoneID string, set ResourceRecordSet) error 28 | 29 | // AddRecordSetsToZone will add new sets of records to a zone. Existing record sets for 30 | // the exact name/type combination will be replaced. 31 | AddRecordSetsToZone(ctx context.Context, serverID string, zoneID string, sets []ResourceRecordSet) error 32 | 33 | // RemoveRecordSetFromZone removes a record set from a zone. The record set is matched 34 | // by name and type. 35 | // 36 | // Deprecated: Superceded by RemoveRecordSetsFromZone 37 | RemoveRecordSetFromZone(ctx context.Context, serverID string, zoneID string, name string, recordType string) error 38 | 39 | // RemoveRecordSetsFromZone removes record sets from a zone. The record sets are matched 40 | // by name and type. 41 | RemoveRecordSetsFromZone(ctx context.Context, serverID string, zoneID string, sets []ResourceRecordSet) error 42 | 43 | // RetrieveFromMaster retrieves a slave zone from its master 44 | RetrieveFromMaster(ctx context.Context, serverID string, zoneID string) error 45 | 46 | // NotifySlaves sends a DNS NOTIFY to all slaves 47 | NotifySlaves(ctx context.Context, serverID string, zoneID string) error 48 | 49 | // ExportZone exports the entire zone in AXFR format 50 | ExportZone(ctx context.Context, serverID string, zoneID string) ([]byte, error) 51 | 52 | // VerifyZone verifies a zone's configuration 53 | VerifyZone(ctx context.Context, serverID string, zoneID string) error 54 | 55 | // RectifyZone rectifies the zone data 56 | RectifyZone(ctx context.Context, serverID string, zoneID string) error 57 | 58 | // Modifies basic zone data 59 | ModifyBasicZoneData(ctx context.Context, serverID string, zoneID string, update ZoneBasicDataUpdate) error 60 | } 61 | -------------------------------------------------------------------------------- /apis/zones/types.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | type ResourceRecordSet struct { 4 | Name string `json:"name"` 5 | Type string `json:"type"` 6 | TTL int `json:"ttl"` 7 | ChangeType RecordSetChangeType `json:"changetype,omitempty"` 8 | Records []Record `json:"records"` 9 | Comments []Comment `json:"comments"` 10 | } 11 | 12 | type Record struct { 13 | Content string `json:"content"` 14 | Disabled bool `json:"disabled"` 15 | SetPTR bool `json:"set-ptr,omitempty"` 16 | } 17 | 18 | type Comment struct { 19 | Content string `json:"content"` 20 | Account string `json:"account"` 21 | ModifiedAt int `json:"modified_at"` 22 | } 23 | -------------------------------------------------------------------------------- /apis/zones/types_rrsetchangetype.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import "fmt" 4 | 5 | type RecordSetChangeType int 6 | 7 | const ( 8 | _ = iota 9 | ChangeTypeDelete RecordSetChangeType = iota 10 | ChangeTypeReplace 11 | ) 12 | 13 | func (k RecordSetChangeType) MarshalJSON() ([]byte, error) { 14 | switch k { 15 | case ChangeTypeDelete: 16 | return []byte(`"DELETE"`), nil 17 | case ChangeTypeReplace: 18 | return []byte(`"REPLACE"`), nil 19 | default: 20 | return nil, fmt.Errorf("unsupported change type: %d", k) 21 | } 22 | } 23 | 24 | func (k *RecordSetChangeType) UnmarshalJSON(input []byte) error { 25 | switch string(input) { 26 | case `"DELETE"`: 27 | *k = ChangeTypeDelete 28 | case `"REPLACE"`: 29 | *k = ChangeTypeReplace 30 | default: 31 | return fmt.Errorf("unsupported change type: %s", string(input)) 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /apis/zones/types_rrsetchangetype_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestChangeTypeSerializesCorrectly(t *testing.T) { 11 | data := []struct { 12 | v RecordSetChangeType 13 | e string 14 | }{ 15 | {ChangeTypeDelete, `"DELETE"`}, 16 | {ChangeTypeReplace, `"REPLACE"`}, 17 | } 18 | 19 | for i := range data { 20 | t.Run(fmt.Sprintf("serializes to %s", data[i].e), func(t *testing.T) { 21 | j, err := json.Marshal(data[i].v) 22 | 23 | assert.Nil(t, err) 24 | assert.Equal(t, data[i].e, string(j)) 25 | }) 26 | } 27 | } 28 | 29 | func TestChangeTypeSerializationReturnErrorOnUnknownValue(t *testing.T) { 30 | var v RecordSetChangeType = 123 31 | 32 | _, err := json.Marshal(v) 33 | assert.NotNil(t, err) 34 | } 35 | 36 | func TestChangeTypeUnserializesCorrectly(t *testing.T) { 37 | data := []struct { 38 | v RecordSetChangeType 39 | e string 40 | }{ 41 | {ChangeTypeDelete, `"DELETE"`}, 42 | {ChangeTypeReplace, `"REPLACE"`}, 43 | } 44 | 45 | for i := range data { 46 | t.Run(fmt.Sprintf("serializes to %s", data[i].e), func(t *testing.T) { 47 | var out RecordSetChangeType 48 | 49 | err := json.Unmarshal([]byte(data[i].e), &out) 50 | 51 | assert.Nil(t, err) 52 | assert.Equal(t, data[i].v, out) 53 | }) 54 | } 55 | } 56 | 57 | func TestChangeTypeUnserializationReturnErrorOnUnknownValue(t *testing.T) { 58 | e := []byte(`"FOO"`) 59 | 60 | var out RecordSetChangeType 61 | 62 | err := json.Unmarshal(e, &out) 63 | assert.NotNil(t, err) 64 | } 65 | -------------------------------------------------------------------------------- /apis/zones/types_zone.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import "encoding/json" 4 | 5 | // ZoneNameservers is a special list type to represent the nameservers of a zone. 6 | // When nil, this type will still serialize to an empty JSON list. 7 | // See https://github.com/mittwald/go-powerdns/issues/4 for more information 8 | type ZoneNameservers []string 9 | 10 | // MarshalJSON implements the `json.Marshaler` interface 11 | func (z ZoneNameservers) MarshalJSON() ([]byte, error) { 12 | if z == nil { 13 | return []byte("[]"), nil 14 | } 15 | 16 | return json.Marshal([]string(z)) 17 | } 18 | 19 | type Zone struct { 20 | ID string `json:"id,omitempty"` 21 | Name string `json:"name"` 22 | Type ZoneType `json:"type"` 23 | URL string `json:"url,omitempty"` 24 | Kind ZoneKind `json:"kind,omitempty"` 25 | ResourceRecordSets []ResourceRecordSet `json:"rrsets,omitempty"` 26 | Serial int `json:"serial,omitempty"` 27 | NotifiedSerial int `json:"notified_serial,omitempty"` 28 | Masters []string `json:"masters,omitempty"` 29 | DNSSec bool `json:"dnssec,omitempty"` 30 | NSec3Param string `json:"nsec3param,omitempty"` 31 | NSec3Narrow bool `json:"nsec3narrow,omitempty"` 32 | Presigned bool `json:"presigned,omitempty"` 33 | SOAEdit ZoneSOAEdit `json:"soa_edit,omitempty"` 34 | SOAEditAPI ZoneSOAEditAPI `json:"soa_edit_api,omitempty"` 35 | APIRectify bool `json:"api_rectify,omitempty"` 36 | Zone string `json:"zone,omitempty"` 37 | Account string `json:"account,omitempty"` 38 | Nameservers ZoneNameservers `json:"nameservers"` 39 | TSIGMasterKeyIDs []string `json:"tsig_master_key_ids,omitempty"` 40 | TSIGSlaveKeyIDs []string `json:"tsig_slave_key_ids,omitempty"` 41 | } 42 | 43 | func (z *Zone) GetRecordSet(name, recordType string) *ResourceRecordSet { 44 | for i := range z.ResourceRecordSets { 45 | if z.ResourceRecordSets[i].Name == name && z.ResourceRecordSets[i].Type == recordType { 46 | return &z.ResourceRecordSets[i] 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /apis/zones/types_zone_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | func TestNilNameserversAreSerializedToEmptyArray(t *testing.T) { 10 | z := Zone{ 11 | ID: "1", 12 | Name: "foo.example", 13 | Type: ZoneTypeZone, 14 | Kind: ZoneKindMaster, 15 | Nameservers: nil, 16 | } 17 | 18 | j, err := json.Marshal(z) 19 | 20 | require.Nil(t, err) 21 | require.Equal(t, `{"id":"1","name":"foo.example","type":"Zone","kind":"Master","nameservers":[]}`, string(j)) 22 | } 23 | 24 | func TestNameserversAreSerializedToArray(t *testing.T) { 25 | z := Zone{ 26 | ID: "1", 27 | Name: "foo.example", 28 | Type: ZoneTypeZone, 29 | Kind: ZoneKindMaster, 30 | Nameservers: ZoneNameservers{"ns.foo.example"}, 31 | } 32 | 33 | j, err := json.Marshal(z) 34 | 35 | require.Nil(t, err) 36 | require.Equal(t, `{"id":"1","name":"foo.example","type":"Zone","kind":"Master","nameservers":["ns.foo.example"]}`, string(j)) 37 | } -------------------------------------------------------------------------------- /apis/zones/types_zonekind.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import "fmt" 4 | 5 | type ZoneKind int 6 | 7 | const ( 8 | _ = iota 9 | ZoneKindNative ZoneKind = iota 10 | ZoneKindMaster 11 | ZoneKindSlave 12 | ZoneKindProducer 13 | ZoneKindConsumer 14 | ) 15 | 16 | func (k ZoneKind) MarshalJSON() ([]byte, error) { 17 | switch k { 18 | case ZoneKindNative: 19 | return []byte(`"Native"`), nil 20 | case ZoneKindMaster: 21 | return []byte(`"Master"`), nil 22 | case ZoneKindSlave: 23 | return []byte(`"Slave"`), nil 24 | case ZoneKindProducer: 25 | return []byte(`"Producer"`), nil 26 | case ZoneKindConsumer: 27 | return []byte(`"Consumer"`), nil 28 | default: 29 | return nil, fmt.Errorf("unsupported zone kind: %d", k) 30 | } 31 | } 32 | 33 | func (k *ZoneKind) UnmarshalJSON(input []byte) error { 34 | switch string(input) { 35 | case `"Native"`: 36 | *k = ZoneKindNative 37 | case `"Master"`: 38 | *k = ZoneKindMaster 39 | case `"Slave"`: 40 | *k = ZoneKindSlave 41 | case `"Producer"`: 42 | *k = ZoneKindProducer 43 | case `"Consumer"`: 44 | *k = ZoneKindConsumer 45 | default: 46 | return fmt.Errorf("unsupported zone kind: %s", string(input)) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /apis/zones/types_zonekind_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | func TestZoneKindSerializesCorrectly(t *testing.T) { 10 | data := []struct { 11 | v ZoneKind 12 | e string 13 | }{ 14 | {ZoneKindNative, `"Native"`}, 15 | {ZoneKindMaster, `"Master"`}, 16 | {ZoneKindSlave, `"Slave"`}, 17 | {ZoneKindProducer, `"Producer"`}, 18 | {ZoneKindConsumer, `"Consumer"`}, 19 | } 20 | 21 | for i := range data { 22 | t.Run(fmt.Sprintf("serializes to %s", data[i].e), func(t *testing.T) { 23 | j, err := json.Marshal(data[i].v) 24 | 25 | assert.Nil(t, err) 26 | assert.Equal(t, data[i].e, string(j)) 27 | }) 28 | } 29 | } 30 | 31 | func TestZoneTypeSerializationReturnErrorOnUnknownValue(t *testing.T) { 32 | var v ZoneKind = 123 33 | 34 | _, err := json.Marshal(v) 35 | assert.NotNil(t, err) 36 | } 37 | 38 | func TestZoneKindUnserializesCorrectly(t *testing.T) { 39 | data := []struct { 40 | v ZoneKind 41 | e string 42 | }{ 43 | {ZoneKindNative, `"Native"`}, 44 | {ZoneKindMaster, `"Master"`}, 45 | {ZoneKindSlave, `"Slave"`}, 46 | {ZoneKindProducer, `"Producer"`}, 47 | {ZoneKindConsumer, `"Consumer"`}, 48 | } 49 | 50 | for i := range data { 51 | t.Run(fmt.Sprintf("serializes to %s", data[i].e), func(t *testing.T) { 52 | var out ZoneKind 53 | 54 | err := json.Unmarshal([]byte(data[i].e), &out) 55 | 56 | assert.Nil(t, err) 57 | assert.Equal(t, data[i].v, out) 58 | }) 59 | } 60 | } 61 | 62 | func TestZoneKindUnserializationReturnErrorOnUnknownValue(t *testing.T) { 63 | e := []byte(`"FOO"`) 64 | 65 | var out ZoneKind 66 | 67 | err := json.Unmarshal(e, &out) 68 | assert.NotNil(t, err) 69 | } 70 | -------------------------------------------------------------------------------- /apis/zones/types_zonesoaedit.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import "fmt" 4 | 5 | type ZoneSOAEdit int 6 | 7 | const ( 8 | ZoneSOAEditUnset ZoneSOAEdit = iota 9 | ZoneSOAEditIncrementWeeks = iota 10 | ZoneSOAEditInceptionEpoch 11 | ZoneSOAEditInceptionIncrement 12 | ZoneSOAEditEpoch 13 | ZoneSOAEditNone 14 | ) 15 | 16 | func (v ZoneSOAEdit) MarshalJSON() ([]byte, error) { 17 | switch v { 18 | case ZoneSOAEditIncrementWeeks: 19 | return []byte(`"INCREMENT-WEEKS"`), nil 20 | case ZoneSOAEditInceptionEpoch: 21 | return []byte(`"INCEPTION-EPOCH"`), nil 22 | case ZoneSOAEditInceptionIncrement: 23 | return []byte(`"INCEPTION-INCREMENT"`), nil 24 | case ZoneSOAEditEpoch: 25 | return []byte(`"EPOCH"`), nil 26 | case ZoneSOAEditNone: 27 | return []byte(`"NONE"`), nil 28 | default: 29 | return nil, fmt.Errorf("unsupported SOA-EDIT value: %d", v) 30 | } 31 | } 32 | 33 | func (v *ZoneSOAEdit) UnmarshalJSON(input []byte) error { 34 | switch string(input) { 35 | case `"INCREMENT-WEEKS"`: 36 | *v = ZoneSOAEditIncrementWeeks 37 | case `"INCEPTION-EPOCH"`: 38 | *v = ZoneSOAEditInceptionEpoch 39 | case `"INCEPTION-INCREMENT"`: 40 | *v = ZoneSOAEditInceptionIncrement 41 | case `"EPOCH"`: 42 | *v = ZoneSOAEditEpoch 43 | case `"NONE"`: 44 | *v = ZoneSOAEditNone 45 | case `""`: 46 | *v = ZoneSOAEditUnset 47 | default: 48 | return fmt.Errorf("unsupported SOA-EDIT value: %s", string(input)) 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /apis/zones/types_zonesoaedit_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestZoneSOAEditSerializesCorrectly(t *testing.T) { 12 | data := []struct { 13 | v ZoneSOAEdit 14 | e string 15 | }{ 16 | {ZoneSOAEditIncrementWeeks, `"INCREMENT-WEEKS"`}, 17 | {ZoneSOAEditInceptionEpoch, `"INCEPTION-EPOCH"`}, 18 | {ZoneSOAEditInceptionIncrement, `"INCEPTION-INCREMENT"`}, 19 | {ZoneSOAEditEpoch, `"EPOCH"`}, 20 | {ZoneSOAEditNone, `"NONE"`}, 21 | } 22 | 23 | for i := range data { 24 | t.Run(fmt.Sprintf("serializes to %s", data[i].e), func(t *testing.T) { 25 | j, err := json.Marshal(data[i].v) 26 | 27 | assert.Nil(t, err) 28 | assert.Equal(t, data[i].e, string(j)) 29 | }) 30 | } 31 | } 32 | 33 | func TestZoneSOAEditSerializationReturnErrorOnUnknownValue(t *testing.T) { 34 | var v ZoneSOAEdit = 123 35 | 36 | _, err := json.Marshal(v) 37 | assert.NotNil(t, err) 38 | } 39 | 40 | func TestZoneSOAEditUnserializesCorrectly(t *testing.T) { 41 | data := []struct { 42 | v ZoneSOAEdit 43 | e string 44 | }{ 45 | {ZoneSOAEditIncrementWeeks, `"INCREMENT-WEEKS"`}, 46 | {ZoneSOAEditInceptionEpoch, `"INCEPTION-EPOCH"`}, 47 | {ZoneSOAEditInceptionIncrement, `"INCEPTION-INCREMENT"`}, 48 | {ZoneSOAEditEpoch, `"EPOCH"`}, 49 | {ZoneSOAEditNone, `"NONE"`}, 50 | } 51 | 52 | for i := range data { 53 | t.Run(fmt.Sprintf("serializes to %s", data[i].e), func(t *testing.T) { 54 | var out ZoneSOAEdit 55 | 56 | err := json.Unmarshal([]byte(data[i].e), &out) 57 | 58 | assert.Nil(t, err) 59 | assert.Equal(t, data[i].v, out) 60 | }) 61 | } 62 | } 63 | 64 | func TestZoneSOAEditUnserializationReturnErrorOnUnknownValue(t *testing.T) { 65 | e := []byte(`"FOO"`) 66 | 67 | var out ZoneSOAEdit 68 | 69 | err := json.Unmarshal(e, &out) 70 | assert.NotNil(t, err) 71 | } 72 | -------------------------------------------------------------------------------- /apis/zones/types_zonesoaeditapi.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import "fmt" 4 | 5 | type ZoneSOAEditAPI int 6 | 7 | const ( 8 | _ = iota 9 | ZoneSOAEditAPIDefault ZoneSOAEditAPI = iota 10 | ZoneSOAEditAPIIncrease 11 | ZoneSOAEditAPIEpoch 12 | ZoneSOAEditAPISoaEdit 13 | ZoneSOAEditAPISoaEditIncrease 14 | ZoneSOAEditAPINone 15 | ) 16 | 17 | func (v ZoneSOAEditAPI) MarshalJSON() ([]byte, error) { 18 | switch v { 19 | case ZoneSOAEditAPIDefault: 20 | return []byte(`"DEFAULT"`), nil 21 | case ZoneSOAEditAPIIncrease: 22 | return []byte(`"INCREASE"`), nil 23 | case ZoneSOAEditAPIEpoch: 24 | return []byte(`"EPOCH"`), nil 25 | case ZoneSOAEditAPISoaEdit: 26 | return []byte(`"SOA-EDIT"`), nil 27 | case ZoneSOAEditAPISoaEditIncrease: 28 | return []byte(`"SOA-EDIT-INCREASE"`), nil 29 | case ZoneSOAEditAPINone: 30 | return []byte(`"NONE"`), nil 31 | default: 32 | return nil, fmt.Errorf("unsupported SOA-EDIT-API value: %d", v) 33 | } 34 | } 35 | 36 | func (v *ZoneSOAEditAPI) UnmarshalJSON(input []byte) error { 37 | switch string(input) { 38 | case `"DEFAULT"`: 39 | *v = ZoneSOAEditAPIDefault 40 | case `"INCREASE"`: 41 | *v = ZoneSOAEditAPIIncrease 42 | case `"EPOCH"`: 43 | *v = ZoneSOAEditAPIEpoch 44 | case `"SOA-EDIT"`: 45 | *v = ZoneSOAEditAPISoaEdit 46 | case `"SOA-EDIT-INCREASE"`: 47 | *v = ZoneSOAEditAPISoaEditIncrease 48 | case `"NONE"`: 49 | *v = ZoneSOAEditAPINone 50 | default: 51 | return fmt.Errorf("unsupported SOA-EDIT-API value: %s", string(input)) 52 | } 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /apis/zones/types_zonesoaeditapi_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestZoneSOAEditAPISerializesCorrectly(t *testing.T) { 12 | data := []struct { 13 | v ZoneSOAEditAPI 14 | e string 15 | }{ 16 | {ZoneSOAEditAPIDefault, `"DEFAULT"`}, 17 | {ZoneSOAEditAPIIncrease, `"INCREASE"`}, 18 | {ZoneSOAEditAPIEpoch, `"EPOCH"`}, 19 | {ZoneSOAEditAPISoaEdit, `"SOA-EDIT"`}, 20 | {ZoneSOAEditAPISoaEditIncrease, `"SOA-EDIT-INCREASE"`}, 21 | {ZoneSOAEditAPINone, `"NONE"`}, 22 | } 23 | 24 | for i := range data { 25 | t.Run(fmt.Sprintf("serializes to %s", data[i].e), func(t *testing.T) { 26 | j, err := json.Marshal(data[i].v) 27 | 28 | assert.Nil(t, err) 29 | assert.Equal(t, data[i].e, string(j)) 30 | }) 31 | } 32 | } 33 | 34 | func TestZoneSOAEditAPISerializationReturnErrorOnUnknownValue(t *testing.T) { 35 | var v ZoneSOAEditAPI = 123 36 | 37 | _, err := json.Marshal(v) 38 | assert.NotNil(t, err) 39 | } 40 | 41 | func TestZoneSOAEditAPIUnserializesCorrectly(t *testing.T) { 42 | data := []struct { 43 | v ZoneSOAEditAPI 44 | e string 45 | }{ 46 | {ZoneSOAEditAPIDefault, `"DEFAULT"`}, 47 | {ZoneSOAEditAPIIncrease, `"INCREASE"`}, 48 | {ZoneSOAEditAPIEpoch, `"EPOCH"`}, 49 | {ZoneSOAEditAPISoaEdit, `"SOA-EDIT"`}, 50 | {ZoneSOAEditAPISoaEditIncrease, `"SOA-EDIT-INCREASE"`}, 51 | {ZoneSOAEditAPINone, `"NONE"`}, 52 | } 53 | 54 | for i := range data { 55 | t.Run(fmt.Sprintf("serializes to %s", data[i].e), func(t *testing.T) { 56 | var out ZoneSOAEditAPI 57 | 58 | err := json.Unmarshal([]byte(data[i].e), &out) 59 | 60 | assert.Nil(t, err) 61 | assert.Equal(t, data[i].v, out) 62 | }) 63 | } 64 | } 65 | 66 | func TestZoneSOAEditAPIUnserializationReturnErrorOnUnknownValue(t *testing.T) { 67 | e := []byte(`"FOO"`) 68 | 69 | var out ZoneSOAEditAPI 70 | 71 | err := json.Unmarshal(e, &out) 72 | assert.NotNil(t, err) 73 | } 74 | -------------------------------------------------------------------------------- /apis/zones/types_zonetype.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import "fmt" 4 | 5 | type ZoneType int 6 | 7 | const ( 8 | ZoneTypeZone ZoneType = iota 9 | ) 10 | 11 | func (k ZoneType) MarshalJSON() ([]byte, error) { 12 | switch k { 13 | case ZoneTypeZone: 14 | return []byte(`"Zone"`), nil 15 | default: 16 | return nil, fmt.Errorf("unsupported zone type: %d", k) 17 | } 18 | } 19 | 20 | func (k *ZoneType) UnmarshalJSON(input []byte) error { 21 | switch string(input) { 22 | case `"Zone"`: 23 | *k = ZoneTypeZone 24 | default: 25 | return fmt.Errorf("unsupported zone type: %s", string(input)) 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /apis/zones/zones_addrecordset.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/mittwald/go-powerdns/pdnshttp" 9 | ) 10 | 11 | func (c *client) AddRecordSetToZone(ctx context.Context, serverID string, zoneID string, set ResourceRecordSet) error { 12 | return c.AddRecordSetsToZone(ctx, serverID, zoneID, []ResourceRecordSet{set}) 13 | } 14 | 15 | func (c *client) AddRecordSetsToZone(ctx context.Context, serverID string, zoneID string, sets []ResourceRecordSet) error { 16 | path := fmt.Sprintf("/servers/%s/zones/%s", url.PathEscape(serverID), url.PathEscape(zoneID)) 17 | 18 | for idx := range sets { 19 | sets[idx].ChangeType = ChangeTypeReplace 20 | } 21 | patch := Zone{ 22 | ResourceRecordSets: sets, 23 | } 24 | 25 | return c.httpClient.Patch(ctx, path, nil, pdnshttp.WithJSONRequestBody(&patch)) 26 | } 27 | -------------------------------------------------------------------------------- /apis/zones/zones_create.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/mittwald/go-powerdns/pdnshttp" 9 | ) 10 | 11 | func (c *client) CreateZone(ctx context.Context, serverID string, zone Zone) (*Zone, error) { 12 | created := Zone{} 13 | path := fmt.Sprintf("/servers/%s/zones", url.PathEscape(serverID)) 14 | 15 | zone.ID = "" 16 | zone.Type = ZoneTypeZone 17 | 18 | if zone.Kind == 0 { 19 | zone.Kind = ZoneKindNative 20 | } 21 | 22 | err := c.httpClient.Post(ctx, path, &created, pdnshttp.WithJSONRequestBody(&zone)) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return &created, nil 28 | } 29 | -------------------------------------------------------------------------------- /apis/zones/zones_create_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "github.com/mittwald/go-powerdns/pdnshttp" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "gopkg.in/h2non/gock.v1" 9 | "io" 10 | "net/http" 11 | "testing" 12 | ) 13 | 14 | func TestCreateZoneCreatesZone(t *testing.T) { 15 | gock.New("http://dns.example"). 16 | Post("/api/v1/servers/localhost/zones"). 17 | Reply(http.StatusCreated). 18 | SetHeader("Content-Type", "application/json"). 19 | BodyString(`{ 20 | "account": "", 21 | "api_rectify": false, 22 | "dnssec": false, 23 | "id": "some-generated-id", 24 | "kind": "Native", 25 | "last_check": 0, 26 | "masters": [], 27 | "name": "test.example.", 28 | "notified_serial": 0, 29 | "nsec3narrow": false, 30 | "nsec3param": "", 31 | "rrsets": [{ 32 | "comments": [], 33 | "name": "www.test.example.", 34 | "records": [{ 35 | "content": "127.0.0.1", 36 | "disabled": false 37 | }], 38 | "ttl": 60, 39 | "type": "A" 40 | }, { 41 | "comments": [], 42 | "name": "test.example.", 43 | "records": [{ 44 | "content": "a.misconfigured.powerdns.server. hostmaster.example3.de. 2019031801 10800 3600 604800 3600", 45 | "disabled": false 46 | }], 47 | "ttl": 3600, 48 | "type": "SOA" 49 | }, { 50 | "comments": [], 51 | "name": "test.example.", 52 | "records": [{ 53 | "content": "ns1.example.com.", 54 | "disabled": false 55 | }, { 56 | "content": "ns2.example.com.", 57 | "disabled": false 58 | }], 59 | "ttl": 3600, 60 | "type": "NS" 61 | }], 62 | "serial": 2019031801, 63 | "soa_edit": "", 64 | "soa_edit_api": "DEFAULT", 65 | "url": "/api/v1/servers/localhost/zones/example3.de." 66 | }`) 67 | 68 | hc := &http.Client{Transport: gock.DefaultTransport} 69 | c := pdnshttp.NewClient("http://dns.example", hc, &pdnshttp.APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 70 | sc := New(c) 71 | 72 | zone, err := sc.CreateZone( 73 | context.Background(), 74 | "localhost", 75 | Zone{ 76 | Name: "test.example.", 77 | Kind: ZoneKindNative, 78 | Nameservers: []string{ 79 | "ns1.example.com", 80 | "ns2.example.com", 81 | }, 82 | ResourceRecordSets: []ResourceRecordSet{ 83 | { 84 | Name: "www.test.example", 85 | Type: "A", 86 | TTL: 3600, 87 | Records: []Record{ 88 | {Content: "127.0.0.1"}, 89 | }, 90 | }, 91 | }, 92 | }, 93 | ) 94 | 95 | assert.Nil(t, err) 96 | require.NotNil(t, zone) 97 | assert.Equal(t, "some-generated-id", zone.ID) 98 | } 99 | -------------------------------------------------------------------------------- /apis/zones/zones_delete.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | func (c *client) DeleteZone(ctx context.Context, serverID string, zoneID string) error { 10 | path := fmt.Sprintf("/servers/%s/zones/%s", url.PathEscape(serverID), url.PathEscape(zoneID)) 11 | 12 | return c.httpClient.Delete(ctx, path, nil) 13 | } 14 | -------------------------------------------------------------------------------- /apis/zones/zones_export.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "github.com/mittwald/go-powerdns/pdnshttp" 8 | "net/http" 9 | "net/url" 10 | ) 11 | 12 | func (c *client) ExportZone(ctx context.Context, serverID, zoneID string) ([]byte, error) { 13 | output := bytes.Buffer{} 14 | path := fmt.Sprintf("/servers/%s/zones/%s/export", url.PathEscape(serverID), url.PathEscape(zoneID)) 15 | 16 | err := c.httpClient.Get(ctx, path, &output) 17 | if err != nil { 18 | if e, ok := err.(pdnshttp.ErrUnexpectedStatus); ok { 19 | if e.StatusCode == http.StatusUnprocessableEntity { 20 | return nil, pdnshttp.ErrNotFound{} 21 | } 22 | } 23 | 24 | return nil, err 25 | } 26 | 27 | return output.Bytes(), nil 28 | } 29 | -------------------------------------------------------------------------------- /apis/zones/zones_get.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/mittwald/go-powerdns/pdnshttp" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | type GetZoneOption interface { 12 | ApplyToGetZoneRequest(req *http.Request) error 13 | } 14 | 15 | type getZoneOptionFunc func(req *http.Request) error 16 | 17 | func (g getZoneOptionFunc) ApplyToGetZoneRequest(req *http.Request) error { 18 | return g(req) 19 | } 20 | 21 | func WithoutResourceRecordSets() GetZoneOption { 22 | return getZoneOptionFunc(func(req *http.Request) error { 23 | _ = pdnshttp.WithQueryValue("rrsets", "false")(req) 24 | return nil 25 | }) 26 | } 27 | 28 | func WithResourceRecordSetFilter(name, recordType string) GetZoneOption { 29 | return getZoneOptionFunc(func(req *http.Request) error { 30 | _ = pdnshttp.WithQueryValue("rrset_name", name)(req) 31 | _ = pdnshttp.WithQueryValue("rrset_type", recordType)(req) 32 | return nil 33 | }) 34 | } 35 | 36 | func (c *client) GetZone(ctx context.Context, serverID, zoneID string, opts ...GetZoneOption) (*Zone, error) { 37 | zone := Zone{} 38 | path := fmt.Sprintf("/servers/%s/zones/%s", url.PathEscape(serverID), url.PathEscape(zoneID)) 39 | 40 | req, err := c.httpClient.NewRequest(http.MethodGet, path, nil) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | for _, opt := range opts { 46 | if err := opt.ApplyToGetZoneRequest(req); err != nil { 47 | return nil, err 48 | } 49 | } 50 | 51 | if err := c.httpClient.Do(ctx, req, &zone); err != nil { 52 | if e, ok := err.(pdnshttp.ErrUnexpectedStatus); ok { 53 | if e.StatusCode == http.StatusUnprocessableEntity { 54 | return nil, pdnshttp.ErrNotFound{} 55 | } 56 | } 57 | 58 | return nil, err 59 | } 60 | 61 | return &zone, nil 62 | } 63 | -------------------------------------------------------------------------------- /apis/zones/zones_list.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/mittwald/go-powerdns/pdnshttp" 9 | ) 10 | 11 | func (c *client) ListZones(ctx context.Context, serverID string) ([]Zone, error) { 12 | zones := make([]Zone, 0) 13 | path := fmt.Sprintf("/servers/%s/zones", url.PathEscape(serverID)) 14 | 15 | err := c.httpClient.Get(ctx, path, &zones) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return zones, nil 21 | } 22 | 23 | func (c *client) ListZone(ctx context.Context, serverID string, zoneName string) ([]Zone, error) { 24 | zones := make([]Zone, 0) 25 | path := fmt.Sprintf("/servers/%s/zones", url.PathEscape(serverID)) 26 | 27 | err := c.httpClient.Get(ctx, path, &zones, pdnshttp.WithQueryValue("zone", zoneName)) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return zones, nil 33 | } 34 | -------------------------------------------------------------------------------- /apis/zones/zones_modifybasicdata.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/mittwald/go-powerdns/pdnshttp" 9 | ) 10 | 11 | type ZoneBasicDataUpdate struct { 12 | Kind ZoneKind `json:"kind,omitempty"` 13 | Masters []string `json:"masters,omitempty"` 14 | Account string `json:"account,omitempty"` 15 | SOAEdit ZoneSOAEdit `json:"soa_edit,omitempty"` 16 | SOAEditAPI ZoneSOAEditAPI `json:"soa_edit_api,omitempty"` 17 | APIRectify *bool `json:"api_rectify,omitempty"` 18 | DNSSec *bool `json:"dnssec,omitempty"` 19 | NSec3Param string `json:"nsec3param,omitempty"` 20 | } 21 | 22 | func (c *client) ModifyBasicZoneData(ctx context.Context, serverID string, zoneID string, update ZoneBasicDataUpdate) error { 23 | path := fmt.Sprintf("/servers/%s/zones/%s", url.PathEscape(serverID), url.PathEscape(zoneID)) 24 | 25 | err := c.httpClient.Put(ctx, path, nil, pdnshttp.WithJSONRequestBody(&update)) 26 | if err != nil { 27 | return err 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /apis/zones/zones_notifyslaves.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | func (c *client) NotifySlaves(ctx context.Context, serverID string, zoneID string) error { 10 | path := fmt.Sprintf("/servers/%s/zones/%s/notify", url.PathEscape(serverID), url.PathEscape(zoneID)) 11 | 12 | return c.httpClient.Put(ctx, path, nil) 13 | } 14 | -------------------------------------------------------------------------------- /apis/zones/zones_rectify.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | func (c *client) RectifyZone(ctx context.Context, serverID string, zoneID string) error { 10 | path := fmt.Sprintf("/servers/%s/zones/%s/rectify", url.PathEscape(serverID), url.PathEscape(zoneID)) 11 | 12 | return c.httpClient.Put(ctx, path, nil) 13 | } 14 | -------------------------------------------------------------------------------- /apis/zones/zones_removerecordset.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/mittwald/go-powerdns/pdnshttp" 9 | ) 10 | 11 | func (c *client) RemoveRecordSetFromZone(ctx context.Context, serverID string, zoneID string, name string, recordType string) error { 12 | set := ResourceRecordSet{ 13 | Name: name, 14 | Type: recordType, 15 | ChangeType: ChangeTypeDelete, 16 | } 17 | 18 | return c.RemoveRecordSetsFromZone(ctx, serverID, zoneID, []ResourceRecordSet{set}) 19 | } 20 | 21 | func (c *client) RemoveRecordSetsFromZone(ctx context.Context, serverID string, zoneID string, sets []ResourceRecordSet) error { 22 | path := fmt.Sprintf("/servers/%s/zones/%s", url.PathEscape(serverID), url.PathEscape(zoneID)) 23 | 24 | for idx := range sets { 25 | sets[idx].ChangeType = ChangeTypeDelete 26 | } 27 | patch := Zone{ 28 | ResourceRecordSets: sets, 29 | } 30 | 31 | return c.httpClient.Patch(ctx, path, nil, pdnshttp.WithJSONRequestBody(&patch)) 32 | } 33 | -------------------------------------------------------------------------------- /apis/zones/zones_retrievemaster.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | func (c *client) RetrieveFromMaster(ctx context.Context, serverID string, zoneID string) error { 10 | path := fmt.Sprintf("/servers/%s/zones/%s/axfr-retrieve", url.PathEscape(serverID), url.PathEscape(zoneID)) 11 | 12 | return c.httpClient.Put(ctx, path, nil) 13 | } 14 | -------------------------------------------------------------------------------- /apis/zones/zones_verify.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | ) 8 | 9 | func (c *client) VerifyZone(ctx context.Context, serverID string, zoneID string) error { 10 | path := fmt.Sprintf("/servers/%s/zones/%s/check", url.PathEscape(serverID), url.PathEscape(zoneID)) 11 | 12 | return c.httpClient.Get(ctx, path, nil) 13 | } 14 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package pdns 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/mittwald/go-powerdns/apis/cryptokeys" 7 | "io" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/mittwald/go-powerdns/apis/cache" 12 | "github.com/mittwald/go-powerdns/apis/search" 13 | "github.com/mittwald/go-powerdns/apis/servers" 14 | "github.com/mittwald/go-powerdns/apis/zones" 15 | "github.com/mittwald/go-powerdns/pdnshttp" 16 | ) 17 | 18 | type client struct { 19 | baseURL string 20 | httpClient *http.Client 21 | authenticator pdnshttp.ClientAuthenticator 22 | debugOutput io.Writer 23 | 24 | cache cache.Client 25 | cryptokeys cryptokeys.Client 26 | search search.Client 27 | servers servers.Client 28 | zones zones.Client 29 | } 30 | 31 | type ClientOption func(c *client) error 32 | 33 | // New creates a new PowerDNS client. Various client options can be used to configure 34 | // the PowerDNS client (see examples). 35 | func New(opt ...ClientOption) (Client, error) { 36 | c := client{ 37 | baseURL: "http://localhost:8081", 38 | httpClient: http.DefaultClient, 39 | debugOutput: io.Discard, 40 | authenticator: &pdnshttp.NoopAuthenticator{}, 41 | } 42 | 43 | for i := range opt { 44 | if err := opt[i](&c); err != nil { 45 | return nil, err 46 | } 47 | } 48 | 49 | if c.authenticator != nil { 50 | err := c.authenticator.OnConnect(c.httpClient) 51 | if err != nil { 52 | return nil, err 53 | } 54 | } 55 | 56 | hc := pdnshttp.NewClient(c.baseURL, c.httpClient, c.authenticator, c.debugOutput) 57 | 58 | c.servers = servers.New(hc) 59 | c.zones = zones.New(hc) 60 | c.search = search.New(hc) 61 | c.cache = cache.New(hc) 62 | c.cryptokeys = cryptokeys.New(hc) 63 | 64 | return &c, nil 65 | } 66 | 67 | func (c *client) Status() error { 68 | req, err := http.NewRequest("GET", c.baseURL, nil) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if err := c.authenticator.OnRequest(req); err != nil { 74 | return err 75 | } 76 | 77 | _, err = c.httpClient.Do(req) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (c *client) WaitUntilUp(ctx context.Context) error { 86 | up := make(chan error) 87 | cancel := false 88 | 89 | go func() { 90 | for !cancel { 91 | req, err := http.NewRequest("GET", c.baseURL, nil) 92 | if err != nil { 93 | time.Sleep(1 * time.Second) 94 | continue 95 | } 96 | 97 | _, err = c.httpClient.Do(req) 98 | if err != nil { 99 | time.Sleep(1 * time.Second) 100 | continue 101 | } 102 | 103 | up <- nil 104 | return 105 | } 106 | }() 107 | 108 | select { 109 | case <-up: 110 | return nil 111 | case <-ctx.Done(): 112 | cancel = true 113 | return errors.New("context exceeded") 114 | } 115 | } 116 | 117 | func (c *client) Servers() servers.Client { 118 | return c.servers 119 | } 120 | 121 | func (c *client) Zones() zones.Client { 122 | return c.zones 123 | } 124 | 125 | func (c *client) Search() search.Client { 126 | return c.search 127 | } 128 | 129 | func (c *client) Cache() cache.Client { 130 | return c.cache 131 | } 132 | 133 | func (c *client) Cryptokeys() cryptokeys.Client { 134 | return c.cryptokeys 135 | } 136 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package pdns 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "testing" 11 | "time" 12 | 13 | "github.com/mittwald/go-powerdns/apis/search" 14 | "github.com/mittwald/go-powerdns/apis/zones" 15 | "github.com/mittwald/go-powerdns/pdnshttp" 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | func TestMain(m *testing.M) { 21 | flag.Parse() 22 | 23 | if testing.Short() { 24 | fmt.Println("skipping integration tests") 25 | os.Exit(0) 26 | } 27 | 28 | runOrPanic("docker", "compose", "rm", "-sfv") 29 | runOrPanic("docker", "compose", "down", "-v") 30 | runOrPanic("docker", "compose", "up", "-d") 31 | 32 | defer func() { 33 | runOrPanic("docker", "compose", "down", "-v") 34 | }() 35 | 36 | c, err := New( 37 | WithBaseURL("http://localhost:8081"), 38 | WithAPIKeyAuthentication("secret"), 39 | ) 40 | 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 46 | defer cancel() 47 | c.WaitUntilUp(ctx) 48 | 49 | e := m.Run() 50 | 51 | if e != 0 { 52 | fmt.Println("") 53 | fmt.Println("TESTS FAILED") 54 | fmt.Println("Leaving containers running for further inspection") 55 | fmt.Println("") 56 | } else { 57 | runOrPanic("docker", "compose", "down", "-v") 58 | } 59 | 60 | os.Exit(e) 61 | } 62 | 63 | func runOrPanic(cmd string, args ...string) { 64 | c := exec.Command(cmd, args...) 65 | c.Stdout = os.Stdout 66 | c.Stderr = os.Stderr 67 | 68 | if err := c.Run(); err != nil { 69 | panic(err) 70 | } 71 | } 72 | 73 | func TestCanConnect(t *testing.T) { 74 | c := buildClient(t) 75 | 76 | statusErr := c.Status() 77 | assert.Nil(t, statusErr) 78 | } 79 | 80 | func TestListServers(t *testing.T) { 81 | c := buildClient(t) 82 | 83 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 84 | defer cancel() 85 | servers, err := c.Servers().ListServers(ctx) 86 | 87 | assert.Nil(t, err, "ListServers returned error") 88 | assert.Lenf(t, servers, 1, "ListServers should return one server") 89 | } 90 | 91 | func TestGetServer(t *testing.T) { 92 | c := buildClient(t) 93 | 94 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 95 | defer cancel() 96 | server, err := c.Servers().GetServer(ctx, "localhost") 97 | 98 | require.Nil(t, err, "GetServer returned error") 99 | require.NotNil(t, server) 100 | require.Equal(t, "authoritative", server.DaemonType) 101 | } 102 | 103 | func TestGetEmptyZones(t *testing.T) { 104 | c := buildClient(t) 105 | 106 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 107 | defer cancel() 108 | z, err := c.Zones().ListZones(ctx, "localhost") 109 | 110 | require.Nil(t, err, "ListZones returned error") 111 | 112 | assert.Len(t, z, 0) 113 | } 114 | 115 | func TestCreateZone(t *testing.T) { 116 | c := buildClient(t) 117 | 118 | zone := zones.Zone{ 119 | Name: "example.de.", 120 | Type: zones.ZoneTypeZone, 121 | Kind: zones.ZoneKindNative, 122 | Nameservers: []string{ 123 | "ns1.example.com.", 124 | "ns2.example.com.", 125 | }, 126 | ResourceRecordSets: []zones.ResourceRecordSet{ 127 | { 128 | Name: "example.de.", 129 | Type: "A", 130 | TTL: 60, 131 | Records: []zones.Record{ 132 | {Content: "127.0.0.1"}, 133 | }, 134 | }, 135 | }, 136 | } 137 | 138 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 139 | defer cancel() 140 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 141 | 142 | require.Nil(t, err, "CreateZone returned error") 143 | 144 | assert.NotEmpty(t, created.ID) 145 | assert.Equal(t, "example.de.", created.Name) 146 | } 147 | 148 | func TestCreateZoneProducedReadableErrorMessages(t *testing.T) { 149 | c := buildClient(t) 150 | 151 | zone := zones.Zone{ 152 | Name: "test-error-message.de.", 153 | Type: zones.ZoneTypeZone, 154 | Kind: zones.ZoneKindNative, 155 | Nameservers: []string{"ns1.example.com.", "ns2.example.com."}, 156 | } 157 | 158 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 159 | defer cancel() 160 | 161 | _, err := c.Zones().CreateZone(ctx, "localhost", zone) 162 | require.Nil(t, err, "CreateZone returned error") 163 | 164 | _, err2 := c.Zones().CreateZone(ctx, "localhost", zone) 165 | require.Error(t, err2, "CreateZone should return error") 166 | require.Equal(t, "unexpected status code 409: http://localhost:8081/api/v1/servers/localhost/zones Conflict", err2.Error()) 167 | } 168 | 169 | func TestDeleteZone(t *testing.T) { 170 | c := buildClient(t) 171 | 172 | zone := zones.Zone{ 173 | Name: "example-delete.de.", 174 | Type: zones.ZoneTypeZone, 175 | Kind: zones.ZoneKindNative, 176 | Nameservers: []string{ 177 | "ns1.example.com.", 178 | "ns2.example.com.", 179 | }, 180 | ResourceRecordSets: []zones.ResourceRecordSet{ 181 | { 182 | Name: "example-delete.de.", 183 | Type: "A", 184 | TTL: 60, 185 | Records: []zones.Record{ 186 | {Content: "127.0.0.1"}, 187 | }, 188 | }, 189 | }, 190 | } 191 | 192 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 193 | defer cancel() 194 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 195 | 196 | require.Nil(t, err, "CreateZone returned error") 197 | 198 | assert.NotEmpty(t, created.ID) 199 | assert.Equal(t, "example-delete.de.", created.Name) 200 | 201 | deleteErr := c.Zones().DeleteZone(ctx, "localhost", created.ID) 202 | require.Nil(t, deleteErr, "DeleteZone returned error") 203 | 204 | _, getErr := c.Zones().GetZone(ctx, "localhost", created.ID) 205 | assert.NotNil(t, getErr) 206 | assert.IsType(t, pdnshttp.ErrNotFound{}, getErr) 207 | assert.True(t, pdnshttp.IsNotFound(getErr)) 208 | } 209 | 210 | func TestAddRecordToZone(t *testing.T) { 211 | c := buildClient(t) 212 | 213 | zone := zones.Zone{ 214 | Name: "example2.de.", 215 | Type: zones.ZoneTypeZone, 216 | Kind: zones.ZoneKindNative, 217 | Nameservers: []string{ 218 | "ns1.example.com.", 219 | "ns2.example.com.", 220 | }, 221 | ResourceRecordSets: []zones.ResourceRecordSet{ 222 | {Name: "foo.example2.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 223 | }, 224 | } 225 | 226 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 227 | defer cancel() 228 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 229 | 230 | require.Nil(t, err, "CreateZone returned error") 231 | 232 | err = c.Zones().AddRecordSetToZone(ctx, "localhost", created.ID, zones.ResourceRecordSet{ 233 | Name: "bar.example2.de.", 234 | Type: "A", 235 | TTL: 60, 236 | Records: []zones.Record{{Content: "127.0.0.2"}}, 237 | }) 238 | 239 | require.Nil(t, err, "AddRecordSetToZone returned error") 240 | 241 | updated, err := c.Zones().GetZone(ctx, "localhost", created.ID) 242 | 243 | require.Nil(t, err) 244 | 245 | rs := updated.GetRecordSet("bar.example2.de.", "A") 246 | require.NotNil(t, rs) 247 | } 248 | 249 | func TestAddRecordSetsToZone(t *testing.T) { 250 | c := buildClient(t) 251 | 252 | zone := zones.Zone{ 253 | Name: "example6.de.", 254 | Type: zones.ZoneTypeZone, 255 | Kind: zones.ZoneKindNative, 256 | Nameservers: []string{ 257 | "ns1.example.com.", 258 | "ns2.example.com.", 259 | }, 260 | ResourceRecordSets: []zones.ResourceRecordSet{ 261 | {Name: "foo.example6.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 262 | }, 263 | } 264 | 265 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 266 | defer cancel() 267 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 268 | 269 | require.Nil(t, err, "CreateZone returned error") 270 | 271 | err = c.Zones().AddRecordSetsToZone(ctx, "localhost", created.ID, 272 | []zones.ResourceRecordSet{ 273 | {Name: "bar.example6.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.2"}}}, 274 | {Name: "baz.example6.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.3"}}}, 275 | }, 276 | ) 277 | 278 | require.Nil(t, err, "AddRecordSetsToZone returned error") 279 | 280 | updated, err := c.Zones().GetZone(ctx, "localhost", created.ID) 281 | 282 | require.Nil(t, err) 283 | 284 | rs := updated.GetRecordSet("bar.example6.de.", "A") 285 | require.NotNil(t, rs) 286 | rs = updated.GetRecordSet("baz.example6.de.", "A") 287 | require.NotNil(t, rs) 288 | } 289 | 290 | func TestSelectZoneWithoutRRSets(t *testing.T) { 291 | c := buildClient(t) 292 | 293 | zone := zones.Zone{ 294 | Name: "example5.de.", 295 | Type: zones.ZoneTypeZone, 296 | Kind: zones.ZoneKindNative, 297 | Nameservers: []string{ 298 | "ns1.example.com.", 299 | "ns2.example.com.", 300 | }, 301 | ResourceRecordSets: []zones.ResourceRecordSet{ 302 | {Name: "foo.example5.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 303 | }, 304 | } 305 | 306 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 307 | defer cancel() 308 | 309 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 310 | 311 | require.NoError(t, err, "CreateZone returned error") 312 | 313 | zoneWithoutRRSets, err := c.Zones().GetZone(ctx, "localhost", created.ID, zones.WithoutResourceRecordSets()) 314 | require.NoError(t, err, "GetZone returned error") 315 | require.Len(t, zoneWithoutRRSets.ResourceRecordSets, 0, "ResourceRecordSets should be empty") 316 | } 317 | 318 | func TestSelectFilteredRRSetsFromZone(t *testing.T) { 319 | c := buildClient(t) 320 | 321 | zone := zones.Zone{ 322 | Name: "example4.de.", 323 | Type: zones.ZoneTypeZone, 324 | Kind: zones.ZoneKindNative, 325 | Nameservers: []string{ 326 | "ns1.example.com.", 327 | "ns2.example.com.", 328 | }, 329 | ResourceRecordSets: []zones.ResourceRecordSet{ 330 | {Name: "foo.example4.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 331 | {Name: "bar.example4.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "10.0.0.1"}}}, 332 | {Name: "bar.example4.de.", Type: "TXT", TTL: 60, Records: []zones.Record{{Content: `"Hello!"`}}}, 333 | }, 334 | } 335 | 336 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 337 | defer cancel() 338 | 339 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 340 | 341 | require.NoError(t, err, "CreateZone returned error") 342 | 343 | zoneWithRRSets, err := c.Zones().GetZone(ctx, "localhost", created.ID, zones.WithResourceRecordSetFilter("bar.example4.de.", "TXT")) 344 | 345 | require.NoError(t, err) 346 | require.Len(t, zoneWithRRSets.ResourceRecordSets, 1) 347 | require.Equal(t, "bar.example4.de.", zoneWithRRSets.ResourceRecordSets[0].Name) 348 | require.Equal(t, "TXT", zoneWithRRSets.ResourceRecordSets[0].Type) 349 | require.Equal(t, `"Hello!"`, zoneWithRRSets.ResourceRecordSets[0].Records[0].Content) 350 | } 351 | 352 | func TestRemoveRecordFromZone(t *testing.T) { 353 | c := buildClient(t) 354 | 355 | zone := zones.Zone{ 356 | Name: "example3.de.", 357 | Type: zones.ZoneTypeZone, 358 | Kind: zones.ZoneKindNative, 359 | Nameservers: []string{ 360 | "ns1.example.com.", 361 | "ns2.example.com.", 362 | }, 363 | ResourceRecordSets: []zones.ResourceRecordSet{ 364 | {Name: "foo.example3.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 365 | }, 366 | } 367 | 368 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 369 | defer cancel() 370 | 371 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 372 | 373 | require.Nil(t, err, "CreateZone returned error") 374 | 375 | err = c.Zones().AddRecordSetToZone(ctx, "localhost", created.ID, zones.ResourceRecordSet{ 376 | Name: "bar.example3.de.", 377 | Type: "A", 378 | TTL: 60, 379 | Records: []zones.Record{{Content: "127.0.0.2"}}, 380 | }) 381 | 382 | require.Nil(t, err, "AddRecordSetToZone returned error") 383 | 384 | updated, err := c.Zones().GetZone(ctx, "localhost", created.ID) 385 | require.Nil(t, err) 386 | rs := updated.GetRecordSet("bar.example3.de.", "A") 387 | require.NotNil(t, rs) 388 | 389 | err = c.Zones().RemoveRecordSetFromZone(ctx, "localhost", created.ID, "bar.example3.de.", "A") 390 | require.Nil(t, err, "RemoveRecordSetFromZone returned error") 391 | 392 | updated, err = c.Zones().GetZone(ctx, "localhost", created.ID) 393 | require.Nil(t, err) 394 | rs = updated.GetRecordSet("bar.example3.de.", "A") 395 | require.Nil(t, rs) 396 | } 397 | 398 | func TestRemoveRecordsFromZone(t *testing.T) { 399 | c := buildClient(t) 400 | 401 | zone := zones.Zone{ 402 | Name: "example7.de.", 403 | Type: zones.ZoneTypeZone, 404 | Kind: zones.ZoneKindNative, 405 | Nameservers: []string{ 406 | "ns1.example.com.", 407 | "ns2.example.com.", 408 | }, 409 | ResourceRecordSets: []zones.ResourceRecordSet{ 410 | {Name: "foo.example7.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 411 | }, 412 | } 413 | 414 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 415 | defer cancel() 416 | 417 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 418 | 419 | require.Nil(t, err, "CreateZone returned error") 420 | 421 | err = c.Zones().AddRecordSetsToZone(ctx, "localhost", created.ID, 422 | []zones.ResourceRecordSet{ 423 | {Name: "bar.example7.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.2"}}}, 424 | {Name: "baz.example7.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.3"}}}, 425 | }, 426 | ) 427 | 428 | require.Nil(t, err, "AddRecordSetsToZone returned error") 429 | 430 | updated, err := c.Zones().GetZone(ctx, "localhost", created.ID) 431 | require.Nil(t, err) 432 | rs1 := updated.GetRecordSet("bar.example7.de.", "A") 433 | require.NotNil(t, rs1) 434 | rs2 := updated.GetRecordSet("baz.example7.de.", "A") 435 | require.NotNil(t, rs2) 436 | 437 | err = c.Zones().RemoveRecordSetsFromZone(ctx, "localhost", created.ID, []zones.ResourceRecordSet{*rs1, *rs2}) 438 | require.Nil(t, err, "RemoveRecordSetsFromZone returned error") 439 | 440 | updated, err = c.Zones().GetZone(ctx, "localhost", created.ID) 441 | require.Nil(t, err) 442 | rs := updated.GetRecordSet("bar.example7.de.", "A") 443 | require.Nil(t, rs) 444 | rs = updated.GetRecordSet("baz.example7.de.", "A") 445 | require.Nil(t, rs) 446 | } 447 | 448 | func TestSearchZone(t *testing.T) { 449 | c := buildClient(t) 450 | 451 | zone := zones.Zone{ 452 | Name: "example-search.de.", 453 | Type: zones.ZoneTypeZone, 454 | Kind: zones.ZoneKindNative, 455 | Nameservers: []string{ 456 | "ns1.example.com.", 457 | "ns2.example.com.", 458 | }, 459 | ResourceRecordSets: []zones.ResourceRecordSet{ 460 | {Name: "example-search.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 461 | }, 462 | } 463 | 464 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 465 | defer cancel() 466 | 467 | _, err := c.Zones().CreateZone(ctx, "localhost", zone) 468 | 469 | require.Nil(t, err, "CreateZone returned error") 470 | 471 | results, sErr := c.Search().Search(ctx, "localhost", "example-search.de", 10, search.ObjectTypeZone) 472 | 473 | require.Nil(t, sErr) 474 | require.True(t, len(results) > 0, "number of search results should be > 0") 475 | 476 | assert.Equal(t, "example-search.de.", results[0].Name) 477 | assert.Equal(t, search.ObjectTypeZone, results[0].ObjectType) 478 | } 479 | 480 | func TestExportZone(t *testing.T) { 481 | c := buildClient(t) 482 | 483 | zone := zones.Zone{ 484 | Name: "example-export.de.", 485 | Type: zones.ZoneTypeZone, 486 | Kind: zones.ZoneKindNative, 487 | Nameservers: []string{ 488 | "ns1.example.com.", 489 | "ns2.example.com.", 490 | }, 491 | ResourceRecordSets: []zones.ResourceRecordSet{ 492 | {Name: "example-export.de.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 493 | }, 494 | } 495 | 496 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 497 | defer cancel() 498 | 499 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 500 | 501 | require.Nil(t, err, "CreateZone returned error") 502 | 503 | export, sErr := c.Zones().ExportZone(ctx, "localhost", created.ID) 504 | 505 | date := time.Now().UTC().Format("20060102") + "01" 506 | 507 | require.Nil(t, sErr) 508 | require.Equal(t, "example-export.de.\t60\tIN\tA\t127.0.0.1\nexample-export.de.\t3600\tIN\tNS\tns1.example.com.\nexample-export.de.\t3600\tIN\tNS\tns2.example.com.\nexample-export.de.\t3600\tIN\tSOA\ta.misconfigured.dns.server.invalid. hostmaster.example-export.de. "+date+" 10800 3600 604800 3600\n", string(export)) 509 | } 510 | 511 | func TestModifyBasicZoneData(t *testing.T) { 512 | c := buildClient(t) 513 | 514 | zone := zones.Zone{ 515 | Name: "example8.de.", 516 | Type: zones.ZoneTypeZone, 517 | Kind: zones.ZoneKindNative, 518 | Nameservers: []string{ 519 | "ns1.example.com.", 520 | "ns2.example.com.", 521 | }, 522 | APIRectify: true, 523 | DNSSec: true, 524 | } 525 | 526 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 527 | defer cancel() 528 | 529 | created, err := c.Zones().CreateZone(ctx, "localhost", zone) 530 | require.Nil(t, err, "CreateZone returned error") 531 | 532 | require.Equal(t, zones.ZoneSOAEditAPIDefault, created.SOAEditAPI) 533 | require.Equal(t, true, created.APIRectify) 534 | require.Equal(t, true, created.DNSSec) 535 | 536 | update := zones.ZoneBasicDataUpdate{ 537 | SOAEditAPI: zones.ZoneSOAEditAPIIncrease, 538 | APIRectify: ptr(false), 539 | } 540 | 541 | err = c.Zones().ModifyBasicZoneData(ctx, "localhost", created.ID, update) 542 | require.Nil(t, err, "ModifyBasicZoneData returned error") 543 | 544 | modified, err := c.Zones().GetZone(ctx, "localhost", created.ID) 545 | require.Nil(t, err) 546 | 547 | require.Equal(t, zones.ZoneSOAEditAPIIncrease, modified.SOAEditAPI) 548 | require.Equal(t, false, modified.APIRectify) 549 | require.Equal(t, created.DNSSec, modified.DNSSec) 550 | } 551 | 552 | func buildClient(t *testing.T) Client { 553 | debug := io.Discard 554 | 555 | if testing.Verbose() { 556 | debug = os.Stderr 557 | } 558 | 559 | c, err := New( 560 | WithBaseURL("http://localhost:8081"), 561 | WithAPIKeyAuthentication("secret"), 562 | WithDebuggingOutput(debug), 563 | ) 564 | 565 | assert.Nil(t, err) 566 | return c 567 | } 568 | 569 | func ptr[T any](t T) *T { 570 | return &t 571 | } 572 | 573 | // This example uses the "context.WithTimeout" function to wait until the PowerDNS API is reachable 574 | // up until a given timeout is reached. After that, the "WaitUntilUp" method will return with an error. 575 | func ExampleClient_waitUntilUp() { 576 | client, _ := New( 577 | WithBaseURL("http://localhost:8081"), 578 | WithAPIKeyAuthentication("secret"), 579 | ) 580 | 581 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 582 | defer cancel() 583 | 584 | err := client.WaitUntilUp(ctx) 585 | if err != nil { 586 | panic(err) 587 | } 588 | } 589 | 590 | func ExampleClient_listServers() { 591 | client, _ := New( 592 | WithBaseURL("http://localhost:8081"), 593 | WithAPIKeyAuthentication("secret"), 594 | ) 595 | 596 | servers, err := client.Servers().ListServers(context.Background()) 597 | if err != nil { 598 | panic(err) 599 | } 600 | for i := range servers { 601 | fmt.Printf("found server: %s\n", servers[i].ID) 602 | } 603 | } 604 | 605 | func ExampleClient_getServer() { 606 | client, _ := New( 607 | WithBaseURL("http://localhost:8081"), 608 | WithAPIKeyAuthentication("secret"), 609 | ) 610 | 611 | server, err := client.Servers().GetServer(context.Background(), "localhost") 612 | if err != nil { 613 | if pdnshttp.IsNotFound(err) { 614 | // handle not found 615 | } else { 616 | panic(err) 617 | } 618 | } 619 | 620 | fmt.Printf("found server: %s\n", server.ID) 621 | } 622 | 623 | // This example uses the "Zones()" sub-client to create a new zone. 624 | func ExampleClient_createZone() { 625 | client, _ := New( 626 | WithBaseURL("http://localhost:8081"), 627 | WithAPIKeyAuthentication("secret"), 628 | ) 629 | 630 | input := zones.Zone{ 631 | Name: "mydomain.example.", 632 | Type: zones.ZoneTypeZone, 633 | Kind: zones.ZoneKindNative, 634 | Nameservers: []string{ 635 | "ns1.example.com.", 636 | "ns2.example.com.", 637 | }, 638 | ResourceRecordSets: []zones.ResourceRecordSet{ 639 | {Name: "foo.mydomain.example.", Type: "A", TTL: 60, Records: []zones.Record{{Content: "127.0.0.1"}}}, 640 | }, 641 | } 642 | 643 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 644 | defer cancel() 645 | 646 | zone, err := client.Zones().CreateZone(ctx, "localhost", input) 647 | if err != nil { 648 | panic(err) 649 | } 650 | 651 | fmt.Printf("zone ID: %s\n", zone.ID) 652 | // Output: zone ID: mydomain.example. 653 | } 654 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // This package contains a client library used for connecting to the PowerDNS API. 2 | package pdns 3 | -------------------------------------------------------------------------------- /doc_test.go: -------------------------------------------------------------------------------- 1 | package pdns 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // This example uses with WithAPIKeyAuthentication function to add API-key based authentication 8 | // to the PowerDNS client. 9 | func ExampleNew_withAPIKeyAuthentication() { 10 | client, err := New( 11 | WithBaseURL("http://your-dns-server.example:8081"), 12 | WithAPIKeyAuthentication("super-secret"), 13 | ) 14 | 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | client.Status() 20 | } 21 | 22 | // This example uses the WithDebuggingOutput function; this will cause all HTTP requests 23 | // and responses to be logged to the io.Writer that is supplied to WithDebuggingOutput. 24 | func ExampleNew_withDebugging() { 25 | client, err := New( 26 | WithBaseURL("http://your-dns-server.example:8081"), 27 | WithAPIKeyAuthentication("super-secret"), 28 | WithDebuggingOutput(os.Stdout), 29 | ) 30 | 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | client.Status() 36 | } 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | powerdns: 3 | image: powerdns/pdns-auth-49:4.9.1 4 | environment: 5 | PDNS_AUTH_API_KEY: secret 6 | ports: 7 | - 8081:8081 8 | - 8053:53/udp 9 | - 8053:53/tcp 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mittwald/go-powerdns 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/stretchr/testify v1.3.0 7 | gopkg.in/h2non/gock.v1 v1.0.14 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.0 // indirect 12 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= 4 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= 5 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= 6 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 11 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 12 | gopkg.in/h2non/gock.v1 v1.0.14 h1:fTeu9fcUvSnLNacYvYI54h+1/XEteDyHvrVCZEEEYNM= 13 | gopkg.in/h2non/gock.v1 v1.0.14/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= 14 | -------------------------------------------------------------------------------- /interface.go: -------------------------------------------------------------------------------- 1 | package pdns 2 | 3 | import ( 4 | "context" 5 | "github.com/mittwald/go-powerdns/apis/cryptokeys" 6 | 7 | "github.com/mittwald/go-powerdns/apis/cache" 8 | "github.com/mittwald/go-powerdns/apis/search" 9 | "github.com/mittwald/go-powerdns/apis/servers" 10 | "github.com/mittwald/go-powerdns/apis/zones" 11 | ) 12 | 13 | // Client is the root-level interface for interacting with the PowerDNS API. 14 | // You can instantiate an implementation of this interface using the "New" function. 15 | type Client interface { 16 | 17 | // Status checks if the PowerDNS API is reachable. This does a simple HTTP connection check; 18 | // it will NOT check if your authentication is set up correctly (except you're using TLS client 19 | // authentication. 20 | Status() error 21 | 22 | // WaitUntilUp will block until the PowerDNS API accepts HTTP requests. You can use the "ctx" 23 | // parameter to make this method wait only for (or until) a certain time (see examples). 24 | WaitUntilUp(ctx context.Context) error 25 | 26 | // Servers returns a specialized API for interacting with PowerDNS servers 27 | Servers() servers.Client 28 | 29 | // Zones returns a specialized API for interacting with PowerDNS zones 30 | Zones() zones.Client 31 | 32 | // Search returns a specialized API for searching 33 | Search() search.Client 34 | 35 | // Cache returns a specialized API for caching 36 | Cache() cache.Client 37 | 38 | // Cryptokeys returns a specialized API for cryptokeys 39 | Cryptokeys() cryptokeys.Client 40 | } 41 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package pdns 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "github.com/mittwald/go-powerdns/pdnshttp" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | ) 11 | 12 | // WithBaseURL sets a client's base URL 13 | func WithBaseURL(baseURL string) ClientOption { 14 | return func(c *client) error { 15 | c.baseURL = baseURL 16 | return nil 17 | } 18 | } 19 | 20 | // WithHTTPClient can be used to override a client's HTTP client. 21 | // Otherwise, the default HTTP client will be used 22 | func WithHTTPClient(httpClient *http.Client) ClientOption { 23 | return func(c *client) error { 24 | c.httpClient = httpClient 25 | return nil 26 | } 27 | } 28 | 29 | // WithAPIKeyAuthentication adds API-key based authentication to the PowerDNS client. 30 | // In effect, each HTTP request will have an additional header that contains the API key 31 | // supplied to this function: 32 | // X-API-Key: {{ key }} 33 | func WithAPIKeyAuthentication(key string) ClientOption { 34 | return func(c *client) error { 35 | c.authenticator = &pdnshttp.APIKeyAuthenticator{ 36 | APIKey: key, 37 | } 38 | 39 | return nil 40 | } 41 | } 42 | 43 | // WithBasicAuthentication adds basic authentication to the PowerDNS client. 44 | func WithBasicAuthentication(username string, password string) ClientOption { 45 | return func(c *client) error { 46 | c.authenticator = &pdnshttp.BasicAuthenticator{ 47 | Username: username, 48 | Password: password, 49 | } 50 | 51 | return nil 52 | } 53 | } 54 | 55 | // WithTLSAuthentication configures TLS-based authentication for the PowerDNS client. 56 | // This is not a feature that is provided by PowerDNS natively, but might be implemented 57 | // when the PowerDNS API is run behind a reverse proxy. 58 | func WithTLSAuthentication(caFile string, clientCertFile string, clientKeyFile string) ClientOption { 59 | return func(c *client) error { 60 | cert, err := tls.LoadX509KeyPair(clientCertFile, clientKeyFile) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | caBytes, err := ioutil.ReadFile(caFile) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | ca, err := x509.ParseCertificates(caBytes) 71 | 72 | auth := pdnshttp.TLSClientCertificateAuthenticator{ 73 | ClientCert: cert, 74 | ClientKey: cert.PrivateKey, 75 | CACerts: ca, 76 | } 77 | 78 | c.authenticator = &auth 79 | return nil 80 | } 81 | } 82 | 83 | // WithDebuggingOutput can be used to supply an io.Writer to the client into which all 84 | // outgoing HTTP requests and their responses will be logged. Useful for debugging. 85 | func WithDebuggingOutput(out io.Writer) ClientOption { 86 | return func(c *client) error { 87 | c.debugOutput = out 88 | return nil 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pdnshttp/auth.go: -------------------------------------------------------------------------------- 1 | package pdnshttp 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type ClientAuthenticator interface { 8 | OnRequest(*http.Request) error 9 | OnConnect(*http.Client) error 10 | } 11 | 12 | // NoopAuthenticator provides an "empty" implementation of the 13 | // ClientAuthenticator interface. 14 | type NoopAuthenticator struct{} 15 | 16 | // OnRequest is applied each time a HTTP request is built. 17 | func (NoopAuthenticator) OnRequest(*http.Request) error { 18 | return nil 19 | } 20 | 21 | // OnConnect is applied on the entire connection as soon as it is set up. 22 | func (NoopAuthenticator) OnConnect(*http.Client) error { 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /pdnshttp/auth_basic.go: -------------------------------------------------------------------------------- 1 | package pdnshttp 2 | 3 | import "net/http" 4 | 5 | type BasicAuthenticator struct { 6 | Username string 7 | Password string 8 | } 9 | 10 | func (a *BasicAuthenticator) OnRequest(r *http.Request) error { 11 | r.SetBasicAuth(a.Username, a.Password) 12 | return nil 13 | } 14 | 15 | func (a *BasicAuthenticator) OnConnect(*http.Client) error { 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pdnshttp/auth_key.go: -------------------------------------------------------------------------------- 1 | package pdnshttp 2 | 3 | import "net/http" 4 | 5 | type APIKeyAuthenticator struct { 6 | APIKey string 7 | } 8 | 9 | func (a *APIKeyAuthenticator) OnRequest(r *http.Request) error { 10 | r.Header.Set("X-API-Key", a.APIKey) 11 | return nil 12 | } 13 | 14 | func (a *APIKeyAuthenticator) OnConnect(*http.Client) error { 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /pdnshttp/auth_tls.go: -------------------------------------------------------------------------------- 1 | package pdnshttp 2 | 3 | import ( 4 | "crypto" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "fmt" 8 | "net/http" 9 | ) 10 | 11 | type TLSClientCertificateAuthenticator struct { 12 | CACerts []*x509.Certificate 13 | ClientCert tls.Certificate 14 | ClientKey crypto.PrivateKey 15 | } 16 | 17 | func (a *TLSClientCertificateAuthenticator) OnRequest(r *http.Request) error { 18 | return nil 19 | } 20 | 21 | func (a *TLSClientCertificateAuthenticator) OnConnect(c *http.Client) error { 22 | if c.Transport == nil { 23 | c.Transport = http.DefaultTransport 24 | } 25 | 26 | t, ok := c.Transport.(*http.Transport) 27 | if !ok { 28 | return fmt.Errorf("client.Transport is no *http.Transport, instead %t", c.Transport) 29 | } 30 | 31 | if t.TLSClientConfig == nil { 32 | t.TLSClientConfig = &tls.Config{} 33 | } 34 | 35 | if t.TLSClientConfig.Certificates == nil { 36 | t.TLSClientConfig.Certificates = make([]tls.Certificate, 0, 1) 37 | } 38 | 39 | t.TLSClientConfig.Certificates = append(t.TLSClientConfig.Certificates, a.ClientCert) 40 | 41 | if t.TLSClientConfig.RootCAs == nil { 42 | systemPool, err := x509.SystemCertPool() 43 | if err != nil { 44 | return err 45 | } 46 | 47 | t.TLSClientConfig.RootCAs = systemPool 48 | } 49 | 50 | for i := range a.CACerts { 51 | t.TLSClientConfig.RootCAs.AddCert(a.CACerts[i]) 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /pdnshttp/client.go: -------------------------------------------------------------------------------- 1 | package pdnshttp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httputil" 10 | "net/url" 11 | "strings" 12 | ) 13 | 14 | type Client struct { 15 | baseURL string 16 | httpClient *http.Client 17 | authenticator ClientAuthenticator 18 | debugOutput io.Writer 19 | } 20 | 21 | // NewClient returns a new PowerDNS HTTP client 22 | func NewClient(baseURL string, hc *http.Client, auth ClientAuthenticator, debugOutput io.Writer) *Client { 23 | u, err := url.ParseRequestURI(baseURL) 24 | if err != nil { 25 | panic(err) 26 | } 27 | if strings.TrimSuffix(u.Path, "/") == "" { 28 | u.Path = "/api/v1" 29 | } else { 30 | u.Path = strings.TrimSuffix(u.Path, "/") 31 | } 32 | c := Client{ 33 | baseURL: u.String(), 34 | httpClient: hc, 35 | authenticator: auth, 36 | debugOutput: debugOutput, 37 | } 38 | 39 | return &c 40 | } 41 | 42 | // NewRequest builds a new request. Usually, this method should not be used; 43 | // prefer using the "Get", "Post", ... methods if possible. 44 | func (c *Client) NewRequest(method string, path string, body io.Reader) (*http.Request, error) { 45 | path = strings.TrimPrefix(path, "/") 46 | req, err := http.NewRequest(method, c.baseURL+"/"+path, body) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | if c.authenticator != nil { 52 | if err := c.authenticator.OnRequest(req); err != nil { 53 | return nil, err 54 | } 55 | } 56 | 57 | return req, err 58 | } 59 | 60 | // Get executes a GET request 61 | func (c *Client) Get(ctx context.Context, path string, out interface{}, opts ...RequestOption) error { 62 | return c.doRequest(ctx, http.MethodGet, path, out, opts...) 63 | } 64 | 65 | // Post executes a POST request 66 | func (c *Client) Post(ctx context.Context, path string, out interface{}, opts ...RequestOption) error { 67 | return c.doRequest(ctx, http.MethodPost, path, out, opts...) 68 | } 69 | 70 | // Put executes a PUT request 71 | func (c *Client) Put(ctx context.Context, path string, out interface{}, opts ...RequestOption) error { 72 | return c.doRequest(ctx, http.MethodPut, path, out, opts...) 73 | } 74 | 75 | // Patch executes a PATCH request 76 | func (c *Client) Patch(ctx context.Context, path string, out interface{}, opts ...RequestOption) error { 77 | return c.doRequest(ctx, http.MethodPatch, path, out, opts...) 78 | } 79 | 80 | // Delete executes a DELETE request 81 | func (c *Client) Delete(ctx context.Context, path string, out interface{}, opts ...RequestOption) error { 82 | return c.doRequest(ctx, http.MethodDelete, path, out, opts...) 83 | } 84 | 85 | func (c *Client) Do(ctx context.Context, req *http.Request, out interface{}) error { 86 | req = req.WithContext(ctx) 87 | 88 | reqDump, _ := httputil.DumpRequestOut(req, true) 89 | c.debugOutput.Write(reqDump) 90 | 91 | res, err := c.httpClient.Do(req) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | resDump, _ := httputil.DumpResponse(res, true) 97 | c.debugOutput.Write(resDump) 98 | 99 | if res.StatusCode == http.StatusNotFound { 100 | return ErrNotFound{URL: req.URL.String()} 101 | } else if res.StatusCode >= 400 { 102 | if res.Header.Get("Content-Type") == "application/json" { 103 | // Get a human readable error message 104 | // from PowerDNS API response 105 | var er ErrResponse 106 | 107 | if err := json.NewDecoder(res.Body).Decode(&er); err != nil { 108 | return err 109 | } 110 | 111 | return ErrUnexpectedStatus{ 112 | URL: req.URL.String(), 113 | StatusCode: res.StatusCode, 114 | ErrResponse: er, 115 | } 116 | } 117 | 118 | body, err := ioutil.ReadAll(res.Body) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | return ErrUnexpectedStatus{ 124 | URL: req.URL.String(), 125 | StatusCode: res.StatusCode, 126 | ErrResponse: ErrResponse{ 127 | Message: string(body), 128 | }, 129 | } 130 | } 131 | 132 | if out != nil { 133 | if w, ok := out.(io.Writer); ok { 134 | _, err := io.Copy(w, res.Body) 135 | return err 136 | } 137 | 138 | if err := json.NewDecoder(res.Body).Decode(out); err != nil { 139 | return err 140 | } 141 | } 142 | 143 | return nil 144 | } 145 | 146 | func (c *Client) doRequest(ctx context.Context, method string, path string, out interface{}, opts ...RequestOption) error { 147 | req, err := c.NewRequest(method, path, nil) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | for i := range opts { 153 | if err := opts[i](req); err != nil { 154 | return err 155 | } 156 | } 157 | 158 | return c.Do(ctx, req, out) 159 | } 160 | -------------------------------------------------------------------------------- /pdnshttp/client_test.go: -------------------------------------------------------------------------------- 1 | package pdnshttp 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/require" 6 | "gopkg.in/h2non/gock.v1" 7 | "io" 8 | "net/http" 9 | "testing" 10 | ) 11 | 12 | func TestGetExecutedCorrectly(t *testing.T) { 13 | gock.New("http://test.example"). 14 | Get("/api/v1/servers"). 15 | MatchHeader("X-API-Key", "secret"). 16 | Reply(http.StatusOK). 17 | JSON(map[string]string{"foo": "bar"}) 18 | 19 | hc := &http.Client{Transport: gock.DefaultTransport} 20 | c := NewClient("http://test.example", hc, &APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 21 | 22 | var out interface{} 23 | 24 | err := c.Get(context.Background(), "/servers", &out) 25 | require.Nil(t, err) 26 | require.True(t, gock.IsDone(), "still has pending mocks") 27 | } 28 | 29 | func TestBaseURLAlreadyContainsPath(t *testing.T) { 30 | gock.New("http://test.example"). 31 | Get("/api/v2/servers"). 32 | MatchHeader("X-API-Key", "secret"). 33 | Reply(http.StatusOK). 34 | JSON(map[string]string{"foo": "bar"}) 35 | 36 | hc := &http.Client{Transport: gock.DefaultTransport} 37 | c := NewClient("http://test.example/api/v2", hc, &APIKeyAuthenticator{APIKey: "secret"}, io.Discard) 38 | 39 | var out interface{} 40 | 41 | err := c.Get(context.Background(), "/servers", &out) 42 | require.Nil(t, err) 43 | require.True(t, gock.IsDone(), "still has pending mocks") 44 | } 45 | -------------------------------------------------------------------------------- /pdnshttp/errors.go: -------------------------------------------------------------------------------- 1 | package pdnshttp 2 | 3 | import "fmt" 4 | 5 | type ErrNotFound struct { 6 | URL string 7 | } 8 | 9 | func (e ErrNotFound) Error() string { 10 | return fmt.Sprintf("not found: %s", e.URL) 11 | } 12 | 13 | type ErrUnexpectedStatus struct { 14 | URL string 15 | StatusCode int 16 | ErrResponse 17 | } 18 | 19 | func (e ErrUnexpectedStatus) Error() string { 20 | if len(e.ErrResponse.Messages) > 0 { 21 | return fmt.Sprintf("unexpected status code %d: %s %s %s", e.StatusCode, e.URL, e.ErrResponse.Message, e.ErrResponse.Messages) 22 | } 23 | return fmt.Sprintf("unexpected status code %d: %s %s", e.StatusCode, e.URL, e.ErrResponse.Message) 24 | } 25 | 26 | // ErrResponse represents error response from PowerDNS HTTP API 27 | type ErrResponse struct { 28 | Message string `json:"error"` 29 | Messages []string `json:"errors,omitempty"` 30 | } 31 | 32 | func IsNotFound(err error) bool { 33 | switch err.(type) { 34 | case ErrNotFound: 35 | return true 36 | case *ErrNotFound: 37 | return true 38 | } 39 | 40 | return false 41 | } 42 | -------------------------------------------------------------------------------- /pdnshttp/req_opt.go: -------------------------------------------------------------------------------- 1 | package pdnshttp 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | ) 10 | 11 | // RequestOption is a special type of function that can be passed to most HTTP 12 | // request functions in this package; it is used to modify an HTTP request and 13 | // to implement special request logic. 14 | type RequestOption func(*http.Request) error 15 | 16 | // WithJSONRequestBody adds a JSON body to a request. The input type can be 17 | // anything, as long as it can be marshaled by "json.Marshal". This method will 18 | // also automatically set the correct content type and content-length. 19 | func WithJSONRequestBody(in interface{}) RequestOption { 20 | return func(req *http.Request) error { 21 | if in == nil { 22 | return nil 23 | } 24 | 25 | buf := bytes.Buffer{} 26 | enc := json.NewEncoder(&buf) 27 | err := enc.Encode(in) 28 | 29 | if err != nil { 30 | return err 31 | } 32 | 33 | rc := ioutil.NopCloser(&buf) 34 | 35 | copyBuf := buf.Bytes() 36 | 37 | req.Body = rc 38 | req.Header.Set("Content-Type", "application/json") 39 | req.ContentLength = int64(buf.Len()) 40 | req.GetBody = func() (io.ReadCloser, error) { 41 | r := bytes.NewReader(copyBuf) 42 | return ioutil.NopCloser(r), nil 43 | } 44 | 45 | return nil 46 | } 47 | } 48 | 49 | // WithQueryValue adds a query parameter to a request's URL. 50 | func WithQueryValue(key, value string) RequestOption { 51 | return func(req *http.Request) error { 52 | q := req.URL.Query() 53 | q.Set(key, value) 54 | 55 | req.URL.RawQuery = q.Encode() 56 | return nil 57 | } 58 | } 59 | --------------------------------------------------------------------------------