diff --git a/pkg/hoverclient/browser_backend.go b/pkg/hoverclient/browser_backend.go
index fd2338d..5666654 100644
--- a/pkg/hoverclient/browser_backend.go
+++ b/pkg/hoverclient/browser_backend.go
@@ -247,6 +247,40 @@ func (b *browserBackend) handOffCookies(browser *rod.Browser, c *Client) error {
return nil
}
+func (b *browserBackend) syncJarCookiesToBrowser(ctx context.Context, c *Client) error {
+ if b.browser == nil || c.http.Jar == nil {
+ return nil
+ }
+ targetURL, err := url.Parse(b.signinHost())
+ if err != nil {
+ return fmt.Errorf("parse host URL %q: %w", b.signinHost(), err)
+ }
+ cookies := c.http.Jar.Cookies(targetURL)
+ if len(cookies) == 0 {
+ return nil
+ }
+ cookieURL := targetURL.String()
+ var params []*proto.NetworkCookieParam
+ for _, cookie := range cookies {
+ path := cookie.Path
+ if path == "" {
+ path = "/"
+ }
+ params = append(params, &proto.NetworkCookieParam{
+ Name: cookie.Name,
+ Value: cookie.Value,
+ URL: cookieURL,
+ Path: path,
+ Secure: cookie.Secure || targetURL.Scheme == "https",
+ HTTPOnly: cookie.HttpOnly,
+ })
+ }
+ if err := b.browser.Context(ctx).SetCookies(params); err != nil {
+ return fmt.Errorf("set browser cookies from jar: %w", err)
+ }
+ return nil
+}
+
// ---------------------------------------------------------------------------
// READ operations — delegate to the HTTP backend after ensuring login.
// ---------------------------------------------------------------------------
@@ -438,6 +472,9 @@ func (b *browserBackend) SetNameservers(ctx context.Context, c *Client, domainNa
return fmt.Errorf("hover browser SetNameservers: browser not initialised (Login must succeed before write operations)")
}
base := b.signinHost()
+ if err := b.syncJarCookiesToBrowser(ctx, c); err != nil {
+ return fmt.Errorf("hover browser SetNameservers: cookie sync: %w", err)
+ }
// Open a page and navigate to the control_panel domain page to read the CSRF.
// Use browser.Context(ctx) to override the browser's stored (login-timeout)
@@ -464,16 +501,17 @@ func (b *browserBackend) SetNameservers(ctx context.Context, c *Client, domainNa
return fmt.Errorf("hover browser SetNameservers: eval CSRF: %w", err)
}
csrf := obj.Value.String()
- if csrf == "" {
- return fmt.Errorf("hover browser SetNameservers: CSRF meta tag not found at %s", cpURL)
- }
// Build PUT endpoint + payload (same as HTTP backend).
putEndpoint := fmt.Sprintf("%s/api/control_panel/domains/domain-%s", base, url.PathEscape(domainName))
payload := map[string]any{"field": "nameservers", "value": ns}
+ headers := map[string]string{}
+ if csrf != "" {
+ headers["X-CSRF-Token"] = csrf
+ }
rawBody, code, err := browserFetchWithHeaders(ctx, page, "PUT", putEndpoint,
- "application/json", payload, map[string]string{"X-CSRF-Token": csrf})
+ "application/json", payload, headers)
if err != nil {
return fmt.Errorf("hover browser SetNameservers %q: fetch: %w", domainName, err)
}
diff --git a/pkg/hoverclient/browser_backend_write_test.go b/pkg/hoverclient/browser_backend_write_test.go
index 87ea762..1ec293d 100644
--- a/pkg/hoverclient/browser_backend_write_test.go
+++ b/pkg/hoverclient/browser_backend_write_test.go
@@ -34,6 +34,10 @@ import (
// The recordedReqs map (method → recorded request info) is populated by the
// handlers. The control_panel page returns a fixed CSRF token.
func fakeWriteMux(t *testing.T) (*http.ServeMux, *writeRequestLog) {
+ return fakeWriteMuxWithControlPanelHTML(t, `
`)
+}
+
+func fakeWriteMuxWithControlPanelHTML(t *testing.T, controlPanelHTML string) (*http.ServeMux, *writeRequestLog) {
t.Helper()
log := &writeRequestLog{}
mux := http.NewServeMux()
@@ -76,7 +80,7 @@ func fakeWriteMux(t *testing.T) (*http.ServeMux, *writeRequestLog) {
// SetNameservers CSRF page: GET /control_panel/domain/
mux.HandleFunc("/control_panel/", func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(``))
+ _, _ = w.Write([]byte(controlPanelHTML))
})
// SetNameservers PUT: /api/control_panel/domains/
@@ -89,6 +93,36 @@ func fakeWriteMux(t *testing.T) (*http.ServeMux, *writeRequestLog) {
return mux, log
}
+func fakeWriteMuxRequiringCookie(t *testing.T, controlPanelHTML, cookieName string) (*http.ServeMux, *writeRequestLog) {
+ t.Helper()
+ log := &writeRequestLog{}
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "fake-session", Path: "/", HttpOnly: true})
+ _, _ = w.Write([]byte(`signin`))
+ })
+ mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"})
+ })
+ mux.HandleFunc("/control_panel/", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(controlPanelHTML))
+ })
+ mux.HandleFunc("/api/control_panel/", func(w http.ResponseWriter, r *http.Request) {
+ if _, err := r.Cookie(cookieName); err != nil {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"succeeded":false,"error_code":"login","error":"You must login first"}`))
+ return
+ }
+ log.record(r)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
+ })
+
+ return mux, log
+}
+
// writeRequestLog captures the last recorded HTTP request (method, path, body,
// headers) across all write endpoints. Thread-safe.
type writeRequestLog struct {
@@ -347,6 +381,58 @@ func TestBrowserBackend_SetNameserversInBrowser(t *testing.T) {
}
}
+func TestBrowserBackend_SetNameserversInBrowserWithoutCSRFMeta(t *testing.T) {
+ mux, log := fakeWriteMuxWithControlPanelHTML(t, `domainno csrf meta`)
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ c := newWriteBrowserClient(t, srv)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ ns := []string{"ns1.example.com", "ns2.example.com"}
+ if err := c.SetNameservers(ctx, "example.com", ns); err != nil {
+ t.Fatalf("SetNameservers without CSRF meta: %v", err)
+ }
+
+ req, ok := log.firstMatching(http.MethodPut, "/api/control_panel/domains/domain-example.com")
+ if !ok {
+ t.Fatal("PUT /api/control_panel/domains/domain-example.com not observed by server")
+ }
+ if got := req.Header.Get("X-CSRF-Token"); got != "" {
+ t.Errorf("X-CSRF-Token = %q, want empty when control panel has no CSRF meta", got)
+ }
+}
+
+func TestBrowserBackend_SetNameserversSyncsJarCookiesToBrowser(t *testing.T) {
+ const sessionCookie = "__uzma"
+ mux, log := fakeWriteMuxRequiringCookie(t, `domainno csrf meta`, sessionCookie)
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ c := newWriteBrowserClient(t, srv)
+ bb := c.backend.(*browserBackend)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := bb.browser.Context(ctx).SetCookies(nil); err != nil {
+ t.Fatalf("clear browser cookies: %v", err)
+ }
+
+ ns := []string{"ns1.example.com", "ns2.example.com"}
+ if err := c.SetNameservers(ctx, "example.com", ns); err != nil {
+ t.Fatalf("SetNameservers after browser cookie loss: %v", err)
+ }
+
+ req, ok := log.firstMatching(http.MethodPut, "/api/control_panel/domains/domain-example.com")
+ if !ok {
+ t.Fatal("PUT /api/control_panel/domains/domain-example.com not observed by server")
+ }
+ if _, err := (&http.Request{Header: req.Header}).Cookie(sessionCookie); err != nil {
+ t.Fatalf("PUT missing synced %s cookie: %v", sessionCookie, err)
+ }
+}
+
// --------------------------------------------------------------------------
// TestBrowserBackend_SetNameserversRejectsEmpty
// --------------------------------------------------------------------------
diff --git a/pkg/hoverclient/browser_live_test.go b/pkg/hoverclient/browser_live_test.go
index 61ae0b1..18e8fef 100644
--- a/pkg/hoverclient/browser_live_test.go
+++ b/pkg/hoverclient/browser_live_test.go
@@ -3,8 +3,10 @@ package hoverclient
import (
"context"
"os"
+ "sort"
"strings"
"testing"
+ "time"
)
func TestLiveBrowserLoginAndHTTPReuseProbe(t *testing.T) {
@@ -26,6 +28,64 @@ func TestLiveBrowserLoginAndHTTPReuseProbe(t *testing.T) {
t.Logf("go_http_reuse_viable=%t domains=%d clearance_cookies=%v", result.GoHTTPReuseViable, result.DomainCount, result.ClearanceCookieNames())
}
+func TestLiveSetNameserversNoop(t *testing.T) {
+ if os.Getenv("HOVER_LIVE_TEST") != "1" {
+ t.Skip("set HOVER_LIVE_TEST=1 to run live Hover browser write probe")
+ }
+ domain := strings.TrimSpace(os.Getenv("HOVER_LIVE_NS_DOMAIN"))
+ if domain == "" {
+ t.Skip("set HOVER_LIVE_NS_DOMAIN to the disposable Hover domain to test")
+ }
+ wantNS := splitLiveNameservers(os.Getenv("HOVER_LIVE_NS_EXPECTED"))
+ if len(wantNS) == 0 {
+ t.Fatal("set HOVER_LIVE_NS_EXPECTED to the current nameservers, comma-separated")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ creds := liveCredentialsFromEnv(t)
+ opts := liveBrowserOptionsFromEnv(t)
+ c, err := NewClientWithOptions(creds, nil, ClientOptions{Browser: opts})
+ if err != nil {
+ t.Fatalf("NewClientWithOptions: %v", err)
+ }
+ t.Cleanup(func() { _ = c.backend.Close() })
+
+ domains, err := c.ListDomains(ctx)
+ if err != nil {
+ t.Fatalf("ListDomains before write: %v", err)
+ }
+ var found bool
+ for _, d := range domains {
+ if strings.EqualFold(d.Name, domain) {
+ found = true
+ assertNameserversMatch(t, "ListDomains nameservers", d.Nameservers, wantNS)
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("domain %q not found in Hover account", domain)
+ }
+
+ before, err := c.GetDomainDelegation(ctx, domain)
+ if err != nil {
+ t.Fatalf("GetDomainDelegation before write: %v", err)
+ }
+ assertNameserversMatch(t, "delegation before write", before.Nameservers, wantNS)
+
+ if err := c.SetNameservers(ctx, domain, wantNS); err != nil {
+ t.Fatalf("SetNameservers no-op write: %v", err)
+ }
+
+ after, err := c.GetDomainDelegation(ctx, domain)
+ if err != nil {
+ t.Fatalf("GetDomainDelegation after write: %v", err)
+ }
+ assertNameserversMatch(t, "delegation after write", after.Nameservers, wantNS)
+ t.Logf("SetNameservers no-op write succeeded for %s with %s", domain, strings.Join(wantNS, ","))
+}
+
func liveCredentialsFromEnv(t *testing.T) Credentials {
t.Helper()
var missing []string
@@ -51,6 +111,43 @@ func liveCredentialsFromEnv(t *testing.T) Credentials {
return Credentials{Username: username, Password: password, TOTPSecret: totp}
}
+func splitLiveNameservers(raw string) []string {
+ var out []string
+ for _, part := range strings.Split(raw, ",") {
+ ns := strings.Trim(strings.TrimSpace(part), ".")
+ if ns != "" {
+ out = append(out, strings.ToLower(ns))
+ }
+ }
+ return out
+}
+
+func assertNameserversMatch(t *testing.T, label string, got, want []string) {
+ t.Helper()
+ gotNorm := normalizeNameservers(got)
+ wantNorm := normalizeNameservers(want)
+ if len(gotNorm) != len(wantNorm) {
+ t.Fatalf("%s = %v, want %v", label, gotNorm, wantNorm)
+ }
+ for i := range wantNorm {
+ if gotNorm[i] != wantNorm[i] {
+ t.Fatalf("%s = %v, want %v", label, gotNorm, wantNorm)
+ }
+ }
+}
+
+func normalizeNameservers(ns []string) []string {
+ out := make([]string, 0, len(ns))
+ for _, item := range ns {
+ trimmed := strings.Trim(strings.TrimSpace(item), ".")
+ if trimmed != "" {
+ out = append(out, strings.ToLower(trimmed))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
func liveBrowserOptionsFromEnv(t *testing.T) BrowserOptions {
t.Helper()
opts, err := BrowserOptionsFromEnv()
diff --git a/plugin.json b/plugin.json
index 87dc41f..a0db2f9 100644
--- a/plugin.json
+++ b/plugin.json
@@ -1,6 +1,6 @@
{
"name": "workflow-plugin-hover",
- "version": "0.5.4",
+ "version": "0.5.5",
"description": "Hover DNS provider (browser-style login + TOTP, no official SDK)",
"author": "GoCodeAlone",
"license": "MIT",