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.