diff --git a/README.md b/README.md index 4dbc4d1..c54f458 100644 --- a/README.md +++ b/README.md @@ -503,7 +503,7 @@ Per-category updates are partial — only categories you name are changed; other - `kernel proxies create` - Create a new proxy configuration - `--output json`, `-o json` - Output raw JSON object - - `--name ` - Proxy configuration name + - `--name ` - Proxy configuration name (required) - `--type ` - Proxy type: datacenter, isp, residential, mobile, custom (required) - `--protocol ` - Protocol to use (default: https) - `--country ` - ISO 3166 country code or "EU" (location-based types) @@ -516,6 +516,7 @@ Per-category updates are partial — only categories you name are changed; other - `--port ` - Proxy port (custom; required) - `--username ` - Username for proxy authentication (custom) - `--password ` - Password for proxy authentication (custom) + - `--ca-bundle ` - Path to a PEM-encoded CA certificate bundle (custom TLS-terminating proxies) - `kernel proxies update ` - Rename a proxy configuration (recreate the proxy to change its type or config) - `--name ` - New proxy name (required) @@ -873,6 +874,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 proxy with a CA bundle +kernel proxies create --type custom --host proxy.example.com --port 8080 --ca-bundle ./proxy-ca.pem --name "My TLS 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" diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index 6611387..cd5011c 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -3,6 +3,7 @@ package proxies import ( "context" "fmt" + "os" "strings" "github.com/kernel/cli/pkg/table" @@ -33,14 +34,14 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { default: return fmt.Errorf("invalid proxy type: %s", in.Type) } + if in.Name == "" { + return fmt.Errorf("--name is required") + } params := kernel.ProxyNewParams{ + Name: kernel.Opt(in.Name), Type: proxyType, } - - if in.Name != "" { - params.Name = kernel.Opt(in.Name) - } if len(in.BypassHosts) > 0 { params.BypassHosts = normalizeBypassHosts(in.BypassHosts) } @@ -143,6 +144,16 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { if in.Password != "" { config.Password = kernel.Opt(in.Password) } + if in.CaBundleFile != "" { + caBundle, err := os.ReadFile(in.CaBundleFile) + if err != nil { + return fmt.Errorf("failed to read CA bundle file %q: %w", in.CaBundleFile, err) + } + if len(caBundle) == 0 { + return fmt.Errorf("CA bundle file %q is empty", in.CaBundleFile) + } + config.CaBundle = kernel.Opt(string(caBundle)) + } params.Config = kernel.ProxyNewParamsConfigUnion{ OfCustom: &config, } @@ -216,6 +227,7 @@ 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") bypassHosts, _ := cmd.Flags().GetStringSlice("bypass-host") output, _ := cmd.Flags().GetString("output") @@ -223,21 +235,22 @@ func runProxiesCreate(cmd *cobra.Command, args []string) error { 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, + CaBundleFile: caBundle, + Output: output, }) } diff --git a/cmd/proxies/create_test.go b/cmd/proxies/create_test.go index e985015..2595051 100644 --- a/cmd/proxies/create_test.go +++ b/cmd/proxies/create_test.go @@ -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) { @@ -133,6 +136,7 @@ func TestProxyCreate_Residential_CityWithoutCountry(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Residential Proxy", Type: "residential", City: "sanfrancisco", // Missing country @@ -147,6 +151,7 @@ func TestProxyCreate_Residential_InvalidOS(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Residential Proxy", Type: "residential", OS: "linux", // Invalid OS }) @@ -155,6 +160,16 @@ func TestProxyCreate_Residential_InvalidOS(t *testing.T) { assert.Contains(t, err.Error(), "invalid OS value: linux (must be windows, macos, or android)") } +func TestProxyCreate_MissingName(t *testing.T) { + p := ProxyCmd{proxies: &FakeProxyService{}} + err := p.Create(context.Background(), ProxyCreateInput{ + Type: "datacenter", + Country: "US", + }) + + assert.EqualError(t, err, "--name is required") +} + func TestProxyCreate_Mobile_Success(t *testing.T) { buf := captureOutput(t) @@ -225,11 +240,69 @@ func TestProxyCreate_Custom_Success(t *testing.T) { assert.Contains(t, output, "Successfully created proxy") } +func TestProxyCreate_Custom_WithCaBundle(t *testing.T) { + caBundle := "-----BEGIN CERTIFICATE-----\ntest certificate\n-----END CERTIFICATE-----\n" + caBundlePath := filepath.Join(t.TempDir(), "proxy-ca.pem") + require.NoError(t, os.WriteFile(caBundlePath, []byte(caBundle), 0o600)) + + 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.True(t, customConfig.CaBundle.Valid()) + assert.Equal(t, caBundle, customConfig.CaBundle.Value) + + return &kernel.ProxyNewResponse{ID: "custom-ca-new", Type: kernel.ProxyNewResponseTypeCustom}, nil + }, + } + + err := ProxyCmd{proxies: fake}.Create(context.Background(), ProxyCreateInput{ + Name: "Custom Proxy", + Type: "custom", + Host: "proxy.example.com", + Port: 8080, + CaBundleFile: caBundlePath, + }) + + assert.NoError(t, err) +} + +func TestProxyCreate_Custom_CaBundleFileReadError(t *testing.T) { + p := ProxyCmd{proxies: &FakeProxyService{}} + err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Custom Proxy", + Type: "custom", + Host: "proxy.example.com", + Port: 8080, + CaBundleFile: "/does/not/exist/proxy-ca.pem", + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read CA bundle file") +} + +func TestProxyCreate_Custom_EmptyCaBundleFile(t *testing.T) { + caBundlePath := filepath.Join(t.TempDir(), "proxy-ca.pem") + require.NoError(t, os.WriteFile(caBundlePath, nil, 0o600)) + + p := ProxyCmd{proxies: &FakeProxyService{}} + err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Custom Proxy", + Type: "custom", + Host: "proxy.example.com", + Port: 8080, + CaBundleFile: caBundlePath, + }) + + assert.EqualError(t, err, "CA bundle file \""+caBundlePath+"\" is empty") +} + func TestProxyCreate_Custom_MissingHost(t *testing.T) { fake := &FakeProxyService{} p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Custom Proxy", Type: "custom", Port: 8080, // Missing required host @@ -244,6 +317,7 @@ func TestProxyCreate_Custom_MissingPort(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Custom Proxy", Type: "custom", Host: "proxy.example.com", // Missing required port (will be 0) @@ -289,6 +363,7 @@ func TestProxyCreate_Protocol_Valid(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Test Proxy", Type: "datacenter", Country: "US", Protocol: tt.protocol, @@ -303,6 +378,7 @@ func TestProxyCreate_Protocol_Invalid(t *testing.T) { fake := &FakeProxyService{} p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Test Proxy", Type: "datacenter", Country: "US", Protocol: "ftp", @@ -325,6 +401,7 @@ func TestProxyCreate_BypassHosts_Normalized(t *testing.T) { p := ProxyCmd{proxies: fake} err := p.Create(context.Background(), ProxyCreateInput{ + Name: "Test Proxy", Type: "datacenter", Country: "US", BypassHosts: []string{" localhost ", "", "internal.service.local"}, diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 8820939..7642001 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -46,13 +46,16 @@ Examples: kernel proxies create --type datacenter --country US --name "US Datacenter" # Create a custom proxy - kernel proxies create --type custom --host proxy.example.com --port 8080 --username myuser --password mypass + kernel proxies create --type custom --host proxy.example.com --port 8080 --username myuser --password mypass --name "My Custom Proxy" + + # Create a custom TLS-terminating proxy with a CA bundle + kernel proxies create --type custom --host proxy.example.com --port 8080 --ca-bundle ./proxy-ca.pem --name "My TLS Proxy" # Create a residential proxy with location - kernel proxies create --type residential --country US --city sanfrancisco --state CA + kernel proxies create --type residential --country US --city sanfrancisco --state CA --name "SF Residential" # Create a proxy with bypass hosts - kernel proxies create --type datacenter --country US --bypass-host localhost,internal.service.local`, + kernel proxies create --type datacenter --country US --bypass-host localhost,internal.service.local --name "Internal Services"`, RunE: runProxiesCreate, } @@ -96,7 +99,8 @@ func init() { addJSONOutputFlag(proxiesCreateCmd) // Add flags for create command - proxiesCreateCmd.Flags().String("name", "", "Proxy configuration name") + proxiesCreateCmd.Flags().String("name", "", "Proxy configuration name (required)") + _ = proxiesCreateCmd.MarkFlagRequired("name") proxiesCreateCmd.Flags().String("type", "", "Proxy type (datacenter|isp|residential|mobile|custom)") _ = proxiesCreateCmd.MarkFlagRequired("type") proxiesCreateCmd.Flags().String("protocol", "https", "Protocol to use for the proxy connection (http|https)") @@ -116,6 +120,7 @@ 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", "", "Path to a PEM-encoded CA certificate bundle for a TLS-terminating custom proxy") proxiesCreateCmd.Flags().StringSlice("bypass-host", nil, "Hostname(s) to bypass proxy and connect directly (repeat or comma-separated)") // Update flags diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index ca44ec2..7ca96f9 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -52,11 +52,12 @@ type ProxyCreateInput struct { ASN string OS string // Custom proxy config - Host string - Port int - Username string - Password string - Output string + Host string + Port int + Username string + Password string + CaBundleFile string + Output string } type ProxyUpdateInput struct { diff --git a/go.mod b/go.mod index f125d5c..54ec0a7 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.83.1-0.20260724213320-12b3ec62f63e + github.com/kernel/kernel-go-sdk v0.85.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index b7e971f..a872a5b 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ 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.83.1-0.20260724213320-12b3ec62f63e h1:Pe1fRnIM4Zf7ddVg/wd+qTD0X4t2NmXRIeXyY98AILI= -github.com/kernel/kernel-go-sdk v0.83.1-0.20260724213320-12b3ec62f63e/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.85.0 h1:rACZOx5dcjO4rasFmMoh2GS14w4yRBbYXxb92eGvD2E= +github.com/kernel/kernel-go-sdk v0.85.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=