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
46 changes: 42 additions & 4 deletions pkg/hoverclient/browser_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
88 changes: 87 additions & 1 deletion pkg/hoverclient/browser_backend_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<html><head><meta name="csrf-token" content="test-csrf-abc"></head></html>`)
}

func fakeWriteMuxWithControlPanelHTML(t *testing.T, controlPanelHTML string) (*http.ServeMux, *writeRequestLog) {
t.Helper()
log := &writeRequestLog{}
mux := http.NewServeMux()
Expand Down Expand Up @@ -76,7 +80,7 @@ func fakeWriteMux(t *testing.T) (*http.ServeMux, *writeRequestLog) {

// SetNameservers CSRF page: GET /control_panel/domain/<name>
mux.HandleFunc("/control_panel/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<html><head><meta name="csrf-token" content="test-csrf-abc"></head></html>`))
_, _ = w.Write([]byte(controlPanelHTML))
})

// SetNameservers PUT: /api/control_panel/domains/<domain>
Expand All @@ -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(`<html><body>signin</body></html>`))
})
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 {
Expand Down Expand Up @@ -347,6 +381,58 @@ func TestBrowserBackend_SetNameserversInBrowser(t *testing.T) {
}
}

func TestBrowserBackend_SetNameserversInBrowserWithoutCSRFMeta(t *testing.T) {
mux, log := fakeWriteMuxWithControlPanelHTML(t, `<html><head><title>domain</title></head><body>no csrf meta</body></html>`)
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, `<html><head><title>domain</title></head><body>no csrf meta</body></html>`, 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
// --------------------------------------------------------------------------
Expand Down
97 changes: 97 additions & 0 deletions pkg/hoverclient/browser_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package hoverclient
import (
"context"
"os"
"sort"
"strings"
"testing"
"time"
)

func TestLiveBrowserLoginAndHTTPReuseProbe(t *testing.T) {
Expand All @@ -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)
}
Comment thread
intel352 marked this conversation as resolved.
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
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down