DEV Community

Cover image for Receive Realtime Socket Push in Pure Bash
Omran Jamal
Omran Jamal

Posted on • Originally published at omranjamal.me

Receive Realtime Socket Push in Pure Bash

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.

via GIPHY

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`);
});
Enter fullscreen mode Exit fullscreen mode

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.

Via Tenor

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

via Giphy

First, we need to know how to open file descriptors to remote TCP ports.

exec 7<> /dev/tcp/localhost/9090
Enter fullscreen mode Exit fullscreen mode

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 and 9090 are host and port respectively.

Read From File Descriptor

Via Tenor

So there's a read command.

read -u 7 TITLE
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?

Wait, that's illegal

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"
Enter fullscreen mode Exit fullscreen mode

It should show you something like this on your screen (if you're using Unity Desktop like me)
Ubuntu - Unity Desktop notification bubble.Unity Desktop Notification Bubble
That's once down. Need to do it forever.

Do It Forever

Via Tenor

Infinite loop time.

exec 7<> /dev/tcp/localhost/9090

while :
do
    read -u 7 TITLE
    read -u 7 DESCRIPTION

    notify-send "$TITLE" "$DESCRIPTION"
done
Enter fullscreen mode Exit fullscreen mode

TBH, this should suffice.

But, What If The Connection Drops?

Via Tenor

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now you can run it like this ...

bash ./client.sh localhost 9090
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Test It

Via Tenor

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."}'
Enter fullscreen mode Exit fullscreen mode

Running At Startup

To be honest, a whole host of things would be done to run programs at startup. Here are a few links ...

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)