-
Notifications
You must be signed in to change notification settings - Fork 0
/
domains.go
414 lines (369 loc) · 17.2 KB
/
domains.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
// Package hoverdnsapi offers some API actions to control your Hover/TuCOWS DNS entries
// programmatically. This isn't a supported utility form Hover, it's just a thing I wrote because
// I needed it at the time.
package hoverdnsapi
import (
"encoding/json"
"fmt"
"io/ioutil"
golog "log"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strings"
"time"
)
const (
authHeader = "hoverauth"
)
var (
// https://www.hover.com/api/domains -> DomainList
parsedBaseURL = mustParse("https://www.hover.com/api")
)
func mustParse(aURL string) *url.URL {
if parsed, err := url.Parse(aURL); err != nil {
panic(fmt.Sprintf("url [%s] unparseable: %v", aURL, err))
} else {
return parsed
}
}
// Address holds an address used for admin, billing, or tech contact. Empirically, it seems at
// least US and Canada formats are squeezed into a US format. Please PR if you discover additional
// formats.
type Address struct {
Status string `json:"status"` // Status seems to be "active" in all my zones
OrganizationName string `json:"org_name"` // Name of Organization
FirstName string `json:"first_name"` // First naem seems to be given non-family name, not positional
LastName string `json:"last_name"` // Last Name seems to be family name, not positional
Address1 string `json:"address1"`
Address2 string `json:"address2"`
Address3 string `json:"address3"`
City string `json:"city"`
State string `json:"state"` // State seems to be the US state or the Canadian province
Zip string `json:"zip"` // 5-digit US (ie 10001) or 6-char slammed Canadian (V0H1X0 no space)
Country string `json:"country"` // 2-leter state code; this seems to match the second (non-country) of a ISO-3166-2 code
Phone string `json:"phone"` // phone format all over the map, but thy seem to write it as a ITU E164, but a "." separating country code and subscriber number
Facsimile string `json:"fax"` // same format as phone
Email string `json:"email"` // rfc2822 format email address such as rfc2822 para 3.4.1
}
// ContactBlock is merely the four contact addresses that Hover uses, but it's easier to work with
// a defined type in static constants during testing
type ContactBlock struct {
Admin Address `json:"admin"`
Billing Address `json:"billing"`
Tech Address `json:"tech"`
Owner Address `json:"owner"`
}
// Domain structure describes the config for an entire domain within Hover: the dates involved,
// contact addresses, nameservers, etc: it seems to cover everything about the domain in one
// structure, which is convenient when you want to compare data across many domains.
type Domain struct {
ID string `json:"id"` // A unique opaque identifier defined by Hover
DomainName string `json:"domain_name"` // the actual domain name. ie: "example.com"
NumEmails int `json:"num_emails,omitempty"` // This appears to be the number of email accounts either permitted or defined for the domain
RenewalDate string `json:"renewal_date,omitempty"` // This renewal date appears to be the first day of non-service after a purchased year of valid service: the first day offline if you don't renew. RFC3339/ISO8601 -formatted yyyy-mm-dd.
DisplayDate string `json:"display_date"` // Display Date seems to be the same as Renewal Date but perhaps can allow for odd display corner-cases such as leap-years, leap-seconds, or timezones oddities. RFC3339/ISO8601 to granularity of day as well.
RegisteredDate string `json:"registered_date,omitempty"` // Date the domain was first registered, which is likely also the first day of service (or partial-day, technically) RFC3339/ISO8601 to granularity of day as well.
Active bool `json:"active,omitempty"` // Domain Entries also show which zones are active
Contacts ContactBlock `json:"contacts"`
Entries []Entry `json:"entries,omitempty"` // entries in a zone, if expanded
HoverUser User `json:"hover_user,omitempty"`
Glue struct{} `json:"glue,omitempty"` // I'm not sure how Hover records Glue Records here, or whether they're still used. Please PR a suggested format!
NameServers []string `json:"nameservers,omitempty"`
Locked bool `json:"locked,omitempty"`
Renewable bool `json:"renewable,omitempty"`
AutoRenew bool `json:"auto_renew,omitempty"`
Status string `json:"status,omitempty"` // Status seems to be "active" in all my zones
WhoisPrivacy bool `json:"whois_privacy,omitempty"` // boolean as lower-case string: keep your real address out of whois?
}
// Entry is a single DNS record, such as a single NS, TXT, A, PTR, AAAA record within a zone.
type Entry struct {
CanRevert bool `json:"can_revert"`
Content string `json:"content"` // free-form text of verbatim value to store (ie "192.168.0.1" for A-rec)
ID string `json:"id"` // A unique opaque identifier defined by Hover
Default bool `json:"is_default"` // seems to track the default @ or "*" record
Name string `json:"name"` // entry name, or "*" for default
TTL int `json:"ttl"` // TimeToLive, seconds
Type string `json:"type"` // record type: A, MX, PTR, TXT, etc
}
// PlaintextAuth is a structure into which the username and password are read from a plaintext
// file. This is necessary because when this code is written, Hover offers no API, so raw logins
// are mimicked as clients. This has risks, of course. The trade-off is that plaintext risk
// versus no functionality means we have no alternative. For this reason, reading the auth from a
// file on disk means it cannot be offered in-code during integration tests, and similarly, can be
// provided by a configmap or similar during a production deployment.
//
// For versatility, the intent is to accept JSON, YAML, and even XML if it's trivial to do.
type PlaintextAuth struct {
Username string `json:"username"` // username such as 'chickenandpork', exactly as typed in the login form
PlaintextPassword string `json:"plaintextpassword"` // password, in plaintext, for login, exactly as typed in the login form
}
// The User record in a Domain seems to record additional contact information that augments the
// Billing Contact with the credit card used and some metadata around it.
type User struct {
Billing struct {
Description string `json:"description,omitempty"` // This seems to be a description of my card, such as "Visa ending 1234"
PayMode string `json:"pay_mode,omitempty"` // some reference to how payments are processed: mine all say "apple_pay", and they're in my Apple
// Pay Wallet, but my account on Hover predates the existence of Apple Wallet, so ... I'm not sure
} `json:"billing,omitempty"`
Email string `json:"email,omitempty"`
EmailSecondary string `json:"email_secondary,omitempty"`
}
var (
// HoverAddress is a constant-ish var that I use to ensure that within my domains, the ones
// I expect to have Hovers contact info (their default) do. For example, Tech Contacts
// where I don't want to be that guy (for managed domains, they should be the tech
// contact). Of course, if the values in this constant are incorrect, TuCows is the
// authority, but please PR me a correction to help me maintain accuracy.
HoverAddress = Address{
Status: "active",
OrganizationName: "Hover, a service of Tucows.com Co",
FirstName: "Support",
LastName: "Contact",
Address1: "96 Mowat Ave.",
City: "Toronto",
State: "ON",
Zip: "M6K 3M1",
Country: "CA",
Phone: "+1.8667316556",
Email: "help@hover.com",
}
)
// DomainList is a structure mapping the json response to a request for a list of domains. It
// tends to be a very rich response including an array of full Domain instances.
type DomainList struct {
Succeeded bool `json:"succeeded"`
Domains []Domain `json:"domains"`
}
// Client is the client context for communicating with Hover DNS API; should only need one of these
// but keeping state isolated to instances rather than global where possible.
type Client struct {
HTTPClient *http.Client
log YALI // Yet Another Logger Interface, NopLogger to discard
authCookie string // intentionally private
domains DomainList // intentionally private
Username string
Password string
}
// APIURL is an attempt to keep the URLs all based from parsedBaseURL, but more symbollically
// generated and less risk of typos. The gain on this function is dubious, and this may disappear
//
// TODO: consider rolling in c.BaseURL
func APIURL(resource string) string {
newURL := *parsedBaseURL
newURL.Path = fmt.Sprintf("%s/%s", newURL.Path, resource)
return newURL.String()
}
// APIURLDNS extends the consistency objectives of APIURL by bookending a domain unique ID with
// the /domains/ and /dns pre/post wrappers
func APIURLDNS(domainID string) string {
return APIURL(fmt.Sprintf("domains/%s/dns", domainID))
}
// GetDomainEntries gets the entries for a specific domain -- essentially the zone records
func (c *Client) GetDomainEntries(domain string) error {
if _, err := c.GetAuth(); err != nil {
return fmt.Errorf(`Exception "%s" getting auth for [%s]`, err, APIURLDNS(domain))
}
resp, err := c.HTTPClient.Get(APIURLDNS(domain))
if err != nil {
c.log.Printf(`Exception "%s" hitting [%s]`, err, APIURLDNS(domain))
return fmt.Errorf(`Exception "%s" hitting [%s]`, err, APIURLDNS(domain))
} else {
body, _ := ioutil.ReadAll(resp.Body)
var nd DomainList
json.Unmarshal([]byte(body), &nd)
for n, v := range c.domains.Domains {
if v.DomainName == domain {
c.log.Printf(`replacing "%+v"`, c.domains.Domains[n])
for _, d := range nd.Domains {
if d.DomainName == domain {
c.domains.Domains[n] = d
}
}
c.log.Printf(`replaced "%+v"`, c.domains.Domains[n])
}
}
return nil
}
}
// FillDomains fills the list of domains allocated to the usernamr and password to the Domains
// structure. It will use GetAuth() to perform a login if necessary.
func (c *Client) FillDomains() error {
if _, err := c.GetAuth(); err == nil {
resp, err := c.HTTPClient.Get(APIURL("domains"))
c.log.Printf("Hitting [%s]\n", APIURL("domains"))
if err != nil {
c.log.Printf("hoverdnsapi: GET of %s threw: [%+v]. Domains not expected to be filled.", APIURL("domains"), err)
return fmt.Errorf("hoverdnsapi: GET of %s threw: [%+v]. Domains not expected to be filled", APIURL("domains"), err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
c.log.Printf("hover: Info: getting domains as user=%s pass=%s returned non-200: Status: %+v StatusCode: %+v\n", c.Username, c.Password, resp.Status, resp.StatusCode)
return fmt.Errorf("hoverdnsapi: GET of %s as user=%s returned non-200 error: Status: %+v StatusCode: %+v", APIURL("domains"), c.Username, resp.Status, resp.StatusCode)
} else {
json.NewDecoder(resp.Body).Decode(&c.domains)
//c.log.Printf("hover: getting returned: [%+v]\n", c.domains)
}
} else {
c.log.Printf("Auth for user=%s at %s failed\n", c.Username, APIURL("domains"))
return fmt.Errorf("hoverdnsapi: Auth for GET of %s as user=%s failed", APIURL("domains"), c.Username)
}
return nil
}
// ExistingTXTRecords checks whether the given TXT record exists; err != nil if not found
func (c *Client) ExistingTXTRecords(fqdn string) error {
return fmt.Errorf("hover: (%s) we actually got here: %s", fqdn, c.authCookie)
}
// GetAuth returns the authentication key for the username and password, performing a login if the
// key is not already known from a previous login.
func (c *Client) GetAuth() (string, error) {
if auth, ok := c.GetCookie(authHeader); ok {
return auth, nil
}
c.log.Printf("Getting fresh authCookie for user=%s at %s\n", c.Username, APIURL("login"))
req, _ := http.NewRequest("POST", APIURL("login"), strings.NewReader(url.Values{
"username": {c.Username},
"password": {c.Password},
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.HTTPClient.Do(req)
if err != nil {
c.log.Printf("Error while executing POST: %v", err)
return "", err
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
c.log.Println(string(body))
if auth, ok := c.GetCookie(authHeader); ok {
c.log.Printf("Auth found for user=%s at %s\n", c.Username, APIURL("login"))
return auth, nil
}
return "", fmt.Errorf("hover: No auth in response: %+v -> %s", c.HTTPClient, body)
}
// GetCookie searches existing cookies from a login to Hover's API to find the given cookie.
func (c *Client) GetCookie(key string) (value string, ok bool) {
if c.HTTPClient == nil {
return "", false
}
if c.HTTPClient.Jar == nil {
return "", false
}
if 1 > len(c.HTTPClient.Jar.Cookies(parsedBaseURL)) {
c.log.Printf("no cookies for %s", parsedBaseURL)
return "", false
}
c.log.Printf("breaking apart cookies for %+v\n", parsedBaseURL)
for _, v := range c.HTTPClient.Jar.Cookies(parsedBaseURL) {
c.log.Printf("k/v: %s/%s\n", v.Name, v.Value)
if v.Name == key {
c.log.Printf("returning found: k/v: %s/%s\n", v.Name, v.Value)
return v.Value, true
}
}
c.log.Printf("Failed to find value for key[%s]\n", key)
return "", false
}
// GetDomainByName searches iteratively and returns the Domain record that has the given name
func (c *Client) GetDomainByName(domainname string) (*Domain, bool) {
for _, v := range c.domains.Domains {
if v.DomainName == domainname {
return &v, true
} else {
c.log.Printf(`Domain "%s" is not objective "%s"\n`, v.DomainName, domainname)
}
}
return nil, false
}
// Upsert inserts or updates a TXT record using the specified parameters
func (c *Client) Upsert(fqdn, domain, value string, ttl uint) error {
actions := []Action{}
if err := c.ExistingTXTRecords(fqdn); err == nil {
actions = append(actions, Action{action: Update, domain: domain, fqdn: fqdn, value: value, ttl: ttl})
} else {
actions = append(actions, Action{action: Add, domain: domain, fqdn: fqdn, value: value, ttl: ttl})
}
if err := c.DoActions(actions...); err != nil {
return fmt.Errorf("hover: failed to add record(s) for %s: %w", domain, err)
}
return nil
}
// Delete merely enqueues a delete action for DoActions to process
func (c *Client) Delete(fqdn, domain string) error {
c.log.Printf(`deleting fqdn "%s" from domain "%s"`, fqdn, domain)
if err := c.DoActions(Action{action: Delete, fqdn: fqdn, domain: domain}); err != nil {
return fmt.Errorf("hover: failed to delete record for %s: %w", domain, err)
}
return nil
}
// HTTPDelete actually does an HTTP call with the DELETE method. BOG-standard Go only offers GET
// and POST.
//
// TODO: move to a separate file as a layer onto net/http
func (c *Client) HTTPDelete(url string) (err error) {
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("HTTPDelete: creating new request: %w", err)
}
//req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", d.token))
resp, err := c.HTTPClient.Do(req)
defer func() { _ = resp.Body.Close() }()
if err != nil {
return fmt.Errorf("HTTPDelete: executing delete request: %w", err)
}
return nil
}
// HTTPUpdate actually does an HTTP call with the PUT method. BOG-standard Go only offers GET
// and POST.
//
// # update an existing DNS record: client.call("put", "dns/dns1234567", {"content": "127.0.0.1"})
//
// TODO: I just couldn't get this to work properly: every attempt was 422: Unactionable. Replaced by delete/add
// GetEntryByFQDN attempts to find a single Entry in the Domain, returning a non-nil result if
// found. "ok" is manipulated so that an "if" can be used to check whether it was found without
// having to rely on sentinel or implicit values of the returned (ie a nil Entry might not always
// mean "not found", but it does today)
func (d Domain) GetEntryByFQDN(fqdn string) (e *Entry, ok bool) {
if !strings.HasSuffix(fqdn, "."+d.DomainName) {
return nil, false
}
hostname := fqdn[0:len(fqdn)-len(d.DomainName)-1] + ""
fmt.Printf("searching for [%s] (ie [%s]) in [%s]\n", hostname, fmt.Sprintf("%s.%s", hostname, d.DomainName), d.DomainName)
for _, e := range d.Entries {
if e.Name == hostname {
return &e, true
}
}
return nil, false
}
// NewClient Creates a Hover client using plaintext passwords against plain username.
// Consider the risk of where the text is stored.
func NewClient(username, password, filename string, timeout time.Duration, opt ...interface{}) *Client {
j, _ := cookiejar.New(nil)
var defaultLogger YALI = golog.New(os.Stderr, "", golog.LstdFlags)
for _, vv := range opt {
switch v := vv.(type) {
case YALI:
defaultLogger = v
}
}
if filename != "" {
if observed, err := ReadConfigFile(filename); err == nil {
username = observed.Username
password = observed.PlaintextPassword
}
}
fmt.Printf("logging in: u: %+v p: %+v f:%+v\n", username, password, filename)
return &Client{
HTTPClient: &http.Client{
Jar: j,
Timeout: timeout,
},
//BaseURL: "https://www.hover.com/api/login",
//Cookie: blank
//Domains: make(map[string]string, 2),
Username: username,
Password: password,
log: defaultLogger,
}
}