Skip to main content

Notifications in Mac from a Linux SSH server

Aim

As part of my work, I usually SSH into a Linux machine from my Mac, open Emacs, and do coding and other stuff in Emacs. Quite frequently, I execute commands to compile/execute something. These commands usually take some time to run. In the meantime, I switch to some other task and check the command's progress every once in a while.

Instead of having to check for the command's completion manually, I wanted to be notified whenever the command is done.

What I wanted is - when I run a long running command like in the following image -

/images/command.png

I need a notification when the command finishes like so -

/images/notification.png

Execution

Suppose we want to display a notification whenever a command finishes after taking more than 20 seconds. To achieve this, we need someone to monitor the time taken by all the shell commands issued. If any command takes more than 20 seconds, we need a way to communicate it to the Mac. So, we have to answer the following questions to get this working -

  1. How will we monitor all the commands issued?

  2. How will we communicate a command's completion to Mac?

  3. How will we display the notification?

Monitoring every command

In Emacs, I use a shell buffer as my shell. Since Emacs is the one proxying all the commands issued in this buffer to the actual shell process, it would be a perfect fit to monitor the commands.

Emacs's shell buffer emulates a terminal. Every command we issue in this buffer is passed on to the shell process with stdout and stderr bound to the buffer. Where this buffer differentiates itself from a terminal is that it is like any other Emacs's text buffer. This means we can search through the command history and output without requiring to grep, jump to previous commands just like jumping to the end of a function in Python code, etc. Basically, shell mode is a terminal with all of Emacs's text editing capabilities.

In Emacs, every keystroke invokes a function. In Emacs's shell mode, when we press the Enter key, the function comint-send-input runs sending the input to the shell process. Emacs allows customizing the behaviour of functions using hooks. Our function comint-send-input runs the functions in comint-input-filter-functions before sending the command to the shell process and the ones in comint-output-filter-functions after receiving the output.

So, before sending every command, we will note down the command and the timestamp. Once we receive the output from the command, we can do whatever we want based on the time it took for the command to complete. The following code is run on every command submission -

(defun set-command-submission-time (command)
  (ring-insert fifo (cons command (current-time)))
  command)
(add-hook 'comint-input-filter-functions #'set-command-submission-time)

Since we can issue multiple commands at once to the shell, we need a collection data structure to store the timestamps. fifo is a ring object that implements a FIFO queue. On every command submission, this code inserts into fifo a cons of the actual command the timestamp.

The following code runs on every command completion -

(defun check-message (msg)
  (when (and (not (ring-empty-p fifo))
             (string-match (concat comint-prompt-regexp "$") msg))
    (let* ((entry (ring-remove fifo))
           (message (car entry))
           (time-taken (float-time (time-since (cdr entry)))))
      (when (>= time-taken 20)
        (notify-command-to-user message time-taken))))
  msg)
(add-hook 'comint-output-filter-functions #'check-message)

The function check-message is called every time some output is inserted into the buffer. In the let* block, we pop the message and timestamp from the fifo and in case the command was issued more than 20 seconds back, we notify the user using notify-command-to-user. How exactly we are going to do this is the topic of the next section.

One problem with this is - the function check-message is run every time some output is inserted into the buffer; not just at the end of a command execution. So, to figure out when a command has finished execution, I'm using the following heuristic - check if the output matches the command prompt regexp. If it does, the command has finished execution. This is just a heuristic and there are cases where this can fail.

One more detail involved here is - I frequently have multiple shells open. If all these shells use the same fifo, then a timestamp queued by one shell might be dequeued by another shell. To avoid this, we make fifo a buffer local variable.

Communication to Mac

We now have a way to find out when a command took more than 20 seconds to complete. Next task is to figure out how to communicate this to the SSH client (Mac). The only link between the Mac and the server is the SSH tunnel created. We are going to use this tunnel for communication. OpenSSH client has an amazing feature called remote port forwarding 1.

The command to do this looks something like

ssh -R localhost:8888:localhost:7777 <server>

This instructs the SSH client to start listening a port, 8888 in this case, on the server. This listener then forwards all the packets it receives to the client at port 7777. It uses the established SSH tunnel for the network path.

We now have a network path we can use to communicate to the client. Now we need to decide on a protocol - what data will we send to Mac? I went with HTTP. We'll make Emacs send a HTTP POST call to localhost:8888. SSH forwards this to Mac at port 7777. I will run a simple HTTP service in Mac that listens on port 7777 and displays the notification.

So, the notify-command-to-user function does the following -

(defun notify-command-to-user (command time-taken)
  (call-process "curl" nil nil nil "-X" "POST" "-d" (format "%d\n%s" time-taken command) "localhost:7777"))

Basically we are calling curl -X POST -d <command and time-taken> localhost:7777 from Elisp.

Displaying notifications in Mac

Following is the Python code that starts a HTTP server on port 7777 -

import http
import http.server as server
import subprocess
import sys
class NotificationHandler(server.BaseHTTPRequestHandler):
     def do_POST(self):
         length = int(self.headers["Content-Length"])
         # Since the socket is not an interactive socket, we can be
         # sure that `command` contains the entire body. We don't
         # bother with any Content-Encoding stuff.
         try:
             body = self.rfile.read(length)
             body = body.decode("utf-8")
             # time and command are separated by a newline
             separator = body.index("\n")
             time = int(body[:separator])
             command = body[separator:]
             # We've to escape \ and " in the command. Otherwise, we
             # may be vulnerable to accidental command injection.
             command = command.replace("\\", "\\\\").replace('"', '\\"')
         except Exception:
             print(f"Failed to parse the body - {body}", file=sys.stderr)
             self.send_error(http.HTTPStatus.BAD_REQUEST)
             return
         # Display a notification with this command now
         try:
             subprocess.check_output(
                 [
                     "osascript",
                     "-e",
                     f'display notification "{command}" with title "Command Finished" subtitle "Took {time}s"',
                 ]
             )
         except subprocess.CalledProcessError as e:
             print(f"Command {e.cmd} failed due to error {e.output}", file=sys.stderr)
         self.send_response_only(http.HTTPStatus.ACCEPTED)
         self.flush_headers()
if __name__ == "__main__":
     server_address = ("", 7777)
     httpd = server.HTTPServer(server_address, NotificationHandler)
     httpd.serve_forever()

We first create a NotificationHandler class that can handle the POST call 2. This class's do_POST method is invoked for every POST request. In this, we read the body and sanitize the command by escaping double quotes and backslashes to prevent any accidental command injection.

We then use osascript command to display a notification.

So finally, Emacs figures out what commands to notify the users about, SSH provides the network path, and a HTTP server in Mac displays the notification.

Final Setup

To get this working without any configuration in future,

  1. I added HTTP server to startup applications in my Mac.

  2. Added the above Emacs scripts to my Emacs startup file.

  3. Instead of SSHing to the machine like ssh <server>, I have to use ssh -R localhost:8888:localhost:7777 <server>.

That's it.

Security Concerns

  1. We are now running a HTTP server in our Mac capable of displaying notifications. Anyone with a network path to our system can spam us with notifications. I'm not sure how best to avoid this. One way is to use iptables equivalent to restrict access to port 7777 only to localhost.

  2. Adding to the above point, Python's http.server module explicitly mentions that this is not a secure HTTP server.