Create an OCI bastion service via Terraform

Maintaining bastion hosts (a “jump box” or other network entry point directly exposed to the Internet) is somewhat frowned upon by security conscious architects, for good reasons. In my opinion the only way to connect on-premises systems to the cloud is by means of a dedicated, low-latency/high-bandwidth, and most importantly well-secured link.

I never liked the idea of exposing systems to the Internet – too much can go wrong and you’d be surprised about the number of port-scans you see, followed by attempts at breaking in. Sometimes of course opening a system to the Internet is unavoidable: a website offering services to the public is quite secure if it cannot be reached but won’t generate a lot of revenue that way. Thankfully there are ways to expose such applications safely to the Internet, a topic that’s out of scope of this post though.

My very personal need for the bastion service

I create lots of demos using Oracle Cloud Infrastructure (OCI) and setting up a dedicated link isn’t always practical. The solution for me is to use Oracle’s bastion service. This way I can ensure time-based secure access to my resources in a private subnet. Most importantly there is no need to connect a VM directly to the Internet. And since it’s all fully automated it doesn’t cause any more work than terraform up followed by a terraform destroy when the demo completed.

This blog post describes how I create a VCN with a private subnet containing a VM. The entire infrastructure is intended as a DEMO only. None of the resources will live longer than for the duration of a conference talk. Please don’t follow this approach if you would like to deploy systems in the cloud for > 45 minutes. Also be aware that it’s entirely possible for you to incur cost when calling terraform up on the code. As always, the code will be available on Github.

Creating a Bastion Service

The bastion service is created by Terraform. Following the advice from the excellent Terraform Up and Running (2nd ed) I separated the resource creation into three directories:

  • Network
  • Compute
  • Bastion

To keep things reasonably simple I refrained from creating modules.

Directory layout

Please have a look at the book for more details about the directory structure. You’ll notice that I simplified the example a little.

$ tree .
.
├── bastionsvc
│   ├── main.tf
│   ├── terraform.tfstate
│   └── variables.tf
├── compute
│   ├── compute.tf
│   ├── main.tf
│   ├── outputs.tf
│   ├── terraform.tfstate
│   ├── terraform.tfstate.backup
│   └── variables.tf
├── network
│   ├── network.tf
│   ├── outputs.tf
│   ├── terraform.tfstate
│   ├── terraform.tfstate.backup
│   └── variables.tf
├── readme.md
└── variables.tf

I decided to split the network code into a generic section and the bastion service for reason explained later.

Generic Network Code

The network code is responsible for creating the Virtual Cloud Network (VCN) including subnets, security lists, necessary gateways etc. When I initially used the bastion service I struggled a bit with Network Security Groups (NSG) and went with a security list instead. I guess I should re-visit that decision at some point.

The network must be created first. In addition to creating all the necessary infrastructure it exports an output variable used by the compute and bastion code. These read remote state to get the necessary OCIDs.

Note that the choice of a remote data source has its drawbacks as described in the documentation. These don’t apply for my demos as I’m the only user of the code. And while I’m at it, using local state is acceptable only because I know I’m the only one using the code. Local state doesn’t necessarily work terribly well for team-development.

Here are some key features of the network code. As these tend to go stale over time, have a look at the Github repository for the latest and greatest revision.

resource "oci_core_vcn" "vcn" {

  compartment_id = var.compartment_ocid
  cidr_block     = "10.0.2.0/24"
  defined_tags   = var.network_defined_tags
  display_name   = "demovcn"
  dns_label      = "demo"

}

# --------------------------------------------------------------------- subnet

resource "oci_core_subnet" "private_subnet" {

  cidr_block                 = var.private_sn_cidr_block
  compartment_id             = var.compartment_ocid
  vcn_id                     = oci_core_vcn.vcn.id
  defined_tags               = var.network_defined_tags
  display_name               = "private subnet"
  dns_label                  = "private"
  prohibit_public_ip_on_vnic = true
  prohibit_internet_ingress  = true
  route_table_id             = oci_core_route_table.private_rt.id
  security_list_ids          = [
    oci_core_security_list.private_sl.id
  ]
}

The security list allows SSH only from within the same subnet:

# --------------------------------------------------------------------- security list

resource "oci_core_security_list" "private_sl" {

  compartment_id = var.compartment_ocid
  vcn_id         = oci_core_vcn.vcn.id

...

  egress_security_rules {

    destination = var.private_sn_cidr_block
    protocol    = "6"

    description      = "SSH outgoing"
    destination_type = ""

    stateless = false
    tcp_options {

      max = 22
      min = 22

    }
  }

  ingress_security_rules {

    protocol = "6"
    source   = var.private_sn_cidr_block

    description = "SSH inbound"

    source_type = "CIDR_BLOCK"
    tcp_options {

      max = 22
      min = 22

    }

  }
}

The bastion service and its corresponding session are going to be created in the same private subnet as the compute instance for the sake of simplicity.

Compute Instance

The compute instance is created as a VM.Standard.E3.Flex shape with 2 OCPUs. There’s nothing too special about the resource, except maybe that I’m explicitly enabling the bastion plugin agent, a prerequisite for using the service.

resource "oci_core_instance" "private_instance" {
  agent_config {
    is_management_disabled = false
    is_monitoring_disabled = false

...

    plugins_config {
      desired_state = "ENABLED"
      name = "Bastion"
    }
  }

  defined_tags = var.compute_defined_tags

  create_vnic_details {
    
    assign_private_dns_record = true
    assign_public_ip = false
    hostname_label = "privateinst"
    subnet_id = data.terraform_remote_state.network_state.outputs.private_subnet_id
    nsg_ids = []
  }

...

Give it a couple of minutes for all agents to start.

Bastion Service

Once the VM’s bastion agent is up it is possible to create the bastion service:

resource "oci_bastion_bastion" "demo_bastionsrv" {

  bastion_type     = "STANDARD"
  compartment_id   = var.compartment_ocid
  target_subnet_id = data.terraform_remote_state.network_state.outputs.private_subnet_id

  client_cidr_block_allow_list = [
    var.local_laptop_id
  ]

  defined_tags = var.network_defined_tags

  name = "demobastionsrv"
}


resource "oci_bastion_session" "demo_bastionsession" {

  bastion_id = oci_bastion_bastion.demo_bastionsrv.id
  defined_tags = var.network_defined_tags
  
  key_details {
  
    public_key_content = var.ssh_bastion_key
  }

  target_resource_details {

    session_type       = "MANAGED_SSH"
    target_resource_id = data.terraform_remote_state.compute_state.outputs.private_instance_id

    target_resource_operating_system_user_name = "opc"
    target_resource_port                       = "22"
  }

  session_ttl_in_seconds = 3600

  display_name = "bastionsession-private-host"
}

output "connection_details" {
  value = oci_bastion_session.demo_bastionsession.ssh_metadata.command
}

The Bastion is set up in the private subnet created by the network code. Note that I’m defining the session’s client_cidr_block_allow_list specifically to only allow my external IP to access the service. The session is of type Managed SSH, thus requires a Linux host.

And this is all I can say about the creation of a bastion session in Terraform.

Terraform in action

Once all the resources have been created all I need to do is adapt the SSH command provided by my output variable shown here:

connection_details = "ssh -i <privateKey> -o ProxyCommand=\"ssh -i <privateKey> -W %h:%p -p 22 ocid1.bastionsession.oc1.eu-frankfurt-1.am...@host.bastion.eu-frankfurt-1.oci.oraclecloud.com\" -p 22 opc@10.0.2.94"

After adopting the SSH command I can connect to the instance.

$ ssh -i ...
The authenticity of host '10.0.2.94 (<no hostip for proxy command>)' can't be established.
ECDSA key fingerprint is SHA256:Ot...
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.0.2.94' (ECDSA) to the list of known hosts.
Activate the web console with: systemctl enable --now cockpit.socket

[opc@privateinst ~]$ hostname
privateinst
[opc@privateinst ~]$ logout

That’s it! I am connected to the instance and experiment with my demo.

Another reason I love Terraform: when the demo has concluded I can simply tear down all resources with very few commands.