· Alexander · GitHub · 6 min read
Using Linode CLI to Update Cloud Firewall Rules in GitHub Actions
Updating Linode Cloud Firewall rules during workflow runs in GitHub Actions

In my ever-increasing push to lock down my Virtual Machines (VMs) in Linode (ref link = $100 credit when you activate a payment method!), I have started using the Cloud Firewall more to limit connections to them. It’s basically like your local network Router’s firewall but runs in the Cloud.
Currently, I have port 22 (SSH) limited to my home WAN IP. This makes it hard to automate the deployment of my blog from GitHub without having to manually update the rules to all ipv4 traffic on port 22 before I run my workflows. A few of them are automated to update dependencies and fail if the rules are not updated in time. I do not like failed run email spam, so I decided to finally automate it.

Linode CLI
Linode CLI is a utility that allows you to add, modify, and remove Linode services from the command line via the Linode API. If you are familiar with any other cloud service provider then this should sound familiar to you. With this utility I can update the Cloud firewall rules to allow the current GitHub Actions runner’s public IP during the workflow run.
Using Linode CLI via GitHub Actions
For my use-case I am currently using it via the official GitHub Actions action to modify my Cloud Firewall inbound rules during my workflow run. To do this I had to modify my workflows with a few extra steps and a custom bash script to run the update commands for the firewall. One that sets up the Linode CLI tool, one to grab the public IP of the runner, and two bare-bones run steps for running the custom script.
Updated Workflow file (condensed):
name: 'Deploy Blog'on: push: branches: - main paths-ignore: - .gitignore - .vscode - README.md - LICENSE - _drafts workflow_dispatch:jobs: build: if: github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, 'noci') runs-on: ubuntu-latest steps: - name: checkout repo uses: actions/checkout@v4 with: fetch-depth: 0 # for posts's lastmod submodules: true
- name: setup ruby uses: ruby/setup-ruby@v1 id: setup with: ruby-version: '3.0' bundler-cache: true
- name: Public IP id: ip uses: candidob/get-runner-ip@v1.0.0
- name: Setup Linode Cloud Firewall uses: linode/action-linode-cli@v1 with: token: "${{ secrets.LINODE_CLI_TOKEN }}"
#<... build blog & test blog steps go here ...>
- name: Setup SSH id: ssh-setup if: (steps.testing.outcome == 'success' && steps.build.outcome == 'success') run: | mkdir ~/.ssh touch ~/.ssh/known_hosts echo ${{ secrets.LINODEFINGERPRINT }} >> ~/.ssh/known_hosts
- name: Open Linode Cloud Firewall for GH if: (steps.ssh-setup.outcome == 'success') id: linode-fw-open env: PUB_IP: "${{ steps.ip.outputs.ipv4 }}/32" HOME_IP: "${{ secrets.HOMEIP }}" LINODE_FW_ID: "${{ secrets.LINODE_FW_ID }}" run: | bash ./.github/linode.sh -a --verbose
- name: deploy blog uses: burnett01/rsync-deployments@5.2.1 id: deploy if: (steps.testing.outcome == 'success' && steps.build.outcome == 'success') with: switches: -avr path: _site/ remote_path: /opt/sites/blog/ remote_host: ${{ secrets.LINODEHOST }} remote_port: 22 remote_user: ${{ secrets.LINODEUSER }} remote_key: ${{ secrets.LINODESSHKEY }}
- name: Close Linode Cloud Firewall for GH if: (steps.deploy.outcome == 'success') || (steps.deploy.outcome == 'failed' || steps.deploy.outcome == 'skipped') id: linode-fw-close run: | bash ./.github/linode.sh -d --verboseLine by line breakdown:
- 31-33: This step sets up the
candidob/get-runner-ip@v1.0.0action which retrieves the public IPv4 IP of the runner and sets it as an output. - 35-38: This step sets up the official Linode CLI action and authenticates it via an API PAT Token from Linode using a repository secret.
- 50-58: This step runs the
linode.shscript with the-aswitch for updating the Cloud Firewall rules with the public IP of the runner using the variablePUB_IP.PUB_IPis set to the output from the step on lines 31-33 with the addition of /32 for single IP subnet mask.- When running the firewall update command all firewall rules are removed and the new ones are added… I did not realize this the first time.
- 73-77: This step runs the
linode.shscript with the-dswitch to remove the runner IP from the firewall rules after the blog has been deployed via the previous step. However, this step is set to run even if the deployment step before failed or was skipped.
Below is the linode.sh script for CLI command management. I plan to expand this script later which is why its not just commands in the workflow steps.
#/bin/bash
while getopts ":a:d" option; docase $option ina)linode-cli firewalls rules-update $LINODE_FW_ID \ --inbound '[{"action":"ACCEPT", "protocol": "TCP", "ports": "22", "addresses": {"ipv4": ["'$HOME_IP'", "'$PUB_IP'"]}, "label": "accept-inbound-SSH"}, {"action":"ACCEPT", "protocol": "TCP", "ports": "80", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::/0"]}, "label": "accept-inbound-HTTP"}, {"action":"ACCEPT", "protocol": "TCP", "ports": "443", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::/0"]}, "label": "accept-inbound-HTTPS"}]';;d)linode-cli firewalls rules-update $LINODE_FW_ID \ --inbound '[{"action":"ACCEPT", "protocol": "TCP", "ports": "22", "addresses": {"ipv4": ["'$HOME_IP'"]}, "label": "accept-inbound-SSH"}, {"action":"ACCEPT", "protocol": "TCP", "ports": "80", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::/0"]}, "label": "accept-inbound-HTTP"}, {"action":"ACCEPT", "protocol": "TCP", "ports": "443", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::/0"]}, "label": "accept-inbound-HTTPS"}]';;esacdoneAt the moment it’s a pretty basic case switcher based on the options passed to it during runtime. For this to work I had to retrieve the firewall ID of my Cloud Firewall instance. This can be easily done by running the following curl command in a PowerShell or Linux terminal.
curl -H "Authorization: Bearer API_TOKEN_HERE" https://api.linode.com/v4/networking/firewallsThe output:
{ "data": [ { "id": 000000, "label": "Main", "created": "2021-10-14T06:47:49", "updated": "2024-02-18T23:18:32", "status": "enabled", "rules": { "inbound": [ { "ports": "80, 443", "protocol": "TCP", "addresses": { "ipv4": ["0.0.0.0/0"], "ipv6": ["::/0"] }, "action": "ACCEPT", "label": "accept-inbound-HTTP" }, { "action": "ACCEPT", "addresses": { "ipv4": ["1.2.3.4/32"] }, "ports": "22", "protocol": "TCP", "label": "accept-inbound-SSH", "description": null } ], "inbound_policy": "DROP", "outbound": [], "outbound_policy": "ACCEPT" }, "tags": [], "entities": [ { "id": 0000000, "type": "linode", "label": "xxxxxxxx", "url": "/v4/linode/instances/0000000" }, { "id": 11111111, "type": "linode", "label": "xxxxxxxx", "url": "/v4/linode/instances/11111111" }, { "id": 22222222, "type": "linode", "label": "xxxxxxxx", "url": "/v4/linode/instances/22222222" } ] } ], "page": 1, "pages": 1, "results": 1}Line 4 provides the firewall ID. This can be passed via the repository secret LINODE_FW_ID.
Conclusion
Now that I have this working I can have my workflow runs auto update the firewall rules temporarily to allow the runner to communicate with my Linode hosts. Then the workflow can return the rules to their original state upon completion. This keeps my hosts more secure since I do not have to leave the SSH port “open” to the internet.
More on this topic later! :)
This may or may not have been a 3AM rabbit hole while trying to fix something else…