diff --git a/.changeset/hyperdrive-vpc-service-support.md b/.changeset/hyperdrive-vpc-service-support.md new file mode 100644 index 0000000000..3380599ead --- /dev/null +++ b/.changeset/hyperdrive-vpc-service-support.md @@ -0,0 +1,13 @@ +--- +"wrangler": minor +--- + +Add Workers VPC service support for Hyperdrive origins + +Hyperdrive configs can now connect to databases through Workers VPC services using the `--service-id` option: + +```bash +wrangler hyperdrive create my-config --service-id --database mydb --user myuser --password mypassword +``` + +This enables Hyperdrive to connect to databases hosted in private networks that are accessible through Workers VPC TCP services. diff --git a/.changeset/vpc-cert-verification-mode.md b/.changeset/vpc-cert-verification-mode.md new file mode 100644 index 0000000000..dc0784f721 --- /dev/null +++ b/.changeset/vpc-cert-verification-mode.md @@ -0,0 +1,19 @@ +--- +"wrangler": minor +--- + +Add `--cert-verification-mode` option to `wrangler vpc service create` and `wrangler vpc service update` + +You can now configure the TLS certificate verification mode when creating or updating a VPC connectivity service. This controls how the connection to the origin server verifies TLS certificates. + +Available modes: + +- `verify_full` (default) -- verify certificate chain and hostname +- `verify_ca` -- verify certificate chain only, skip hostname check +- `disabled` -- do not verify the server certificate at all + +```sh +wrangler vpc service create my-service --type tcp --tcp-port 5432 --ipv4 10.0.0.1 --tunnel-id --cert-verification-mode verify_ca +``` + +This applies to both TCP and HTTP VPC service types. When omitted, the default `verify_full` behavior is used. diff --git a/.changeset/vpc-tcp-service-support.md b/.changeset/vpc-tcp-service-support.md new file mode 100644 index 0000000000..7d160bf1a3 --- /dev/null +++ b/.changeset/vpc-tcp-service-support.md @@ -0,0 +1,13 @@ +--- +"wrangler": minor +--- + +Add TCP service type support for Workers VPC + +You can now create TCP services in Workers VPC using the `--type tcp` option: + +```bash +wrangler vpc service create my-db --type tcp --tcp-port 5432 --ipv4 10.0.0.1 --tunnel-id +``` + +This enables exposing TCP-based services like PostgreSQL, MySQL, and other database servers through Workers VPC. diff --git a/packages/wrangler/src/__tests__/hyperdrive.test.ts b/packages/wrangler/src/__tests__/hyperdrive.test.ts index 95b62c6658..01bcb73d14 100644 --- a/packages/wrangler/src/__tests__/hyperdrive.test.ts +++ b/packages/wrangler/src/__tests__/hyperdrive.test.ts @@ -712,6 +712,125 @@ describe("hyperdrive commands", () => { `); }); + it("should create a hyperdrive config with a VPC service ID", async ({ + expect, + }) => { + const reqProm = mockHyperdriveCreate(); + await runWrangler( + "hyperdrive create test123 --service-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --database=neondb --user=test --password=password" + ); + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "name": "test123", + "origin": { + "database": "neondb", + "password": "password", + "scheme": "postgresql", + "service_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": "test", + }, + } + `); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating 'test123' + ✅ Created new Hyperdrive PostgreSQL config: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + To access your new Hyperdrive Config in your Worker, add the following snippet to your configuration file: + { + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + ] + }" + `); + }); + + it("should create a hyperdrive config with a VPC service ID and mysql scheme", async ({ + expect, + }) => { + const reqProm = mockHyperdriveCreate(); + await runWrangler( + "hyperdrive create test123 --service-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --database=mydb --user=test --password=password --origin-scheme=mysql" + ); + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "name": "test123", + "origin": { + "database": "mydb", + "password": "password", + "scheme": "mysql", + "service_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": "test", + }, + } + `); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating 'test123' + ✅ Created new Hyperdrive MySQL config: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + To access your new Hyperdrive Config in your Worker, add the following snippet to your configuration file: + { + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + ] + }" + `); + }); + + it("should reject a create hyperdrive config with --service-id and --origin-host", async ({ + expect, + }) => { + await expect(() => + runWrangler( + "hyperdrive create test123 --service-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --origin-host=example.com --database=neondb --user=test --password=password" + ) + ).rejects.toThrow(); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Arguments service-id and origin-host are mutually exclusive + + " + `); + }); + + it("should reject a create hyperdrive config with --service-id and --connection-string", async ({ + expect, + }) => { + await expect(() => + runWrangler( + "hyperdrive create test123 --service-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --connection-string=postgresql://user:password@example.com:5432/neondb" + ) + ).rejects.toThrow(); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Arguments service-id and connection-string are mutually exclusive + + " + `); + }); + + it("should reject a create hyperdrive config with --service-id and --access-client-id", async ({ + expect, + }) => { + await expect(() => + runWrangler( + "hyperdrive create test123 --service-id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --access-client-id=test.access --access-client-secret=secret --database=neondb --user=test --password=password" + ) + ).rejects.toThrow(); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Arguments service-id and access-client-id are mutually exclusive + + " + `); + }); + it("should reject a create hyperdrive over access command if access client ID is set but not access client secret", async ({ expect, }) => { @@ -1530,6 +1649,79 @@ describe("hyperdrive commands", () => { `); }); + it("should handle updating a hyperdrive config to use a VPC service ID", async ({ + expect, + }) => { + const reqProm = mockHyperdriveUpdate(); + await runWrangler( + "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --service-id=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "origin": { + "service_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + }, + } + `); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "test123", + "origin": { + "scheme": "postgresql", + "database": "neondb", + "user": "test", + "service_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + }, + "origin_connection_limit": 25 + }" + `); + }); + + it("should handle updating a hyperdrive config to use a VPC service ID with database credentials", async ({ + expect, + }) => { + const reqProm = mockHyperdriveUpdate(); + await runWrangler( + "hyperdrive update xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx --service-id=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy --database=newdb --origin-user=newuser --origin-password='passw0rd!'" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "origin": { + "database": "newdb", + "password": "passw0rd!", + "service_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "user": "newuser", + }, + } + `); + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Updating 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ✅ Updated xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Hyperdrive config + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "test123", + "origin": { + "scheme": "postgresql", + "database": "newdb", + "user": "newuser", + "service_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" + }, + "origin_connection_limit": 25 + }" + `); + }); + it("should throw an exception when updating a hyperdrive config's origin but neither port nor access credentials are provided", async ({ expect, }) => { @@ -1928,7 +2120,12 @@ function mockHyperdriveUpdate( // eslint-disable-next-line @typescript-eslint/no-explicit-any } = reqBody.origin as any; origin = { ...origin, ...reqOrigin }; - if (reqOrigin.port) { + if (reqOrigin.service_id) { + delete origin.host; + delete origin.port; + delete origin.access_client_id; + delete origin.access_client_secret; + } else if (reqOrigin.port) { delete origin.access_client_id; delete origin.access_client_secret; } else if ( @@ -1977,18 +2174,21 @@ function mockHyperdriveCreate(): Promise { resolve(reqBody); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reqOrigin = reqBody.origin as any; return HttpResponse.json( createFetchResult( { id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", name: reqBody.name, origin: { - host: reqBody.origin.host, - port: reqBody.origin.port, - database: reqBody.origin.database, - scheme: reqBody.origin.scheme, - user: reqBody.origin.user, - access_client_id: reqBody.origin.access_client_id, + host: reqOrigin.host, + port: reqOrigin.port, + database: reqOrigin.database, + scheme: reqOrigin.scheme, + user: reqOrigin.user, + access_client_id: reqOrigin.access_client_id, + service_id: reqOrigin.service_id, }, caching: reqBody.caching, mtls: reqBody.mtls, diff --git a/packages/wrangler/src/__tests__/vpc.test.ts b/packages/wrangler/src/__tests__/vpc.test.ts index a07b8a3a30..90bc360d72 100644 --- a/packages/wrangler/src/__tests__/vpc.test.ts +++ b/packages/wrangler/src/__tests__/vpc.test.ts @@ -2,7 +2,11 @@ import { http, HttpResponse } from "msw"; // eslint-disable-next-line no-restricted-imports import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ServiceType } from "../vpc/index"; -import { validateHostname, validateRequest } from "../vpc/validation"; +import { + extractPortFromHostname, + validateHostname, + validateRequest, +} from "../vpc/validation"; import { endEventLoop } from "./helpers/end-event-loop"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; @@ -324,6 +328,499 @@ describe("vpc service commands", () => { Tunnel ID: 550e8400-e29b-41d4-a716-446655440002" `); }); + + it("should handle creating a TCP service with IPv4", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-tcp-db --type tcp --tcp-port 5432 --ipv4 10.0.0.5 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "host": { + "ipv4": "10.0.0.5", + "network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", + }, + }, + "name": "test-tcp-db", + "tcp_port": 5432, + "type": "tcp", + } + `); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating VPC service 'test-tcp-db' + ✅ Created VPC service: service-uuid + Name: test-tcp-db + Type: tcp + TCP Port: 5432 + IPv4: 10.0.0.5 + Tunnel ID: 550e8400-e29b-41d4-a716-446655440000" + `); + }); + + it("should handle creating a TCP service with hostname", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-tcp-hostname --type tcp --tcp-port 3306 --hostname mysql.internal --tunnel-id 550e8400-e29b-41d4-a716-446655440001 --resolver-ips 10.0.0.1" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "host": { + "hostname": "mysql.internal", + "resolver_network": { + "resolver_ips": [ + "10.0.0.1", + ], + "tunnel_id": "550e8400-e29b-41d4-a716-446655440001", + }, + }, + "name": "test-tcp-hostname", + "tcp_port": 3306, + "type": "tcp", + } + `); + }); + + it("should reject TCP service creation without --tcp-port", async () => { + await expect(() => + runWrangler( + "vpc service create test-tcp-no-port --type tcp --ipv4 10.0.0.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + ) + ).rejects.toThrow("TCP services require a --tcp-port to be specified"); + }); + + it("should handle updating a TCP service", async () => { + const reqProm = mockWvpcServiceUpdate(); + await runWrangler( + "vpc service update service-uuid --name test-tcp-updated --type tcp --tcp-port 5433 --ipv4 10.0.0.6 --tunnel-id 550e8400-e29b-41d4-a716-446655440001" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "host": { + "ipv4": "10.0.0.6", + "network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440001", + }, + }, + "name": "test-tcp-updated", + "tcp_port": 5433, + "type": "tcp", + } + `); + }); + + it("should handle getting a TCP service", async () => { + mockWvpcTcpServiceGet(); + await runWrangler("vpc service get tcp-service-uuid"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🔍 Getting VPC service 'tcp-service-uuid' + ✅ Retrieved VPC service: tcp-service-uuid + Name: test-tcp-service + Type: tcp + TCP Port: 5432 + App Protocol: postgresql + IPv4: 10.0.0.5 + Tunnel ID: 550e8400-e29b-41d4-a716-446655440000 + Created: 1/1/2024, 12:00:00 AM + Modified: 1/1/2024, 12:00:00 AM" + `); + }); + + it("should handle listing TCP services with port in table", async () => { + mockWvpcTcpServiceList(); + await runWrangler("vpc service list"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 📋 Listing VPC services + ┌─┬─┬─┬─┬─┬─┬─┬─┐ + │ id │ name │ type │ ports │ host │ tunnel │ created │ modified │ + ├─┼─┼─┼─┼─┼─┼─┼─┤ + │ tcp-service-uuid │ test-tcp-service │ tcp │ TCP:5432 (postgresql) │ 10.0.0.5 │ 550e8400... │ 1/1/2024, 12:00:00 AM │ 1/1/2024, 12:00:00 AM │ + └─┴─┴─┴─┴─┴─┴─┴─┘" + `); + }); + + it("should handle creating a TCP service with --app-protocol postgresql", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-pg --type tcp --tcp-port 5432 --app-protocol postgresql --ipv4 10.0.0.5 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "app_protocol": "postgresql", + "host": { + "ipv4": "10.0.0.5", + "network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", + }, + }, + "name": "test-pg", + "tcp_port": 5432, + "type": "tcp", + } + `); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating VPC service 'test-pg' + ✅ Created VPC service: service-uuid + Name: test-pg + Type: tcp + TCP Port: 5432 + App Protocol: postgresql + IPv4: 10.0.0.5 + Tunnel ID: 550e8400-e29b-41d4-a716-446655440000" + `); + }); + + it("should handle creating a TCP service with --app-protocol mysql", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-mysql --type tcp --tcp-port 3306 --app-protocol mysql --ipv4 10.0.0.6 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "app_protocol": "mysql", + "host": { + "ipv4": "10.0.0.6", + "network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", + }, + }, + "name": "test-mysql", + "tcp_port": 3306, + "type": "tcp", + } + `); + }); + + it("should reject --app-protocol with invalid value", async () => { + await expect(() => + runWrangler( + "vpc service create test-bad-proto --type tcp --tcp-port 5432 --app-protocol redis --ipv4 10.0.0.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + ) + ).rejects.toThrow(); + expect(std.err).toContain("Invalid values"); + expect(std.err).toContain( + 'Argument: app-protocol, Given: "redis", Choices: "postgresql", "mysql"' + ); + }); + + it("should handle updating a TCP service with --app-protocol", async () => { + const reqProm = mockWvpcServiceUpdate(); + await runWrangler( + "vpc service update service-uuid --name test-pg-updated --type tcp --tcp-port 5432 --app-protocol postgresql --ipv4 10.0.0.5 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "app_protocol": "postgresql", + "host": { + "ipv4": "10.0.0.5", + "network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", + }, + }, + "name": "test-pg-updated", + "tcp_port": 5432, + "type": "tcp", + } + `); + }); + + it("should handle creating a TCP service with --cert-verification-mode verify_ca", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-tcp-tls --type tcp --tcp-port 5432 --ipv4 10.0.0.5 --tunnel-id 550e8400-e29b-41d4-a716-446655440000 --cert-verification-mode verify_ca" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "host": { + "ipv4": "10.0.0.5", + "network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", + }, + }, + "name": "test-tcp-tls", + "tcp_port": 5432, + "tls_settings": { + "cert_verification_mode": "verify_ca", + }, + "type": "tcp", + } + `); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating VPC service 'test-tcp-tls' + ✅ Created VPC service: service-uuid + Name: test-tcp-tls + Type: tcp + TCP Port: 5432 + Cert Verification Mode: verify_ca + IPv4: 10.0.0.5 + Tunnel ID: 550e8400-e29b-41d4-a716-446655440000" + `); + }); + + it("should handle creating an HTTP service with --cert-verification-mode disabled", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-http-tls --type http --http-port 80 --ipv4 10.0.0.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000 --cert-verification-mode disabled" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "host": { + "ipv4": "10.0.0.1", + "network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", + }, + }, + "http_port": 80, + "name": "test-http-tls", + "tls_settings": { + "cert_verification_mode": "disabled", + }, + "type": "http", + } + `); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating VPC service 'test-http-tls' + ✅ Created VPC service: service-uuid + Name: test-http-tls + Type: http + HTTP Port: 80 + Cert Verification Mode: disabled + IPv4: 10.0.0.1 + Tunnel ID: 550e8400-e29b-41d4-a716-446655440000" + `); + }); + + it("should not include tls_settings when --cert-verification-mode is not specified", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-no-tls --type tcp --tcp-port 5432 --ipv4 10.0.0.5 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + ); + + const reqBody = await reqProm; + expect(reqBody.tls_settings).toBeUndefined(); + }); + + it("should handle getting a service with tls_settings", async () => { + const serviceWithTls: ConnectivityService = { + ...mockTcpService, + tls_settings: { + cert_verification_mode: "verify_ca", + }, + }; + + msw.use( + http.get( + "*/accounts/:accountId/connectivity/directory/services/:serviceId", + () => { + return HttpResponse.json(createFetchResult(serviceWithTls, true)); + }, + { once: true } + ) + ); + + await runWrangler("vpc service get tcp-service-uuid"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🔍 Getting VPC service 'tcp-service-uuid' + ✅ Retrieved VPC service: tcp-service-uuid + Name: test-tcp-service + Type: tcp + TCP Port: 5432 + App Protocol: postgresql + Cert Verification Mode: verify_ca + IPv4: 10.0.0.5 + Tunnel ID: 550e8400-e29b-41d4-a716-446655440000 + Created: 1/1/2024, 12:00:00 AM + Modified: 1/1/2024, 12:00:00 AM" + `); + }); + + it("should handle updating a service with --cert-verification-mode", async () => { + const reqProm = mockWvpcServiceUpdate(); + await runWrangler( + "vpc service update service-uuid --name test-updated --type http --http-port 80 --ipv4 10.0.0.2 --tunnel-id 550e8400-e29b-41d4-a716-446655440001 --cert-verification-mode verify_full" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "host": { + "ipv4": "10.0.0.2", + "network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440001", + }, + }, + "http_port": 80, + "name": "test-updated", + "tls_settings": { + "cert_verification_mode": "verify_full", + }, + "type": "http", + } + `); + }); + + it("should reject --cert-verification-mode with invalid value", async () => { + await expect(() => + runWrangler( + "vpc service create test-bad-tls --type tcp --tcp-port 5432 --ipv4 10.0.0.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000 --cert-verification-mode invalid" + ) + ).rejects.toThrow(); + expect(std.err).toContain("Invalid values"); + expect(std.err).toContain("cert-verification-mode"); + expect(std.err).toContain('"invalid"'); + }); + + it("should extract port from hostname for TCP services", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-tcp-hostport --type tcp --hostname mysql.internal:3306 --tunnel-id 550e8400-e29b-41d4-a716-446655440001" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "host": { + "hostname": "mysql.internal", + "resolver_network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440001", + }, + }, + "name": "test-tcp-hostport", + "tcp_port": 3306, + "type": "tcp", + } + `); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating VPC service 'test-tcp-hostport' + ✅ Created VPC service: service-uuid + Name: test-tcp-hostport + Type: tcp + TCP Port: 3306 + Hostname: mysql.internal + Tunnel ID: 550e8400-e29b-41d4-a716-446655440001" + `); + }); + + it("should accept matching --tcp-port when hostname also includes port", async () => { + const reqProm = mockWvpcServiceCreate(); + await runWrangler( + "vpc service create test-tcp-match --type tcp --tcp-port 5432 --hostname db.internal:5432 --tunnel-id 550e8400-e29b-41d4-a716-446655440001" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "host": { + "hostname": "db.internal", + "resolver_network": { + "tunnel_id": "550e8400-e29b-41d4-a716-446655440001", + }, + }, + "name": "test-tcp-match", + "tcp_port": 5432, + "type": "tcp", + } + `); + }); + + it("should reject conflicting --tcp-port and hostname port", async () => { + await expect(() => + runWrangler( + "vpc service create test-tcp-conflict --type tcp --tcp-port 5432 --hostname db.internal:3306 --tunnel-id 550e8400-e29b-41d4-a716-446655440001" + ) + ).rejects.toThrow( + "Conflicting TCP port: --hostname includes port 3306 but --tcp-port is 5432" + ); + }); +}); + +describe("extractPortFromHostname", () => { + it("should extract port from hostname:port", () => { + expect(extractPortFromHostname("db.example.com:5432")).toEqual({ + hostname: "db.example.com", + port: 5432, + }); + }); + + it("should return undefined port for plain hostname", () => { + expect(extractPortFromHostname("db.example.com")).toEqual({ + hostname: "db.example.com", + port: undefined, + }); + }); + + it("should not extract port from IPv6 addresses", () => { + expect(extractPortFromHostname("2001:db8::1")).toEqual({ + hostname: "2001:db8::1", + port: undefined, + }); + }); + + it("should not extract port from bracketed IPv6 addresses", () => { + expect(extractPortFromHostname("[::1]")).toEqual({ + hostname: "[::1]", + port: undefined, + }); + }); + + it("should handle port at boundary values", () => { + expect(extractPortFromHostname("host:1")).toEqual({ + hostname: "host", + port: 1, + }); + expect(extractPortFromHostname("host:65535")).toEqual({ + hostname: "host", + port: 65535, + }); + }); + + it("should reject port 0 or above 65535", () => { + expect(extractPortFromHostname("host:0")).toEqual({ + hostname: "host:0", + port: undefined, + }); + expect(extractPortFromHostname("host:65536")).toEqual({ + hostname: "host:65536", + port: undefined, + }); + }); }); describe("hostname validation", () => { @@ -526,6 +1023,22 @@ const mockService: ConnectivityService = { updated_at: "2024-01-01T00:00:00Z", }; +const mockTcpService: ConnectivityService = { + service_id: "tcp-service-uuid", + type: ServiceType.Tcp, + name: "test-tcp-service", + tcp_port: 5432, + app_protocol: "postgresql", + host: { + ipv4: "10.0.0.5", + network: { + tunnel_id: "550e8400-e29b-41d4-a716-446655440000", + }, + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", +}; + // Mock API Handlers function mockWvpcServiceCreate(): Promise { return new Promise((resolve) => { @@ -547,6 +1060,7 @@ function mockWvpcServiceCreate(): Promise { http_port: reqBody.http_port, https_port: reqBody.https_port, host: reqBody.host, + tls_settings: reqBody.tls_settings, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", }, @@ -580,6 +1094,7 @@ function mockWvpcServiceUpdate(): Promise { http_port: reqBody.http_port, https_port: reqBody.https_port, host: reqBody.host, + tls_settings: reqBody.tls_settings, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", }, @@ -623,3 +1138,27 @@ function mockWvpcServiceList() { ) ); } + +function mockWvpcTcpServiceGet() { + msw.use( + http.get( + "*/accounts/:accountId/connectivity/directory/services/:serviceId", + () => { + return HttpResponse.json(createFetchResult(mockTcpService, true)); + }, + { once: true } + ) + ); +} + +function mockWvpcTcpServiceList() { + msw.use( + http.get( + "*/accounts/:accountId/connectivity/directory/services", + () => { + return HttpResponse.json(createFetchResult([mockTcpService], true)); + }, + { once: true } + ) + ); +} diff --git a/packages/wrangler/src/hyperdrive/client.ts b/packages/wrangler/src/hyperdrive/client.ts index 35388c1d47..f9b421ff8c 100644 --- a/packages/wrangler/src/hyperdrive/client.ts +++ b/packages/wrangler/src/hyperdrive/client.ts @@ -28,9 +28,10 @@ export type NetworkOriginHoA = { host: string; access_client_id: string; - // Ensure post is not set, and secrets are not set + // Ensure port is not set, and secrets are not set port?: never; access_client_secret?: never; + service_id?: never; }; export type NetworkOriginHoAWithSecrets = Omit< @@ -47,13 +48,28 @@ export type NetworkOriginHostAndPort = { // Ensure HoA fields are not set access_client_id?: never; access_client_secret?: never; + service_id?: never; +}; + +export type NetworkOriginVpcService = { + service_id: string; + + // Ensure other network fields are not set + host?: never; + port?: never; + access_client_id?: never; + access_client_secret?: never; }; // NetworkOrigin is never partial in the API, it must be submitted in it's entirety -export type NetworkOrigin = NetworkOriginHoA | NetworkOriginHostAndPort; +export type NetworkOrigin = + | NetworkOriginHoA + | NetworkOriginHostAndPort + | NetworkOriginVpcService; export type NetworkOriginWithSecrets = | NetworkOriginHoAWithSecrets - | NetworkOriginHostAndPort; + | NetworkOriginHostAndPort + | NetworkOriginVpcService; // Public responses of the full PublicOrigin type are never partial in the API export type PublicOrigin = OriginDatabase & NetworkOrigin; diff --git a/packages/wrangler/src/hyperdrive/index.ts b/packages/wrangler/src/hyperdrive/index.ts index cb4e126ddc..9200068143 100644 --- a/packages/wrangler/src/hyperdrive/index.ts +++ b/packages/wrangler/src/hyperdrive/index.ts @@ -39,13 +39,24 @@ export const upsertOptions = ( "The connection string for the database you want Hyperdrive to connect to - ex: protocol://user:password@host:port/database", group: "Configure using a connection string", }, + "service-id": { + type: "string", + description: "The Workers VPC Service ID of the origin database", + conflicts: [ + "origin-host", + "origin-port", + "connection-string", + "access-client-id", + "access-client-secret", + ], + }, "origin-host": { alias: "host", type: "string", description: "The host of the origin database", - conflicts: "connection-string", + conflicts: ["connection-string", "service-id"], group: - "Configure using individual parameters [conflicts with --connection-string]", + "Configure using individual parameters [conflicts with --connection-string, --service-id]", }, "origin-port": { alias: "port", @@ -53,11 +64,12 @@ export const upsertOptions = ( description: "The port number of the origin database", conflicts: [ "connection-string", + "service-id", "access-client-id", "access-client-secret", ], group: - "Configure using individual parameters [conflicts with --connection-string]", + "Configure using individual parameters [conflicts with --connection-string, --service-id]", }, "origin-scheme": { alias: "scheme", @@ -95,18 +107,18 @@ export const upsertOptions = ( type: "string", description: "The Client ID of the Access token to use when connecting to the origin database", - conflicts: ["connection-string", "origin-port"], + conflicts: ["connection-string", "origin-port", "service-id"], implies: ["access-client-secret"], group: - "Hyperdrive over Access [conflicts with --connection-string, --origin-port]", + "Hyperdrive over Access [conflicts with --connection-string, --origin-port, --service-id]", }, "access-client-secret": { type: "string", description: "The Client Secret of the Access token to use when connecting to the origin database", - conflicts: ["connection-string", "origin-port"], + conflicts: ["connection-string", "origin-port", "service-id"], group: - "Hyperdrive over Access [conflicts with --connection-string, --origin-port]", + "Hyperdrive over Access [conflicts with --connection-string, --origin-port, --service-id]", }, "caching-disabled": { type: "boolean", @@ -246,7 +258,11 @@ export function getOriginFromArgs< : OriginDatabaseWithSecrets; let networkOrigin: NetworkOriginWithSecrets | undefined; - if (args.accessClientId || args.accessClientSecret) { + if (args.serviceId) { + networkOrigin = { + service_id: args.serviceId, + }; + } else if (args.accessClientId || args.accessClientSecret) { if (!args.accessClientId || !args.accessClientSecret) { throw new UserError( "You must provide both an Access Client ID and Access Client Secret when configuring Hyperdrive-over-Access" diff --git a/packages/wrangler/src/vpc/create.ts b/packages/wrangler/src/vpc/create.ts index a9ee89d76e..726f69cf5b 100644 --- a/packages/wrangler/src/vpc/create.ts +++ b/packages/wrangler/src/vpc/create.ts @@ -1,8 +1,9 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { createService } from "./client"; -import { buildRequest, validateRequest } from "./validation"; -import { serviceOptions, ServiceType } from "./index"; +import { displayServiceDetails } from "./shared"; +import { buildRequest, toServiceArgs, validateRequest } from "./validation"; +import { serviceOptions } from "./index"; export const vpcServiceCreateCommand = createCommand({ metadata: { @@ -15,71 +16,16 @@ export const vpcServiceCreateCommand = createCommand({ }, positionalArgs: ["name"], validateArgs: (args) => { - // Validate arguments - this will throw UserError if validation fails - validateRequest({ - name: args.name, - type: args.type as ServiceType, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - ipv4: args.ipv4, - ipv6: args.ipv6, - hostname: args.hostname, - tunnelId: args.tunnelId, - resolverIps: args.resolverIps, - }); + validateRequest(toServiceArgs(args)); }, async handler(args, { config }) { logger.log(`🚧 Creating VPC service '${args.name}'`); - const request = buildRequest({ - name: args.name, - type: args.type as ServiceType, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - ipv4: args.ipv4, - ipv6: args.ipv6, - hostname: args.hostname, - tunnelId: args.tunnelId, - resolverIps: args.resolverIps, - }); - + const serviceArgs = toServiceArgs(args); + const request = buildRequest(serviceArgs); const service = await createService(config, request); logger.log(`✅ Created VPC service: ${service.service_id}`); - logger.log(` Name: ${service.name}`); - logger.log(` Type: ${service.type}`); - - // Display service-specific details - if (service.type === ServiceType.Http) { - if (service.http_port) { - logger.log(` HTTP Port: ${service.http_port}`); - } - if (service.https_port) { - logger.log(` HTTPS Port: ${service.https_port}`); - } - } - - // Display host details - if (service.host.ipv4) { - logger.log(` IPv4: ${service.host.ipv4}`); - } - if (service.host.ipv6) { - logger.log(` IPv6: ${service.host.ipv6}`); - } - if (service.host.hostname) { - logger.log(` Hostname: ${service.host.hostname}`); - } - - // Display network details - if (service.host.network) { - logger.log(` Tunnel ID: ${service.host.network.tunnel_id}`); - } else if (service.host.resolver_network) { - logger.log(` Tunnel ID: ${service.host.resolver_network.tunnel_id}`); - if (service.host.resolver_network.resolver_ips) { - logger.log( - ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` - ); - } - } + displayServiceDetails(service); }, }); diff --git a/packages/wrangler/src/vpc/get.ts b/packages/wrangler/src/vpc/get.ts index 6a6880c267..f8e6d0acb4 100644 --- a/packages/wrangler/src/vpc/get.ts +++ b/packages/wrangler/src/vpc/get.ts @@ -1,7 +1,7 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getService } from "./client"; -import { ServiceType } from "./index"; +import { displayServiceDetails } from "./shared"; export const vpcServiceGetCommand = createCommand({ metadata: { @@ -23,41 +23,7 @@ export const vpcServiceGetCommand = createCommand({ const service = await getService(config, args.serviceId); logger.log(`✅ Retrieved VPC service: ${service.service_id}`); - logger.log(` Name: ${service.name}`); - logger.log(` Type: ${service.type}`); - - // Display service-specific details - if (service.type === ServiceType.Http) { - if (service.http_port) { - logger.log(` HTTP Port: ${service.http_port}`); - } - if (service.https_port) { - logger.log(` HTTPS Port: ${service.https_port}`); - } - } - - // Display host details - if (service.host.ipv4) { - logger.log(` IPv4: ${service.host.ipv4}`); - } - if (service.host.ipv6) { - logger.log(` IPv6: ${service.host.ipv6}`); - } - if (service.host.hostname) { - logger.log(` Hostname: ${service.host.hostname}`); - } - - // Display network details - if (service.host.network) { - logger.log(` Tunnel ID: ${service.host.network.tunnel_id}`); - } else if (service.host.resolver_network) { - logger.log(` Tunnel ID: ${service.host.resolver_network.tunnel_id}`); - if (service.host.resolver_network.resolver_ips) { - logger.log( - ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` - ); - } - } + displayServiceDetails(service); logger.log(` Created: ${new Date(service.created_at).toLocaleString()}`); logger.log(` Modified: ${new Date(service.updated_at).toLocaleString()}`); diff --git a/packages/wrangler/src/vpc/index.ts b/packages/wrangler/src/vpc/index.ts index 3a3f7c4a36..942b1cfc05 100644 --- a/packages/wrangler/src/vpc/index.ts +++ b/packages/wrangler/src/vpc/index.ts @@ -19,6 +19,13 @@ export const vpcServiceNamespace = createNamespace({ export enum ServiceType { Http = "http", + Tcp = "tcp", +} + +export type CertVerificationMode = "verify_full" | "verify_ca" | "disabled"; + +export interface TlsSettings { + cert_verification_mode: CertVerificationMode; } export interface ServicePortOptions { @@ -66,6 +73,7 @@ export interface ConnectivityService { http_port?: number; https_port?: number; host: ServiceHost; + tls_settings?: TlsSettings; created_at: string; updated_at: string; } @@ -78,6 +86,7 @@ export interface ConnectivityServiceRequest { http_port?: number; https_port?: number; host: ServiceHost; + tls_settings?: TlsSettings; } export interface ConnectivityServiceListParams { @@ -94,17 +103,32 @@ export const serviceOptions = { type: { type: "string", demandOption: true, - choices: ["http"], + choices: ["tcp", "http"], group: "Required Configuration", description: "The type of the VPC service", }, + "tcp-port": { + type: "number", + conflicts: ["http-port", "https-port"], + description: "TCP port number", + group: "TCP Options", + }, + "app-protocol": { + type: "string", + choices: ["postgresql", "mysql"] as const, + conflicts: ["http-port", "https-port"], + description: "Application protocol for the TCP service", + group: "TCP Options", + }, "http-port": { type: "number", + conflicts: ["tcp-port"], description: "HTTP port (default: 80)", group: "Port Configuration", }, "https-port": { type: "number", + conflicts: ["tcp-port"], description: "HTTPS port number (default: 443)", group: "Port Configuration", }, @@ -139,4 +163,11 @@ export const serviceOptions = { group: "Required Configuration", description: "UUID of the Cloudflare tunnel", }, + "cert-verification-mode": { + type: "string", + choices: ["verify_full", "verify_ca", "disabled"] as const, + description: + "TLS certificate verification mode for the connection to the origin", + group: "TLS Options", + }, } as const; diff --git a/packages/wrangler/src/vpc/shared.ts b/packages/wrangler/src/vpc/shared.ts index d0163acb6c..0a8c7a57ac 100644 --- a/packages/wrangler/src/vpc/shared.ts +++ b/packages/wrangler/src/vpc/shared.ts @@ -1,9 +1,70 @@ +import { logger } from "../logger"; +import { ServiceType } from "./index"; import type { ConnectivityService } from "./index"; +export function displayServiceDetails(service: ConnectivityService) { + logger.log(` Name: ${service.name}`); + logger.log(` Type: ${service.type}`); + + // Display service-specific details + if (service.type === ServiceType.Tcp) { + if (service.tcp_port) { + logger.log(` TCP Port: ${service.tcp_port}`); + } + if (service.app_protocol) { + logger.log(` App Protocol: ${service.app_protocol}`); + } + } else if (service.type === ServiceType.Http) { + if (service.http_port) { + logger.log(` HTTP Port: ${service.http_port}`); + } + if (service.https_port) { + logger.log(` HTTPS Port: ${service.https_port}`); + } + } + + // Display TLS settings + if (service.tls_settings) { + logger.log( + ` Cert Verification Mode: ${service.tls_settings.cert_verification_mode}` + ); + } + + // Display host details + if (service.host.ipv4) { + logger.log(` IPv4: ${service.host.ipv4}`); + } + if (service.host.ipv6) { + logger.log(` IPv6: ${service.host.ipv6}`); + } + if (service.host.hostname) { + logger.log(` Hostname: ${service.host.hostname}`); + } + + // Display network details + if (service.host.network) { + logger.log(` Tunnel ID: ${service.host.network.tunnel_id}`); + } else if (service.host.resolver_network) { + logger.log(` Tunnel ID: ${service.host.resolver_network.tunnel_id}`); + if (service.host.resolver_network.resolver_ips) { + logger.log( + ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` + ); + } + } +} + export function formatServiceForTable(service: ConnectivityService) { // Build port info based on service type let ports = ""; - if (service.type === "http") { + if (service.type === "tcp") { + if (service.tcp_port) { + ports = `TCP:${service.tcp_port}`; + if (service.app_protocol) { + ports += ` (${service.app_protocol})`; + } + } + } else if (service.type === "http") { const httpPorts = []; if (service.http_port) { httpPorts.push(`HTTP:${service.http_port}`); diff --git a/packages/wrangler/src/vpc/update.ts b/packages/wrangler/src/vpc/update.ts index c0817addbc..4b58efe510 100644 --- a/packages/wrangler/src/vpc/update.ts +++ b/packages/wrangler/src/vpc/update.ts @@ -1,8 +1,9 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { updateService } from "./client"; -import { buildRequest, validateRequest } from "./validation"; -import { serviceOptions, ServiceType } from "./index"; +import { displayServiceDetails } from "./shared"; +import { buildRequest, toServiceArgs, validateRequest } from "./validation"; +import { serviceOptions } from "./index"; export const vpcServiceUpdateCommand = createCommand({ metadata: { @@ -20,71 +21,16 @@ export const vpcServiceUpdateCommand = createCommand({ }, positionalArgs: ["service-id"], validateArgs: (args) => { - // Validate arguments - this will throw UserError if validation fails - validateRequest({ - name: args.name, - type: args.type as ServiceType, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - ipv4: args.ipv4, - ipv6: args.ipv6, - hostname: args.hostname, - tunnelId: args.tunnelId, - resolverIps: args.resolverIps, - }); + validateRequest(toServiceArgs(args)); }, async handler(args, { config }) { logger.log(`🚧 Updating VPC service '${args.serviceId}'`); - const request = buildRequest({ - name: args.name, - type: args.type as ServiceType, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - ipv4: args.ipv4, - ipv6: args.ipv6, - hostname: args.hostname, - tunnelId: args.tunnelId, - resolverIps: args.resolverIps, - }); - + const serviceArgs = toServiceArgs(args); + const request = buildRequest(serviceArgs); const service = await updateService(config, args.serviceId, request); logger.log(`✅ Updated VPC service: ${service.service_id}`); - logger.log(` Name: ${service.name}`); - logger.log(` Type: ${service.type}`); - - // Display service-specific details - if (service.type === ServiceType.Http) { - if (service.http_port) { - logger.log(` HTTP Port: ${service.http_port}`); - } - if (service.https_port) { - logger.log(` HTTPS Port: ${service.https_port}`); - } - } - - // Display host details - if (service.host.ipv4) { - logger.log(` IPv4: ${service.host.ipv4}`); - } - if (service.host.ipv6) { - logger.log(` IPv6: ${service.host.ipv6}`); - } - if (service.host.hostname) { - logger.log(` Hostname: ${service.host.hostname}`); - } - - // Display network details - if (service.host.network) { - logger.log(` Tunnel ID: ${service.host.network.tunnel_id}`); - } else if (service.host.resolver_network) { - logger.log(` Tunnel ID: ${service.host.resolver_network.tunnel_id}`); - if (service.host.resolver_network.resolver_ips) { - logger.log( - ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` - ); - } - } + displayServiceDetails(service); }, }); diff --git a/packages/wrangler/src/vpc/validation.ts b/packages/wrangler/src/vpc/validation.ts index e0b9cb7664..1d8563b7bb 100644 --- a/packages/wrangler/src/vpc/validation.ts +++ b/packages/wrangler/src/vpc/validation.ts @@ -1,11 +1,17 @@ import net from "node:net"; import { UserError } from "@cloudflare/workers-utils"; import { ServiceType } from "./index"; -import type { ConnectivityServiceRequest, ServiceHost } from "./index"; +import type { + CertVerificationMode, + ConnectivityServiceRequest, + ServiceHost, +} from "./index"; export interface ServiceArgs { name: string; type: ServiceType; + tcpPort?: number; + appProtocol?: string; httpPort?: number; httpsPort?: number; ipv4?: string; @@ -13,6 +19,7 @@ export interface ServiceArgs { hostname?: string; tunnelId: string; resolverIps?: string; + certVerificationMode?: string; } export function validateHostname(hostname: string): void { @@ -72,6 +79,87 @@ export function validateHostname(hostname: string): void { } } +/** + * For TCP services, extract a port number from a "host:port" hostname if present. + * Uses the URL class to reliably parse the host and port, which correctly + * handles IPv6 addresses, bracketed notation, and edge cases. + * + * Returns the hostname with the port stripped and the extracted port number, + * or the original hostname and undefined if no port suffix was found. + */ +export function extractPortFromHostname(hostname: string): { + hostname: string; + port: number | undefined; +} { + try { + // Use a dummy scheme so the URL constructor can parse "host:port" + const url = new URL(`tcp://${hostname}`); + if (url.port === "") { + return { hostname, port: undefined }; + } + const port = parseInt(url.port, 10); + if (port < 1) { + return { hostname, port: undefined }; + } + return { hostname: url.hostname, port }; + } catch { + return { hostname, port: undefined }; + } +} + +/** + * Convert yargs-style command args into ServiceArgs, applying + * hostname:port resolution for TCP services so that + * `--hostname db.internal:5432` is equivalent to + * `--hostname db.internal --tcp-port 5432`. + */ +export function toServiceArgs(args: { + name: string; + type: string; + tcpPort?: number; + appProtocol?: string; + httpPort?: number; + httpsPort?: number; + ipv4?: string; + ipv6?: string; + hostname?: string; + tunnelId: string; + resolverIps?: string; + certVerificationMode?: string; +}): ServiceArgs { + const type = args.type as ServiceType; + let { hostname } = args; + let tcpPort = args.tcpPort; + + if (type === ServiceType.Tcp && hostname) { + const extracted = extractPortFromHostname(hostname); + if (extracted.port !== undefined) { + if (tcpPort && tcpPort !== extracted.port) { + throw new UserError( + `Conflicting TCP port: --hostname includes port ${extracted.port} but --tcp-port is ${tcpPort}. Provide the port in one place only.` + ); + } + hostname = extracted.hostname; + tcpPort = extracted.port; + } + } + + return { + name: args.name, + type, + tcpPort, + appProtocol: args.appProtocol, + httpPort: args.httpPort, + httpsPort: args.httpsPort, + ipv4: args.ipv4, + ipv6: args.ipv6, + hostname, + tunnelId: args.tunnelId, + resolverIps: args.resolverIps, + certVerificationMode: args.certVerificationMode, + }; +} + export function validateRequest(args: ServiceArgs) { // Validate host configuration - must have either IP addresses or hostname, not both const hasIpAddresses = Boolean(args.ipv4 || args.ipv6); @@ -110,6 +198,11 @@ export function validateRequest(args: ServiceArgs) { ); } } + + // Validate TCP services require a port + if (args.type === ServiceType.Tcp && !args.tcpPort) { + throw new UserError("TCP services require a --tcp-port to be specified"); + } } export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest { @@ -148,10 +241,22 @@ export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest { }; // Add service-specific fields - if (args.type === ServiceType.Http) { + if (args.type === ServiceType.Tcp) { + request.tcp_port = args.tcpPort; + if (args.appProtocol) { + request.app_protocol = args.appProtocol; + } + } else if (args.type === ServiceType.Http) { request.http_port = args.httpPort; request.https_port = args.httpsPort; } + // Add TLS settings if specified + if (args.certVerificationMode) { + request.tls_settings = { + cert_verification_mode: args.certVerificationMode as CertVerificationMode, + }; + } + return request; }