Skip to content

Commit

Permalink
385: Put APIGateway in front of private loadbalancer (#834)
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswilty committed Oct 22, 2024
1 parent 7bd3b5b commit ec3e89c
Show file tree
Hide file tree
Showing 9 changed files with 347 additions and 149 deletions.
5 changes: 4 additions & 1 deletion backend/src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import queryTypes from 'query-types';

import { importMetaUrl } from './importMetaUtils';
import nonSessionRoutes from './nonSessionRoutes';
import { usingForwardedHeader } from './proxySetup';
import sessionRoutes from './sessionRoutes';
import uiRoutes from './uiRoutes';

const app = express().use(express.json()).use(queryTypes.middleware());
const app = usingForwardedHeader(express())
.use(express.json())
.use(queryTypes.middleware());

const isDevelopment = env.NODE_ENV === 'development';
const isServingUI = env.NODE_ENV === 'prodlite';
Expand Down
65 changes: 65 additions & 0 deletions backend/src/server/proxySetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Express, Request } from 'express';

declare module 'node:net' {
interface Socket {
encrypted?: boolean;
}
}
/**
* Unfortunately, "trust proxy" is by default broken when Express is behind an
* AWS HTTP API Gateway:
* - https://github.com/expressjs/express/issues/5459
* - https://repost.aws/en/questions/QUtBHMaz7IQ6aM4RCBMnJvgw/why-does-apigw-http-api-use-forwarded-header-while-other-services-still-use-x-forwarded-headers
*
* Therefore we use Express API overrides to modify our Request IP and Protocol properties:
* - https://expressjs.com/en/guide/overriding-express-api.html
*/
export function usingForwardedHeader(app: Express): Express {
Object.defineProperties(app.request, {
ip: {
configurable: true,
enumerable: true,
get() {
const proxies = parseForwardedHeader(this as Request);
return proxies?.for ?? (this as Request).socket.remoteAddress;
},
},
protocol: {
configurable: true,
enumerable: true,
get() {
const proxies = parseForwardedHeader(this as Request);
return proxies?.proto ?? (this as Request).socket.encrypted
? 'https'
: 'http';
},
},
});
return app;
}

/**
* Forwarded header looks like this:
*
* ```
* for=12.345.67.89;proto=https;host=somehost.org,for=98.76.54.321;proto=http;host=someproxy.net
* ```
*
* Note we only need the first entry, as that is the client.
*
* @param request incoming express Request object
*/
function parseForwardedHeader(request: Request) {
return request
.header('Forwarded')
?.split(',')
.at(0)
?.split(';')
.reduce((result, proxyProps) => {
const [key, value] = proxyProps.split('=');
if (key && value) {
result[key] = value;
}
return result;
}, {} as Record<string, string>);
}
13 changes: 12 additions & 1 deletion backend/src/server/sessionRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ router.use((req, _res, next) => {
next();
});

// TODO: Remove this debug logging!
if (isProduction) {
router.use('/openai', (req, res, next) => {
console.log('Request:', req.path, `secure=${req.secure}`, req.headers);
res.on('finish', () => {
console.log('Response:', req.path, res.getHeaders());
});
next();
});
}

// handshake
router.get('/start', handleStart);

Expand All @@ -109,7 +120,7 @@ router.post('/openai/model/configure', handleConfigureModel);
router.post('/reset/all', handleResetProgress);
router.post('/reset/:level', handleResetLevel);

// Testing endpoints
// Load testing endpoints
router.post('/test/load', handleTest);

export default router;
17 changes: 13 additions & 4 deletions cloud/bin/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { App } from 'aws-cdk-lib';
import { App, Environment } from 'aws-cdk-lib';
import 'source-map-support/register';

import {
Expand All @@ -12,6 +12,12 @@ import {
} from '../lib';

const app = new App();

const env: Environment = {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
};

const tags = {
owner: appName,
classification: 'unrestricted',
Expand All @@ -24,22 +30,25 @@ const generateStackName = stackName(app);
const generateDescription = resourceDescription(app);

const uiStack = new UiStack(app, generateStackName('ui'), {
tags,
description: generateDescription('UI stack'),
env,
tags,
});

// Don't need this stack yet.
/*
const authStack = new AuthStack(app, generateStackName('auth'), {
tags,
description: generateDescription('Auth stack'),
env,
tags,
webappUrl: uiStack.cloudfrontUrl,
});
*/

new ApiStack(app, generateStackName('api'), {
tags,
description: generateDescription('API stack'),
env,
tags,
// userPool: authStack.userPool,
// userPoolClient: authStack.userPoolClient,
// userPoolDomain: authStack.userPoolDomain,
Expand Down
7 changes: 7 additions & 0 deletions cloud/cdk.context.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"availability-zones:account=600982866784:region=eu-west-1": [
"eu-west-1a",
"eu-west-1b",
"eu-west-1c"
]
}
104 changes: 92 additions & 12 deletions cloud/lib/api-stack.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { CorsHttpMethod, HttpApi, VpcLink } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpAlbIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
//import { UserPool, UserPoolClient, UserPoolDomain } from 'aws-cdk-lib/aws-cognito';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2';
import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets';
import {
Cluster,
ContainerImage,
PropagatedTagSource,
Secret as EnvSecret,
} from 'aws-cdk-lib/aws-ecs';
import { ApplicationLoadBalancedFargateService } from 'aws-cdk-lib/aws-ecs-patterns';
//import { ListenerAction } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
//import { AuthenticateCognitoAction } from 'aws-cdk-lib/aws-elasticloadbalancingv2-actions';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { Stack, StackProps } from 'aws-cdk-lib/core';
import {
CfnOutput,
RemovalPolicy,
Stack,
StackProps,
Tags,
} from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import { join } from 'node:path';

import { resourceName } from './resourceNamingUtils';
import {
appName,
resourceDescription,
resourceName,
} from './resourceNamingUtils';

type ApiStackProps = StackProps & {
// userPool: UserPool;
Expand All @@ -26,10 +40,11 @@ type ApiStackProps = StackProps & {
export class ApiStack extends Stack {
constructor(scope: Construct, id: string, props: ApiStackProps) {
super(scope, id, props);
// TODO Enable cognito auth
// TODO Enable cognito/JWT authorization
const { /*userPool, userPoolClient, userPoolDomain,*/ webappUrl } = props;

const generateResourceName = resourceName(scope);
const generateResourceDescription = resourceDescription(scope);

const dockerImageAsset = new DockerImageAsset(
this,
Expand All @@ -51,15 +66,16 @@ export class ApiStack extends Stack {
'dev/SpyLogic/ApiKey'
);

// Create a load-balanced Fargate service and make it public
// Create a private, application-load-balanced Fargate service
const containerPort = 3001;
const serviceName = generateResourceName('fargate');
const loadBalancerName = generateResourceName('alb');
const fargateServiceName = generateResourceName('fargate');
const loadBalancerName = generateResourceName('loadbalancer');
const loadBalancerLogName = generateResourceName('loadbalancer-logs');
const fargateService = new ApplicationLoadBalancedFargateService(
this,
serviceName,
fargateServiceName,
{
serviceName,
serviceName: fargateServiceName,
cluster,
cpu: 256, // Default is 256
desiredCount: 1, // Bump this up for prod!
Expand All @@ -70,6 +86,7 @@ export class ApiStack extends Stack {
NODE_ENV: 'production',
PORT: `${containerPort}`,
CORS_ALLOW_ORIGIN: webappUrl,
COOKIE_NAME: `${appName}.sid`,
},
secrets: {
OPENAI_API_KEY: EnvSecret.fromSecretsManager(
Expand All @@ -84,13 +101,25 @@ export class ApiStack extends Stack {
},
memoryLimitMiB: 512, // Default is 512
loadBalancerName,
publicLoadBalancer: true, // Default is true
openListener: false,
publicLoadBalancer: false,
propagateTags: PropagatedTagSource.SERVICE,
}
);
fargateService.targetGroup.configureHealthCheck({
path: '/health',
});
fargateService.loadBalancer.logAccessLogs(
new Bucket(this, loadBalancerLogName, {
bucketName: loadBalancerLogName,
autoDeleteObjects: true,
removalPolicy: RemovalPolicy.DESTROY,
})
);

// Hook up Cognito to load balancer
// https://stackoverflow.com/q/71124324
// TODO This needs HTTPS and a Route53 domain, so in meantime try VPCLink:
// TODO Needs HTTPS and a Route53 domain, so for now we're using APIGateway and VPCLink:
// https://repost.aws/knowledge-center/api-gateway-alb-integration
/*
const authActionName = generateResourceName('alb-auth');
Expand All @@ -104,6 +133,57 @@ export class ApiStack extends Stack {
});
*/

fargateService.targetGroup.configureHealthCheck({ path: '/health' });
// Create an HTTP APIGateway with a VPCLink integrated with our load balancer
const securityGroupName = generateResourceName('vpclink-sg');
const vpcLinkSecurityGroup = new SecurityGroup(this, securityGroupName, {
vpc,
securityGroupName,
allowAllOutbound: false,
});
vpcLinkSecurityGroup.connections.allowFromAnyIpv4(
Port.tcp(80),
'APIGW to VPCLink'
);
vpcLinkSecurityGroup.connections.allowTo(
fargateService.loadBalancer,
Port.tcp(80),
'VPCLink to ALB'
);

const vpcLinkName = generateResourceName('vpclink');
const vpcLink = new VpcLink(this, vpcLinkName, {
vpc,
vpcLinkName,
securityGroups: [vpcLinkSecurityGroup],
});
Object.entries(props.tags ?? {}).forEach(([key, value]) => {
Tags.of(vpcLink).add(key, value);
});

const apiName = generateResourceName('api');
const api = new HttpApi(this, apiName, {
apiName,
description: generateResourceDescription('API'),
corsPreflight: {
allowOrigins: [webappUrl],
allowMethods: [CorsHttpMethod.ANY],
allowHeaders: ['Content-Type', 'Authorization'],
allowCredentials: true,
},
});
api.addRoutes({
path: '/{proxy+}',
integration: new HttpAlbIntegration(
generateResourceName('api-integration'),
fargateService.loadBalancer.listeners[0],
{ vpcLink }
),
});

new CfnOutput(this, 'APIGatewayURL', {
value:
api.defaultStage?.url ??
'FATAL ERROR: Gateway does not have a default stage',
});
}
}
6 changes: 2 additions & 4 deletions cloud/lib/ui-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';

import { resourceName } from './resourceNamingUtils';
import { appName, resourceName } from './resourceNamingUtils';

export class UiStack extends Stack {
public readonly cloudfrontUrl: string;
Expand Down Expand Up @@ -83,9 +83,7 @@ export class UiStack extends Stack {
}),
cachePolicy: new CachePolicy(this, cachePolicyName, {
cachePolicyName,
cookieBehavior: CacheCookieBehavior.allowList(
'prompt-injection.sid'
),
cookieBehavior: CacheCookieBehavior.allowList(`${appName}.sid`),
}),
compress: true,
allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
Expand Down
Loading

0 comments on commit ec3e89c

Please sign in to comment.