diff --git a/docs/providers/hover.md b/docs/providers/hover.md new file mode 100644 index 0000000..7b60aa9 --- /dev/null +++ b/docs/providers/hover.md @@ -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). diff --git a/go.mod b/go.mod index b90cd32..840fb9f 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 78b1e61..40ba3ad 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/dnsprovider/hover.go b/internal/dnsprovider/hover.go new file mode 100644 index 0000000..b720654 --- /dev/null +++ b/internal/dnsprovider/hover.go @@ -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 +} diff --git a/internal/dnsprovider/hover_test.go b/internal/dnsprovider/hover_test.go new file mode 100644 index 0000000..941e1bc --- /dev/null +++ b/internal/dnsprovider/hover_test.go @@ -0,0 +1,200 @@ +package dnsprovider + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient" +) + +func TestNewHoverAdapter_RequiresUsername(t *testing.T) { + _, err := newHoverAdapter(map[string]string{"password": "p"}) + if err == nil || !strings.Contains(err.Error(), "creds.username") { + t.Errorf("want missing-username error, got %v", err) + } +} + +func TestNewHoverAdapter_RequiresPassword(t *testing.T) { + _, err := newHoverAdapter(map[string]string{"username": "u"}) + if err == nil || !strings.Contains(err.Error(), "creds.password") { + t.Errorf("want missing-password error, got %v", err) + } +} + +func TestNewHoverAdapter_AcceptsValidTOTP(t *testing.T) { + a, err := newHoverAdapter(map[string]string{ + "username": "u", "password": "p", "totp_secret": "JBSWY3DPEHPK3PXP", + }) + if err != nil { + t.Fatalf("construct with TOTP: %v", err) + } + if a == nil { + t.Fatal("nil adapter") + } +} + +func TestNewHoverAdapter_RejectsInvalidTOTP(t *testing.T) { + _, err := newHoverAdapter(map[string]string{ + "username": "u", "password": "p", "totp_secret": "not-base32!", + }) + if err == nil || !strings.Contains(err.Error(), "totp_secret") { + t.Errorf("want invalid-totp error, got %v", err) + } +} + +func TestNewAdapter_HoverDispatch(t *testing.T) { + a, err := NewAdapter("hover", map[string]string{"username": "u", "password": "p"}) + if err != nil || a == nil { + t.Fatalf("dispatch hover: %v / nil=%v", err, a == nil) + } + a2, err := NewAdapter("Hover", map[string]string{"username": "u", "password": "p"}) + if err != nil || a2 == nil { + t.Fatalf("case-fold Hover: %v / nil=%v", err, a2 == nil) + } +} + +// Stub satisfies hoverClientIface (defined in hover.go production file). +type stubHoverClient struct { + domain *hoverclient.Domain + listResult []hoverclient.DNSRecord + listErr error + createCalls []hoverclient.DNSRecord + createDomainIDs []string + createErr error + deleteIDs []string + deleteErr error +} + +func (s *stubHoverClient) GetDomain(_ context.Context, _ string) (*hoverclient.Domain, error) { + if s.domain != nil { + return s.domain, nil + } + return &hoverclient.Domain{ID: "dom123"}, nil +} + +func (s *stubHoverClient) ListRecords(_ context.Context, _ string) ([]hoverclient.DNSRecord, error) { + return s.listResult, s.listErr +} + +func (s *stubHoverClient) CreateRecord(_ context.Context, domainID string, rec hoverclient.DNSRecord) (*hoverclient.DNSRecord, error) { + s.createDomainIDs = append(s.createDomainIDs, domainID) + s.createCalls = append(s.createCalls, rec) + if s.createErr != nil { + return nil, s.createErr + } + out := rec + out.ID = "rec123" + return &out, nil +} + +func (s *stubHoverClient) UpdateRecord(_ context.Context, _ string, _ hoverclient.DNSRecord) error { + return nil +} + +func (s *stubHoverClient) DeleteRecord(_ context.Context, recordID string) error { + s.deleteIDs = append(s.deleteIDs, recordID) + return s.deleteErr +} + +// Exercises REAL adapter method: list → delete stale TXT → create new values. +func TestHoverAdapter_UpsertTXT_ExercisesAdapter(t *testing.T) { + stub := &stubHoverClient{ + listResult: []hoverclient.DNSRecord{ + {ID: "old1", Type: "TXT", Name: "_workflow-dns-policy", Content: "stale1"}, + {ID: "old2", Type: "TXT", Name: "_workflow-dns-policy", Content: "stale2"}, + {ID: "foreign", Type: "A", Name: "host", Content: "1.2.3.4"}, + }, + } + a := &hoverAdapter{client: stub} + err := a.UpsertTXT(context.Background(), "_workflow-dns-policy.example.com", []string{"v=wfinfra-v1 o=sre"}, 300) + if err != nil { + t.Fatalf("upsert: %v", err) + } + if len(stub.deleteIDs) != 2 || stub.deleteIDs[0] != "old1" || stub.deleteIDs[1] != "old2" { + t.Errorf("deleteIDs: %+v (want [old1, old2] — foreign A record must survive)", stub.deleteIDs) + } + if len(stub.createCalls) != 1 { + t.Fatalf("create calls: %d, want 1", len(stub.createCalls)) + } + got := stub.createCalls[0] + if got.Type != "TXT" || got.Content != "v=wfinfra-v1 o=sre" { + t.Errorf("created record wrong: %+v", got) + } + if stub.createDomainIDs[0] != "dom123" { + t.Errorf("CreateRecord called with domainID=%q, want dom123 (from GetDomain)", stub.createDomainIDs[0]) + } +} + +func TestHoverAdapter_GetTXT(t *testing.T) { + stub := &stubHoverClient{ + listResult: []hoverclient.DNSRecord{ + {Type: "TXT", Name: "_workflow-dns-policy", Content: "v=wfinfra-v1 o=sre"}, + {Type: "TXT", Name: "_workflow-dns-policy", Content: "v=wfinfra-v1 o=multisite"}, + {Type: "TXT", Name: "other", Content: "should-not-appear"}, + {Type: "A", Name: "_workflow-dns-policy", Content: "1.2.3.4"}, + }, + } + a := &hoverAdapter{client: stub} + got, err := a.GetTXT(context.Background(), "_workflow-dns-policy.example.com") + if err != nil { + t.Fatalf("get: %v", err) + } + if len(got) != 2 || got[0] != "v=wfinfra-v1 o=sre" || got[1] != "v=wfinfra-v1 o=multisite" { + t.Errorf("GetTXT: %+v (want 2 policy TXT entries; non-TXT + other-name filtered)", got) + } +} + +func TestHoverAdapter_UpsertRecord_PriorityDroppedForA(t *testing.T) { + stub := &stubHoverClient{} + a := &hoverAdapter{client: stub} + id, err := a.UpsertRecord(context.Background(), "example.com", "host", "A", "1.2.3.4", 300, 10) + if err != nil { + t.Fatalf("upsert: %v", err) + } + if id != "rec123" { + t.Errorf("returned ID: %q, want rec123", id) + } + if len(stub.createCalls) != 1 || stub.createCalls[0].Type != "A" || stub.createCalls[0].Content != "1.2.3.4" { + t.Errorf("create: %+v", stub.createCalls) + } +} + +func TestHoverAdapter_UpsertRecord_RejectsNegativePriority(t *testing.T) { + stub := &stubHoverClient{} + a := &hoverAdapter{client: stub} + _, err := a.UpsertRecord(context.Background(), "example.com", "host", "A", "1.2.3.4", 300, -1) + if err == nil || !strings.Contains(err.Error(), "priority must be >= 0") { + t.Errorf("want negative-priority error, got %v", err) + } +} + +func TestHoverAdapter_DeleteRecord(t *testing.T) { + stub := &stubHoverClient{ + listResult: []hoverclient.DNSRecord{ + {ID: "del1", Type: "A", Name: "host", Content: "1.2.3.4"}, + {ID: "keep1", Type: "A", Name: "other", Content: "5.6.7.8"}, + {ID: "keep2", Type: "TXT", Name: "host", Content: "txt"}, + }, + } + a := &hoverAdapter{client: stub} + if err := a.DeleteRecord(context.Background(), "example.com", "host", "A"); err != nil { + t.Fatalf("delete: %v", err) + } + if len(stub.deleteIDs) != 1 || stub.deleteIDs[0] != "del1" { + t.Errorf("deleteIDs: %+v (want [del1] only — name+type match)", stub.deleteIDs) + } +} + +func TestHoverAdapter_DeleteRecord_PropagatesListError(t *testing.T) { + stub := &stubHoverClient{listErr: errors.New("network unreachable")} + a := &hoverAdapter{client: stub} + err := a.DeleteRecord(context.Background(), "example.com", "host", "A") + if err == nil || !strings.Contains(err.Error(), "list records") { + t.Errorf("want list-records error, got %v", err) + } + if strings.Contains(err.Error(), "creds redacted") == false { + t.Errorf("want (creds redacted) suffix, got %v", err) + } +}