Background info

Oracle Cloud (OCI) offers a free tier for cloud resources, which I’m using for some time now. More info here. (I’m no affiliate or something like that)

I have a virtual machine with some services running for private use. It’s like a homelab server, just not at home.

I’m running a reverse proxy so that some services are intentionally made public to the world via port 443. It also has a SSH server and other services that I don’t want to expose to the internet, but I want to have access with my IP address.

You could use overlay networks for this purpose, but I wanted to reach it via the public IP directly. It’s also possible to achieve the following with other tools or build something yourself with the OCI API, but I want to do more with Ansible, this is why I chose this solution

Security lists

OCI has virtual cloud networks (VCN), which can have subnets. You can assign security lists to subnets, so that only specific (port based) traffic is allowed there.

This is an example of a list that allows ingress traffic on port 443 and 80 for everyone. (0.0.0.0/0) and has no policy for egress traffic. The egress traffic is handled by another security list.

Why Ansible automation?

With Ansible you can do a lot in OCI, but I’ll limit this to just dynamically changing an existing security list.

Let’s say you made a list that allows all traffic to the server from your home IP address. It would look like this:

Now my ISP changes this IP from time to time, so I’ll need a way to update it. There would be ways to trigger actions with dynamic DNS etc., but I just wanted a simple script-like solution that runs on a server in my network.

Limitations

Be aware that this will allow all traffic for your current source NAT uplink IP. Which means if you use this on your company network or on a public hotspot, everyone using this uplink IP will have the same access.

In my case only I use the IP address for the duration of the assignment.

Requirements

Security list assignment

The security list has to be assigned to a resource, like a subnet.

OCI auth

On your Oracle Cloud profile you can add an API key. (which can be done for a user with limited rights also)

When you download the public- and private key, you’ll also get a configuration file preview. You have to add the path to the private OCI keyfile there and save it as ~/.oci/config (Linux, macOS)

[DEFAULT]
user=ocid1.user.oc1.......
fingerprint=<fingerprint>
tenancy=ocid1.tenancy.oc1.....
region=eu-frankfurt-1
key_file=/path/to/key/file.pem

Ansible / the OCI SDK will automatically fetch the key from there later, so you don’t need auth in Ansible.

Python OCI SDK

Install the python OCI SDK, for example via

pip install oci

Ansible Collection

Install the OCI ansible collection, for example via

ansible-galaxy collection install oracle.oci

Generate Ansible galaxy role

This would work in a single playbook too, but I’m trying to stick to the Ansible galaxy role structure.

ansible-galaxy init oci-allow-my-ip

Ansible Role

Info: Of course you can also create a fresh OCI security list with Ansible, but to make this example easier, I’ll just update an existing list.

Let’s use the list from before, which has just one ingress rule, which allows my IP address everything:

At the top you can see the OCID, which is a unique ID for a resource. In this case a security list. Because it already exists compartment_id and vcn_id isn’t required in the Ansible role.

File and folder structure

… with just the relevant files:

.
├── oci-allow-my-ip
│   ├── README.md
│   ├── meta
│   │   └── main.yml
│   ├── tasks
│   │   └── main.yml
│   └── vars
│       └── main.yml
└── site.yml

vars/main.yml

You can copy the OCID from OCI and paste it in vars/main.yml like this

oci_security_list_id: "ocid1.securitylist.oc1......."
# ... more vars

You can name oci_security_list_id whatever you like, you just have to use the same name in the task later. You can also add other variables there, in case you need to do more (like compartment_id or vcn_id), but it’s not required for this example.

tasks/main.yml

I left some debug tasks and some TCP and UDP options in this example, so you may adapt this to your needs.

If you only want to allow TCP, UDP or ICMP: protocol needs the protocol number, not the name (ICMP: 1, ICMPv6: 58 TCP: 6, UDP: 17). In my example I use all.

Docs for oracle.oci.oci_network_security_list

---

- name: Install Python OCI SDK if it's not installed
  ansible.builtin.pip:
    name: oci
    state: present

- name: Get own public IP with ipify
  community.general.ipify_facts:
  register: ip_address

# - name: Debug output from what ipify returned
#   ansible.builtin.debug:
#     msg: "My IP address is {{ ip_address.ansible_facts.ipify_public_ip }}"

- name: Update OCI security list with current IP
  oracle.oci.oci_network_security_list:
    name: "allow-my-ip"
    # compartment_id: "{{ oci_compartment_id }}"   # <- not necessary for just updating an existing list
    # vcn_id: "{{ oci_vcn_id }}"                   # <- not necessary for just updating an existing list
    security_list_id: "{{ oci_security_list_id }}"
    state: "present"
    ingress_security_rules:
      - description: "allow my dynamic IP"
        is_stateless: false
        protocol: "all"
        source: "{{ ip_address.ansible_facts.ipify_public_ip }}/32"
        source_type: "CIDR_BLOCK"
        # tcp_options:
        #   destination_port_range:
        #     min: 7777
        #     max: 7777
        # udp_options:
        #   destination_port_range:
        #     min: 7777
        #     max: 7777
    egress_security_rules: [] # <- I don't want to specify egress rules, so this is an empty list, which is required then.
        # description: "something"
        # is_stateless: false
        # protocol: "all"
        # destination: "0.0.0.0/0"
        # destination_type: "CIDR_BLOCK"
        # tcp_options:
        #   destination_port_range:
        #     min: 8888
        #     max: 8888
        # udp_options:
        #   destination_port_range:
        #     min: 8888
        #     max: 8888
  # register: oci_security_rules

# - name: Dump result
#   ansible.builtin.debug:
#     msg: '{{ oci_security_rules }}'
...

So the version without all the debug and helper comments looks like this

---
- name: Install Python OCI SDK if it's not installed
  ansible.builtin.pip:
    name: oci
    state: present

- name: Get own public IP with ipify
  community.general.ipify_facts:
  register: ip_address

- name: Update OCI security list with current IP
  oracle.oci.oci_network_security_list:
    name: "allow-my-ip"
    security_list_id: "{{ oci_security_list_id }}"
    state: "present"
    ingress_security_rules:
      - description: "allow my dynamic IP"
        is_stateless: false
        protocol: "all"
        source: "{{ ip_address.ansible_facts.ipify_public_ip }}/32"
        source_type: "CIDR_BLOCK"
    egress_security_rules: []
...

site.yml (main playbook)

You can then execute the role from a playbook. (on a regular basis to always update your current IP)

---
- name: Main playbook for OCI Cloud
  hosts: localhost
  vars:
    # specify your python interpreter if you use multiple
    ansible_python_interpreter: "/Users/toor/.pyenv/shims/python"
  gather_facts: false

  roles:
    - oci-allow-my-ip
    # ... more roles here

...