Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,9 @@ kernel proxies create --type datacenter --country US --protocol http --name "US
# Create a custom proxy
kernel proxies create --type custom --host proxy.example.com --port 8080 --username myuser --password mypass --name "My Custom Proxy"

# Create a custom TLS-terminating (MITM) proxy, trusting its CA bundle in the browser
kernel proxies create --type custom --host proxy.example.com --port 8080 --ca-bundle-file ./mitm-ca.pem --name "MITM Proxy"

# Create a residential proxy with location and OS
kernel proxies create --type residential --country US --city sanfrancisco --state CA --zip 94107 --asn AS15169 --os windows --name "SF Residential"

Expand Down
10 changes: 9 additions & 1 deletion cmd/auth_connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -839,7 +839,7 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli
return nil
}

tableData := pterm.TableData{{"Timestamp", "Type", "Status", "Step", "Browser Session", "Details"}}
tableData := pterm.TableData{{"Timestamp", "Type", "Status", "Step", "Browser Session", "Telemetry", "Details"}}
for _, e := range events {
details := e.ErrorMessage
if details == "" {
Expand All @@ -848,12 +848,20 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli
if details == "" && e.PreviousStatus != "" {
details = fmt.Sprintf("%s -> %s", e.PreviousStatus, e.Status)
}
// Telemetry only means something when the event has a browser session
// whose events `kernel browsers telemetry` could fetch, and when the API
// actually reported the field.
telemetry := "-"
if e.BrowserSessionID != "" && e.JSON.TelemetryCaptured.Valid() {
telemetry = lo.Ternary(e.TelemetryCaptured, "yes", "no")
}
tableData = append(tableData, []string{
util.FormatLocal(e.Timestamp),
string(e.Type),
string(e.Status),
string(e.Step),
util.OrDash(e.BrowserSessionID),
telemetry,
util.OrDash(details),
})
}
Expand Down
15 changes: 14 additions & 1 deletion cmd/auth_connections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -690,13 +690,23 @@ func TestSubmit_CanonicalAndLegacyAreMutuallyExclusive(t *testing.T) {
func TestTimeline_RendersEventsAndPagination(t *testing.T) {
buf := capturePtermOutput(t)
var captured kernel.AuthConnectionTimelineParams
// Decoded from JSON so the telemetry_captured field registers as present;
// the CLI distinguishes "reported false" from "not reported at all".
var loginEvent kernel.ManagedAuthTimelineEvent
require.NoError(t, json.Unmarshal([]byte(`{
"id": "e1",
"type": "login",
"status": "SUCCESS",
"browser_session_id": "browser_1",
"telemetry_captured": true
}`), &loginEvent))
fake := &FakeAuthConnectionService{
TimelineFunc: func(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) {
captured = query
// Return perPage+1 events so the CLI reports another page exists.
return &pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent]{
Items: []kernel.ManagedAuthTimelineEvent{
{ID: "e1", Type: "login", Status: "SUCCESS", BrowserSessionID: "browser_1"},
loginEvent,
{ID: "e2", Type: "reauth", Status: "FAILED", ErrorMessage: "boom"},
{ID: "e3", Type: "health_check", Status: "SUCCESS"},
},
Expand All @@ -714,6 +724,9 @@ func TestTimeline_RendersEventsAndPagination(t *testing.T) {
out := buf.String()
assert.Contains(t, out, "browser_1")
assert.Contains(t, out, "boom")
// Telemetry capture is reported for events that have a browser session.
assert.Contains(t, out, "Telemetry")
assert.Regexp(t, `browser_1.*yes`, out)
// The third event is truncated off the page.
assert.NotContains(t, out, "health_check")
assert.Contains(t, out, "Has more: yes")
Expand Down
5 changes: 5 additions & 0 deletions cmd/proxies/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ func getProxyCheckConfigRows(proxy *kernel.ProxyCheckResponse) [][]string {
hasPassword = "Yes"
}
rows = append(rows, []string{"Has Password", hasPassword})
hasCaBundle := "No"
if config.HasCaBundle {
hasCaBundle = "Yes"
}
rows = append(rows, []string{"Has CA Bundle", hasCaBundle})
}

return rows
Expand Down
84 changes: 69 additions & 15 deletions cmd/proxies/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package proxies
import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/kernel/cli/pkg/table"
Expand Down Expand Up @@ -133,6 +135,11 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error {
return fmt.Errorf("--port is required for custom proxy type")
}

caBundle, err := resolveCaBundle(in.CaBundle, in.CaBundleFile)
if err != nil {
return err
}

config := kernel.ProxyNewParamsConfigCustom{
Host: in.Host,
Port: int64(in.Port),
Expand All @@ -143,11 +150,18 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error {
if in.Password != "" {
config.Password = kernel.Opt(in.Password)
}
if caBundle != "" {
config.CaBundle = kernel.Opt(caBundle)
}
params.Config = kernel.ProxyNewParamsConfigUnion{
OfCustom: &config,
}
}

if proxyType != kernel.ProxyNewParamsTypeCustom && (in.CaBundle != "" || in.CaBundleFile != "") {
pterm.Warning.Println("--ca-bundle is only supported for custom proxies and will be ignored")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong flag named in CA warning

Low Severity

The ignored-flag warning always names --ca-bundle, even when only --ca-bundle-file was provided. The condition checks both inputs, so users who pass the file flag get a warning that refers to a different flag.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7ec80fc. Configure here.


// Set protocol (defaults to https if not specified)
if in.Protocol != "" {
// Validate and convert protocol
Expand Down Expand Up @@ -195,6 +209,11 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error {
}
rows = append(rows, []string{"Protocol", protocol})

// The CA bundle is write-only, so confirm the API stored it.
if proxy.Config.HasCaBundle {
rows = append(rows, []string{"Has CA Bundle", "Yes"})
}

table.PrintTableNoPad(rows, true)
return nil
}
Expand All @@ -216,31 +235,66 @@ func runProxiesCreate(cmd *cobra.Command, args []string) error {
port, _ := cmd.Flags().GetInt("port")
username, _ := cmd.Flags().GetString("username")
password, _ := cmd.Flags().GetString("password")
caBundle, _ := cmd.Flags().GetString("ca-bundle")
caBundleFile, _ := cmd.Flags().GetString("ca-bundle-file")
bypassHosts, _ := cmd.Flags().GetStringSlice("bypass-host")

output, _ := cmd.Flags().GetString("output")

svc := client.Proxies
p := ProxyCmd{proxies: &svc}
return p.Create(cmd.Context(), ProxyCreateInput{
Name: name,
Type: proxyType,
Protocol: protocol,
BypassHosts: bypassHosts,
Country: country,
City: city,
State: state,
Zip: zip,
ASN: asn,
OS: os,
Host: host,
Port: port,
Username: username,
Password: password,
Output: output,
Name: name,
Type: proxyType,
Protocol: protocol,
BypassHosts: bypassHosts,
Country: country,
City: city,
State: state,
Zip: zip,
ASN: asn,
OS: os,
Host: host,
Port: port,
Username: username,
Password: password,
CaBundle: caBundle,
CaBundleFile: caBundleFile,
Output: output,
})
}

// resolveCaBundle resolves the --ca-bundle / --ca-bundle-file inputs into a
// PEM-encoded CA certificate bundle. The two inputs are mutually exclusive
// (enforced by cobra); a file path of "-" reads stdin. It returns an empty
// string when neither input is set.
func resolveCaBundle(inline, file string) (string, error) {
data := inline
if file != "" {
var b []byte
var err error
if file == "-" {
b, err = io.ReadAll(os.Stdin)
} else {
b, err = os.ReadFile(file)
}
if err != nil {
return "", fmt.Errorf("failed to read CA bundle file: %w", err)
}
data = string(b)
}

data = strings.TrimSpace(data)
if data == "" {
return "", nil
}
if !strings.Contains(data, "-----BEGIN ") {
return "", fmt.Errorf("invalid CA bundle: expected PEM-encoded certificate data (a '-----BEGIN CERTIFICATE-----' block)")
}

return data, nil
}

func normalizeBypassHosts(hosts []string) []string {
normalized := make([]string, 0, len(hosts))
for _, host := range hosts {
Expand Down
113 changes: 113 additions & 0 deletions cmd/proxies/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ package proxies
import (
"context"
"errors"
"os"
"path/filepath"
"testing"

"github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestProxyCreate_Datacenter_Success(t *testing.T) {
Expand Down Expand Up @@ -225,6 +228,116 @@ func TestProxyCreate_Custom_Success(t *testing.T) {
assert.Contains(t, output, "Successfully created proxy")
}

const testCaBundlePEM = "-----BEGIN CERTIFICATE-----\nMIIBtestbundle\n-----END CERTIFICATE-----"

func TestProxyCreate_Custom_CaBundleInline(t *testing.T) {
buf := captureOutput(t)

fake := &FakeProxyService{
NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) {
customConfig := body.Config.OfCustom
assert.NotNil(t, customConfig)
assert.Equal(t, testCaBundlePEM, customConfig.CaBundle.Value)

resp := &kernel.ProxyNewResponse{
ID: "custom-new",
Type: kernel.ProxyNewResponseTypeCustom,
}
resp.Config.HasCaBundle = true
return resp, nil
},
}

p := ProxyCmd{proxies: fake}
err := p.Create(context.Background(), ProxyCreateInput{
Type: "custom",
Host: "proxy.example.com",
Port: 8080,
CaBundle: testCaBundlePEM,
})

assert.NoError(t, err)
assert.Contains(t, buf.String(), "Has CA Bundle")
}

func TestProxyCreate_Custom_CaBundleFromFile(t *testing.T) {
captureOutput(t)

path := filepath.Join(t.TempDir(), "ca.pem")
// Trailing whitespace is trimmed before the bundle is sent.
require.NoError(t, os.WriteFile(path, []byte(testCaBundlePEM+"\n"), 0o600))

var sent string
fake := &FakeProxyService{
NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) {
sent = body.Config.OfCustom.CaBundle.Value
return &kernel.ProxyNewResponse{ID: "custom-new", Type: kernel.ProxyNewResponseTypeCustom}, nil
},
}

p := ProxyCmd{proxies: fake}
err := p.Create(context.Background(), ProxyCreateInput{
Type: "custom",
Host: "proxy.example.com",
Port: 8080,
CaBundleFile: path,
})

assert.NoError(t, err)
assert.Equal(t, testCaBundlePEM, sent)
}

func TestProxyCreate_Custom_CaBundleFileMissing(t *testing.T) {
captureOutput(t)

p := ProxyCmd{proxies: &FakeProxyService{}}
err := p.Create(context.Background(), ProxyCreateInput{
Type: "custom",
Host: "proxy.example.com",
Port: 8080,
CaBundleFile: filepath.Join(t.TempDir(), "does-not-exist.pem"),
})

assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read CA bundle file")
}

func TestProxyCreate_Custom_CaBundleNotPEM(t *testing.T) {
captureOutput(t)

p := ProxyCmd{proxies: &FakeProxyService{}}
err := p.Create(context.Background(), ProxyCreateInput{
Type: "custom",
Host: "proxy.example.com",
Port: 8080,
CaBundle: "not a certificate",
})

assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid CA bundle: expected PEM-encoded certificate data")
}

func TestProxyCreate_CaBundleIgnoredForNonCustom(t *testing.T) {
buf := captureOutput(t)

fake := &FakeProxyService{
NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) {
assert.Nil(t, body.Config.OfCustom)
return &kernel.ProxyNewResponse{ID: "dc-new", Type: kernel.ProxyNewResponseTypeDatacenter}, nil
},
}

p := ProxyCmd{proxies: fake}
err := p.Create(context.Background(), ProxyCreateInput{
Type: "datacenter",
Country: "US",
CaBundle: testCaBundlePEM,
})

assert.NoError(t, err)
assert.Contains(t, buf.String(), "--ca-bundle is only supported for custom proxies")
}

func TestProxyCreate_Custom_MissingHost(t *testing.T) {
fake := &FakeProxyService{}

Expand Down
5 changes: 5 additions & 0 deletions cmd/proxies/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ func getProxyConfigRows(proxy *kernel.ProxyGetResponse) [][]string {
hasPassword = "Yes"
}
rows = append(rows, []string{"Has Password", hasPassword})
hasCaBundle := "No"
if config.HasCaBundle {
hasCaBundle = "Yes"
}
rows = append(rows, []string{"Has CA Bundle", hasCaBundle})
}

return rows
Expand Down
6 changes: 5 additions & 1 deletion cmd/proxies/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ func formatProxyConfig(proxy *kernel.ProxyListResponse) string {
if config.Username != "" {
auth = fmt.Sprintf(", Auth: %s", config.Username)
}
return fmt.Sprintf("%s:%d%s", config.Host, config.Port, auth)
caBundle := ""
if config.HasCaBundle {
caBundle = ", CA bundle"
}
return fmt.Sprintf("%s:%d%s%s", config.Host, config.Port, auth, caBundle)
}
}
return "-"
Expand Down
6 changes: 6 additions & 0 deletions cmd/proxies/proxies.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ Examples:
# Create a residential proxy with location
kernel proxies create --type residential --country US --city sanfrancisco --state CA

# Create a custom TLS-terminating (MITM) proxy with its CA bundle
kernel proxies create --type custom --host proxy.example.com --port 8080 --ca-bundle-file ./mitm-ca.pem

# Create a proxy with bypass hosts
kernel proxies create --type datacenter --country US --bypass-host localhost,internal.service.local`,
RunE: runProxiesCreate,
Expand Down Expand Up @@ -116,6 +119,9 @@ func init() {
proxiesCreateCmd.Flags().Int("port", 0, "Proxy port")
proxiesCreateCmd.Flags().String("username", "", "Username for proxy authentication")
proxiesCreateCmd.Flags().String("password", "", "Password for proxy authentication")
proxiesCreateCmd.Flags().String("ca-bundle", "", "PEM-encoded CA certificate bundle for a TLS-terminating (MITM) custom proxy")
proxiesCreateCmd.Flags().String("ca-bundle-file", "", "Read the PEM-encoded CA certificate bundle from a file (use '-' for stdin)")
proxiesCreateCmd.MarkFlagsMutuallyExclusive("ca-bundle", "ca-bundle-file")
proxiesCreateCmd.Flags().StringSlice("bypass-host", nil, "Hostname(s) to bypass proxy and connect directly (repeat or comma-separated)")

// Update flags
Expand Down
Loading
Loading