Cover Photo Credit -- Robert Katzki / Unsplash
TL;DR: Use
read
and net-redirections in bash in a few nested infinite loops to handle pushes form a server in real time and use notify-send to display a notification.
Today we're gonna receive realtime push notifications to our Ubuntu Desktops with nothing but Bash.
We obviously need a server to send the push notifications. We'll implement that in Node and Express for now, and maybe recreate that in bash too in a future article (no guarantees though).
Please note that yes, we can do this really easily with polling but this post is actually about net-redirections in Bash and how it allows for true push, without the need for (long) polling a server with something like curl.
Are you ready? 'cause I am.
First, The Server
We kept it simple, just a http server, that receives JSON data as POST on port 9091
and sends it over TCP bidirectional sockets that are registered through a TCP server running on port 9090
. In the following format ...
[notification title]
[notification description]
The title and description on lines separated by a \n
, that means every odd line from the server will be a title and every even numbered line will be a description.
Install Express first, then we can talk.
yarn add express
For receiving the JSON payload, we are using express because personally I just like that express comes built-in with a JSON body parser now. On the TCP end, we are using node's built-in net
package, and storing each socket object's reference temporarily in a JavaScript Set.
const net = require("net");
const express = require("express");
const sockets = new Set();
const app = express().use(express.json());
app.post("/", (req, res) => {
sockets.forEach((socket) => {
socket.write(`${req.body.title}\n${req.body.description}\n`);
});
res.json({
...req.body,
sentTo: sockets.size,
});
});
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => {
sockets.delete(socket);
});
});
server.listen(9090, "0.0.0.0", () => {
console.log(`Listen on 0.0.0.0:9090`);
});
app.listen(9091, "0.0.0.0", () => {
console.log(`Server on http://0.0.0.0:9091`);
});
That's it, that's the server, nothing fancy. No authentication either. I'm sure the great people before me have covered it in detail.
Bash = The Fun Part
Requirements
Bash (no, really, it has to be BASH - Bourne Again Shell).
Also, bash needs to be compiled with --enable-net-redirections
.
Why Not Other Shells?
If you're ever working with UNIX or UNIX-like operating systems like Linux or MacOS, whenever you communicate with literally anything in the real world, you do it through a file descriptor, because often file descriptors "describe" a file on your computer or device connected to your computer.
Bash actually takes it a step further, and allows you to open TCP or UDP ports to remote servers as file descriptors, such that by writing and reading from one such file descriptor, you will be communicating with a remote server.
There may be other shell programs that also allow this, but I am not adventurous enough to get to know them.
– OP
Open a File Descriptor
First, we need to know how to open file descriptors to remote TCP ports.
exec 7<> /dev/tcp/localhost/9090
Hey, that was easy, we should do this more often.
- The
7
is an index for the file descriptor, so that we can refer to it by number later. - The
<>
signifies this is a read-write descriptor, the write isn't useful for our use case but meh, shouldn't hurt. - You can replace
/dev/tcp/
with/dev/udp/
to do UDP communication if you want. -
localhost
and9090
are host and port respectively.
Read From File Descriptor
So there's a read
command.
read -u 7 TITLE
How convenient.
You might be wondering ...
Don't I need to read the data and put it into a variable?
– You
Yes, yes we do and that's exactly what the TITLE
thing is. read
reads from the file descriptor mentioned in the -u
parameter (in our case, 7
) and puts it in a variable named at the first ordered argument (in our case TITLE
)
It's also important to note that read
reads upto and including a \n
(new-line character), and blocks until it reads a new-line character into buffer or until the file descriptor closes.
If you want to prove it, you can echo
it.
exec 7<> /dev/tcp/localhost/9090
read -u 7 TITLE
echo $TITLE
Note: This is assuming the server running at 9090 is writing something to the stream upon connecting, which isn't true in the code above. This is just for illustrative purposes.
Read the read
man pages for a full list of flags and arguments.
How Does read
Introduce Variables?
Well, surprise. read
isn't an external program. Neither is exec
. They are both provided by bash (or any other shell you are currently using) to make your (the programmer) life easier.
They are kinda introduced in the same way /dev/tcp
was. A virtual program.
Do It Twice & Show Notification
We'll use [notify-send](https://manpages.ubuntu.com/manpages/xenial/man1/notify-send.1.html)
exec 7<> /dev/tcp/localhost/9090
read -u 7 TITLE
read -u 7 DESCRIPTION
notify-send "$TITLE" "$DESCRIPTION"
It should show you something like this on your screen (if you're using Unity Desktop like me)
Unity Desktop Notification Bubble
That's once down. Need to do it forever.
Do It Forever
Infinite loop time.
exec 7<> /dev/tcp/localhost/9090
while :
do
read -u 7 TITLE
read -u 7 DESCRIPTION
notify-send "$TITLE" "$DESCRIPTION"
done
TBH, this should suffice.
But, What If The Connection Drops?
One more infinite loop to go.
while :
do
# Make sure the connection has been established.
if exec 7<> /dev/tcp/localhost/9090 ; then
while :
do
# Make sure, both title and description has been read.
if read -u 7 TITLE && read -u 7 DESCRIPTION ; then
notify-send "$TITLE" "$DESCRIPTION"
else
# `read` failing means there's something wrong
# with the file descriptor (may be closed.)
break
fi
done
fi
# Sleep 5 seconds, before retrying.
sleep 5
done
This is might be a bit much to unpack, but read the comments.
Hardcoding Is Horrible
Thankfully, shells allow you to use and pass arguments, so that we don't have to hardcode the host and port.
while :
do
if exec 7<> /dev/tcp/$1/$2 ; then
while :
do
if read -u 7 TITLE && read -u 7 DESCRIPTION ; then
notify-send "$TITLE" "$DESCRIPTION"
else
break
fi
done
fi
sleep 5
done
Now you can run it like this ...
bash ./client.sh localhost 9090
Final Code
I just sprinkled in some helpful messages, and added a bash Shebang.
#!/usr/bin/env bash
while :
do
echo "Attempting to connect to $1:$2 ..."
if exec 7<> /dev/tcp/$1/$2 ; then
echo "Connection Established to $1:$2"
while :
do
if read -u 7 TITLE && read -u 7 DESCRIPTION ; then
notify-send "$TITLE" "$DESCRIPTION"
else
break
fi
done
fi
echo "Connection lost. Retrying in 5 seconds ..."
sleep 5
done
It's a gist with along with the server code too if you need.
Don't forget to make the script executable before running.
chmod +x ./client.sh
Test It
Well, you could use Insomnia or Postman, but we love the CLI, so here are commands ...
# Start the server
node server.js
# Start the client
./client.sh localhost 9090
# Send the JSON payload with cURL
curl -X POST http://localhost:9091/ \
-H 'Content-Type: application/json' \
-d '{"title":"Testing","description":"This is a test."}'
Running At Startup
To be honest, a whole host of things would be done to run programs at startup. Here are a few links ...
- https://askubuntu.com/questions/814/how-to-run-scripts-on-start-up (every answer is a valid approach)
- https://transang.me/three-ways-to-create-a-startup-script-in-ubuntu/
- https://stackoverflow.com/questions/6442364/running-script-upon-login-mac
Could This Be Improved?
Yes, in the following ways. (left as an exercise to the reader)
- Use a cli tool to sanitize whatever comes through the TCP socket.
- Introduce auth keys to be send as the first INIT message from client to server.
- Send messages symmetrically encrypted (or asymmetrically if you're bold enough) to stop eavesdropping.
- Setup authentication on the sending server.
Top comments (0)