DIY bastion for Oracle OCI

Sven Illert -

Recently I was restructuring my Oracle OCI private tenant to be only built using terraform. I mainly did this because I need to learn that beast for work but I am also interested in the technology by myself. Of course my main hosting machine for the blog is a nice little Ampere A1 VM that resides in a private subnet, let’s call it web01. The internet facing part is a free load balancer that handles all the TLS encryption. To access the internal VM via SSH I didn’t want to use the OCI Bastion service, because it is limited to a session duration of 3 hours and I don’t want to always create a new session when I’m working on the server.

Initially I made the internal VM accessible from my home through the load balancer with TCP forwarding, but that causes a lot of messages of the type error: kex_exchange_identification: Connection closed by remote host in the SSH daemon log, because the health checker of the load balancer always closes the connection after a successful attempt. So I created another little Ampere A1 VM named vpn01 that resides in the public network that is hardened, can connect via SSH to the web01 VM and will only be accessible from specific IP addresses. To achieve the latter I’d like to present two ways on how to do this. The hardening may be a topic of a future blog post.

Terraform

Since I have built all my OCI resources in terraform I also created a network security group (NSG) for ssh access from the internet. Allowing the world, aka ::/0, to acces your resources via SSH would be a bad idea even if you only allow logons via key based authentication. There may be some bugs in the SSH implementation that could be exploited. The most secure way to put a server into the internet is by blocking all the traffic to it. But since you can’t access anything this way, you should at least open it to the IP address of your home network. But how do you know what is your internet facing IP address? Nothing easier than that.

% curl -s icanhazip.com
2003:yy:ef29:xxxx:e0fb:adb2:xxxx:ba34
% curl -4 -s icanhazip.com
123.456.789.012

Now how do we get this information into a terraform script? For this there’s the possibility to use an external data provider in terraform. In preparation for this I have written a small script that executes the above commands and prints that out in JSON format.

#!/bin/sh

echo "{"
echo " \"ipv4\" : \"$(curl -4 -s icanhazip.com)\","
echo " \"ipv6\" : \"$(curl -6 -s icanhazip.com)\""
echo "}"

The output from above would be the following, which in turn will be the input for the use in the terraform script.

{
 "ipv4" : "123.456.789.012",
 "ipv6" : "2003:yy:ef29:xxxx:e0fb:adb2:xxxx:ba34"
}

Now we need to configure the external data provider as follows.

data "external" "my-ip" {
  program = [ "./myip.sh" ]
}

The NSG using this data looks like in the next code block. Make sure that you provide the correct path to the result of the data provider which is data.external.my-ip.result in my case.

resource "oci_core_network_security_group" "nsg-ingress-ssh" {
  compartment_id = oci_identity_compartment.cp-ek-web.id
  vcn_id = oci_core_vcn.vcn-ek-web.id
  display_name = "nsg-ingress-ssh"
}

resource "oci_core_network_security_group_security_rule" "nsg-ingress-ssh-rule1" {
  network_security_group_id = oci_core_network_security_group.nsg-ingress-ssh.id
  direction = var.nsg-ingress
  protocol = var.nsg-tcp
  source_type = "CIDR_BLOCK"
  source = "${data.external.my-ip.result.ipv6}/128"
  tcp_options {
    destination_port_range {
      min = 22
      max = 22
    }
  }
}

resource "oci_core_network_security_group_security_rule" "nsg-ingress-ssh-rule2" {
  network_security_group_id = oci_core_network_security_group.nsg-ingress-ssh.id
  direction = var.nsg-ingress
  protocol = var.nsg-tcp
  source_type = "CIDR_BLOCK"
  source = "${data.external.my-ip.result.ipv4}/32"
  tcp_options {
    destination_port_range {
      min = 22
      max = 22
    }
  }
}

And this is it. Of course your compute instance should be modified to use that NSG and you have to issue an terraform apply when your IP address changes. To have no interruption of ongoing changes in other parts of your configuration you can update only the NSG in question with the -target parameter.

% terraform apply -target=oci_core_network_security_group_security_rule.nsg-ingress-ssh

oci cli

To achive the same as above but without invoking terraform there’s an alternative. Since you already need the OCI CLI client installed when using terraform, you can also create a shell script (probably even PowerShell if you use the wrong operating system). To get all the necessary information and to test the OCI CLI you can use the following commands to get the ids of the resources in question. Of course you have to parse the output and look for the correct fields that match your resources. In the end we need the NSG id to get the rules.

% oci iam compartment list
% oci network vcn list --compartment-id ocid1.compartment.oc1..xxxx
% oci network nsg list --vcn-id ocid1.vcn.oc1.eu-frankfurt-1.yyyy --compartment-id ocid1.compartment.oc1..xxxx
% oci network nsg rules list --nsg-id ocid1.networksecuritygroup.oc1.eu-frankfurt-1.zzzz

With this information we got the existing ruleset in JSON that we’d like to reimport with current IP addresses. This ruleset would be saved in an OCI CLI compatible format to a file like myip.json and I’d leave all the ids in there so that terraform would not destroy and recreate all the rules. Please note that I have put the placeholders ##IPV6## and ##IPV4## in the file. The JSON template was generated with the command oci network nsg rules update --generate-full-command-json-input and filled with the data from above.

{
  "nsgId": "ocid1.networksecuritygroup.oc1.eu-frankfurt-1.zzzz",
  "securityRules": [
    {
      "direction": "INGRESS",
      "id": "33345B",
      "isStateless": false,
      "protocol": "6",
      "source": "##IPV6##/128",
      "sourceType": "CIDR_BLOCK",
      "tcpOptions": {
        "destinationPortRange": {
          "max": 22,
          "min": 22
        }
      }
    },
    {
      "direction": "INGRESS",
      "id": "E134CF",
      "isStateless": false,
      "protocol": "6",
      "source": "##IPV4##/32",
      "sourceType": "CIDR_BLOCK",
      "tcpOptions": {
        "destinationPortRange": {
          "max": 22,
          "min": 22
        }
      }
    }
  ]
}

Now we can update the rules with the current IP addresses from the home router to allow only access to you and not the world itself. The following shell script (I named it ipupdate.sh) does this automagically whenever your ISP changes the IP address of your router and you executed the script.

#!/bin/sh

cp myip.json myip-input.json

sed -i '' -e "s/##IPV6##/$(curl -6 -s icanhazip.com)/" myip-input.json
sed -i '' -e "s/##IPV4##/$(curl -4 -s icanhazip.com)/" myip-input.json

oci network nsg rules update --from-json file://$PWD/myip-input.json

rm myip-input.json

Conclusion

There isn’t much into creating a nice little bastion server that forwards all your SSH traffic to a private network. Of course that machine could be used for VPN purposes etc. This post also does not describe any further necessary steps to establish a communication from vcn01 to web01. When you want to connect from your home computer to the web01 server directly, you can extend your ssh configuration like follows. The critical part is the ProxyJump configuration. But I think you get the idea.

Host vpn01
	HostName 1.2.3.4 # PUBLIC IP
	User sven
	IdentityFile /Users/sven/.ssh/id_oci
	ServerAliveInterval 30
	SendEnv TERM_PROGRAM

Host web01
	HostName 10.1.2.3 # PRIVATE IP
	User sven
	IdentityFile /Users/sven/.ssh/id_oci
	ServerAliveInterval 30
	SendEnv TERM_PROGRAM
	ProxyJump vpn01