A Weird Imagination

HTTP over Unix sockets

Posted in

The problem#

Previously, I wrote about using named pipes for IPC to allow controlling a process by another process running on the same computer possibly as a different user, with the access control set by file permissions. But I observed that the restricted unidirectional communication mechanism limited how useful it could be, suggesting another design might be better in settings where bidirectional communication including confirmation of commands may be useful.

Is there a good general solution to this problem without losing the convenience of access control via file permissions?

The solution#

Let's use everyone's favorite RPC mechanism: HTTP. But HTTP normally runs over TCP, and even if we bind to localhost, the HTTP server would still be accessible to any user on the same computer and require selecting a port number that's not already in use. Instead, we can bind the HTTP server to a Unix socket, which similar to named pipes, look a lot like a file, but allow communication like a network socket.

Python's built-in HTTP server doesn't directly support binding to a Unix socket, but the following is slightly modified from an example I found of how to get it to:

import http.server
import json
import os
import socket
import sys
import traceback

def process_cmd(cmd, *args):
    print(f"In process_cmd({cmd}, {args})...")

class HTTPHandler(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        size = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(size)
        args = json.loads(body) if body else []
        try:
            result = process_cmd(self.path[1:], *args)
            self.send(200, result or 'Success')
        except Exception:
            self.send(500, str(traceback.format_exc()))

    def do_GET(self):
        self.do_POST()

    def send(self, code, reply):
        # avoid exception in server.py address_string()
        self.client_address = ('',)
        self.send_response(code)
        self.end_headers()
        self.wfile.write(reply.encode('utf-8'))

sock_file = sys.argv[1]
try:
    os.remove(sock_file)
except OSError:
    pass
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(sock_file)
sock.listen(0)

server = http.server.HTTPServer(sock_file, HTTPHandler,
                                False)
server.socket = sock
server.serve_forever()

sock.shutdown(socket.SHUT_RDWR)
sock.close()
os.remove(sock_file)

Then you can query the server using curl:

$ ./server.py http.socket &
$ curl --unix-socket http.socket http://anyhostname/foo
[GET response...]
$ curl --unix-socket http.socket http://anyhostname/foo \
    --json '["some", "args"]'
[POST reponse...]

or in Python, using requests-unixsocket:

import requests_unixsocket
session = requests_unixsocket.Session()
host = "http+unix//http.socket/"
r = session.get(host + "foo")
# Inspect r.status_code and r.text or r.json()
r = session.post(host + "foo", json=["some", "args"])
# Inspect r.status_code and r.text or r.json()

The details#

HTTP server support for Unix sockets#

Many HTTP servers actually support binding to Unix sockets. Even if you aren't worried about access control for connections to localhost, it's more convenient to assign servers named files instead of numbered ports. A common setup is to have one or more application servers listening on Unix sockets with nginx between those servers and the client, using ngx_http_upstream's support for Unix sockets.

I found guides on doing so for Go, Rust, node.js, and, of course, nginx since its listen directive supports Unix sockets. Python web servers like Daphne also support them.

Socket permissions#

The example code deletes the socket and recreates it every time it runs to ensure it doesn't fail to open the socket because it's already open. One complication is that then the socket permissions are reset every time the program is run, so you will want to ensure the permissions on the file it creates are what you want.

According to the man page, the only permission bit that matters for Unix sockets on Linux is the write bit (2), but it warns that other systems may not use the file permissions at all, so if you want to be portable to non-Linux systems, you may want to rely entirely on the access control to the directory you put the socket in.

In Python, you can use os.umask() function (the documentation on the umask command gives more detail) to control the permission bits of files your program creates, including Unix sockets. So os.umask(0o0002) would ensure both the user and group have write permissions or os.umask(0o0000) would ensure everyone has write permissions.

To control the group owner of the created file, you can set the sticky bit of the directory you are putting it in (chmod g+s dir), and then files created in the directory will default to having the same group owner as the directory.

Implementing process_cmd()#

The signature for process_cmd() is intentionally the same as in my previous post, so you can use the same process_cmd() function. The difference is that if it returns a string, then that string will be sent to the client. This means that the implementation of the listtabs command can be changed so instead of writing the tabs object to a file, it can return it to the client:

    elif cmd == 'listtabs':
        tabs = []
        # ... compute tabs
        return json.dumps(tabs)

HTTP request methods#

The example above doesn't actually distinguish between GET and POST HTTP request methods other than not supporting arguments for GET. It doesn't matter that much, but you may want to be more careful with which verb is allowed for which command and possibly add some more expressive query string parsing. The simplest would be to just accept the args JSON after the ? in the request path, although that wouldn't be very idiomatic for how HTTP APIs usually look.

HTTP clients with Unix socket support#

Most HTTP clients including all browsers I checked do not support connecting to Unix sockets. As mentioned above, you can always get nginx to proxy to a normal TCP connection, which might be useful for testing.

From the command line, you can use curl with the --unix-socket option shown above. Note that the host section of the URL can then be anything you want as when connecting to a Unix socket, it is not used to decide which server to connect to. Notably, the similar tool wget does not support Unix sockets. You can also get a low-level connection to a socket using these tools.

In Python, you can use the requests-unixsocket library mentioned above. There's also the lower-level option of HTTPUnixSocketConnection:

import json
from httpunixsocketconnection import HTTPUnixSocketConnection

conn = HTTPUnixSocketConnection(unix_socket='http.socket')

# GET
conn.request("GET", "/foo")
# Always call .getresponse() before making another request.
res = conn.getresponse()
status_code = res.status
response_content = res.read().decode("utf-8")

# POST
conn.request("POST", "/foo", json.dumps(["some", "args"]))
res = conn.getresponse()

Other programming lanaguages' HTTP libraries can likely also be convinced to connect to a Unix socket.

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.