diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1aea02e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - run: go vet ./... + - run: go test ./... -race -count=1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0696aca --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# workflow-plugin-hover + +[![CI](https://github.com/GoCodeAlone/workflow-plugin-hover/actions/workflows/ci.yml/badge.svg)](https://github.com/GoCodeAlone/workflow-plugin-hover/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +> ๐Ÿงช **Experimental** โ€” Hover DNS provider for the GoCodeAlone/workflow IaC surface. +> Hover has no official API; this plugin mimics the browser auth flow used by +> [pjslauta/hover-dyn-dns](https://github.com/pjslauta/hover-dyn-dns). Watch out +> for UI changes on hover.com that may break CSRF token parsing. + +## Auth flow + +1. GET `/signin` โ†’ parse `` (CSRF token). +2. POST `/signin` with `username`, `password`, `_token`. +3. GET `/signin/totp` โ†’ parse fresh `_token`. +4. POST `/signin/totp` with `code` (TOTP RFC 6238) + `_token`. +5. Session cookie now carries subsequent `/api/dns` requests. + +Re-auth fires whenever the in-memory session is older than 1h. + +## Configuration + +```yaml +modules: + - name: hover + type: iac.provider.hover + config: + username: ${HOVER_USERNAME} + password: ${HOVER_PASSWORD} + totp_secret: ${HOVER_TOTP_SECRET} + +resources: + - name: example-com + type: infra.dns + config: + provider: hover + domain: example.com + records: + - { type: A, name: '@', data: 203.0.113.10, ttl: 900 } + - { type: CNAME, name: 'www', data: example.com., ttl: 900 } +``` + +## Required secrets + +| Name | Sensitive | Source | +|------|-----------|--------| +| `HOVER_USERNAME` | no | Hover account login | +| `HOVER_PASSWORD` | **yes** | Hover account password | +| `HOVER_TOTP_SECRET` | **yes** | Base32 seed from Hover 2FA setup (the QR-code page shows a "Secret Key" field; copy that) | + +`wfctl secrets setup --plugin workflow-plugin-hover` prompts for each; +sensitive fields are masked. + +## TOTP + +In-process RFC 6238 (SHA-1, 30s step, 6 digits). The seed is decoded +once at plugin start; codes are computed on each login. Tested +against [RFC 6238 Appendix B vectors](https://datatracker.ietf.org/doc/html/rfc6238#appendix-B). + +## Caveats + +- **UI brittleness**: Hover's signin page can change. The plugin + fails loud with `CSRF token not found at /signin` when the regex + no longer matches. +- **CAPTCHA**: Hover may serve a CAPTCHA challenge on suspicious + logins. The plugin doesn't solve CAPTCHAs; you'll need to log + in manually from the same IP to seed trust, OR use a static + egress IP for the plugin runner. +- **Rate limit**: Stick to small zones; Hover's account portal + isn't optimised for bulk DNS edits. + +## Development + +```sh +GOWORK=off go build ./... +GOWORK=off go test ./... -race -count=1 +``` diff --git a/cmd/workflow-plugin-hover/main.go b/cmd/workflow-plugin-hover/main.go new file mode 100644 index 0000000..636e31c --- /dev/null +++ b/cmd/workflow-plugin-hover/main.go @@ -0,0 +1,14 @@ +// Command workflow-plugin-hover is a workflow IaC plugin that +// implements `infra.dns` against Hover's account portal. +// +// Hover has no official API. This plugin mimics the browser-side +// auth (login + TOTP) used by pjslauta/hover-dyn-dns. +package main + +import ( + "github.com/GoCodeAlone/workflow-plugin-hover/internal" +) + +func main() { + internal.Serve() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8699e2c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/GoCodeAlone/workflow-plugin-hover + +go 1.26.0 diff --git a/internal/hover/client.go b/internal/hover/client.go new file mode 100644 index 0000000..7cc2526 --- /dev/null +++ b/internal/hover/client.go @@ -0,0 +1,279 @@ +package hover + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +const ( + hoverHost = "https://www.hover.com" + defaultUserAgent = "wfctl-hover-plugin/0.1 (+https://github.com/GoCodeAlone/workflow-plugin-hover)" + sessionStaleAfter = 1 * time.Hour +) + +// Credentials carries the operator-provided login material. +type Credentials struct { + Username string + Password string + TOTPSecret TOTPSecret +} + +// Client is a Hover account-portal client. Concurrency-safe; the +// underlying cookie jar serialises across goroutines via mu. +type Client struct { + mu sync.Mutex + http *http.Client + creds Credentials + loggedAt time.Time + UserAgent string +} + +// NewClient returns a fresh Client. Pass http=nil for an internal +// jar-backed http.Client. Tests inject a stub to redirect requests. +func NewClient(creds Credentials, httpClient *http.Client) (*Client, error) { + if creds.Username == "" || creds.Password == "" { + return nil, errors.New("hover: username + password required") + } + if httpClient == nil { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("hover: cookie jar: %w", err) + } + httpClient = &http.Client{Jar: jar, Timeout: 30 * time.Second} + } + if httpClient.Jar == nil { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + httpClient.Jar = jar + } + return &Client{http: httpClient, creds: creds, UserAgent: defaultUserAgent}, nil +} + +// ensureLogin re-authenticates iff the session is stale. Safe to call +// before every API hit; idempotent within sessionStaleAfter. +func (c *Client) ensureLogin(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.loggedAt.IsZero() && time.Since(c.loggedAt) < sessionStaleAfter { + return nil + } + + csrf, err := c.fetchSignInCSRF(ctx) + if err != nil { + return err + } + + // Step 1 โ€” submit credentials. + form := url.Values{ + "username": {c.creds.Username}, + "password": {c.creds.Password}, + "_token": {csrf}, + } + if err := c.postForm(ctx, hoverHost+"/signin", form); err != nil { + return fmt.Errorf("hover signin step 1: %w", err) + } + + // Step 2 โ€” submit TOTP. Hover re-issues a fresh `_token` on the + // TOTP page; refetch. + csrf2, err := c.fetchTOTPCSRF(ctx) + if err != nil { + return err + } + code := c.creds.TOTPSecret.Code() + form = url.Values{ + "code": {code}, + "_token": {csrf2}, + } + if err := c.postForm(ctx, hoverHost+"/signin/totp", form); err != nil { + return fmt.Errorf("hover signin step 2 (totp): %w", err) + } + + c.loggedAt = time.Now() + return nil +} + +var csrfRe = regexp.MustCompile(`]+name="_token"[^>]+value="([^"]+)"`) + +func (c *Client) fetchSignInCSRF(ctx context.Context) (string, error) { + return c.fetchCSRF(ctx, hoverHost+"/signin") +} + +func (c *Client) fetchTOTPCSRF(ctx context.Context) (string, error) { + return c.fetchCSRF(ctx, hoverHost+"/signin/totp") +} + +func (c *Client) fetchCSRF(ctx context.Context, urlStr string) (string, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + m := csrfRe.FindSubmatch(body) + if len(m) < 2 { + return "", fmt.Errorf("hover: CSRF token not found at %s (login UI changed?)", urlStr) + } + return string(m[1]), nil +} + +func (c *Client) postForm(ctx context.Context, urlStr string, form url.Values) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +// DNSRecord mirrors Hover's internal API record shape. +type DNSRecord struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + TTL int `json:"ttl,omitempty"` +} + +// Domain is the API shape returned by GET /api/domains. +type Domain struct { + ID string `json:"id"` + Name string `json:"domain_name"` + Records []DNSRecord `json:"entries"` +} + +// ListRecords returns records for the named zone. Caller MUST pass +// the apex domain (e.g. "example.com"). +func (c *Client) ListRecords(ctx context.Context, domain string) ([]DNSRecord, error) { + if err := c.ensureLogin(ctx); err != nil { + return nil, err + } + endpoint := fmt.Sprintf("%s/api/domains/%s/dns", hoverHost, url.PathEscape(domain)) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("hover list records %q: HTTP %d: %s", domain, resp.StatusCode, strings.TrimSpace(string(body))) + } + var wrap struct { + Domains []Domain `json:"domains"` + } + if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil { + return nil, fmt.Errorf("hover list records parse: %w", err) + } + for _, d := range wrap.Domains { + if strings.EqualFold(d.Name, domain) { + return d.Records, nil + } + } + return nil, fmt.Errorf("hover: domain %q not found in account", domain) +} + +// CreateRecord adds a new DNS record for the domain. +func (c *Client) CreateRecord(ctx context.Context, domainID string, rec DNSRecord) (*DNSRecord, error) { + if err := c.ensureLogin(ctx); err != nil { + return nil, err + } + form := url.Values{ + "domain_id": {domainID}, + "name": {rec.Name}, + "type": {rec.Type}, + "content": {rec.Content}, + } + if rec.TTL > 0 { + form.Set("ttl", fmt.Sprintf("%d", rec.TTL)) + } + endpoint := hoverHost + "/api/dns" + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("hover create record: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var out struct { + DNSRecord DNSRecord `json:"dns_record"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("hover create record parse: %w", err) + } + return &out.DNSRecord, nil +} + +// UpdateRecord PATCHes an existing record's content (and TTL when > 0). +func (c *Client) UpdateRecord(ctx context.Context, recordID string, rec DNSRecord) error { + if err := c.ensureLogin(ctx); err != nil { + return err + } + form := url.Values{"content": {rec.Content}} + if rec.TTL > 0 { + form.Set("ttl", fmt.Sprintf("%d", rec.TTL)) + } + endpoint := fmt.Sprintf("%s/api/dns/%s", hoverHost, url.PathEscape(recordID)) + req, _ := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("hover update %q: HTTP %d: %s", recordID, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +// DeleteRecord removes a record by ID. +func (c *Client) DeleteRecord(ctx context.Context, recordID string) error { + if err := c.ensureLogin(ctx); err != nil { + return err + } + endpoint := fmt.Sprintf("%s/api/dns/%s", hoverHost, url.PathEscape(recordID)) + req, _ := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("hover delete %q: HTTP %d: %s", recordID, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} diff --git a/internal/hover/client_test.go b/internal/hover/client_test.go new file mode 100644 index 0000000..0540860 --- /dev/null +++ b/internal/hover/client_test.go @@ -0,0 +1,145 @@ +package hover + +import ( + "context" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strings" + "testing" +) + +// signinCSRFHTML is what we return on GET /signin + /signin/totp so +// the client's CSRF regex finds a token. +const signinCSRFHTML = `
` + +func newStubClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + jar, _ := cookiejar.New(nil) + httpc := &http.Client{ + Jar: jar, + Transport: rewriteTransport{base: srv.URL}, + } + creds := Credentials{ + Username: "alice", + Password: "pw", + TOTPSecret: mustParse(t, rfc6238Secret), + } + c, err := NewClient(creds, httpc) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return c, srv +} + +type rewriteTransport struct{ base string } + +func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = "http" + clone.URL.Host = r.base[len("http://"):] + return http.DefaultTransport.RoundTrip(clone) +} + +func TestClient_Login_TwoStep(t *testing.T) { + var hits []string + var totpForm string + c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) { + hits = append(hits, r.Method+" "+r.URL.Path) + switch r.URL.Path { + case "/signin": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(signinCSRFHTML)) + return + } + // POST: just succeed. + w.WriteHeader(http.StatusOK) + case "/signin/totp": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(signinCSRFHTML)) + return + } + _ = r.ParseForm() + totpForm = r.Form.Encode() + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected hit: %s %s", r.Method, r.URL.Path) + } + }) + defer srv.Close() + + if err := c.ensureLogin(context.Background()); err != nil { + t.Fatalf("ensureLogin: %v", err) + } + + wantHits := []string{ + "GET /signin", + "POST /signin", + "GET /signin/totp", + "POST /signin/totp", + } + if len(hits) != len(wantHits) { + t.Fatalf("hits = %v; want %v", hits, wantHits) + } + for i, want := range wantHits { + if hits[i] != want { + t.Errorf("hits[%d] = %q want %q", i, hits[i], want) + } + } + + // TOTP form must include a 6-digit code + the CSRF token from the + // GET response. + if !strings.Contains(totpForm, "_token=t0kEnVaLuE") { + t.Errorf("TOTP POST missing CSRF: %q", totpForm) + } + if !strings.Contains(totpForm, "code=") { + t.Errorf("TOTP POST missing code: %q", totpForm) + } +} + +func TestClient_Login_SkipsWhenFresh(t *testing.T) { + var hits int + c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) { + hits++ + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(signinCSRFHTML)) + return + } + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + + if err := c.ensureLogin(context.Background()); err != nil { + t.Fatal(err) + } + firstRound := hits + if err := c.ensureLogin(context.Background()); err != nil { + t.Fatal(err) + } + if hits != firstRound { + t.Errorf("second ensureLogin hit network; want cache hit. first=%d second=%d", firstRound, hits) + } +} + +func TestClient_CSRFParseFailure_RaisesClearError(t *testing.T) { + c, srv := newStubClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("no token here")) + }) + defer srv.Close() + + err := c.ensureLogin(context.Background()) + if err == nil { + t.Fatal("expected CSRF parse error") + } + if !strings.Contains(err.Error(), "CSRF token not found") { + t.Errorf("wrong error: %v", err) + } +} + +func TestNewClient_RequiresCredentials(t *testing.T) { + _, err := NewClient(Credentials{}, nil) + if err == nil { + t.Fatal("expected error on empty creds") + } +} diff --git a/internal/hover/totp.go b/internal/hover/totp.go new file mode 100644 index 0000000..64fd9bb --- /dev/null +++ b/internal/hover/totp.go @@ -0,0 +1,75 @@ +// Package hover implements the Hover DNS provider client. +// +// Hover ships no official API. This package mimics the browser-side +// authentication flow exercised by pjslauta/hover-dyn-dns: +// +// 1. POST https://www.hover.com/signin (username, password) with +// CSRF `_token` parsed from the signin page. +// 2. POST https://www.hover.com/signin/totp (code, _token). +// 3. Subsequent requests carry the session cookie jar. +// +// TOTP codes are RFC 6238 (HMAC-SHA1, 30s window, 6 digits). +package hover + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "fmt" + "strings" + "time" +) + +// TOTPSecret holds a decoded HOTP key. Construct via ParseBase32. +type TOTPSecret struct { + key []byte +} + +// ParseBase32 parses a Google-Authenticator-style base32 seed (case- +// insensitive, padding optional) into a TOTPSecret. Spaces are +// stripped so users can paste from the Hover 2FA setup dialog. +func ParseBase32(seed string) (TOTPSecret, error) { + s := strings.ReplaceAll(strings.ReplaceAll(seed, " ", ""), "-", "") + s = strings.ToUpper(s) + // Pad to a multiple of 8 (base32 requirement) using `=`. + if mod := len(s) % 8; mod != 0 { + s += strings.Repeat("=", 8-mod) + } + key, err := base32.StdEncoding.DecodeString(s) + if err != nil { + return TOTPSecret{}, fmt.Errorf("hover: invalid TOTP seed base32: %w", err) + } + if len(key) < 10 { + return TOTPSecret{}, fmt.Errorf("hover: TOTP seed too short (decoded to %d bytes; RFC 6238 recommends โ‰ฅ 20)", len(key)) + } + return TOTPSecret{key: key}, nil +} + +// CodeAt returns the 6-digit code for the given Unix time t (seconds). +// 30-second step per RFC 6238 ยง4. Pure HMAC-SHA1 โ€” no external deps. +func (s TOTPSecret) CodeAt(t int64) string { + const step = 30 + counter := uint64(t / step) + var msg [8]byte + binary.BigEndian.PutUint64(msg[:], counter) + + mac := hmac.New(sha1.New, s.key) + _, _ = mac.Write(msg[:]) + sum := mac.Sum(nil) + + // RFC 6238 dynamic truncation: low nibble of last byte is the + // offset into sum for the 31-bit code. + offset := int(sum[len(sum)-1] & 0x0f) + bin := (uint32(sum[offset])&0x7f)<<24 | + uint32(sum[offset+1])<<16 | + uint32(sum[offset+2])<<8 | + uint32(sum[offset+3]) + code := bin % 1_000_000 + return fmt.Sprintf("%06d", code) +} + +// Code returns the 6-digit code for the current wall-clock time. +func (s TOTPSecret) Code() string { + return s.CodeAt(time.Now().Unix()) +} diff --git a/internal/hover/totp_test.go b/internal/hover/totp_test.go new file mode 100644 index 0000000..d6a5a73 --- /dev/null +++ b/internal/hover/totp_test.go @@ -0,0 +1,91 @@ +package hover + +import ( + "strings" + "testing" +) + +// RFC 6238 Appendix B test vectors (TOTP, SHA-1, 30s step). +// +// T Time (UTC) Code +// 59 1970-01-01 00:00:59 94287082 +// 1111111109 287082 +// ... etc. +// +// We use the canonical 20-byte ASCII secret "12345678901234567890", +// which encodes as base32 to: GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ. +const rfc6238Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + +func mustParse(t *testing.T, s string) TOTPSecret { + t.Helper() + sec, err := ParseBase32(s) + if err != nil { + t.Fatalf("ParseBase32(%q): %v", s, err) + } + return sec +} + +func TestParseBase32_AcceptsCanonicalSecret(t *testing.T) { + sec := mustParse(t, rfc6238Secret) + if string(sec.key) != "12345678901234567890" { + t.Errorf("decoded key = %q want %q", sec.key, "12345678901234567890") + } +} + +func TestParseBase32_HandlesWhitespaceAndCase(t *testing.T) { + sec := mustParse(t, "gezd gnbv gy3t qojq gezd gnbv gy3t qojq") + if string(sec.key) != "12345678901234567890" { + t.Errorf("whitespace+lowercase roundtrip failed: %q", sec.key) + } +} + +func TestParseBase32_RejectsTooShort(t *testing.T) { + _, err := ParseBase32("AAAA") + if err == nil { + t.Fatal("expected error for too-short seed") + } + if !strings.Contains(err.Error(), "too short") { + t.Errorf("err: %v", err) + } +} + +func TestParseBase32_RejectsBadAlphabet(t *testing.T) { + _, err := ParseBase32("####################") + if err == nil { + t.Fatal("expected error for non-base32 chars") + } +} + +// TestCodeAt_RFC6238Vectors exercises the canonical RFC vectors. +func TestCodeAt_RFC6238Vectors(t *testing.T) { + sec := mustParse(t, rfc6238Secret) + cases := []struct { + t int64 + want string + }{ + {59, "287082"}, // Truncated to last 6 of 94287082 + {1111111109, "081804"}, // Truncated to last 6 of 7081804 + {1111111111, "050471"}, + {1234567890, "005924"}, + {2000000000, "279037"}, + } + for _, c := range cases { + got := sec.CodeAt(c.t) + if got != c.want { + t.Errorf("CodeAt(%d) = %q want %q", c.t, got, c.want) + } + } +} + +func TestCode_UsesWallClock(t *testing.T) { + sec := mustParse(t, rfc6238Secret) + got := sec.Code() + if len(got) != 6 { + t.Errorf("Code returned %q (len %d); want 6 digits", got, len(got)) + } + for _, r := range got { + if r < '0' || r > '9' { + t.Errorf("non-digit %q in code %q", r, got) + } + } +} diff --git a/internal/serve.go b/internal/serve.go new file mode 100644 index 0000000..ef822e6 --- /dev/null +++ b/internal/serve.go @@ -0,0 +1,11 @@ +// Package internal โ€” Hover plugin entry point. +package internal + +// Serve is the gRPC plugin entry-point. Full driver registration +// lands once workflow#640 Phase 3 (typed IaC ResourceDriver +// surface) stabilises. Until then the Hover client lives in +// internal/hover and the scaffold compiles + tests on its own. +func Serve() { + // placeholder; see workflow-plugin-namecheap/internal/serve.go + // for the eventual SDK invocation. +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..b4168e7 --- /dev/null +++ b/plugin.json @@ -0,0 +1,50 @@ +{ + "name": "workflow-plugin-hover", + "version": "0.1.0", + "description": "Hover DNS provider (browser-style login + TOTP, no official SDK)", + "author": "GoCodeAlone", + "license": "MIT", + "type": "external", + "tier": "community", + "private": false, + "minEngineVersion": "0.60.6", + "keywords": [ + "dns", + "hover", + "iac", + "infra.dns", + "totp" + ], + "homepage": "https://github.com/GoCodeAlone/workflow-plugin-hover", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-hover", + "capabilities": { + "moduleTypes": [ + "iac.provider.hover" + ], + "stepTypes": [], + "triggerTypes": [], + "resourceTypes": [ + "infra.dns" + ] + }, + "required_secrets": [ + { + "name": "HOVER_USERNAME", + "sensitive": false, + "description": "Hover account username", + "prompt": "Hover username" + }, + { + "name": "HOVER_PASSWORD", + "sensitive": true, + "description": "Hover account password", + "prompt": "Hover password" + }, + { + "name": "HOVER_TOTP_SECRET", + "sensitive": true, + "description": "Base32-encoded TOTP seed shown when you enabled 2FA. The plugin generates 6-digit codes per RFC 6238 on each login.", + "prompt": "Hover TOTP seed (base32, 16+ chars)" + } + ] +}