Hacking Bluetooth to Brew Coffee from GitHub Actions: Part 3 - GitHub Actions
permalinkThis is the last part of a three-part series covering the odyssey of getting a new coffeemaker, learning BTLE and how it works, reverse-engineering the Bluetooth interface and Android applications for the coffeemaker, writing a Rust-based CLI interface, and finally, hooking it all up to a GitHub actions bot that lets you brew a coffee just by filing an issue!
In part 2 we got the command-line application working, and now it’s time to connect the dots and build a secure, yet web-accessible interface.
We could choose a standard web host, add some sort of authentication on top of it, build the right web tooling to integrate with the nice command-line application we built, and all the associated security so random people can’t brew coffee. But as you’ve guessed from the title of these posts, we’re going hook this command-line app into a private GitHub repo as our “interface”.
Making use of GitHub issues for automating weird things isn’t new, but I think this is the first time you can make coffee from it!
Getting Started
Here’s our goal:
- We want to allow users to brew a coffee from a GitHub issue, which will be pre-populated from a number of pre-defined templates
- The issue will contain part of the command-line that we want to run, and we’ll need to validate that it’s reasonable and correct, and that nobody is trying to inject any sort of “funny business” to break/backdoor the runner
- We don’t want coffee brewers to have to chase down the status of the brewing operation, so we’re going to make use of issue comments as our basic UI. The user will be able to follow the progress of their coffee inside of the issue, and get a notification when it’s done.
This is what the user will see just before they brew the coffee:
The first question you might have is how we’re going to talk to a Bluetooth coffeemaker from GitHub’s system. This part turns out to be pretty easy: we can use GitHub self-hosted runners as a backdoor into the coffeemaker’s physical location! By running this on a computing device that has a Bluetooth radio in proximity to the coffeemaker, we can send commands to it in response to events occurring in a repo. Conveniently the Raspberry Pi 3 Model B and Pi 4 both support Bluetooth, but in our case we’re going to be using a spare MacBook that’s kicking around.
First thing, we need to create a new runner on GitHub for our project, and then set up the runner on the MacBook:
curl -O -L https://github.com/actions/runner/releases/download/${version}/actions-runner-osx-x64-${version}.tar.gz
tar xzf ./actions-runner-osx-x64-${version}.tar.gz
./config.sh --url https://github.com/mmastrac/brew-a-coffee --token ${token}
./run.sh
GitHub actions are pretty flexible and we have a huge number of events that can trigger them. In our case, we want the creation of a new issue to trigger a run, so our trigger becomes:
on:
issues:
types: [opened]
We’ll pull in the create-or-update-comment
action from peter-evans
for updating the user about the status of their coffee:
steps:
- name: Add comment
uses: peter-evans/create-or-update-comment@v2
And once the coffee is brewed or the process has failed for some other reason, we’ll want to close that issue, so we’re going to pull in peter-evans/close-issue
for this:
- name: Close issue
if: always()
uses: peter-evans/close-issue@v2
The actual brewing part will be pretty easy as well, but it’s going to require use to fetch the text of the issue and use that to create the command-line to run.
Let’s take a look at the event information that GitHub provides to us in $GITHUB_EVENT_PATH
. There’s a lot that GitHub provides for us in this file, and this particular one is trimmed down significantly:
{
"action": "opened",
"issue": {
...
"body": "This is my issue body comment\r\n",
...
"title": "This is my issue title!",
...
}
...
jq
is one the best tools for integrating JSON APIs with shell scripts, so we’ll make use of that. We’ll create a small test JSON file called test.json
that contains just the interesting subset of what’s available in the file at $GITHUB_EVENT_PATH
:
{
"action": "opened",
"issue": {
"body": "This is my issue body comment\r\n",
"title": "This is my issue title!"
}
}
First, we can test extraction of the issue body:
$ jq -r '.issue.body' < test.json
This is my issue body comment
$
That worked, but we’ve got some extra whitespace there. We can trim that with another jq
command, gsub
. By replacing leading or trailing whitespace (gsub("^\\s+|\\s+$";"")
) with nothing, we can get just the text of the comment:
$ jq -r '.issue.body|gsub("^\\s+|\\s+$";"")' < test.json
This is my issue body comment
$
Better!
Extracting the Command-Line
Now what we want to do is allow the user to specify the command-line in the issue, but ensure that they can’t run anything nefarious on the runner. We developed a command-line cappuccino recipe in part 2 that we ran like this:
cargo run -- brew --beverage cappuccino --coffee 40 --milk 100 --taste extrastrong
So let’s extract out everything past the hyphens and make that the required input in our newly-filed issues:
brew --beverage cappuccino --coffee 40 --milk 100 --taste extrastrong
To work on extraction, we’ll update the issue.body
field in our test.json
file to this partial command-line:
{
"action": "opened",
"issue": {
"body": "brew --beverage cappuccino --coffee 40 --milk 100 --taste extrastrong\r\n"
}
}
Since we are creating a valid partial command-line for our brewing app, we can make use of that fact that we know the exact structure. In this case, we know we want it to:
- Start with the subcommand
brew
- Next, contain the beverage to brew with
--beverage <something>
- Finally, contain a list of beverage parameters which are limited to
coffee
,milk
,hotwater
,taste
, andtemperature
. Each parameter is separated from its value by a space (ie:--coffee 100
), and is either a number or an enumeration value (ie:--taste strong
).
We can then build a regular expression that will be limited to just the arguments we’re allowing here. We’ll use the \w
character class as it’s a close match to the values required by our parameters.
We could go further in validating the --beverage
parameter, or the values for the ingredients, but we know that those are carefully checked in the application and we’ll let the application handle the validation:
^brew --beverage \w+( --(coffee|milk|taste|hotwater|temperature) \w+)*$
Now we can put it all together and extract the command-line like so (note that we have to escape the \w
patterns in regular expression):
CMDLINE=$(jq -r '.issue.body|gsub("^\\s+|\\s+$";"")|select(test("^brew --beverage \\w+( --(coffee|milk|taste|hotwater|temperature) \\w+)*$"))' < $GITHUB_EVENT_PATH)
And that’s probably the only tricky part of the process. Now we can build our GitHub action, piece-by-piece.
Building the Workflow
First, the preamble that tells GitHub where and when to run the action, and what permissions it has:
name: Brew
on:
issues:
types: [opened]
jobs:
build:
runs-on: self-hosted
permissions:
issues: write
steps:
Our first step will drop a comment into the issue so the user knows things are happening
- name: Add initial comment
uses: peter-evans/create-or-update-comment@v2
id: comment
with:
issue-number: $
body: ' - [X] Getting ready to brew your ☕️!'
We’ll then install the longshot
executable from cargo
and let them know it was done:
- name: Install longshot
run: cargo install --root /tmp/longshot -- longshot
- name: Update comment
uses: peter-evans/create-or-update-comment@v2
with:
issue-number: $
comment-id: $
body: ' - [X] Installed the `longshot` executable'
Next, we’ll process the requested brew operation using the jq
incantation from earlier. This step will create a body.md
that we’ll use to update the comment, as well as cmdline.txt
that will be used to execute our brewing operation later on:
- name: Process the request
run: |
OUTFILE=body.md
CMDLINE=$(
jq -r '
.issue.body |
gsub("^\\s+|\\s+$";"") |
select(
test("^brew --beverage \\w+( --(coffee|milk|taste|hotwater|temperature) \\w+)*$")
)' < $GITHUB_EVENT_PATH
)
echo Command-line we parsed was: $CMDLINE
if [[ "$CMDLINE" == "" ]]; then
echo " - [X] Couldn't parse the command line from your comment? 🤔" > $OUTFILE
exit 1
fi
echo -n ' - [X]' Running brew command: \`$CMDLINE\` > $OUTFILE
echo ' [(Log here)](https://github.com/'${GITHUB_REPOSITORY}'/actions/runs/'${GITHUB_RUN_ID}')' >> $OUTFILE
echo "/tmp/longshot/bin/longshot $CMDLINE --device-name $" > cmdline.txt
We then update the comment with body.md
:
- name: Update comment
uses: peter-evans/create-or-update-comment@v2
with:
issue-number: $
comment-id: $
body-file: body.md
And run the brewing command:
- name: Brew coffee
run: |
echo '<details><summary>Log</summary><pre>' > log.md
sh -c "`cat cmdline.txt`" | tee -a log.md
echo '</pre></details>' >> log.md
echo '✅ Success!' >> log.md
- name: Update comment on success
uses: peter-evans/create-or-update-comment@v2
with:
issue-number: $
comment-id: $
body-file: log.md
Finally, we’ll log a message to the comment on an error, and close the issue unconditionally:
- name: Update comment on failure
if: failure()
uses: peter-evans/create-or-update-comment@v2
with:
issue-number: $
comment-id: $
body: |
<br>
❌ Failed! Please check the log for the reason.
- name: Close issue
if: always()
uses: peter-evans/close-issue@v2
And with all those steps, we can get ourselves a coffee from GitHub!
While you can’t access my private repository that I’m using to brew us coffee at home, you can definitely try out the example repo that I’ve set up here which uses the command-line interface’s simulator and runs on GitHub’s action runners instead:
https://github.com/mmastrac/brew-a-coffee-demo
To recap, we:
- Figured out how to talk to the coffeemaker over Bluetooth and sniffed some packets
- Reverse engineered the application, figured out how to construct packets, and implemented a command-line applications
- And finally, hooked the entire system up to GitHub actions!
Follow me on Mastadon for more updates on this adventure!
Read full post