diff --git a/cmd/browsers.go b/cmd/browsers.go index d799667..0b25ddd 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -9,12 +9,14 @@ import ( "io" "math/big" "net/http" + neturl "net/url" "os" "path/filepath" "regexp" "strconv" "strings" + "github.com/kernel/cli/pkg/auth" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" @@ -35,6 +37,7 @@ type BrowsersService interface { Update(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserUpdateResponse, err error) Delete(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) (err error) DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error) + Curl(ctx context.Context, id string, body kernel.BrowserCurlParams, opts ...option.RequestOption) (res *kernel.BrowserCurlResponse, err error) LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error) } @@ -2518,6 +2521,29 @@ func init() { browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") + // curl + curlCmd := &cobra.Command{ + Use: "curl ", + Short: "Make HTTP requests through a browser session", + Long: `Execute HTTP requests through Chrome's network stack, inheriting the +browser's TLS fingerprint, cookies, proxy configuration, and headers. +Works like curl but requests go through the browser session.`, + Args: cobra.ExactArgs(2), + RunE: runBrowsersCurl, + } + curlCmd.Flags().StringP("request", "X", "", "HTTP method (default: GET)") + curlCmd.Flags().StringArrayP("header", "H", nil, "HTTP header (repeatable, \"Key: Value\" format)") + curlCmd.Flags().StringP("data", "d", "", "Request body") + curlCmd.Flags().String("data-file", "", "Read request body from file") + curlCmd.Flags().Int("timeout", 30000, "Request timeout in milliseconds") + curlCmd.Flags().String("encoding", "", "Response encoding: utf8 or base64") + curlCmd.Flags().StringP("output", "o", "", "Write response body to file (uses streaming mode)") + curlCmd.Flags().Bool("raw", false, "Use streaming mode (no JSON wrapper)") + curlCmd.Flags().BoolP("include", "i", false, "Include response headers in output") + curlCmd.Flags().BoolP("silent", "s", false, "Suppress progress output") + curlCmd.Flags().Bool("json", false, "Output full JSON response") + browsersCmd.AddCommand(curlCmd) + // no flags for view; it takes a single positional argument } @@ -3255,6 +3281,245 @@ func runBrowsersComputerWriteClipboard(cmd *cobra.Command, args []string) error return b.ComputerWriteClipboard(cmd.Context(), BrowsersComputerWriteClipboardInput{Identifier: args[0], Text: text}) } +// Curl + +type BrowsersCurlInput struct { + Identifier string + URL string + Method string + Headers []string + Data string + DataFile string + TimeoutMs int + Encoding string + OutputFile string + Raw bool + Include bool + Silent bool + JSON bool +} + +func parseCurlHeaders(raw []string) map[string]string { + if len(raw) == 0 { + return nil + } + headers := make(map[string]string) + for _, h := range raw { + k, v, ok := strings.Cut(h, ":") + if !ok { + continue + } + headers[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return headers +} + +func (b BrowsersCmd) Curl(ctx context.Context, in BrowsersCurlInput) error { + if in.Raw || in.OutputFile != "" { + return b.curlRaw(ctx, in) + } + + // Read body from file if specified + body := in.Data + if in.DataFile != "" { + data, err := os.ReadFile(in.DataFile) + if err != nil { + return fmt.Errorf("reading data file: %w", err) + } + body = string(data) + } + + params := kernel.BrowserCurlParams{ + URL: in.URL, + Headers: parseCurlHeaders(in.Headers), + } + if in.Method != "" { + params.Method = kernel.BrowserCurlParamsMethod(in.Method) + } + if in.TimeoutMs != 0 { + params.TimeoutMs = kernel.Opt(int64(in.TimeoutMs)) + } + if in.Encoding != "" { + params.ResponseEncoding = kernel.BrowserCurlParamsResponseEncoding(in.Encoding) + } + if body != "" { + params.Body = kernel.Opt(body) + } + + result, err := b.browsers.Curl(ctx, in.Identifier, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.JSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + if in.Include { + fmt.Fprintf(os.Stdout, "HTTP %d\n", result.Status) + for k, vals := range result.Headers { + for _, v := range vals { + fmt.Fprintf(os.Stdout, "%s: %s\n", k, v) + } + } + fmt.Fprintln(os.Stdout) + } + + fmt.Fprint(os.Stdout, result.Body) + return nil +} + +func (b BrowsersCmd) curlRaw(ctx context.Context, in BrowsersCurlInput) error { + // Build the full URL for /curl/raw + baseURL := util.GetBaseURL() + method := in.Method + if method == "" { + method = "GET" + } + + params := neturl.Values{} + params.Set("url", in.URL) + params.Set("method", method) + params.Set("timeout_ms", fmt.Sprintf("%d", in.TimeoutMs)) + if in.Encoding != "" { + params.Set("response_encoding", in.Encoding) + } + + // Add custom headers as query params + for _, h := range in.Headers { + k, v, ok := strings.Cut(h, ":") + if !ok { + continue + } + params.Add("header", strings.TrimSpace(k)+": "+strings.TrimSpace(v)) + } + + rawURL := fmt.Sprintf("%s/browsers/%s/curl/raw?%s", + strings.TrimRight(baseURL, "/"), + in.Identifier, + params.Encode(), + ) + + // Read body from file if specified + body := in.Data + if in.DataFile != "" { + data, err := os.ReadFile(in.DataFile) + if err != nil { + return fmt.Errorf("reading data file: %w", err) + } + body = string(data) + } + + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, rawURL, bodyReader) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + // Get auth token from the SDK client's options + token := b.getAuthToken() + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Check for API-level errors (auth failures, session not found, etc.) + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("authentication error (%d): %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + if resp.StatusCode == http.StatusNotFound { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("not found (%d): %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + if in.Include { + fmt.Fprintf(os.Stdout, "HTTP %d\n", resp.StatusCode) + for k, vals := range resp.Header { + for _, v := range vals { + fmt.Fprintf(os.Stdout, "%s: %s\n", k, v) + } + } + fmt.Fprintln(os.Stdout) + } + + if in.OutputFile != "" { + f, err := os.Create(in.OutputFile) + if err != nil { + return fmt.Errorf("creating output file: %w", err) + } + defer f.Close() + _, err = io.Copy(f, resp.Body) + if err != nil { + return fmt.Errorf("writing output file: %w", err) + } + if !in.Silent { + pterm.Success.Printf("Saved response to %s\n", in.OutputFile) + } + return nil + } + + _, err = io.Copy(os.Stdout, resp.Body) + return err +} + +// getAuthToken retrieves the bearer token for raw HTTP requests. +func (b BrowsersCmd) getAuthToken() string { + if apiKey := os.Getenv("KERNEL_API_KEY"); apiKey != "" { + return apiKey + } + tokens, err := auth.LoadTokens() + if err != nil { + return "" + } + return tokens.AccessToken +} + +func runBrowsersCurl(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + + method, _ := cmd.Flags().GetString("request") + headers, _ := cmd.Flags().GetStringArray("header") + data, _ := cmd.Flags().GetString("data") + dataFile, _ := cmd.Flags().GetString("data-file") + timeout, _ := cmd.Flags().GetInt("timeout") + encoding, _ := cmd.Flags().GetString("encoding") + outputFile, _ := cmd.Flags().GetString("output") + raw, _ := cmd.Flags().GetBool("raw") + include, _ := cmd.Flags().GetBool("include") + silent, _ := cmd.Flags().GetBool("silent") + jsonOutput, _ := cmd.Flags().GetBool("json") + + b := BrowsersCmd{browsers: &svc} + return b.Curl(cmd.Context(), BrowsersCurlInput{ + Identifier: args[0], + URL: args[1], + Method: method, + Headers: headers, + Data: data, + DataFile: dataFile, + TimeoutMs: timeout, + Encoding: encoding, + OutputFile: outputFile, + Raw: raw, + Include: include, + Silent: silent, + JSON: jsonOutput, + }) +} + func truncateURL(url string, maxLen int) string { if len(url) <= maxLen { return url diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 2bb2c71..4195b3c 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -60,6 +60,7 @@ type FakeBrowsersService struct { UpdateFunc func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) DeleteFunc func(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) error DeleteByIDFunc func(ctx context.Context, id string, opts ...option.RequestOption) error + CurlFunc func(ctx context.Context, id string, body kernel.BrowserCurlParams, opts ...option.RequestOption) (*kernel.BrowserCurlResponse, error) LoadExtensionsFunc func(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error } @@ -105,6 +106,13 @@ func (f *FakeBrowsersService) DeleteByID(ctx context.Context, id string, opts .. return nil } +func (f *FakeBrowsersService) Curl(ctx context.Context, id string, body kernel.BrowserCurlParams, opts ...option.RequestOption) (*kernel.BrowserCurlResponse, error) { + if f.CurlFunc != nil { + return f.CurlFunc(ctx, id, body, opts...) + } + return &kernel.BrowserCurlResponse{}, nil +} + func (f *FakeBrowsersService) LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error { if f.LoadExtensionsFunc != nil { return f.LoadExtensionsFunc(ctx, id, body, opts...) diff --git a/cmd/proxies/check.go b/cmd/proxies/check.go index 5f47fae..a820ff4 100644 --- a/cmd/proxies/check.go +++ b/cmd/proxies/check.go @@ -20,7 +20,7 @@ func (p ProxyCmd) Check(ctx context.Context, in ProxyCheckInput) error { pterm.Info.Printf("Running health check on proxy %s...\n", in.ID) } - proxy, err := p.proxies.Check(ctx, in.ID) + proxy, err := p.proxies.Check(ctx, in.ID, kernel.ProxyCheckParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } diff --git a/cmd/proxies/check_test.go b/cmd/proxies/check_test.go index 8f24ecb..c130206 100644 --- a/cmd/proxies/check_test.go +++ b/cmd/proxies/check_test.go @@ -13,7 +13,7 @@ func TestProxyCheck_ShowsBypassHosts(t *testing.T) { buf := captureOutput(t) fake := &FakeProxyService{ - CheckFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { + CheckFunc: func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { return &kernel.ProxyCheckResponse{ ID: id, Name: "Proxy 1", diff --git a/cmd/proxies/common_test.go b/cmd/proxies/common_test.go index 48f13cf..df49b76 100644 --- a/cmd/proxies/common_test.go +++ b/cmd/proxies/common_test.go @@ -41,7 +41,7 @@ type FakeProxyService struct { GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyGetResponse, error) NewFunc func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error - CheckFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) + CheckFunc func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) } func (f *FakeProxyService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ProxyListResponse, error) { @@ -73,9 +73,9 @@ func (f *FakeProxyService) Delete(ctx context.Context, id string, opts ...option return nil } -func (f *FakeProxyService) Check(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { +func (f *FakeProxyService) Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) { if f.CheckFunc != nil { - return f.CheckFunc(ctx, id, opts...) + return f.CheckFunc(ctx, id, body, opts...) } return &kernel.ProxyCheckResponse{ID: id, Type: kernel.ProxyCheckResponseTypeDatacenter}, nil } diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index c8e7a38..bf55d9f 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -13,7 +13,7 @@ type ProxyService interface { Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProxyGetResponse, err error) New(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (res *kernel.ProxyNewResponse, err error) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) - Check(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error) + Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error) } // ProxyCmd handles proxy operations independent of cobra. diff --git a/go.mod b/go.mod index bbc599b..a90f587 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,8 @@ require ( golang.org/x/sync v0.19.0 ) +replace github.com/kernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20260410014529-98c58b154bb9 + require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect atomicgo.dev/cursor v0.2.0 // indirect diff --git a/go.sum b/go.sum index 2c777ed..0eec95f 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6 h1:RBlGCN3IagI0b+XrWsb5FOUV/18tniuL6oHFAb7MMHE= -github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= @@ -118,6 +116,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stainless-sdks/kernel-go v0.0.0-20260410014529-98c58b154bb9 h1:6swlSdr5UYmQbuM3HWM9+1FDMjVHeBqE+ZPUvkDr73I= +github.com/stainless-sdks/kernel-go v0.0.0-20260410014529-98c58b154bb9/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=