diff --git a/background.ts b/background.ts index c848c8d51eb..cc2e5db520c 100644 --- a/background.ts +++ b/background.ts @@ -842,12 +842,20 @@ ipcMainProxy.handle('service-forward', async(_, service, state) => { if (state) { const hostPort = service.listenPort ?? 0; - await k8smanager.kubeBackend.forwardPort(namespace, service.name, service.port, hostPort); + await doForwardPort(namespace, service.name, service.port, hostPort); } else { - await k8smanager.kubeBackend.cancelForward(namespace, service.name, service.port); + await doCancelForward(namespace, service.name, service.port); } }); +async function doForwardPort(namespace: string, service: string, k8sPort: string | number, hostPort: number) { + return await k8smanager.kubeBackend.forwardPort(namespace, service, k8sPort, hostPort); +} + +async function doCancelForward(namespace: string, service: string, k8sPort: string | number) { + return await k8smanager.kubeBackend.cancelForward(namespace, service, k8sPort); +} + ipcMainProxy.on('k8s-integrations', async() => { mainEvents.emit('integration-update', await integrationManager.listIntegrations() ?? {}); }); @@ -1354,6 +1362,14 @@ class BackgroundCommandWorker implements CommandWorkerInterface { doFactoryReset(keepSystemImages); } + async forwardPort(namespace: string, service: string, k8sPort: string | number, hostPort: number) { + return await doForwardPort(namespace, service, k8sPort, hostPort); + } + + async cancelForward(namespace: string, service: string, k8sPort: string | number) { + return await doCancelForward(namespace, service, k8sPort); + } + /** * Execute the preference update for services that don't require a backend restart. */ diff --git a/bats/tests/k8s/wasm.bats b/bats/tests/k8s/wasm.bats index ab5d8896ac7..57334176de5 100644 --- a/bats/tests/k8s/wasm.bats +++ b/bats/tests/k8s/wasm.bats @@ -141,3 +141,21 @@ EOF assert_success assert_output "Hello world from Spin!" } + +@test 'fail to connect to the service on localhost without port forwarding' { + run try curl --silent --fail "http://localhost:8080/hello" + assert_failure +} + +@test 'connect to the service on localhost with port forwarding' { + rdctl api -X POST -b '{ "namespace": "default", "service": "hello-spin", "k8sPort": 80, "hostPort": 8080 }' port_forwarding + run try curl --silent --fail "http://localhost:8080/hello" + assert_success + assert_output "Hello world from Spin!" +} + +@test 'fail to connect to the service on localhost after removing port forwarding' { + rdctl api -X DELETE "port_forwarding?namespace=default&service=hello-spin&k8sPort=80" + run try curl --silent --fail "http://localhost:8080/hello" + assert_failure +} diff --git a/pkg/rancher-desktop/assets/specs/command-api.yaml b/pkg/rancher-desktop/assets/specs/command-api.yaml index 3e66dfc3c65..4a138fd302f 100644 --- a/pkg/rancher-desktop/assets/specs/command-api.yaml +++ b/pkg/rancher-desktop/assets/specs/command-api.yaml @@ -208,6 +208,47 @@ paths: '400': description: An error occurred + /v1/port_forwarding: + post: + operationId: createPortForward + summary: Create a new port forwarding + requestBody: + description: JSON block consisting of the port forwarding details + content: + application/json: + schema: + type: object + properties: + namespace: + type: string + service: + type: string + k8sPort: + type: string + hostPort: + type: integer + required: true + responses: + '200': + description: The port forwarding was created. + '400': + description: The port forwarding could not be created. + delete: + operationId: deletePortForward + summary: Delete a port forwarding + parameters: + - in: query + name: namespace + - in: query + name: service + - in: query + name: k8sPort + responses: + '200': + description: The port forwarding was deleted. + '400': + description: The port forwarding could not be deleted. + /v1/propose_settings: put: operationId: proposeSettings diff --git a/pkg/rancher-desktop/main/commandServer/httpCommandServer.ts b/pkg/rancher-desktop/main/commandServer/httpCommandServer.ts index fbb9391acc2..f933d3e1796 100644 --- a/pkg/rancher-desktop/main/commandServer/httpCommandServer.ts +++ b/pkg/rancher-desktop/main/commandServer/httpCommandServer.ts @@ -104,6 +104,10 @@ export class HttpCommandServer { }, delete: { '/v1/snapshots': [0, this.deleteSnapshot] }, } as const, + { + post: { '/v1/port_forwarding': [1, this.createPortForwarding] }, + delete: { '/v1/port_forwarding': [1, this.deletePortForwarding] }, + } as const, ); constructor(commandWorker: CommandWorkerInterface) { @@ -548,6 +552,95 @@ export class HttpCommandServer { } } + protected async createPortForwarding(request: express.Request, response: express.Response, _: commandContext): Promise { + let values: Record = {}; + const [data, payloadError] = await serverHelper.getRequestBody(request, MAX_REQUEST_BODY_LENGTH); + let error = ''; + let namespace = ''; + let service = ''; + let k8sPort: string | number = 0; + let hostPort = 0; + + if (!payloadError) { + try { + console.debug(`Request data: ${ data }`); + values = JSON.parse(data); + if ('namespace' in values && 'service' in values && 'k8sPort' in values && 'hostPort' in values) { + namespace = values.namespace; + + service = values.service; + + if (Number.isNaN(values.k8sPort)) { + k8sPort = values.k8sPort; + } else { + k8sPort = parseInt(values.k8sPort, 10); + } + + hostPort = values.hostPort; + } else { + error = 'missing required parameters'; + } + } catch (err) { + // TODO: Revisit this log stmt if sensitive values (e.g. PII, IPs, creds) can be provided via this command + console.log(`updateSettings: error processing JSON request block\n${ data }\n`, err); + error = 'error processing JSON request block'; + } + } else { + error = payloadError; + } + if (!error) { + try { + const result = await this.commandWorker.forwardPort(namespace, service, k8sPort, hostPort); + + if (typeof result === 'number') { + console.debug('createPortForwarding: succeeded 200'); + response.status(200).type('txt').send(`${ result }`); + } else { + console.debug(`createPortForwarding: write back status 400, error forwarding port`); + response.status(400).type('txt').send('Could not forward port'); + } + } catch (err) { + console.error(`createPortForwarding: error forwarding port:`, err); + response.status(400).type('txt').send('Could not forward port'); + } + } else { + console.debug(`createPortForwarding: write back status 400, error: ${ error }`); + response.status(400).type('txt').send(error); + } + } + + protected async deletePortForwarding(request: express.Request, response: express.Response, context: commandContext): Promise { + const namespace = request.query.namespace ?? ''; + const service = request.query.service ?? ''; + const k8sPort = request.query.k8sPort ?? ''; + + if (!namespace) { + response.status(400).type('txt').send('Port forwarding namespace is required in query parameters'); + } else if (!service) { + response.status(400).type('txt').send('Port forwarding service is required in query parameters'); + } else if (!k8sPort) { + response.status(400).type('txt').send('Port forwarding k8sPort is required in query parameters'); + } else if (typeof namespace !== 'string') { + response.status(400).type('txt').send(`Invalid port forwarding namespace ${ JSON.stringify(namespace) }: not a string.`); + } else if (typeof service !== 'string') { + response.status(400).type('txt').send(`Invalid port forwarding service ${ JSON.stringify(service) }: not a string.`); + } else if (typeof k8sPort !== 'string') { + response.status(400).type('txt').send(`Invalid port forwarding k8sPort ${ JSON.stringify(k8sPort) }: not a string.`); + } else { + const k8sPortResolved = Number.isNaN(k8sPort) ? k8sPort : parseInt(k8sPort, 10); + + try { + await this.commandWorker.cancelForward(namespace, service, k8sPortResolved); + + console.debug('deletePortForwarding: succeeded 200'); + response.status(200).type('txt').send('Port forwarding successfully deleted'); + } catch (error: any) { + console.error(`deletePortForwarding: error deleting port forwarding:`, error); + response.status(400).type('txt').send('Could not delete port forwarding'); + } + } + } + wrapShutdown(request: express.Request, response: express.Response, context: commandContext): Promise { console.debug('shutdown: succeeded 202'); response.status(202).type('txt').send('Shutting down.'); @@ -819,6 +912,9 @@ export interface CommandWorkerInterface { deleteSnapshot: (context: commandContext, name: string) => Promise; restoreSnapshot: (context: commandContext, name: string) => Promise; cancelSnapshot: () => Promise; + + forwardPort: (namespace: string, service: string, k8sPort: string | number, hostPort: number) => Promise; + cancelForward: (namespace: string, service: string, k8sPort: string | number) => Promise; } // Extend CommandWorkerInterface to have extra types, as these types are used by