From a37a7ce4ca79ea8fcd2db11bc13513f3b9a14d44 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Thu, 12 Mar 2026 15:12:07 -0500 Subject: [PATCH 1/6] feat(wrangler): add TCP service type support for Workers VPC Add support for creating TCP services in Workers VPC using the `--type tcp` option. This enables exposing TCP-based services like PostgreSQL, MySQL, and other database servers through Workers VPC. Changes: - Add ServiceType.Tcp enum value and --tcp-port CLI option - Add validation requiring --tcp-port for TCP service type - Update create/update/get commands to display TCP port - Update list command to show TCP port in table (TCP:) - Add comprehensive tests for TCP service CRUD operations --- .changeset/vpc-tcp-service-support.md | 13 ++ packages/wrangler/src/__tests__/vpc.test.ts | 163 ++++++++++++++++++++ packages/wrangler/src/vpc/create.ts | 6 +- packages/wrangler/src/vpc/get.ts | 6 +- packages/wrangler/src/vpc/index.ts | 11 +- packages/wrangler/src/vpc/shared.ts | 6 +- packages/wrangler/src/vpc/update.ts | 6 +- packages/wrangler/src/vpc/validation.ts | 10 +- 8 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 .changeset/vpc-tcp-service-support.md 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__/vpc.test.ts b/packages/wrangler/src/__tests__/vpc.test.ts index a07b8a3a30..56231f4e21 100644 --- a/packages/wrangler/src/__tests__/vpc.test.ts +++ b/packages/wrangler/src/__tests__/vpc.test.ts @@ -324,6 +324,130 @@ 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 + 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 │ 10.0.0.5 │ 550e8400... │ 1/1/2024, 12:00:00 AM │ 1/1/2024, 12:00:00 AM │ + └─┴─┴─┴─┴─┴─┴─┴─┘" + `); + }); }); describe("hostname validation", () => { @@ -526,6 +650,21 @@ 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, + 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) => { @@ -623,3 +762,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/vpc/create.ts b/packages/wrangler/src/vpc/create.ts index a9ee89d76e..f69336e8a8 100644 --- a/packages/wrangler/src/vpc/create.ts +++ b/packages/wrangler/src/vpc/create.ts @@ -19,6 +19,7 @@ export const vpcServiceCreateCommand = createCommand({ validateRequest({ name: args.name, type: args.type as ServiceType, + tcpPort: args.tcpPort, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -34,6 +35,7 @@ export const vpcServiceCreateCommand = createCommand({ const request = buildRequest({ name: args.name, type: args.type as ServiceType, + tcpPort: args.tcpPort, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -50,7 +52,9 @@ export const vpcServiceCreateCommand = createCommand({ logger.log(` Type: ${service.type}`); // Display service-specific details - if (service.type === ServiceType.Http) { + if (service.type === ServiceType.Tcp) { + logger.log(` TCP Port: ${service.tcp_port}`); + } else if (service.type === ServiceType.Http) { if (service.http_port) { logger.log(` HTTP Port: ${service.http_port}`); } diff --git a/packages/wrangler/src/vpc/get.ts b/packages/wrangler/src/vpc/get.ts index 6a6880c267..63c17db56e 100644 --- a/packages/wrangler/src/vpc/get.ts +++ b/packages/wrangler/src/vpc/get.ts @@ -27,7 +27,11 @@ export const vpcServiceGetCommand = createCommand({ logger.log(` Type: ${service.type}`); // Display service-specific details - if (service.type === ServiceType.Http) { + if (service.type === ServiceType.Tcp) { + if (service.tcp_port) { + logger.log(` TCP Port: ${service.tcp_port}`); + } + } else if (service.type === ServiceType.Http) { if (service.http_port) { logger.log(` HTTP Port: ${service.http_port}`); } diff --git a/packages/wrangler/src/vpc/index.ts b/packages/wrangler/src/vpc/index.ts index 3a3f7c4a36..0af70b9e0d 100644 --- a/packages/wrangler/src/vpc/index.ts +++ b/packages/wrangler/src/vpc/index.ts @@ -19,6 +19,7 @@ export const vpcServiceNamespace = createNamespace({ export enum ServiceType { Http = "http", + Tcp = "tcp", } export interface ServicePortOptions { @@ -94,17 +95,25 @@ 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", + }, "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", }, diff --git a/packages/wrangler/src/vpc/shared.ts b/packages/wrangler/src/vpc/shared.ts index d0163acb6c..93da687cbf 100644 --- a/packages/wrangler/src/vpc/shared.ts +++ b/packages/wrangler/src/vpc/shared.ts @@ -3,7 +3,11 @@ import type { ConnectivityService } from "./index"; 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}`; + } + } 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..08a68caf8c 100644 --- a/packages/wrangler/src/vpc/update.ts +++ b/packages/wrangler/src/vpc/update.ts @@ -24,6 +24,7 @@ export const vpcServiceUpdateCommand = createCommand({ validateRequest({ name: args.name, type: args.type as ServiceType, + tcpPort: args.tcpPort, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -39,6 +40,7 @@ export const vpcServiceUpdateCommand = createCommand({ const request = buildRequest({ name: args.name, type: args.type as ServiceType, + tcpPort: args.tcpPort, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -55,7 +57,9 @@ export const vpcServiceUpdateCommand = createCommand({ logger.log(` Type: ${service.type}`); // Display service-specific details - if (service.type === ServiceType.Http) { + if (service.type === ServiceType.Tcp) { + logger.log(` TCP Port: ${service.tcp_port}`); + } else if (service.type === ServiceType.Http) { if (service.http_port) { logger.log(` HTTP Port: ${service.http_port}`); } diff --git a/packages/wrangler/src/vpc/validation.ts b/packages/wrangler/src/vpc/validation.ts index e0b9cb7664..361a648e7e 100644 --- a/packages/wrangler/src/vpc/validation.ts +++ b/packages/wrangler/src/vpc/validation.ts @@ -6,6 +6,7 @@ import type { ConnectivityServiceRequest, ServiceHost } from "./index"; export interface ServiceArgs { name: string; type: ServiceType; + tcpPort?: number; httpPort?: number; httpsPort?: number; ipv4?: string; @@ -110,6 +111,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,7 +154,9 @@ 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; + } else if (args.type === ServiceType.Http) { request.http_port = args.httpPort; request.https_port = args.httpsPort; } From 780a0e6106e64b5d5fb92a3b9db02cf65decea9a Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Thu, 12 Mar 2026 15:18:18 -0500 Subject: [PATCH 2/6] feat(wrangler): add Workers VPC service support for Hyperdrive origins Add support for connecting Hyperdrive configs to databases through Workers VPC services using the `--service-id` option. This enables Hyperdrive to connect to databases hosted in private networks that are accessible through Workers VPC TCP services. Changes: - Add --service-id CLI option for hyperdrive create/update commands - Add NetworkOriginVpcService type for VPC service connections - Add conflict validation between --service-id and other origin options - Update mock API handlers to support service_id in origin - Add comprehensive tests including error case coverage --- .changeset/hyperdrive-vpc-service-support.md | 13 ++ .../wrangler/src/__tests__/hyperdrive.test.ts | 214 +++++++++++++++++- packages/wrangler/src/hyperdrive/client.ts | 22 +- packages/wrangler/src/hyperdrive/index.ts | 32 ++- 4 files changed, 263 insertions(+), 18 deletions(-) create mode 100644 .changeset/hyperdrive-vpc-service-support.md 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/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/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" From ed068bad2e1715ed86883a75eb50e5b6c531650f Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 24 Mar 2026 16:41:10 -0500 Subject: [PATCH 3/6] refactor(wrangler): consolidate VPC service display logic into shared helper Extract duplicated service detail display logic from create.ts, update.ts, and get.ts into a shared displayServiceDetails() function in shared.ts. Also adds missing null guard for tcp_port, consistent with get.ts and formatServiceForTable. --- packages/wrangler/src/vpc/create.ts | 41 ++------------------------- packages/wrangler/src/vpc/get.ts | 42 ++------------------------- packages/wrangler/src/vpc/shared.ts | 44 +++++++++++++++++++++++++++++ packages/wrangler/src/vpc/update.ts | 41 ++------------------------- 4 files changed, 52 insertions(+), 116 deletions(-) diff --git a/packages/wrangler/src/vpc/create.ts b/packages/wrangler/src/vpc/create.ts index f69336e8a8..59cf53fe70 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 { displayServiceDetails } from "./shared"; import { buildRequest, validateRequest } from "./validation"; -import { serviceOptions, ServiceType } from "./index"; +import { serviceOptions, type ServiceType } from "./index"; export const vpcServiceCreateCommand = createCommand({ metadata: { @@ -48,42 +49,6 @@ export const vpcServiceCreateCommand = createCommand({ 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.Tcp) { - logger.log(` TCP Port: ${service.tcp_port}`); - } 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 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 63c17db56e..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,45 +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.Tcp) { - if (service.tcp_port) { - logger.log(` TCP Port: ${service.tcp_port}`); - } - } 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 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/shared.ts b/packages/wrangler/src/vpc/shared.ts index 93da687cbf..64495f118f 100644 --- a/packages/wrangler/src/vpc/shared.ts +++ b/packages/wrangler/src/vpc/shared.ts @@ -1,5 +1,49 @@ +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}`); + } + } 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 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 = ""; diff --git a/packages/wrangler/src/vpc/update.ts b/packages/wrangler/src/vpc/update.ts index 08a68caf8c..0aef2fc627 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 { displayServiceDetails } from "./shared"; import { buildRequest, validateRequest } from "./validation"; -import { serviceOptions, ServiceType } from "./index"; +import { serviceOptions, type ServiceType } from "./index"; export const vpcServiceUpdateCommand = createCommand({ metadata: { @@ -53,42 +54,6 @@ export const vpcServiceUpdateCommand = createCommand({ 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.Tcp) { - logger.log(` TCP Port: ${service.tcp_port}`); - } 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 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); }, }); From 9fb9937476786e899bce0c3bbb589914451b6fc3 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Tue, 24 Mar 2026 17:10:26 -0500 Subject: [PATCH 4/6] feat(wrangler): add --app-protocol option for VPC TCP services Support specifying an application protocol (postgresql or mysql) when creating or updating TCP VPC services. The protocol is displayed in service details and appended to the ports column in list tables. --- packages/wrangler/src/__tests__/vpc.test.ts | 96 ++++++++++++++++++++- packages/wrangler/src/vpc/create.ts | 2 + packages/wrangler/src/vpc/index.ts | 7 ++ packages/wrangler/src/vpc/shared.ts | 6 ++ packages/wrangler/src/vpc/update.ts | 2 + packages/wrangler/src/vpc/validation.ts | 4 + 6 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/wrangler/src/__tests__/vpc.test.ts b/packages/wrangler/src/__tests__/vpc.test.ts index 56231f4e21..ebc599880f 100644 --- a/packages/wrangler/src/__tests__/vpc.test.ts +++ b/packages/wrangler/src/__tests__/vpc.test.ts @@ -425,6 +425,7 @@ describe("vpc service commands", () => { 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 @@ -444,10 +445,102 @@ describe("vpc service commands", () => { ┌─┬─┬─┬─┬─┬─┬─┬─┐ │ id │ name │ type │ ports │ host │ tunnel │ created │ modified │ ├─┼─┼─┼─┼─┼─┼─┼─┤ - │ tcp-service-uuid │ test-tcp-service │ tcp │ TCP:5432 │ 10.0.0.5 │ 550e8400... │ 1/1/2024, 12:00:00 AM │ 1/1/2024, 12:00:00 AM │ + │ 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", + } + `); + }); }); describe("hostname validation", () => { @@ -655,6 +748,7 @@ const mockTcpService: ConnectivityService = { type: ServiceType.Tcp, name: "test-tcp-service", tcp_port: 5432, + app_protocol: "postgresql", host: { ipv4: "10.0.0.5", network: { diff --git a/packages/wrangler/src/vpc/create.ts b/packages/wrangler/src/vpc/create.ts index 59cf53fe70..226a03c357 100644 --- a/packages/wrangler/src/vpc/create.ts +++ b/packages/wrangler/src/vpc/create.ts @@ -21,6 +21,7 @@ export const vpcServiceCreateCommand = createCommand({ name: args.name, type: args.type as ServiceType, tcpPort: args.tcpPort, + appProtocol: args.appProtocol, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -37,6 +38,7 @@ export const vpcServiceCreateCommand = createCommand({ name: args.name, type: args.type as ServiceType, tcpPort: args.tcpPort, + appProtocol: args.appProtocol, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, diff --git a/packages/wrangler/src/vpc/index.ts b/packages/wrangler/src/vpc/index.ts index 0af70b9e0d..577cd17a17 100644 --- a/packages/wrangler/src/vpc/index.ts +++ b/packages/wrangler/src/vpc/index.ts @@ -105,6 +105,13 @@ export const serviceOptions = { 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"], diff --git a/packages/wrangler/src/vpc/shared.ts b/packages/wrangler/src/vpc/shared.ts index 64495f118f..29b558d7d4 100644 --- a/packages/wrangler/src/vpc/shared.ts +++ b/packages/wrangler/src/vpc/shared.ts @@ -11,6 +11,9 @@ export function displayServiceDetails(service: ConnectivityService) { 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}`); @@ -50,6 +53,9 @@ export function formatServiceForTable(service: ConnectivityService) { 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 = []; diff --git a/packages/wrangler/src/vpc/update.ts b/packages/wrangler/src/vpc/update.ts index 0aef2fc627..b71833c65e 100644 --- a/packages/wrangler/src/vpc/update.ts +++ b/packages/wrangler/src/vpc/update.ts @@ -26,6 +26,7 @@ export const vpcServiceUpdateCommand = createCommand({ name: args.name, type: args.type as ServiceType, tcpPort: args.tcpPort, + appProtocol: args.appProtocol, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -42,6 +43,7 @@ export const vpcServiceUpdateCommand = createCommand({ name: args.name, type: args.type as ServiceType, tcpPort: args.tcpPort, + appProtocol: args.appProtocol, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, diff --git a/packages/wrangler/src/vpc/validation.ts b/packages/wrangler/src/vpc/validation.ts index 361a648e7e..6e0e5592ad 100644 --- a/packages/wrangler/src/vpc/validation.ts +++ b/packages/wrangler/src/vpc/validation.ts @@ -7,6 +7,7 @@ export interface ServiceArgs { name: string; type: ServiceType; tcpPort?: number; + appProtocol?: string; httpPort?: number; httpsPort?: number; ipv4?: string; @@ -156,6 +157,9 @@ export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest { // Add service-specific fields 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; From b41fb178e38799336f75d8b24470be4054dbdb13 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Wed, 25 Mar 2026 14:57:22 -0500 Subject: [PATCH 5/6] feat(wrangler): add --cert-verification-mode option for VPC services Add TLS certificate verification mode configuration to wrangler vpc service create/update commands. This maps to the new tls_settings field on the connectivity directory API, allowing users to control how the connection to the origin verifies TLS certificates. Available modes: verify_full (default), verify_ca, disabled. Applies to both TCP and HTTP VPC service types. --- .changeset/vpc-cert-verification-mode.md | 19 +++ packages/wrangler/src/__tests__/vpc.test.ts | 161 ++++++++++++++++++++ packages/wrangler/src/vpc/create.ts | 2 + packages/wrangler/src/vpc/index.ts | 15 ++ packages/wrangler/src/vpc/shared.ts | 7 + packages/wrangler/src/vpc/update.ts | 2 + packages/wrangler/src/vpc/validation.ts | 14 +- 7 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 .changeset/vpc-cert-verification-mode.md 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/packages/wrangler/src/__tests__/vpc.test.ts b/packages/wrangler/src/__tests__/vpc.test.ts index ebc599880f..0e734e84db 100644 --- a/packages/wrangler/src/__tests__/vpc.test.ts +++ b/packages/wrangler/src/__tests__/vpc.test.ts @@ -541,6 +541,165 @@ describe("vpc service commands", () => { } `); }); + + 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"'); + }); }); describe("hostname validation", () => { @@ -780,6 +939,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", }, @@ -813,6 +973,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", }, diff --git a/packages/wrangler/src/vpc/create.ts b/packages/wrangler/src/vpc/create.ts index 226a03c357..758176d0a7 100644 --- a/packages/wrangler/src/vpc/create.ts +++ b/packages/wrangler/src/vpc/create.ts @@ -29,6 +29,7 @@ export const vpcServiceCreateCommand = createCommand({ hostname: args.hostname, tunnelId: args.tunnelId, resolverIps: args.resolverIps, + certVerificationMode: args.certVerificationMode, }); }, async handler(args, { config }) { @@ -46,6 +47,7 @@ export const vpcServiceCreateCommand = createCommand({ hostname: args.hostname, tunnelId: args.tunnelId, resolverIps: args.resolverIps, + certVerificationMode: args.certVerificationMode, }); const service = await createService(config, request); diff --git a/packages/wrangler/src/vpc/index.ts b/packages/wrangler/src/vpc/index.ts index 577cd17a17..942b1cfc05 100644 --- a/packages/wrangler/src/vpc/index.ts +++ b/packages/wrangler/src/vpc/index.ts @@ -22,6 +22,12 @@ export enum ServiceType { Tcp = "tcp", } +export type CertVerificationMode = "verify_full" | "verify_ca" | "disabled"; + +export interface TlsSettings { + cert_verification_mode: CertVerificationMode; +} + export interface ServicePortOptions { tcpPort?: number; appProtocol?: string; @@ -67,6 +73,7 @@ export interface ConnectivityService { http_port?: number; https_port?: number; host: ServiceHost; + tls_settings?: TlsSettings; created_at: string; updated_at: string; } @@ -79,6 +86,7 @@ export interface ConnectivityServiceRequest { http_port?: number; https_port?: number; host: ServiceHost; + tls_settings?: TlsSettings; } export interface ConnectivityServiceListParams { @@ -155,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 29b558d7d4..0a8c7a57ac 100644 --- a/packages/wrangler/src/vpc/shared.ts +++ b/packages/wrangler/src/vpc/shared.ts @@ -23,6 +23,13 @@ export function displayServiceDetails(service: ConnectivityService) { } } + // 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}`); diff --git a/packages/wrangler/src/vpc/update.ts b/packages/wrangler/src/vpc/update.ts index b71833c65e..091db8519f 100644 --- a/packages/wrangler/src/vpc/update.ts +++ b/packages/wrangler/src/vpc/update.ts @@ -34,6 +34,7 @@ export const vpcServiceUpdateCommand = createCommand({ hostname: args.hostname, tunnelId: args.tunnelId, resolverIps: args.resolverIps, + certVerificationMode: args.certVerificationMode, }); }, async handler(args, { config }) { @@ -51,6 +52,7 @@ export const vpcServiceUpdateCommand = createCommand({ hostname: args.hostname, tunnelId: args.tunnelId, resolverIps: args.resolverIps, + certVerificationMode: args.certVerificationMode, }); const service = await updateService(config, args.serviceId, request); diff --git a/packages/wrangler/src/vpc/validation.ts b/packages/wrangler/src/vpc/validation.ts index 6e0e5592ad..93eb45c9ab 100644 --- a/packages/wrangler/src/vpc/validation.ts +++ b/packages/wrangler/src/vpc/validation.ts @@ -1,7 +1,11 @@ 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; @@ -15,6 +19,7 @@ export interface ServiceArgs { hostname?: string; tunnelId: string; resolverIps?: string; + certVerificationMode?: string; } export function validateHostname(hostname: string): void { @@ -165,5 +170,12 @@ export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest { 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; } From 5eef2f309ad01a3b570358b110323dcf1b36d538 Mon Sep 17 00:00:00 2001 From: Matt Alonso Date: Wed, 25 Mar 2026 17:05:20 -0500 Subject: [PATCH 6/6] feat(wrangler): support hostname:port shorthand for VPC TCP services For TCP services, allow specifying the port as part of the hostname (e.g. --hostname db.internal:5432) instead of requiring a separate --tcp-port flag. Uses the URL class for reliable host:port parsing. Also refactors command handlers to use a shared toServiceArgs() helper, eliminating duplicated args-object construction between validateArgs and handler. --- packages/wrangler/src/__tests__/vpc.test.ts | 123 +++++++++++++++++++- packages/wrangler/src/vpc/create.ts | 37 +----- packages/wrangler/src/vpc/update.ts | 37 +----- packages/wrangler/src/vpc/validation.ts | 81 +++++++++++++ 4 files changed, 213 insertions(+), 65 deletions(-) diff --git a/packages/wrangler/src/__tests__/vpc.test.ts b/packages/wrangler/src/__tests__/vpc.test.ts index 0e734e84db..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"; @@ -700,6 +704,123 @@ describe("vpc service commands", () => { 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", () => { diff --git a/packages/wrangler/src/vpc/create.ts b/packages/wrangler/src/vpc/create.ts index 758176d0a7..726f69cf5b 100644 --- a/packages/wrangler/src/vpc/create.ts +++ b/packages/wrangler/src/vpc/create.ts @@ -2,8 +2,8 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { createService } from "./client"; import { displayServiceDetails } from "./shared"; -import { buildRequest, validateRequest } from "./validation"; -import { serviceOptions, type ServiceType } from "./index"; +import { buildRequest, toServiceArgs, validateRequest } from "./validation"; +import { serviceOptions } from "./index"; export const vpcServiceCreateCommand = createCommand({ metadata: { @@ -16,40 +16,13 @@ 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, - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - ipv4: args.ipv4, - ipv6: args.ipv6, - hostname: args.hostname, - tunnelId: args.tunnelId, - resolverIps: args.resolverIps, - certVerificationMode: args.certVerificationMode, - }); + 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, - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - ipv4: args.ipv4, - ipv6: args.ipv6, - hostname: args.hostname, - tunnelId: args.tunnelId, - resolverIps: args.resolverIps, - certVerificationMode: args.certVerificationMode, - }); - + const serviceArgs = toServiceArgs(args); + const request = buildRequest(serviceArgs); const service = await createService(config, request); logger.log(`✅ Created VPC service: ${service.service_id}`); diff --git a/packages/wrangler/src/vpc/update.ts b/packages/wrangler/src/vpc/update.ts index 091db8519f..4b58efe510 100644 --- a/packages/wrangler/src/vpc/update.ts +++ b/packages/wrangler/src/vpc/update.ts @@ -2,8 +2,8 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { updateService } from "./client"; import { displayServiceDetails } from "./shared"; -import { buildRequest, validateRequest } from "./validation"; -import { serviceOptions, type ServiceType } from "./index"; +import { buildRequest, toServiceArgs, validateRequest } from "./validation"; +import { serviceOptions } from "./index"; export const vpcServiceUpdateCommand = createCommand({ metadata: { @@ -21,40 +21,13 @@ 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, - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - ipv4: args.ipv4, - ipv6: args.ipv6, - hostname: args.hostname, - tunnelId: args.tunnelId, - resolverIps: args.resolverIps, - certVerificationMode: args.certVerificationMode, - }); + 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, - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - ipv4: args.ipv4, - ipv6: args.ipv6, - hostname: args.hostname, - tunnelId: args.tunnelId, - resolverIps: args.resolverIps, - certVerificationMode: args.certVerificationMode, - }); - + const serviceArgs = toServiceArgs(args); + const request = buildRequest(serviceArgs); const service = await updateService(config, args.serviceId, request); logger.log(`✅ Updated VPC service: ${service.service_id}`); diff --git a/packages/wrangler/src/vpc/validation.ts b/packages/wrangler/src/vpc/validation.ts index 93eb45c9ab..1d8563b7bb 100644 --- a/packages/wrangler/src/vpc/validation.ts +++ b/packages/wrangler/src/vpc/validation.ts @@ -79,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);