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",