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)
Written on June 9, 2021