Post

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.

Giphy Gif

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 variable PUB_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….

This post is licensed under CC BY 4.0 by the author.