diff --git a/README.md b/README.md index c54f458..101b06e 100644 --- a/README.md +++ b/README.md @@ -510,7 +510,7 @@ Per-category updates are partial — only categories you name are changed; other - `--city ` - City name (no spaces, e.g. sanfrancisco) (residential, mobile; requires `--country`) - `--state ` - Two-letter state code (residential, mobile) - `--zip ` - US ZIP code (residential) - - `--asn ` - Autonomous system number (e.g., AS15169) (residential) + - `--asn ` - Autonomous system number, e.g. AS6079 (isp, residential). The ISP pool is a fixed set of static IPs, so only ASNs present in it can be requested; an unsupported value is rejected with the list of available ASNs. - `--os ` - Operating system: windows, macos, android (residential) - `--host ` - Proxy host (custom; required) - `--port ` - Proxy port (custom; required) @@ -880,6 +880,9 @@ kernel proxies create --type custom --host proxy.example.com --port 8080 --ca-bu # 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" +# Create an ISP proxy pinned to a specific ASN +kernel proxies create --type isp --asn AS6079 --name "RCN ISP" + # Create a mobile proxy kernel proxies create --type mobile --country US --city sanfrancisco --name "US Mobile" diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index cd5011c..cba793f 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -49,6 +49,9 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { // Build config based on type switch proxyType { case kernel.ProxyNewParamsTypeDatacenter: + if in.ASN != "" { + return fmt.Errorf("--asn is not supported for datacenter proxies") + } config := kernel.ProxyNewParamsConfigDatacenter{} if in.Country != "" { config.Country = kernel.Opt(in.Country) @@ -62,6 +65,12 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { if in.Country != "" { config.Country = kernel.Opt(in.Country) } + if in.ASN != "" { + // The generated ISP config has no Asn field until Stainless regenerates + // against the spec. Send it as an extra field so the flag works against + // the deployed API now; replace with config.Asn on the next SDK bump. + config.SetExtraFields(map[string]any{"asn": in.ASN}) + } params.Config = kernel.ProxyNewParamsConfigUnion{ OfIsp: &config, } @@ -109,8 +118,11 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { if in.City != "" && in.Country == "" { return fmt.Errorf("--country is required when --city is specified") } - if in.Zip != "" || in.ASN != "" { - pterm.Warning.Println("--zip and --asn are not supported for mobile proxies and will be ignored") + if in.Zip != "" { + return fmt.Errorf("--zip is not supported for mobile proxies") + } + if in.ASN != "" { + return fmt.Errorf("--asn is not supported for mobile proxies") } if in.Country != "" { @@ -127,6 +139,9 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { } case kernel.ProxyNewParamsTypeCustom: + if in.ASN != "" { + return fmt.Errorf("--asn is not supported for custom proxies; the upstream proxy determines the egress network") + } if in.Host == "" { return fmt.Errorf("--host is required for custom proxy type") } diff --git a/cmd/proxies/create_asn_test.go b/cmd/proxies/create_asn_test.go new file mode 100644 index 0000000..d38d4b5 --- /dev/null +++ b/cmd/proxies/create_asn_test.go @@ -0,0 +1,141 @@ +package proxies + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The ASN currently travels as an extra field rather than a generated one, so assert on +// the serialized request: that is what the API sees, and it stays valid once the field +// becomes typed. +func ispConfigJSON(t *testing.T, body kernel.ProxyNewParams) map[string]any { + t.Helper() + + raw, err := json.Marshal(body) + require.NoError(t, err) + + var decoded struct { + Config map[string]any `json:"config"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + return decoded.Config +} + +func TestProxyCreate_ISP_SendsASN(t *testing.T) { + captureOutput(t) + + called := false + fake := &FakeProxyService{ + NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { + called = true + assert.Equal(t, "AS6079", ispConfigJSON(t, body)["asn"]) + + return &kernel.ProxyNewResponse{ID: "isp-new", Name: "RCN ISP", Type: kernel.ProxyNewResponseTypeIsp}, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Create(context.Background(), ProxyCreateInput{ + Name: "RCN ISP", + Type: "isp", + ASN: "AS6079", + }) + + require.NoError(t, err) + assert.True(t, called, "expected the create request to be sent") +} + +func TestProxyCreate_ISP_SendsASNAlongsideCountry(t *testing.T) { + captureOutput(t) + + fake := &FakeProxyService{ + NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { + config := ispConfigJSON(t, body) + assert.Equal(t, "AS6079", config["asn"]) + assert.Equal(t, "US", config["country"], "the extra field must not displace country") + + return &kernel.ProxyNewResponse{ID: "isp-new", Name: "RCN ISP", Type: kernel.ProxyNewResponseTypeIsp}, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Create(context.Background(), ProxyCreateInput{ + Name: "RCN ISP", + Type: "isp", + Country: "US", + ASN: "AS6079", + }) + require.NoError(t, err) +} + +// Omitting --asn must not start sending an empty ASN, which the API would reject as +// malformed rather than treating as "no preference". +func TestProxyCreate_ISP_OmitsEmptyASN(t *testing.T) { + captureOutput(t) + + fake := &FakeProxyService{ + NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { + _, present := ispConfigJSON(t, body)["asn"] + assert.False(t, present, "asn should be absent when not requested") + + return &kernel.ProxyNewResponse{ID: "isp-new", Name: "Any ISP", Type: kernel.ProxyNewResponseTypeIsp}, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Create(context.Background(), ProxyCreateInput{Name: "Any ISP", Type: "isp"}) + require.NoError(t, err) +} + +// The flag is registered on every type, so the types that cannot honour it have to say so +// rather than drop it and hand back a proxy on an arbitrary network. +func TestProxyCreate_ASNRejectedForUnsupportedTypes(t *testing.T) { + tests := []struct { + proxyType string + input ProxyCreateInput + }{ + {"datacenter", ProxyCreateInput{Name: "dc", Type: "datacenter", ASN: "AS6079"}}, + {"mobile", ProxyCreateInput{Name: "mob", Type: "mobile", Country: "US", ASN: "AS6079"}}, + {"custom", ProxyCreateInput{Name: "cus", Type: "custom", Host: "proxy.example.com", Port: 8080, ASN: "AS6079"}}, + } + + for _, tt := range tests { + t.Run(tt.proxyType, func(t *testing.T) { + captureOutput(t) + + fake := &FakeProxyService{ + NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { + require.Failf(t, "unexpected request", "create should not be attempted for %s with --asn", tt.proxyType) + return nil, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Create(context.Background(), tt.input) + require.Error(t, err) + assert.Contains(t, err.Error(), "--asn is not supported") + }) + } +} + +func TestProxyCreate_ZipRejectedForMobile(t *testing.T) { + captureOutput(t) + + fake := &FakeProxyService{ + NewFunc: func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error) { + require.Fail(t, "unexpected request", "create should not be attempted for mobile with --zip") + return nil, nil + }, + } + + p := ProxyCmd{proxies: fake} + err := p.Create(context.Background(), ProxyCreateInput{Name: "mob", Type: "mobile", Country: "US", Zip: "94107"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--zip is not supported") +} diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 7642001..e707afd 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -110,7 +110,7 @@ func init() { proxiesCreateCmd.Flags().String("city", "", "City name (no spaces, e.g. sanfrancisco)") proxiesCreateCmd.Flags().String("state", "", "Two-letter state code") proxiesCreateCmd.Flags().String("zip", "", "US ZIP code") - proxiesCreateCmd.Flags().String("asn", "", "Autonomous system number (e.g. AS15169)") + proxiesCreateCmd.Flags().String("asn", "", "Autonomous system number, e.g. AS6079 (isp, residential)") // OS flag (residential) proxiesCreateCmd.Flags().String("os", "", "Operating system (windows|macos|android)")