Using Linode CLI to Update Cloud Firewall Rules in GitHub Actions
FTC: Some links in this post are income earning affiliate links.
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):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
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/[email protected]
- 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/[email protected]
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 --verbose
Line by line breakdown:
- 31-33: This step sets up the
candidob/[email protected]
action 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.sh
script with the-a
switch for updating the Cloud Firewall rules with the public IP of the runner using the variablePUB_IP
.PUB_IP
is 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.sh
script with the-d
switch 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#/bin/bash
while getopts ":a:d" option; do
case $option in
a)
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"}]'
;;
esac
done
At 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.
1
curl -H "Authorization: Bearer API_TOKEN_HERE" https://api.linode.com/v4/networking/firewalls
The output:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{
"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….