An AWS Lambda Function to Terminate Active Client VPN Connections
What is the problem?
Users utilise an AWS Managed VPN Connection Endpoint to access our private VPC but they forget to turn off the connection when they don’t need it because they are not paying for it, so who cares right? so rather than ask users to remember to disconnect, we can automatically force them off using a Lamda function triggered every 8 hours.
The only problem is that the Amazon VPN client automatically reconnects after a connection has been terminated much to my chagrin! It does so even when, the .ovpn file has specified the following flag
resolv-retry 0
Which makes the entire effort kinda a waste, right?
How did I do it?
I used the Serverless framework to create and manage the lambda function.
Problems faced
I could not for the life of me work out how to get the Lambda to disconnect the ec2 connections by configuring the role permissions associated with the lambda function. I had to manually create an IAM user who could describe and terminate client VPN connections - a major pain. But that creates a secondary problem: now I need to somehow access credentials for that particular IAM user. I could have hard coded the values just like every one does in the documentation - the blast radius is quite small, but I decided to go the extra mile and log these credentials in AWS Secret manager, and call those credentials in my Lambda. Sure, this adds to the time required considerably, but there is greater security in doing so.
On further reading of the serverless documentation, I could have actually specified the lambda function to operate with our custom role - which would obviate the need to procure the relevant secret, from the secrets manager. Even better, the lambda function itself would have the permissions to the do the following and no more, but c’est la vie - I simply could not get the permissions to work.
The following is a nice starting point to get you going when dealing with: (i) terminating Client VPN connections, and (ii) with secrets manager and/or roles should you go down that path.
The Lambda function
require 'json'
require 'aws-sdk-ec2'
require 'aws-sdk'
require 'aws-sdk-secretsmanager'
require 'base64'
def terminate_connection(event: , context:)
# puts event
# puts context
terminated_connections = []
get_vpn_connections.each do |c|
terminated_connection = ec2_client.terminate_client_vpn_connections({client_vpn_endpoint_id: ENV["VPN_ENDPOINT"], username: c.username,
dry_run: false})
terminated_connections << terminated_connection
end
{
statusCode: 200,
body: {
message: "We've killed the following connections",
input: event,
terminated_connections: terminated_connections
}.to_json
}
end
def get_vpn_connections
# https://docs.aws.amazon.com/sdk-for-ruby/v2/api/Aws/EC2/Client.html#describe_client_vpn_connections-instance_method
response = ec2_client.describe_client_vpn_connections({ client_vpn_endpoint_id: ENV["VPN_ENDPOINT"], dry_run: false}) # assuming only a few end points
connection_ids = response.connections.filter{ |c| c.status.code == "active"}
return connection_ids
end
def ec2_client
return @ec2_client || get_ec2_client
end
def get_ec2_client
aws_access_key_id, aws_secret_access_key = JSON.parse(get_secret).first
credentials = Aws::Credentials.new(aws_access_key_id, aws_secret_access_key)
@ec2_client = Aws::EC2::Client.new(region: ENV["REGION"], credentials: credentials)
return @ec2_client
end
def get_secret()
secret_name = "terminate_vpn_user_secret"
client = Aws::SecretsManager::Client.new(region: ENV["REGION"])
# In this sample we only handle the specific exceptions for the 'GetSecretValue' API.
# See https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html
# We rethrow the exception by default.
begin
get_secret_value_response = client.get_secret_value(secret_id: secret_name)
rescue Aws::SecretsManager::Errors::DecryptionFailure => e
# Secrets Manager can't decrypt the protected secret text using the provided KMS key.
# Deal with the exception here, and/or rethrow at your discretion.
raise
rescue Aws::SecretsManager::Errors::InternalServiceError => e
# An error occurred on the server side.
# Deal with the exception here, and/or rethrow at your discretion.
raise
rescue Aws::SecretsManager::Errors::InvalidParameterException => e
# You provided an invalid value for a parameter.
# Deal with the exception here, and/or rethrow at your discretion.
raise
rescue Aws::SecretsManager::Errors::InvalidRequestException => e
# You provided a parameter value that is not valid for the current state of the resource.
# Deal with the exception here, and/or rethrow at your discretion.
raise
rescue Aws::SecretsManager::Errors::ResourceNotFoundException => e
# We can't find the resource that you asked for.
# Deal with the exception here, and/or rethrow at your discretion.
raise
else
# This block is ran if there were no exceptions.
# Decrypts secret using the associated KMS CMK.
# Depending on whether the secret is a string or binary, one of these fields will be populated.
if get_secret_value_response.secret_string
secret = get_secret_value_response.secret_string
else
decoded_binary_secret = Base64.decode64(get_secret_value_response.secret_binary)
end
# Your code goes here.
end
end
Serverless.yml file
# specify your own app name etc.
service: terminate-client-vpn
frameworkVersion: '2'
provider:
name: aws
runtime: ruby2.7
profile: serverless-admin # make sure this exists
lambdaHashingVersion: 20201221
region: ap-south-1
iam:
role:
statements:
- Effect: "Allow"
Action:
- "ec2:DescribeVpnConnections"
- "ec2:TerminateClientVpnConnections"
Resource:
- "*"
- Effect: "Allow"
Action:
- "secretsmanager:GetSecretValue"
Resource:
- "arn:aws:secretsmanager:ap-south-1:add-your-own-here"
# this allows the lambda function to go to the aws secrets manager and retrieve the secret associated with my iam user - i had to resort to such rigmarole because I could not get the lambda function itself to describe and terminate client vpn connections
environment:
VPN_ENDPOINT: "add your own endpoint here"
REGION: ${self:provider.region}
functions:
terminate_connection:
handler: handler.terminate_connection
events:
- schedule: rate(8 hours)