Skip to content

Commit a58e390

Browse files
committed
feat: Add GitHub reverse proxy support
1 parent 4dae432 commit a58e390

File tree

7 files changed

+330
-0
lines changed

7 files changed

+330
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# AWS Lambda in VPC with API Gateway and Least Privilege Access
2+
3+
To facilitate an internal load balancer, GitHub webhooks still need to have a way to reach the application. This is achieved by creating a reverse proxy for GitHub webhooks. This Terraform module deploys an AWS Lambda function inside a VPC, integrates it with API Gateway, and ensures least privilege access. The Lambda function receives webhooks from GitHub, processes them, and forwards them to a private system inside a VPC.
4+
5+
The module includes:
6+
- A Lambda function attached to a VPC.
7+
- Integration with API Gateway to expose the Lambda function as an HTTP endpoint.
8+
- A deny policy to follow the principle of least privilege, preventing the Lambda function code from making certain Amazon EC2 API calls.
9+
- Required IAM roles and permissions for VPC access and logging.
10+
- A Security Group to allow access to the private endpoint in the VPC.
11+
12+
## Features
13+
- Deploys an AWS Lambda function inside a VPC.
14+
- Integrates the Lambda function with API Gateway for receiving webhooks.
15+
- Configures the necessary IAM roles, including the `AWSLambdaVPCAccessExecutionRole`.
16+
- Implements a custom deny policy to prevent the Lambda function from making EC2 network-related API calls.
17+
- CloudWatch logging for monitoring and troubleshooting.
18+
19+
## Usage
20+
21+
```hcl
22+
module "lambda_vpc_webhook" {
23+
source = "./path_to_your_module" # Replace with your module path
24+
25+
# Variables
26+
vpc_id = "vpc-12345678"
27+
vpc_private_subnets = ["subnet-abcdefgh", "subnet-ijklmnop"]
28+
github_secret = "your-github-webhook-secret"
29+
private_system_endpoint = "the ip of the internal load balancer"
30+
}
31+
32+
output "api_gateway_url" {
33+
value = module.lambda_vpc_webhook.api_gateway_url
34+
}
35+
```
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
data "aws_caller_identity" "current" {}
2+
3+
resource "aws_iam_role" "lambda_role" {
4+
name = "${var.deployment_name}-lambda-github-webhook-role"
5+
6+
assume_role_policy = jsonencode({
7+
Version = "2012-10-17",
8+
Statement = [{
9+
Action = "sts:AssumeRole",
10+
Effect = "Allow",
11+
Principal = {
12+
Service = "lambda.amazonaws.com"
13+
}
14+
}]
15+
})
16+
}
17+
18+
resource "aws_iam_role_policy_attachment" "lambda_vpc_access_policy" {
19+
role = aws_iam_role.lambda_role.name
20+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
21+
}
22+
23+
resource "aws_iam_role_policy_attachment" "lambda_logging_policy" {
24+
role = aws_iam_role.lambda_role.name
25+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
26+
}
27+
28+
resource "aws_iam_policy" "lambda_describe_vpc_policy" {
29+
name = "${var.deployment_name}-lambda-describe-vpc-policy"
30+
policy = jsonencode({
31+
Version = "2012-10-17",
32+
Statement = [
33+
{
34+
Effect = "Allow",
35+
Action = [
36+
"ec2:DescribeSecurityGroups",
37+
"ec2:DescribeSubnets",
38+
"ec2:DescribeVpcs"
39+
],
40+
Resource = "*"
41+
}
42+
]
43+
})
44+
}
45+
46+
resource "aws_iam_role_policy_attachment" "lambda_describe_vpc_attachment" {
47+
role = aws_iam_role.lambda_role.name
48+
policy_arn = aws_iam_policy.lambda_describe_vpc_policy.arn
49+
}
50+
51+
# https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html#configuration-vpc-best-practice
52+
resource "aws_iam_policy" "lambda_deny_ec2_policy" {
53+
name = "${var.deployment_name}-lambda-deny-ec2-network-interface-policy"
54+
policy = jsonencode({
55+
Version = "2012-10-17",
56+
Statement = [
57+
{
58+
Effect = "Deny",
59+
Action = [
60+
"ec2:CreateNetworkInterface",
61+
"ec2:DeleteNetworkInterface",
62+
"ec2:DescribeNetworkInterfaces",
63+
"ec2:DetachNetworkInterface",
64+
"ec2:AssignPrivateIpAddresses",
65+
"ec2:UnassignPrivateIpAddresses"
66+
],
67+
Resource = "*",
68+
Condition = {
69+
"ArnEquals": {
70+
"lambda:SourceFunctionArn": [
71+
"arn:aws:lambda:${data.aws_caller_identity.current.account_id}:function:${aws_lambda_function.github_webhook_handler.function_name}"
72+
]
73+
}
74+
}
75+
}
76+
]
77+
})
78+
}
79+
80+
resource "aws_iam_role_policy_attachment" "lambda_deny_ec2_attachment" {
81+
role = aws_iam_role.lambda_role.name
82+
policy_arn = aws_iam_policy.lambda_deny_ec2_policy.arn
83+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import json
2+
import hashlib
3+
import hmac
4+
import os
5+
import urllib3
6+
7+
def verify_github_signature(headers, body, secret):
8+
"""
9+
Verifies the GitHub webhook signature using the provided secret.
10+
"""
11+
signature = headers.get('X-Hub-Signature-256')
12+
13+
if not signature:
14+
print("No signature found in headers.")
15+
return False
16+
17+
# Create the HMAC digest
18+
hmac_gen = hmac.new(key=secret.encode(), msg=body, digestmod=hashlib.sha256)
19+
expected_signature = f'sha256={hmac_gen.hexdigest()}'
20+
21+
return hmac.compare_digest(signature, expected_signature)
22+
23+
def forward_to_private_system(data, private_endpoint):
24+
"""
25+
Forward the data to the private system endpoint.
26+
"""
27+
http = urllib3.PoolManager()
28+
headers = {'Content-Type': 'application/json'}
29+
30+
response = http.request('POST', private_endpoint, body=json.dumps(data), headers=headers)
31+
32+
return response.status, response.data
33+
34+
def lambda_handler(event, context):
35+
github_secret = os.getenv('GITHUB_SECRET')
36+
private_system_endpoint = os.getenv('PRIVATE_SYSTEM_ENDPOINT')
37+
38+
headers = event.get('headers', {})
39+
body = event.get('body', '')
40+
41+
# Verify GitHub signature
42+
if not verify_github_signature(headers, body.encode('utf-8'), github_secret):
43+
print("Signature verification failed.")
44+
return {
45+
'statusCode': 403,
46+
'body': json.dumps('Forbidden: Signature verification failed')
47+
}
48+
49+
# Parse the body as JSON
50+
try:
51+
payload = json.loads(body)
52+
except json.JSONDecodeError as e:
53+
print(f"JSON parsing error: {e}")
54+
return {
55+
'statusCode': 400,
56+
'body': json.dumps('Bad Request: Invalid JSON payload')
57+
}
58+
59+
# Forward the payload to the private system
60+
status, response = forward_to_private_system(payload, private_system_endpoint)
61+
62+
if status == 200:
63+
return {
64+
'statusCode': 200,
65+
'body': json.dumps('Webhook processed and forwarded')
66+
}
67+
else:
68+
print(f"Error forwarding to private system: {response}")
69+
return {
70+
'statusCode': 500,
71+
'body': json.dumps('Internal Server Error: Failed to forward webhook')
72+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
resource "null_resource" "zip_lambda_function" {
2+
provisioner "local-exec" {
3+
command = "zip -j lambda_function.zip lambda_function.py"
4+
}
5+
6+
triggers = {
7+
py_source = filemd5("lambda_function.py")
8+
}
9+
}
10+
11+
resource "aws_lambda_function" "github_webhook_handler" {
12+
function_name = "${var.deployment_name}-github-webhook-handler"
13+
role = aws_iam_role.lambda_role.arn
14+
handler = "lambda_function.lambda_handler" # Python handler
15+
runtime = "python3.12"
16+
17+
filename = "${path.module}/lambda_function.zip"
18+
source_code_hash = filebase64sha256("${path.module}/lambda_function.zip")
19+
20+
environment {
21+
variables = {
22+
GITHUB_SECRET = var.github_secret
23+
PRIVATE_SYSTEM_ENDPOINT = var.private_system_endpoint
24+
}
25+
}
26+
27+
vpc_config {
28+
subnet_ids = var.vpc_private_subnets
29+
security_group_ids = length(var.security_group_ids) > 0 ? var.security_group_ids : [aws_security_group.lambda_sg.id]
30+
}
31+
32+
# Depend on the zip operation
33+
depends_on = [null_resource.zip_lambda_function]
34+
}
35+
36+
# API Gateway to act as a reverse proxy
37+
resource "aws_api_gateway_rest_api" "webhook_api" {
38+
name = "GitHub Webhook Reverse Proxy"
39+
}
40+
41+
# Create resource for webhooks
42+
resource "aws_api_gateway_resource" "webhook_resource" {
43+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
44+
parent_id = aws_api_gateway_rest_api.webhook_api.root_resource_id
45+
path_part = "webhook"
46+
}
47+
48+
# API Gateway Method
49+
resource "aws_api_gateway_method" "post_webhook" {
50+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
51+
resource_id = aws_api_gateway_resource.webhook_resource.id
52+
http_method = "POST"
53+
authorization = "NONE"
54+
}
55+
56+
# Lambda integration for API Gateway
57+
resource "aws_api_gateway_integration" "lambda_integration" {
58+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
59+
resource_id = aws_api_gateway_resource.webhook_resource.id
60+
http_method = aws_api_gateway_method.post_webhook.http_method
61+
integration_http_method = "POST"
62+
type = "AWS_PROXY"
63+
uri = aws_lambda_function.github_webhook_handler.invoke_arn
64+
}
65+
66+
# Deployment of API Gateway
67+
resource "aws_api_gateway_deployment" "api_deployment" {
68+
rest_api_id = aws_api_gateway_rest_api.webhook_api.id
69+
stage_name = "prod"
70+
71+
depends_on = [aws_api_gateway_integration.lambda_integration]
72+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
output "api_gateway_url" {
2+
value = aws_api_gateway_deployment.api_deployment.invoke_url
3+
}
4+

modules/github_reverse_proxy/sg.tf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
resource "aws_security_group" "lambda_sg" {
2+
name = "${var.deployment_name}-lambda-security-group"
3+
description = "Allow Lambda to access private systems"
4+
vpc_id = var.vpc_id
5+
6+
egress {
7+
from_port = 0
8+
to_port = 0
9+
protocol = "-1"
10+
cidr_blocks = [var.vpc_cidr]
11+
}
12+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# ┏━╸╻╺┳╸╻ ╻╻ ╻┏┓ ┏━┓┏━╸╻ ╻┏━╸┏━┓┏━┓┏━╸ ┏━┓┏━┓┏━┓╻ ╻╻ ╻
2+
# ┃╺┓┃ ┃ ┣━┫┃ ┃┣┻┓ ┣┳┛┣╸ ┃┏┛┣╸ ┣┳┛┗━┓┣╸ ┣━┛┣┳┛┃ ┃┏╋┛┗┳┛
3+
# ┗━┛╹ ╹ ╹ ╹┗━┛┗━┛ ╹┗╸┗━╸┗┛ ┗━╸╹┗╸┗━┛┗━╸ ╹ ╹┗╸┗━┛╹ ╹ ╹
4+
5+
variable "deployment_name" {
6+
type = string
7+
description = "Name of the current deployment."
8+
}
9+
10+
variable "environment" {
11+
type = string
12+
description = "Global environment tag to apply on all datadog logs, metrics, etc."
13+
}
14+
15+
variable "region" {
16+
type = string
17+
description = "Region to deploy API Gateway and lambda's to in AWS"
18+
}
19+
20+
variable "vpc_id" {
21+
description = "The VPC where Lambda will be deployed"
22+
}
23+
24+
variable "vpc_private_subnets" {
25+
description = "List of subnet IDs for Lambda to run in"
26+
type = list(string)
27+
}
28+
29+
variable "vpc_cidr" {
30+
type = string
31+
description = "Network CIDR for VPC"
32+
validation {
33+
condition = can(regex("^(?:(?:\\d{1,3}\\.?){4})\\/(\\d{1,2})$", var.vpc_cidr))
34+
error_message = "Network CIDR must be a valid cidr."
35+
}
36+
}
37+
38+
variable "security_group_ids" {
39+
description = "List of security group IDs for Lambda. If non provided, it will use the lambda_sg."
40+
type = list(string)
41+
default = []
42+
}
43+
44+
variable "github_secret" {
45+
description = "GitHub webhook secret"
46+
type = string
47+
}
48+
49+
variable "private_system_endpoint" {
50+
description = "Private system endpoint to forward the webhook"
51+
type = string
52+
}

0 commit comments

Comments
 (0)