8000 Add support for VPC endpoints for private API Gateway REST APIs by whummer · Pull Request #7905 · localstack/localstack · GitHub
[go: up one dir, main page]

Skip to content

Add support for VPC endpoints for private API Gateway REST APIs #7905

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion localstack/services/apigateway/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
r"^/restapis/([A-Za-z0-9_\\-]+)(?:/([A-Za-z0-9\_($|%%24)\\-]+))?/%s/(.*)$" % PATH_USER_REQUEST
)
# URL pattern for invocations
HOST_REGEX_EXECUTE_API = r"(?:.*://)?([a-zA-Z0-9-]+)\.execute-api\.(localhost.localstack.cloud|([^\.]+)\.amazonaws\.com)(.*)"
HOST_REGEX_EXECUTE_API = r"(?:.*://)?([a-zA-Z0-9]+)(?:(-vpce-[^.]+))?\.execute-api\.(localhost.localstack.cloud|([^\.]+)\.amazonaws\.com)(.*)"

# regex path patterns
PATH_REGEX_MAIN = r"^/restapis/([A-Za-z0-9_\-]+)/[a-z]+(\?.*)?"
Expand Down
3 changes: 2 additions & 1 deletion localstack/services/apigateway/invocations.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ def run_authorizer(invocation_context: ApiInvocationContext, authorizer: Dict):


def authorize_invocation(invocation_context: ApiInvocationContext):
client = aws_stack.connect_to_service("apigateway")
region_name = invocation_context.region_name or aws_stack.get_region()
client = aws_stack.connect_to_service("apigateway", region_name=region_name)
authorizers = client.get_authorizers(restApiId=invocation_context.api_id, limit=100).get(
"items", []
)
Expand Down
17 changes: 10 additions & 7 deletions localstack/services/apigateway/router_asf.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,27 +99,28 @@ def __init__(self, router: Router[Handler]):
def register_routes(self) -> None:
"""Registers parameterized routes for API Gateway user invocations."""
if self.registered:
LOG.debug("Skipped API gateway route registration (routes already registered).")
LOG.debug("Skipped API Gateway route registration (routes already registered).")
return
self.registered = True
LOG.debug("Registering parameterized API gateway routes.")
LOG.debug("Registering parameterized API Gateway routes.")
host_pattern = "<regex('[^-]+'):api_id><regex('(-vpce-[^.]+)?'):vpce_suffix>.execute-api.<regex('.*'):server>"
self.router.add(
"/",
host="<api_id>.execute-api.<regex('.*'):server>",
host=host_pattern,
endpoint=self.invoke_rest_api,
defaults={"path": "", "stage": None},
strict_slashes=True,
)
self.router.add(
"/<stage>/",
host="<api_id>.execute-api.<regex('.*'):server>",
host=host_pattern,
endpoint=self.invoke_rest_api,
defaults={"path": ""},
strict_slashes=False,
)
self.router.add(
"/<stage>/<path:path>",
host="<api_id>.execute-api.<regex('.*'):server>",
host=host_pattern,
endpoint=self.invoke_rest_api,
strict_slashes=True,
)
Expand All @@ -136,10 +137,12 @@ def register_routes(self) -> None:
strict_slashes=True,
)

def invoke_rest_api(self, request: Request, **url_params: Dict[str, Any]) -> Response:
if not get_api_account_id_and_region(url_params["api_id"])[1]:
def invoke_rest_api(self, request: Request, **url_params: Dict[str, str]) -> Response:
_, region_name = get_api_account_id_and_region(url_params["api_id"])
if not region_name:
return Response(status=404)
invocation_context = to_invocation_context(request, url_params)
invocation_context.region_name = region_name
result = invoke_rest_api_from_request(invocation_context)
if result is not None:
return convert_response(result)
Expand Down
1 change: 0 additions & 1 deletion localstack/services/awslambda/lambda_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,6 @@ def store_lambda_logs(


def get_main_endpoint_from_container() -> str:
global DOCKER_MAIN_CONTAINER_IP
if config.HOSTNAME_FROM_LAMBDA:
return config.HOSTNAME_FROM_LAMBDA
return get_endpoint_for_network(network=get_container_network_for_lambda())
Expand Down
88 changes: 86 additions & 2 deletions localstack/services/ec2/provider.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import json
import re
from abc import ABC
from datetime import datetime, timezone

from botocore.parsers import ResponseParserError
from moto.core.utils import camelcase_to_underscores, underscores_to_camelcase
from moto.ec2.exceptions import InvalidVpcEndPointIdError
from moto.ec2.models import SubnetBackend, TransitGatewayAttachmentBackend
from moto.ec2.models import (
EC2Backend,
SubnetBackend,
TransitGatewayAttachmentBackend,
VPCBackend,
ec2_backends,
)
from moto.ec2.models.launch_templates import LaunchTemplate as MotoLaunchTemplate
from moto.ec2.models.subnets import Subnet
from moto.ec2.models.vpcs import VPCEndPoint

from localstack.aws.api import RequestContext, handler
from localstack.aws.api.ec2 import (
Expand All @@ -30,7 +38,13 @@
DescribeSubnetsResult,
DescribeTransitGatewaysRequest,
DescribeTransitGatewaysResult,
DescribeVpcEndpointServicesRequest,
DescribeVpcEndpointServicesResult,
DescribeVpcEndpointsRequest,
DescribeVpcEndpointsResult,
DnsOptions,
DnsOptionsSpecification,
DnsRecordIpType,
Ec2Api,
InstanceType,
IpAddressType,
Expand Down Expand Up @@ -68,7 +82,7 @@
from localstack.services.moto import call_moto
from localstack.utils.aws import aws_stack
from localstack.utils.patch import patch
from localstack.utils.strings import first_char_to_upper, long_uid
from localstack.utils.strings import first_char_to_upper, long_uid, short_uid

# additional subnet attributes not yet supported upstream
ADDITIONAL_SUBNET_ATTRS = ("private_dns_name_options_on_launch", "enable_dns64")
Expand Down Expand Up @@ -381,6 +395,66 @@ def modify_launch_template(

return result

@handler("DescribeVpcEndpointServices", expand=False)
def describe_vpc_endpoint_services(
self,
context: RequestContext,
request: DescribeVpcEndpointServicesRequest,
) -> DescribeVpcEndpointServicesResult:
ep_services = VPCBackend._collect_default_endpoint_services(
account_id=context.account_id, region=context.region
)

moto_backend = get_moto_backend(context)
service_names = [s["ServiceName"] for s in ep_services]
execute_api_name = f"com.amazonaws.{context.region}.execute-api"

if execute_api_name not in service_names:
# ensure that the service entry for execute-api exists
zones = moto_backend.describe_availability_zones()
zones = [zone.name for zone in zones]
private_dns_name = f"*.execute-api.{context.region}.amazonaws.com"
service = {
"ServiceName": execute_api_name,
"ServiceId": f"vpce-svc-{short_uid()}",
"ServiceType": [{"ServiceType": "Interface"}],
"AvailabilityZones": zones,
"Owner": "amazon",
"BaseEndpointDnsNames": [f"execute-api.{context.region}.vpce.amazonaws.com"],
"PrivateDnsName": private_dns_name,
"PrivateDnsNames": [{"PrivateDnsName": private_dns_name}],
"VpcEndpointPolicySupported": True,
"AcceptanceRequired": False,
"ManagesVpcEndpoints": False,
"PrivateDnsNameVerificationState": "verified",
"SupportedIpAddressTypes": ["ipv4"],
}
ep_services.append(service)

return call_moto(context)

@handler("DescribeVpcEndpoints", expand=False)
def describe_vpc_endpoints(
self,
context: RequestContext,
request: DescribeVpcEndpointsRequest,
) -> DescribeVpcEndpointsResult:
result: DescribeVpcEndpointsResult = call_moto(context)

for endpoint in result.get("VpcEndpoints"):
endpoint.setdefault("DnsOptions", DnsOptions(DnsRecordIpType=DnsRecordIpType.ipv4))
endpoint.setdefault("IpAddressType", IpAddressType.ipv4)
endpoint.setdefault("RequesterManaged", False)
endpoint.setdefault("RouteTableIds", [])
# AWS parity: Version should not be contained in the policy response
policy = endpoint.get("PolicyDocument")
if policy and '"Version":' in policy:
policy = json.loads(policy)
policy.pop("Version", None)
endpoint["PolicyDocument"] = json.dumps(policy)

return result


@patch(SubnetBackend.modify_subnet_attribute)
def modify_subnet_attribute(fn, self, subnet_id, attr_name, attr_value):
Expand All @@ -399,6 +473,11 @@ def modify_subnet_attribute(fn, self, subnet_id, attr_name, attr_value):
return fn(self, subnet_id, attr_name, attr_value)


def get_moto_backend(context: RequestContext) -> EC2Backend:
"""Get the moto EC2 backend for the given request context"""
return ec2_backends[context.account_id][context.region]


@patch(Subnet.get_filter_value)
def get_filter_value(fn, self, filter_name):
if filter_name in (
Expand All @@ -414,3 +493,8 @@ def delete_transit_gateway_vpc_attachment(fn, self, transit_gateway_attachment_i
transit_gateway_attachment = self.transit_gateway_attachments.get(transit_gateway_attachment_id)
transit_gateway_attachment.state = "deleted"
return transit_gateway_attachment


# fix a bug in upstream moto where a space is encoded in the "Statement" key - TODO remove once fixed upstream
if "Statement " in VPCEndPoint.DEFAULT_POLICY:
VPCEndPoint.DEFAULT_POLICY["Statement"] = VPCEndPoint.DEFAULT_POLICY.pop("Statement ")
35 changes: 35 additions & 0 deletions localstack/testing/pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -1879,6 +1879,41 @@ def factory(email_address: str) -> None:
ses_client.delete_identity(Identity=identity)


@pytest.fixture
def ec2_create_security_group(ec2_client):
ec2_sgs = []

def factory(ports=None, **kwargs):
if "GroupName" not in kwargs:
kwargs["GroupName"] = f"test-sg-{short_uid()}"
security_group = ec2_client.create_security_group(**kwargs)

permissions = [
{
"FromPort": port,
"IpProtocol": "tcp",
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
"ToPort": port,
}
for port in ports or []
]
ec2_client.authorize_security_group_ingress(
GroupName=kwargs["GroupName"],
IpPermissions=permissions,
)

ec2_sgs.append(security_group["GroupId"])
return security_group

yield factory

for sg_group_id in ec2_sgs:
try:
ec2_client.delete_security_group(GroupId=sg_group_id)
except Exception as e:
LOG.debug("Error cleaning up EC2 security group: %s, %s", sg_group_id, e)


@pytest.fixture
def cleanups(ec2_client):
cleanup_fns = []
Expand Down
1 change: 1 addition & 0 deletions tests/integration/apigateway/conftest.py 8295
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def _factory(
resourceId=resource_id,
httpMethod="POST",
authorizationType="NONE",
apiKeyRequired=False,
)

# set AWS policy to give API GW access to backend resources
Expand Down
Loading
0