From 993c411e4a149d2d12212e8985d59c279a0d33ca Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Thu, 21 Nov 2024 11:19:29 +1100 Subject: [PATCH 1/4] addd basic stack cdk code --- .../stacks/client-websocket-conn/README.md | 31 ++++ .../client-websocket-conn/deploy/stack.ts | 153 ++++++++++++++++++ .../client-websocket-conn/lambda/connect.py | 21 +++ .../lambda/disconnect.py | 17 ++ .../client-websocket-conn/lambda/message.py | 72 +++++++++ .../websocket-api-arch.png | Bin 0 -> 23602 bytes 6 files changed, 294 insertions(+) create mode 100644 lib/workload/stateless/stacks/client-websocket-conn/README.md create mode 100644 lib/workload/stateless/stacks/client-websocket-conn/deploy/stack.ts create mode 100644 lib/workload/stateless/stacks/client-websocket-conn/lambda/connect.py create mode 100644 lib/workload/stateless/stacks/client-websocket-conn/lambda/disconnect.py create mode 100644 lib/workload/stateless/stacks/client-websocket-conn/lambda/message.py create mode 100644 lib/workload/stateless/stacks/client-websocket-conn/websocket-api-arch.png diff --git a/lib/workload/stateless/stacks/client-websocket-conn/README.md b/lib/workload/stateless/stacks/client-websocket-conn/README.md new file mode 100644 index 000000000..6c78b28e9 --- /dev/null +++ b/lib/workload/stateless/stacks/client-websocket-conn/README.md @@ -0,0 +1,31 @@ +# WebSocket API Stack + +A serverless WebSocket API implementation using AWS CDK, API Gateway WebSocket APIs, Lambda, and DynamoDB for real-time communication. + +## Architecture + +![Architecture Diagram](./websocket-api-arch.png) + + +### Components + +- **API Gateway WebSocket API**: Handles WebSocket connections +- **Lambda Functions**: Process WebSocket events +- **DynamoDB**: Stores connection information + +## Features + +- Real-time bidirectional communication +- Connection management +- Message broadcasting +- Secure VPC deployment +- Automatic scaling +- Connection cleanup + +## Prerequisites + +- AWS CDK CLI +- Node.js & npm +- Python 3.12 +- AWS Account and configured credentials +- VPC with private subnets diff --git a/lib/workload/stateless/stacks/client-websocket-conn/deploy/stack.ts b/lib/workload/stateless/stacks/client-websocket-conn/deploy/stack.ts new file mode 100644 index 000000000..48ba10afd --- /dev/null +++ b/lib/workload/stateless/stacks/client-websocket-conn/deploy/stack.ts @@ -0,0 +1,153 @@ +import { Stack, RemovalPolicy, StackProps, Duration } from 'aws-cdk-lib'; +import { Table, AttributeType } from 'aws-cdk-lib/aws-dynamodb'; +import { Vpc, SecurityGroup, VpcLookupOptions, IVpc, ISecurityGroup } from 'aws-cdk-lib/aws-ec2'; +import { WebSocketApi, WebSocketStage } from 'aws-cdk-lib/aws-apigatewayv2'; +import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { PythonFunction } from '@aws-cdk/aws-lambda-python-alpha'; +import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; +import * as path from 'path'; + +export interface WebSocketApiStackProps extends StackProps { + connectionTableName: string; + websocketApigatewayName: string; + connectionFunctionName: string; + disconnectFunctionName: string; + messageFunctionName: string; + + lambdaSecurityGroupName: string; + vpcProps: VpcLookupOptions; +} + +export class WebSocketApiStack extends Stack { + private readonly lambdaRuntimePythonVersion = Runtime.PYTHON_3_12; + private readonly props: WebSocketApiStackProps; + private vpc: IVpc; + private lambdaSG: ISecurityGroup; + + constructor(scope: Construct, id: string, props: WebSocketApiStackProps) { + super(scope, id, props); + + this.props = props; + + this.vpc = Vpc.fromLookup(this, 'MainVpc', props.vpcProps); + this.lambdaSG = SecurityGroup.fromLookupByName( + this, + 'LambdaSecurityGroup', + props.lambdaSecurityGroupName, + this.vpc + ); + + // DynamoDB Table for storing connection IDs + const connectionTable = new Table(this, 'WebSocketConnections', { + tableName: props.connectionTableName, + partitionKey: { + name: 'ConnectionId', + type: AttributeType.STRING, + }, + removalPolicy: RemovalPolicy.DESTROY, // For demo purposes, not recommended for production + }); + + // DynamoDB Table for message history + // const messageHistoryTable = new Table(this, "WebSocketMessageHistory", { + // partitionKey: { + // name: "messageId", + // type: AttributeType.STRING, + // }, + // timeToLiveAttribute: "ttl", // Enable TTL + // removalPolicy: RemovalPolicy.DESTROY, + // }); + + // Lambda function for $connect + const connectHandler = this.createPythonFunction(props.connectionFunctionName, { + index: 'connect.py', + handler: 'lambda_handler', + timeout: Duration.minutes(2), + }); + + // Lambda function for $disconnect + const disconnectHandler = this.createPythonFunction(props.disconnectFunctionName, { + index: 'disconnect.py', + handler: 'lambda_handler', + timeout: Duration.minutes(2), + }); + + // Lambda function for $default (broadcast messages) + const messageHandler = this.createPythonFunction(props.messageFunctionName, { + index: 'message.py', + handler: 'lambda_handler', + timeout: Duration.minutes(2), + }); + + // Grant permissions to Lambda functions + connectionTable.grantReadWriteData(connectHandler); + connectionTable.grantReadWriteData(disconnectHandler); + connectionTable.grantReadWriteData(messageHandler); + // messageHistoryTable.grantReadData(connectHandler); + // messageHistoryTable.grantReadWriteData(messageHandler); + + // WebSocket API + const api = new WebSocketApi(this, props.websocketApigatewayName, { + apiName: props.websocketApigatewayName, + connectRouteOptions: { + integration: new WebSocketLambdaIntegration('ConnectIntegration', connectHandler), + }, + disconnectRouteOptions: { + integration: new WebSocketLambdaIntegration('DisconnectIntegration', disconnectHandler), + }, + defaultRouteOptions: { + integration: new WebSocketLambdaIntegration('DefaultIntegration', messageHandler), + }, + }); + + api.addRoute('sendMessage', { + integration: new WebSocketLambdaIntegration('SendMessageIntegration', messageHandler), + }); + + // Deploy WebSocket API to a stage + const stage = new WebSocketStage(this, 'WebSocketStage', { + webSocketApi: api, + stageName: 'dev', + autoDeploy: true, + }); + + // Create the WebSocket API endpoint URL + const webSocketApiEndpoint = `${api.apiEndpoint}/${stage.stageName}`; + + const commonEnvironment = { + CONNECTION_TABLE: connectionTable.tableName, + // MESSAGE_HISTORY_TABLE: messageHistoryTable.tableName, + WEBSOCKET_API_ENDPOINT: webSocketApiEndpoint, + }; + + // Add environment variables individually + for (const [key, value] of Object.entries(commonEnvironment)) { + connectHandler.addEnvironment(key, value); + disconnectHandler.addEnvironment(key, value); + messageHandler.addEnvironment(key, value); + } + + // Grant permissions to the message handler + messageHandler.addToRolePolicy( + new PolicyStatement({ + actions: ['execute-api:ManageConnections'], + resources: [ + `arn:aws:execute-api:${this.region}:${this.account}:${api.apiId}/dev/POST/@connections/*`, + ], + }) + ); + } + + private createPythonFunction(name: string, props: object): PythonFunction { + return new PythonFunction(this, name, { + entry: path.join(__dirname, '../lambda'), + runtime: this.lambdaRuntimePythonVersion, + securityGroups: [this.lambdaSG], + vpc: this.vpc, + vpcSubnets: { subnets: this.vpc.privateSubnets }, + architecture: Architecture.ARM_64, + ...props, + }); + } +} diff --git a/lib/workload/stateless/stacks/client-websocket-conn/lambda/connect.py b/lib/workload/stateless/stacks/client-websocket-conn/lambda/connect.py new file mode 100644 index 000000000..e52329798 --- /dev/null +++ b/lib/workload/stateless/stacks/client-websocket-conn/lambda/connect.py @@ -0,0 +1,21 @@ +import boto3 +import os + +def lambda_handler(event, context): + # Get table names from environment variables + connections_table_name = os.environ['CONNECTION_TABLE'] + + dynamodb = boto3.resource('dynamodb') + connections_table = dynamodb.Table(connections_table_name) + + connection_id = event['requestContext']['connectionId'] + + try: + # Store connection + connections_table.put_item( + Item={'ConnectionId': connection_id} + ) + except Exception as e: + return {'statusCode': 500, 'body': str(e)} + + return {'statusCode': 200} \ No newline at end of file diff --git a/lib/workload/stateless/stacks/client-websocket-conn/lambda/disconnect.py b/lib/workload/stateless/stacks/client-websocket-conn/lambda/disconnect.py new file mode 100644 index 000000000..9cc4fe500 --- /dev/null +++ b/lib/workload/stateless/stacks/client-websocket-conn/lambda/disconnect.py @@ -0,0 +1,17 @@ +import boto3 +import os + +def lambda_handler(event, context): + # Get table name from environment variable + connections_table_name = os.environ['CONNECTION_TABLE'] + + dynamodb = boto3.resource('dynamodb') + table = dynamodb.Table(connections_table_name) + + connection_id = event['requestContext']['connectionId'] + + try: + table.delete_item(Key={'ConnectionId': connection_id}) + return {'statusCode': 200} + except Exception as e: + return {'statusCode': 500, 'body': str(e)} \ No newline at end of file diff --git a/lib/workload/stateless/stacks/client-websocket-conn/lambda/message.py b/lib/workload/stateless/stacks/client-websocket-conn/lambda/message.py new file mode 100644 index 000000000..9452ae87c --- /dev/null +++ b/lib/workload/stateless/stacks/client-websocket-conn/lambda/message.py @@ -0,0 +1,72 @@ +import boto3 +import json +import os + +def lambda_handler(event, context): + + assert os.environ['CONNECTION_TABLE'] is not None, "CONNECTION_TABLE environment variable is not set" + assert os.environ['WEBSOCKET_API_ENDPOINT'] is not None, "WEBSOCKET_API_ENDPOINT environment variable is not set" + + # Get environment variables + connections_table_name = os.environ['CONNECTION_TABLE'] + + # connections URL with replace wss:// header to https + websocket_endpoint = os.environ['WEBSOCKET_API_ENDPOINT'].replace('wss://', 'https://') + + dynamodb = boto3.resource('dynamodb') + connections_table = dynamodb.Table(connections_table_name) + + # Initialize API Gateway client + apigw_client = boto3.client('apigatewaymanagementapi', + endpoint_url=websocket_endpoint) + + print(f"Received event: {event}, websocket endpoint: {websocket_endpoint}") + + try: + # Initialize response data + data = event + response_data = { + 'type': data.get('type', ''), + 'message': data.get('message', '') + } + + # Broadcast to all connections + connections = connections_table.scan()['Items'] + + for connection in connections: + connection_id = connection['ConnectionId'] + try: + apigw_client.post_to_connection( + ConnectionId=connection_id, + Data=json.dumps(response_data) + ) + except apigw_client.exceptions.GoneException: + # Remove stale connection + connections_table.delete_item(Key={'connectionId': connection_id}) + except Exception as e: + print(f"Failed to post message to {connection_id}: {e}") + + return {'statusCode': 200} + + except json.JSONDecodeError: + return { + 'statusCode': 400, + 'body': json.dumps({'error': 'Invalid JSON in request body'}) + } + except KeyError as e: + return { + 'statusCode': 400, + 'body': json.dumps({'error': f'Missing required field: {str(e)}'}) + } + except Exception as e: + print(f"Error: {e}") + return { + 'statusCode': 500, + 'body': json.dumps({'error': 'Internal server error'}) + } + + +# test case +# curl -X POST https://.execute-api..amazonaws.com/Prod/message -H "Content-Type: application/json" -d '{"type": "test", "message": "Hello, world!"}' +# invoke lambda function from aws console, cmd: aws lambda invoke --function-name --payload '{"type": "test", "message": "Hello, world!"}' response.json +# check cloudwatch logs for response \ No newline at end of file diff --git a/lib/workload/stateless/stacks/client-websocket-conn/websocket-api-arch.png b/lib/workload/stateless/stacks/client-websocket-conn/websocket-api-arch.png new file mode 100644 index 0000000000000000000000000000000000000000..a630e7438f63880cb2bf9affe66d8ebb603ea872 GIT binary patch literal 23602 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�n5wqL{&&>LK-4}SD5Z?J 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 q!lmZyjo+4z$ Date: Thu, 21 Nov 2024 14:01:19 +1100 Subject: [PATCH 2/4] add websocket api stack into stateless --- config/config.ts | 3 ++- config/stacks/clientWebsocketApi.ts | 13 +++++++++++ .../stacks/client-websocket-conn/README.md | 1 - .../deploy/{stack.ts => index.ts} | 22 +++++++++++++------ .../statelessStackCollectionClass.ts | 9 ++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 config/stacks/clientWebsocketApi.ts rename lib/workload/stateless/stacks/client-websocket-conn/deploy/{stack.ts => index.ts} (87%) diff --git a/config/config.ts b/config/config.ts index 691b0fcc2..1c7e88332 100644 --- a/config/config.ts +++ b/config/config.ts @@ -63,7 +63,7 @@ import { getOraCompressionIcav2PipelineTableStackProps, } from './stacks/oraCompressionPipelineManager'; import { getOraDecompressionManagerStackProps } from './stacks/oraDecompressionPipelineManager'; - +import { getWebSocketApiStackProps } from './stacks/clientWebsocketApi'; interface EnvironmentConfig { name: string; region: string; @@ -130,6 +130,7 @@ export const getEnvironmentConfig = (stage: AppStage): EnvironmentConfig | null workflowManagerStackProps: getWorkflowManagerStackProps(stage), stackyMcStackFaceProps: getGlueStackProps(stage), fmAnnotatorProps: getFmAnnotatorProps(), + websocketApiStackProps: getWebSocketApiStackProps(stage), }, }; diff --git a/config/stacks/clientWebsocketApi.ts b/config/stacks/clientWebsocketApi.ts new file mode 100644 index 000000000..4585b9464 --- /dev/null +++ b/config/stacks/clientWebsocketApi.ts @@ -0,0 +1,13 @@ +import { WebSocketApiStackProps } from '../../lib/workload/stateless/stacks/client-websocket-conn/deploy'; +import { AppStage, vpcProps } from '../constants'; + +export const getWebSocketApiStackProps = (stage: AppStage): WebSocketApiStackProps => { + return { + connectionTableName: 'OrcaBusClientWebsocketApiConnectionTable', + websocketApigatewayName: `OrcaBusClientWebsocketApi${stage}`, + lambdaSecurityGroupName: 'OrcaBusClientWebsocketApiSecurityGroup', + vpcProps: vpcProps, + websocketApiEndpointParameterName: `/orcabus/client-websocket-api-endpoint`, + websocketStageName: stage, + }; +}; diff --git a/lib/workload/stateless/stacks/client-websocket-conn/README.md b/lib/workload/stateless/stacks/client-websocket-conn/README.md index 6c78b28e9..0b6ec31a3 100644 --- a/lib/workload/stateless/stacks/client-websocket-conn/README.md +++ b/lib/workload/stateless/stacks/client-websocket-conn/README.md @@ -6,7 +6,6 @@ A serverless WebSocket API implementation using AWS CDK, API Gateway WebSocket A ![Architecture Diagram](./websocket-api-arch.png) - ### Components - **API Gateway WebSocket API**: Handles WebSocket connections diff --git a/lib/workload/stateless/stacks/client-websocket-conn/deploy/stack.ts b/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts similarity index 87% rename from lib/workload/stateless/stacks/client-websocket-conn/deploy/stack.ts rename to lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts index 48ba10afd..b46b3d629 100644 --- a/lib/workload/stateless/stacks/client-websocket-conn/deploy/stack.ts +++ b/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts @@ -8,16 +8,17 @@ import { PythonFunction } from '@aws-cdk/aws-lambda-python-alpha'; import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'; import { Construct } from 'constructs'; import * as path from 'path'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; export interface WebSocketApiStackProps extends StackProps { connectionTableName: string; websocketApigatewayName: string; - connectionFunctionName: string; - disconnectFunctionName: string; - messageFunctionName: string; lambdaSecurityGroupName: string; vpcProps: VpcLookupOptions; + + websocketApiEndpointParameterName: string; + websocketStageName: string; } export class WebSocketApiStack extends Stack { @@ -60,21 +61,21 @@ export class WebSocketApiStack extends Stack { // }); // Lambda function for $connect - const connectHandler = this.createPythonFunction(props.connectionFunctionName, { + const connectHandler = this.createPythonFunction('connectHandler', { index: 'connect.py', handler: 'lambda_handler', timeout: Duration.minutes(2), }); // Lambda function for $disconnect - const disconnectHandler = this.createPythonFunction(props.disconnectFunctionName, { + const disconnectHandler = this.createPythonFunction('disconnectHandler', { index: 'disconnect.py', handler: 'lambda_handler', timeout: Duration.minutes(2), }); // Lambda function for $default (broadcast messages) - const messageHandler = this.createPythonFunction(props.messageFunctionName, { + const messageHandler = this.createPythonFunction('messageHandler', { index: 'message.py', handler: 'lambda_handler', timeout: Duration.minutes(2), @@ -108,13 +109,20 @@ export class WebSocketApiStack extends Stack { // Deploy WebSocket API to a stage const stage = new WebSocketStage(this, 'WebSocketStage', { webSocketApi: api, - stageName: 'dev', + stageName: props.websocketStageName, autoDeploy: true, }); // Create the WebSocket API endpoint URL const webSocketApiEndpoint = `${api.apiEndpoint}/${stage.stageName}`; + // save this url into the parameter store for the client to use + new StringParameter(this, 'WebSocketApiEndpoint', { + parameterName: props.websocketApiEndpointParameterName, + description: 'The endpoint URL for the WebSocket API', + stringValue: webSocketApiEndpoint, + }); + const commonEnvironment = { CONNECTION_TABLE: connectionTable.tableName, // MESSAGE_HISTORY_TABLE: messageHistoryTable.tableName, diff --git a/lib/workload/stateless/statelessStackCollectionClass.ts b/lib/workload/stateless/statelessStackCollectionClass.ts index 01fa31e18..bf154fee8 100644 --- a/lib/workload/stateless/statelessStackCollectionClass.ts +++ b/lib/workload/stateless/statelessStackCollectionClass.ts @@ -80,6 +80,8 @@ import { OraDecompressionManagerStackProps, } from './stacks/ora-decompression-manager/deploy'; +import { WebSocketApiStackProps, WebSocketApiStack } from './stacks/client-websocket-conn/deploy'; + export interface StatelessStackCollectionProps { metadataManagerStackProps: MetadataManagerStackProps; sequenceRunManagerStackProps: SequenceRunManagerStackProps; @@ -104,6 +106,7 @@ export interface StatelessStackCollectionProps { workflowManagerStackProps: WorkflowManagerStackProps; stackyMcStackFaceProps: GlueStackProps; fmAnnotatorProps: FMAnnotatorConfigurableProps; + websocketApiStackProps: WebSocketApiStackProps; } export class StatelessStackCollection { @@ -131,6 +134,7 @@ export class StatelessStackCollection { readonly workflowManagerStack: Stack; readonly stackyMcStackFaceStack: Stack; readonly fmAnnotator: Stack; + readonly websocketApiStack: Stack; constructor( scope: Construct, @@ -309,6 +313,11 @@ export class StatelessStackCollection { ...statelessConfiguration.fmAnnotatorProps, domainName: fileManagerStack.domainName, }); + + this.websocketApiStack = new WebSocketApiStack(scope, 'WebSocketApiStack', { + ...this.createTemplateProps(env, 'WebSocketApiStack'), + ...statelessConfiguration.websocketApiStackProps, + }); } /** From 5d1e48f4f8ea29fa7c4ca123698a8f487cfe23c2 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Fri, 22 Nov 2024 09:23:33 +1100 Subject: [PATCH 3/4] add auth func --- config/stacks/clientWebsocketApi.ts | 4 +- .../client-websocket-conn/deploy/index.ts | 31 ++++++++ .../deps/requirements.txt | 2 + .../client-websocket-conn/lambda/auth.py | 74 +++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 lib/workload/stateless/stacks/client-websocket-conn/deps/requirements.txt create mode 100644 lib/workload/stateless/stacks/client-websocket-conn/lambda/auth.py diff --git a/config/stacks/clientWebsocketApi.ts b/config/stacks/clientWebsocketApi.ts index 4585b9464..1ac34c193 100644 --- a/config/stacks/clientWebsocketApi.ts +++ b/config/stacks/clientWebsocketApi.ts @@ -1,5 +1,5 @@ import { WebSocketApiStackProps } from '../../lib/workload/stateless/stacks/client-websocket-conn/deploy'; -import { AppStage, vpcProps } from '../constants'; +import { AppStage, vpcProps, region, cognitoUserPoolIdParameterName } from '../constants'; export const getWebSocketApiStackProps = (stage: AppStage): WebSocketApiStackProps => { return { @@ -9,5 +9,7 @@ export const getWebSocketApiStackProps = (stage: AppStage): WebSocketApiStackPro vpcProps: vpcProps, websocketApiEndpointParameterName: `/orcabus/client-websocket-api-endpoint`, websocketStageName: stage, + cognitoRegion: region, + cognitoUserPoolIdParameterName: cognitoUserPoolIdParameterName, }; }; diff --git a/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts b/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts index b46b3d629..cd079b65b 100644 --- a/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts +++ b/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts @@ -9,6 +9,11 @@ import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'; import { Construct } from 'constructs'; import * as path from 'path'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +// import { +// WebSocketIamAuthorizer, +// WebSocketLambdaAuthorizer, +// WebSocketLambdaAuthorizerProps, +// } from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; export interface WebSocketApiStackProps extends StackProps { connectionTableName: string; @@ -19,6 +24,10 @@ export interface WebSocketApiStackProps extends StackProps { websocketApiEndpointParameterName: string; websocketStageName: string; + + // Cognito configuration for the authorizer + cognitoRegion: string; + cognitoUserPoolIdParameterName: string; } export class WebSocketApiStack extends Stack { @@ -81,6 +90,23 @@ export class WebSocketApiStack extends Stack { timeout: Duration.minutes(2), }); + const userPoolId = StringParameter.fromStringParameterName( + this, + 'CognitoUserPoolIdParameter', + props.cognitoUserPoolIdParameterName + ).stringValue; + + // authorizer function to check the client token based on the JWT token + const connectAuthorizer = this.createPythonFunction('connectAuthorizer', { + index: 'auth.py', + handler: 'lambda_handler', + timeout: Duration.minutes(2), + environment: { + COGNITO_REGION: props.cognitoRegion, + COGNITO_USER_POOL_ID: userPoolId, + }, + }); + // Grant permissions to Lambda functions connectionTable.grantReadWriteData(connectHandler); connectionTable.grantReadWriteData(disconnectHandler); @@ -93,6 +119,10 @@ export class WebSocketApiStack extends Stack { apiName: props.websocketApigatewayName, connectRouteOptions: { integration: new WebSocketLambdaIntegration('ConnectIntegration', connectHandler), + // authorizer: new WebSocketLambdaAuthorizer('ConnectAuthorizer', connectAuthorizer, { + // authorizerName: 'ConnectAuthorizer', + // identitySource: ['route.request.header.Authorization'], + // }), }, disconnectRouteOptions: { integration: new WebSocketLambdaIntegration('DisconnectIntegration', disconnectHandler), @@ -102,6 +132,7 @@ export class WebSocketApiStack extends Stack { }, }); + // Add a route for sending messages for sending messages to the client api.addRoute('sendMessage', { integration: new WebSocketLambdaIntegration('SendMessageIntegration', messageHandler), }); diff --git a/lib/workload/stateless/stacks/client-websocket-conn/deps/requirements.txt b/lib/workload/stateless/stacks/client-websocket-conn/deps/requirements.txt new file mode 100644 index 000000000..f93f1f67b --- /dev/null +++ b/lib/workload/stateless/stacks/client-websocket-conn/deps/requirements.txt @@ -0,0 +1,2 @@ +PyJWT==2.8.0 +requests==2.31.0 \ No newline at end of file diff --git a/lib/workload/stateless/stacks/client-websocket-conn/lambda/auth.py b/lib/workload/stateless/stacks/client-websocket-conn/lambda/auth.py new file mode 100644 index 000000000..76dc43757 --- /dev/null +++ b/lib/workload/stateless/stacks/client-websocket-conn/lambda/auth.py @@ -0,0 +1,74 @@ +import os +import logging +import jwt +import requests +from typing import Dict, Any + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Get environment variables +COGNITO_USER_POOL_ID = os.environ['COGNITO_USER_POOL_ID'] +COGNITO_REGION = os.environ.get('COGNITO_REGION', 'ap-southeast-2') + +def get_public_key(): + """Get Cognito public key for JWT verification""" + url = f'https://cognito-idp.{COGNITO_REGION}.amazonaws.com/{COGNITO_USER_POOL_ID}/.well-known/jwks.json' + try: + response = requests.get(url) + return response.json()['keys'][0] # Get the first key + except Exception as e: + logger.error(f"Error getting public key: {str(e)}") + raise + +def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """Simple Lambda authorizer for WebSocket""" + logger.info("WebSocket authorization request") + + try: + # Get token from headers + token = event.get('headers', {}).get('Authorization', '').replace('Bearer ', '') + if not token: + raise Exception('No token provided') + + # Get public key + public_key = get_public_key() + + # Verify token + decoded = jwt.decode( + token, + public_key, + algorithms=['RS256'], + issuer=f'https://cognito-idp.{COGNITO_REGION}.amazonaws.com/{COGNITO_USER_POOL_ID}' + ) + + # Generate allow policy + return { + 'principalId': decoded['sub'], + 'policyDocument': { + 'Version': '2012-10-17', + 'Statement': [{ + 'Action': 'execute-api:Invoke', + 'Effect': 'Allow', + 'Resource': event['methodArn'] + }] + }, + 'context': { + 'userId': decoded['sub'] + } + } + + except Exception as e: + logger.error(f"Authorization failed: {str(e)}") + # Return deny policy + return { + 'principalId': 'unauthorized', + 'policyDocument': { + 'Version': '2012-10-17', + 'Statement': [{ + 'Action': 'execute-api:Invoke', + 'Effect': 'Deny', + 'Resource': event['methodArn'] + }] + } + } \ No newline at end of file From 979bd02b52386fa15859a4da8f8e32c33c85bee3 Mon Sep 17 00:00:00 2001 From: Ray Liu Date: Tue, 26 Nov 2024 09:52:56 +1100 Subject: [PATCH 4/4] add refactor to websocket api --- config/stacks/clientWebsocketApi.ts | 5 ++ .../client-websocket-conn/deploy/index.ts | 82 +++++++++++-------- .../client-websocket-conn/lambda/auth.py | 65 +++++++-------- .../client-websocket-conn/lambda/connect.py | 7 +- .../lambda/disconnect.py | 2 + .../client-websocket-conn/lambda/message.py | 26 +++++- 6 files changed, 116 insertions(+), 71 deletions(-) diff --git a/config/stacks/clientWebsocketApi.ts b/config/stacks/clientWebsocketApi.ts index 1ac34c193..33b55bb1b 100644 --- a/config/stacks/clientWebsocketApi.ts +++ b/config/stacks/clientWebsocketApi.ts @@ -4,8 +4,13 @@ import { AppStage, vpcProps, region, cognitoUserPoolIdParameterName } from '../c export const getWebSocketApiStackProps = (stage: AppStage): WebSocketApiStackProps => { return { connectionTableName: 'OrcaBusClientWebsocketApiConnectionTable', + messageHistoryTableName: 'OrcaBusClientWebsocketApiMessageHistoryTable', websocketApigatewayName: `OrcaBusClientWebsocketApi${stage}`, lambdaSecurityGroupName: 'OrcaBusClientWebsocketApiSecurityGroup', + connectionFunctionName: 'websocketApiConnect', + disconnectFunctionName: 'websocketApiDisconnect', + messageFunctionName: 'websocketApiMessage', + vpcProps: vpcProps, websocketApiEndpointParameterName: `/orcabus/client-websocket-api-endpoint`, websocketStageName: stage, diff --git a/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts b/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts index cd079b65b..439e0a816 100644 --- a/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts +++ b/lib/workload/stateless/stacks/client-websocket-conn/deploy/index.ts @@ -4,30 +4,31 @@ import { Vpc, SecurityGroup, VpcLookupOptions, IVpc, ISecurityGroup } from 'aws- import { WebSocketApi, WebSocketStage } from 'aws-cdk-lib/aws-apigatewayv2'; import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; -import { PythonFunction } from '@aws-cdk/aws-lambda-python-alpha'; -import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'; +import { PythonFunction, PythonLayerVersion } from '@aws-cdk/aws-lambda-python-alpha'; +import { Runtime, Architecture, LayerVersion } from 'aws-cdk-lib/aws-lambda'; import { Construct } from 'constructs'; import * as path from 'path'; +import { WebSocketLambdaAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; -// import { -// WebSocketIamAuthorizer, -// WebSocketLambdaAuthorizer, -// WebSocketLambdaAuthorizerProps, -// } from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; export interface WebSocketApiStackProps extends StackProps { + // DynamoDB and Lambda configuration connectionTableName: string; websocketApigatewayName: string; + connectionFunctionName: string; + disconnectFunctionName: string; + messageFunctionName: string; + messageHistoryTableName: string; - lambdaSecurityGroupName: string; - vpcProps: VpcLookupOptions; - + // Parameter name for the WebSocket API endpoint websocketApiEndpointParameterName: string; websocketStageName: string; // Cognito configuration for the authorizer cognitoRegion: string; cognitoUserPoolIdParameterName: string; + lambdaSecurityGroupName: string; + vpcProps: VpcLookupOptions; } export class WebSocketApiStack extends Stack { @@ -50,46 +51,54 @@ export class WebSocketApiStack extends Stack { ); // DynamoDB Table for storing connection IDs - const connectionTable = new Table(this, 'WebSocketConnections', { + const connectionTable = new Table(this, 'WebSocketApiConnections', { tableName: props.connectionTableName, partitionKey: { - name: 'ConnectionId', + name: 'connectionId', type: AttributeType.STRING, }, removalPolicy: RemovalPolicy.DESTROY, // For demo purposes, not recommended for production }); - // DynamoDB Table for message history - // const messageHistoryTable = new Table(this, "WebSocketMessageHistory", { - // partitionKey: { - // name: "messageId", - // type: AttributeType.STRING, - // }, - // timeToLiveAttribute: "ttl", // Enable TTL - // removalPolicy: RemovalPolicy.DESTROY, - // }); + //DynamoDB Table for message history + const messageHistoryTable = new Table(this, 'WebSocketApiMessageHistory', { + tableName: props.messageHistoryTableName, + partitionKey: { + name: 'messageId', + type: AttributeType.STRING, + }, + timeToLiveAttribute: 'ttl', // Enable TTL + removalPolicy: RemovalPolicy.DESTROY, + }); // Lambda function for $connect - const connectHandler = this.createPythonFunction('connectHandler', { + const connectHandler = this.createPythonFunction(props.connectionFunctionName, { index: 'connect.py', handler: 'lambda_handler', timeout: Duration.minutes(2), }); // Lambda function for $disconnect - const disconnectHandler = this.createPythonFunction('disconnectHandler', { + const disconnectHandler = this.createPythonFunction(props.disconnectFunctionName, { index: 'disconnect.py', handler: 'lambda_handler', timeout: Duration.minutes(2), }); // Lambda function for $default (broadcast messages) - const messageHandler = this.createPythonFunction('messageHandler', { + const messageHandler = this.createPythonFunction(props.messageFunctionName, { index: 'message.py', handler: 'lambda_handler', timeout: Duration.minutes(2), }); + // build layer from deps + const authLayer = new PythonLayerVersion(this, 'BaseLayer', { + entry: path.join(__dirname, '../deps'), + compatibleRuntimes: [this.lambdaRuntimePythonVersion], + compatibleArchitectures: [Architecture.ARM_64], + }); + const userPoolId = StringParameter.fromStringParameterName( this, 'CognitoUserPoolIdParameter', @@ -97,7 +106,7 @@ export class WebSocketApiStack extends Stack { ).stringValue; // authorizer function to check the client token based on the JWT token - const connectAuthorizer = this.createPythonFunction('connectAuthorizer', { + const connectAuthorizer = this.createPythonFunction('AuthHandler', { index: 'auth.py', handler: 'lambda_handler', timeout: Duration.minutes(2), @@ -105,6 +114,7 @@ export class WebSocketApiStack extends Stack { COGNITO_REGION: props.cognitoRegion, COGNITO_USER_POOL_ID: userPoolId, }, + layers: [authLayer], }); // Grant permissions to Lambda functions @@ -112,17 +122,26 @@ export class WebSocketApiStack extends Stack { connectionTable.grantReadWriteData(disconnectHandler); connectionTable.grantReadWriteData(messageHandler); // messageHistoryTable.grantReadData(connectHandler); - // messageHistoryTable.grantReadWriteData(messageHandler); + messageHistoryTable.grantReadWriteData(messageHandler); // WebSocket API const api = new WebSocketApi(this, props.websocketApigatewayName, { apiName: props.websocketApigatewayName, + description: 'WebSocket API for the app notifications', connectRouteOptions: { integration: new WebSocketLambdaIntegration('ConnectIntegration', connectHandler), - // authorizer: new WebSocketLambdaAuthorizer('ConnectAuthorizer', connectAuthorizer, { - // authorizerName: 'ConnectAuthorizer', - // identitySource: ['route.request.header.Authorization'], - // }), + // FIXME: uncomment this when auth is implemented + // authorizer: new WebSocketLambdaAuthorizer( + // "ConnectAuthorizer", + // connectAuthorizer, + // { + // authorizerName: "ConnectAuthorizer", + // identitySource: [ + // "route.request.header.Authorization", + // "route.request.querystring.Authorization", + // ], + // } + // ), }, disconnectRouteOptions: { integration: new WebSocketLambdaIntegration('DisconnectIntegration', disconnectHandler), @@ -132,7 +151,6 @@ export class WebSocketApiStack extends Stack { }, }); - // Add a route for sending messages for sending messages to the client api.addRoute('sendMessage', { integration: new WebSocketLambdaIntegration('SendMessageIntegration', messageHandler), }); @@ -156,7 +174,7 @@ export class WebSocketApiStack extends Stack { const commonEnvironment = { CONNECTION_TABLE: connectionTable.tableName, - // MESSAGE_HISTORY_TABLE: messageHistoryTable.tableName, + MESSAGE_HISTORY_TABLE: messageHistoryTable.tableName, WEBSOCKET_API_ENDPOINT: webSocketApiEndpoint, }; diff --git a/lib/workload/stateless/stacks/client-websocket-conn/lambda/auth.py b/lib/workload/stateless/stacks/client-websocket-conn/lambda/auth.py index 76dc43757..2a8f40fb8 100644 --- a/lib/workload/stateless/stacks/client-websocket-conn/lambda/auth.py +++ b/lib/workload/stateless/stacks/client-websocket-conn/lambda/auth.py @@ -7,9 +7,18 @@ logger = logging.getLogger() logger.setLevel(logging.INFO) -# Get environment variables -COGNITO_USER_POOL_ID = os.environ['COGNITO_USER_POOL_ID'] -COGNITO_REGION = os.environ.get('COGNITO_REGION', 'ap-southeast-2') +def generate_policy(principal_id, effect, resource): + return { + 'principalId': principal_id, + 'policyDocument': { + 'Version': '2012-10-17', + 'Statement': [{ + 'Action': 'execute-api:Invoke', + 'Effect': effect, + 'Resource': resource + }] + } + } def get_public_key(): """Get Cognito public key for JWT verification""" @@ -21,54 +30,42 @@ def get_public_key(): logger.error(f"Error getting public key: {str(e)}") raise -def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: """Simple Lambda authorizer for WebSocket""" logger.info("WebSocket authorization request") + # Get environment variables + assert 'COGNITO_USER_POOL_ID' in os.environ, "COGNITO_USER_POOL_ID is not set" + assert 'COGNITO_REGION' in os.environ, "COGNITO_REGION is not set" + COGNITO_USER_POOL_ID = os.environ['COGNITO_USER_POOL_ID'] + COGNITO_REGION = os.environ.get('COGNITO_REGION', 'ap-southeast-2') try: # Get token from headers - token = event.get('headers', {}).get('Authorization', '').replace('Bearer ', '') - if not token: - raise Exception('No token provided') + # Check both header and querystring + auth_token = None + if event.get('headers', {}).get('Authorization'): + auth_token = event['headers']['Authorization'] + elif event.get('queryStringParameters', {}).get('Authorization'): + auth_token = event['queryStringParameters']['Authorization'] + + if not auth_token: + return generate_policy('user', 'Deny', event['methodArn']) + # Get public key public_key = get_public_key() # Verify token decoded = jwt.decode( - token, + auth_token, public_key, algorithms=['RS256'], issuer=f'https://cognito-idp.{COGNITO_REGION}.amazonaws.com/{COGNITO_USER_POOL_ID}' ) # Generate allow policy - return { - 'principalId': decoded['sub'], - 'policyDocument': { - 'Version': '2012-10-17', - 'Statement': [{ - 'Action': 'execute-api:Invoke', - 'Effect': 'Allow', - 'Resource': event['methodArn'] - }] - }, - 'context': { - 'userId': decoded['sub'] - } - } - + return generate_policy(decoded['sub'], 'Allow', event['methodArn']) except Exception as e: logger.error(f"Authorization failed: {str(e)}") # Return deny policy - return { - 'principalId': 'unauthorized', - 'policyDocument': { - 'Version': '2012-10-17', - 'Statement': [{ - 'Action': 'execute-api:Invoke', - 'Effect': 'Deny', - 'Resource': event['methodArn'] - }] - } - } \ No newline at end of file + return generate_policy('unauthorized', 'Deny', event['methodArn']) diff --git a/lib/workload/stateless/stacks/client-websocket-conn/lambda/connect.py b/lib/workload/stateless/stacks/client-websocket-conn/lambda/connect.py index e52329798..ee094bbd5 100644 --- a/lib/workload/stateless/stacks/client-websocket-conn/lambda/connect.py +++ b/lib/workload/stateless/stacks/client-websocket-conn/lambda/connect.py @@ -3,6 +3,7 @@ def lambda_handler(event, context): # Get table names from environment variables + assert 'CONNECTION_TABLE' in os.environ, "CONNECTION_TABLE environment variable is not set" connections_table_name = os.environ['CONNECTION_TABLE'] dynamodb = boto3.resource('dynamodb') @@ -13,9 +14,9 @@ def lambda_handler(event, context): try: # Store connection connections_table.put_item( - Item={'ConnectionId': connection_id} + Item={'connectionId': connection_id} ) + return {'statusCode': 200} except Exception as e: + print(f"Error storing connection: {e}") return {'statusCode': 500, 'body': str(e)} - - return {'statusCode': 200} \ No newline at end of file diff --git a/lib/workload/stateless/stacks/client-websocket-conn/lambda/disconnect.py b/lib/workload/stateless/stacks/client-websocket-conn/lambda/disconnect.py index 9cc4fe500..c21c6cdfd 100644 --- a/lib/workload/stateless/stacks/client-websocket-conn/lambda/disconnect.py +++ b/lib/workload/stateless/stacks/client-websocket-conn/lambda/disconnect.py @@ -3,6 +3,7 @@ def lambda_handler(event, context): # Get table name from environment variable + assert 'CONNECTION_TABLE' in os.environ, "CONNECTION_TABLE environment variable is not set" connections_table_name = os.environ['CONNECTION_TABLE'] dynamodb = boto3.resource('dynamodb') @@ -14,4 +15,5 @@ def lambda_handler(event, context): table.delete_item(Key={'ConnectionId': connection_id}) return {'statusCode': 200} except Exception as e: + print(f"Error deleting connection: {e}") return {'statusCode': 500, 'body': str(e)} \ No newline at end of file diff --git a/lib/workload/stateless/stacks/client-websocket-conn/lambda/message.py b/lib/workload/stateless/stacks/client-websocket-conn/lambda/message.py index 9452ae87c..94d4204f5 100644 --- a/lib/workload/stateless/stacks/client-websocket-conn/lambda/message.py +++ b/lib/workload/stateless/stacks/client-websocket-conn/lambda/message.py @@ -1,21 +1,26 @@ import boto3 import json import os +import uuid +from datetime import datetime, timedelta +import time def lambda_handler(event, context): assert os.environ['CONNECTION_TABLE'] is not None, "CONNECTION_TABLE environment variable is not set" assert os.environ['WEBSOCKET_API_ENDPOINT'] is not None, "WEBSOCKET_API_ENDPOINT environment variable is not set" + assert os.environ['MESSAGE_HISTORY_TABLE'] is not None, "MESSAGE_HISTORY_TABLE environment variable is not set" # Get environment variables connections_table_name = os.environ['CONNECTION_TABLE'] + message_history_table_name = os.environ['MESSAGE_HISTORY_TABLE'] # connections URL with replace wss:// header to https websocket_endpoint = os.environ['WEBSOCKET_API_ENDPOINT'].replace('wss://', 'https://') dynamodb = boto3.resource('dynamodb') connections_table = dynamodb.Table(connections_table_name) - + message_table = dynamodb.Table(message_history_table_name) # Initialize API Gateway client apigw_client = boto3.client('apigatewaymanagementapi', endpoint_url=websocket_endpoint) @@ -30,11 +35,28 @@ def lambda_handler(event, context): 'message': data.get('message', '') } + + # save message to dynamodb + message_id = str(uuid.uuid4()) + timestamp = datetime.now().isoformat() + ttl_time = datetime.now() + timedelta(days=2) + ttl_timestamp = int(time.mktime(ttl_time.timetuple())) + message_data = { + 'messageId': message_id, + 'data': response_data, + 'timestamp': timestamp, + 'ttl': ttl_timestamp + } + try: + message_table.put_item(Item=message_data) + except Exception as e: + print(f"Error saving message to dynamodb: {e}") + # Broadcast to all connections connections = connections_table.scan()['Items'] for connection in connections: - connection_id = connection['ConnectionId'] + connection_id = connection['connectionId'] try: apigw_client.post_to_connection( ConnectionId=connection_id,