Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/providers/hover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Hover

Provider key: `hover`

## Cred keys

| key | required | description |
|---|---|---|
| `username` | yes | Hover account username |
| `password` | yes | Hover account password |
| `totp_secret` | optional | TOTP shared secret (base32-encoded) for 2FA accounts |

## YAML example

```yaml
provider: hover
provider_creds:
username: $HOVER_USERNAME
password: $HOVER_PASSWORD
totp_secret: $HOVER_TOTP_SECRET # optional
```

## Notes

- Hover has no public DNS API. Implementation uses HTML-scrape client via [`pkg/hoverclient`](https://github.com/GoCodeAlone/workflow-plugin-hover/tree/main/pkg/hoverclient) (extracted in workflow-plugin-hover v0.3.0).
- Scraping fragile to Hover UI changes; report breakage at [workflow-plugin-hover issues](https://github.com/GoCodeAlone/workflow-plugin-hover/issues).
- Per-record CRUD only — no batch RRset primitive. `UpsertTXT` emulates RRset replace via list → delete same-name TXT → create each new value.
- `UpsertTXT` calls `GetDomain` to resolve domain ID before `CreateRecord` (Hover API takes domain ID, not zone name).
- TOTP secret must be base32-encoded (standard authenticator format). Invalid base32 → adapter rejects at construction.
- Adapter uses `Credentials.TOTPSecret` (typed); `totp_secret` cred string is parsed via `hoverclient.ParseBase32`.
- `priority` arg silently dropped (matches v1 precedent + `hoverclient.DNSRecord` has no Priority field).
5 changes: 2 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26.0

require (
github.com/GoCodeAlone/workflow v0.64.0
github.com/GoCodeAlone/workflow-plugin-hover v0.3.0
github.com/libdns/azure v0.5.0
github.com/libdns/cloudflare v0.2.2
github.com/libdns/digitalocean v0.0.0-20250606071607-dfa7af5c2e31
Expand All @@ -23,6 +24,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/DataDog/datadog-go/v5 v5.8.3 // indirect
Expand Down Expand Up @@ -57,7 +59,6 @@ require (
github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/digitalocean/godo v1.148.0 // indirect
Expand Down Expand Up @@ -118,8 +119,6 @@ require (
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 h1:buYs0TGNbAZgtTq1Qb+
github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0/go.mod h1:329flAKmwrPq2JEwu9iltWv6A83H/Di82Xze+kvdKDw=
github.com/GoCodeAlone/workflow v0.64.0 h1:2CpbYPwIqdGDb3xi3YJpwcteIum4ehBSrnRql/1YvB4=
github.com/GoCodeAlone/workflow v0.64.0/go.mod h1:659GGDrw3QJ7b625y9rf8QhKIpt1VCoEG0MxKu5tGQs=
github.com/GoCodeAlone/workflow-plugin-hover v0.3.0 h1:GmO2soYoAjSII8vmQRat2ocYkRekj9QztyDELvFLLCE=
github.com/GoCodeAlone/workflow-plugin-hover v0.3.0/go.mod h1:C/XsO0Dv9VW7ubd74II9l9yaDiiHEvJKQ/EhvEBkwfY=
github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM=
github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0=
github.com/IBM/sarama v1.47.0 h1:GcQFEd12+KzfPYeLgN69Fh7vLCtYRhVIx0rO4TZO318=
Expand Down Expand Up @@ -472,6 +474,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
133 changes: 133 additions & 0 deletions internal/dnsprovider/hover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package dnsprovider

import (
"context"
"fmt"
"net/http"

"github.com/GoCodeAlone/workflow-plugin-infra/internal/dnspolicy"
"github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient"
)

var _ dnspolicy.Adapter = (*hoverAdapter)(nil)

func init() { Register("hover", newHoverAdapter) }

// hoverClientIface is the minimum pkg/hoverclient surface hoverAdapter consumes.
// Production holds *hoverclient.Client; tests inject stubs.
type hoverClientIface interface {
GetDomain(ctx context.Context, domain string) (*hoverclient.Domain, error)
ListRecords(ctx context.Context, domain string) ([]hoverclient.DNSRecord, error)
CreateRecord(ctx context.Context, domainID string, rec hoverclient.DNSRecord) (*hoverclient.DNSRecord, error)
UpdateRecord(ctx context.Context, recordID string, rec hoverclient.DNSRecord) error
DeleteRecord(ctx context.Context, recordID string) error
}

var _ hoverClientIface = (*hoverclient.Client)(nil)

type hoverAdapter struct {
client hoverClientIface
}

func newHoverAdapter(creds map[string]string) (dnspolicy.Adapter, error) {
c := ExpandCredsMap(creds)
username, password := c["username"], c["password"]
if username == "" {
return nil, fmt.Errorf("hover: missing creds.username (see docs/providers/hover.md)")
}
if password == "" {
return nil, fmt.Errorf("hover: missing creds.password (see docs/providers/hover.md)")
}
hc := hoverclient.Credentials{Username: username, Password: password}
if raw := c["totp_secret"]; raw != "" {
ts, err := hoverclient.ParseBase32(raw)
if err != nil {
return nil, fmt.Errorf("hover: invalid creds.totp_secret: %w (creds redacted)", err)
}
hc.TOTPSecret = ts
}
client, err := hoverclient.NewClient(hc, (*http.Client)(nil))
if err != nil {
return nil, fmt.Errorf("hover: client init: %w (creds redacted)", err)
}
return &hoverAdapter{client: client}, nil
}

func (a *hoverAdapter) GetTXT(ctx context.Context, name string) ([]string, error) {
zone := zoneFromPolicyName(name)
relName := relativeNameFromFQDN(name, zone)
recs, err := a.client.ListRecords(ctx, zone)
if err != nil {
return nil, fmt.Errorf("hover: list records: %w (creds redacted)", err)
}
var out []string
for _, r := range recs {
if r.Type == "TXT" && r.Name == relName {
out = append(out, r.Content)
}
}
return out, nil
}

// UpsertTXT emulates RRset-replace (Hover has no batch primitive):
// list → delete TXT@relName → create each desired value.
func (a *hoverAdapter) UpsertTXT(ctx context.Context, name string, values []string, ttl int) error {
zone := zoneFromPolicyName(name)
relName := relativeNameFromFQDN(name, zone)
dom, err := a.client.GetDomain(ctx, zone)
if err != nil {
return fmt.Errorf("hover: get domain: %w (creds redacted)", err)
}
existing, err := a.client.ListRecords(ctx, zone)
if err != nil {
return fmt.Errorf("hover: list records: %w (creds redacted)", err)
}
for _, r := range existing {
if r.Type == "TXT" && r.Name == relName {
if err := a.client.DeleteRecord(ctx, r.ID); err != nil {
return fmt.Errorf("hover: delete stale TXT: %w (creds redacted)", err)
}
}
}
for _, v := range values {
if _, err := a.client.CreateRecord(ctx, dom.ID, hoverclient.DNSRecord{
Type: "TXT", Name: relName, Content: v, TTL: ttl,
}); err != nil {
return fmt.Errorf("hover: create TXT: %w (creds redacted)", err)
}
}
return nil
}

func (a *hoverAdapter) UpsertRecord(ctx context.Context, zone, name, recordType, data string, ttl, priority int32) (string, error) {
if priority < 0 {
return "", fmt.Errorf("hover: priority must be >= 0, got %d", priority)
}
// Note: priority dropped for non-MX/SRV (matches v1 precedent + Hover DNSRecord has no Priority field).
dom, err := a.client.GetDomain(ctx, zone)
if err != nil {
return "", fmt.Errorf("hover: get domain: %w (creds redacted)", err)
}
rec, err := a.client.CreateRecord(ctx, dom.ID, hoverclient.DNSRecord{
Type: recordType, Name: name, Content: data, TTL: int(ttl),
})
if err != nil {
return "", fmt.Errorf("hover: upsert record: %w (creds redacted)", err)
}
return rec.ID, nil
}

func (a *hoverAdapter) DeleteRecord(ctx context.Context, zone, name, recordType string) error {
existing, err := a.client.ListRecords(ctx, zone)
if err != nil {
return fmt.Errorf("hover: list records: %w (creds redacted)", err)
}
for _, r := range existing {
if r.Type == recordType && r.Name == name {
if err := a.client.DeleteRecord(ctx, r.ID); err != nil {
return fmt.Errorf("hover: delete record: %w (creds redacted)", err)
}
}
}
return nil
}
Loading