Passage

Author: Max Kellermann <max.kellermann@ionos.com>

Passage is a daemon which allows jailed/containerized processes to talk to the host to trigger certain actions.

Configuration

The file /etc/cm4all/passage/config.lua is a Lua script which is executed at startup. It contains at least one passage_listen() call, for example:

passage_listen('/run/cm4all/passage/passage.sock', function(request)
  return m:connect('192.168.1.99')
end)

The first parameter is the socket path to listen on. Passing the global variable systemd (not the string literal "systemd") will listen on the sockets passed by systemd (from unit cm4all-passage.socket):

passage_listen(systemd, function(request) ...

To use this socket from within a container, move it to a dedicated directory and bind-mount this directory into the container. Mounting just the socket doesn’t work because a daemon restart must create a new socket, but the bind mount cannot be refreshed.

The second parameter is a callback function which shall decide what to do with an incoming request. This function receives a request object which can be inspected. Multiple listeners can share the same handler by declaring the function explicitly:

function handler(request)
  if request.command == 'restart' then
    return request:fade_children(control_address)
  else
    error("Unknown command")
  end
end

passage_listen('/foo', handler)
passage_listen('/bar', handler)

It is important that the function finishes quickly. It must never block, because this would block the whole daemon process. This means it must not do any network I/O, launch child processes, and should avoid anything but querying the request’s parameters.

SIGHUP

On systemctl reload cm4all-passage (i.e. SIGHUP), Passage calls the Lua function reload if one was defined. It is up to the Lua script to define the exact meaning of this feature.

Inspecting Incoming Requests

The following attributes can be queried:

  • command: The command string.

  • args: An array containing command arguments.

  • headers: A table containing headers (name/value pairs).

  • body: An optional string of (binary) data; may be nil if none was specified.

  • pid: The client’s process id.

  • uid: The client’s user id.

  • gid: The client’s group id.

  • cgroup: The control group of the client process with the following attributes:

    • path: the cgroup path as noted in /proc/self/cgroup, e.g. /user.slice/user-1000.slice/session-42.scope

    • xattr: A table containing extended attributes of the control group.

    • parent: Information about the parent of this cgroup; it is another object of this type (or nil if there is no parent cgroup).

Actions

The handler function shall return an object describing what to do with the request. The request object contains several methods which create such action objects; they do not actually perform the action.

The following actions are possible:

  • ok([BODY], [HEADERS]): send a successful response to the client.

    Example:

    return request:ok()
    return request:ok('Some message', {foo='A response header'})
    return request:ok(nil, {foo='Only headers, no body'})
    
  • exec_pipe(PATH, ARG, ..., [OPTIONS]): execute the given program (should be an absolute path because there is no $PATH resolution here) and connect a pipe to its standard output; send the pipe’s reading side to the client.

    The second parameter may be a table specifying options:

    • env: a table with environment variables for the child process.

    • stderr='pipe': Connect the program’s stderr to a pipe and return the read side to the client.

    • cgroup='client': Spawn the child process in the same cgroup as the client.

  • http_request(URL): perform a HTTP request and send the response to the Passage client. Non-successful HTTP responses (anything other than 2xx) cause the operation to fail. (This works only with small HTTP responses because Passage responses are limited to one datagram.)

    Instead of a simple URL string, you can construct more complex requests by passing a table:

    return request:http_request({
      url='http://example.com/',
    })
    

    The following table keys are recognized:

    • url: an absolute HTTP URL

    • method: the HTTP request method

    • query: a table containing names and values of query parameters

    • headers: a table containing custom request headers

    • body: the request body; switches the default request method to POST

    • max_size: a size limit for the response body (in bytes); the default is 64 kB

  • error([MESSAGE], [HEADERS]): send an error response to the client. Takes an optional error message parameter. If a message is provided (and not nil), it will be included in the error response. This allows the handler to explicitly return an error status without raising a Lua exception.

    The second parameter allows setting response headers. For example, the Passage client uses the “exit_status” header as its process exit status.

    Example:

    return request:error('Something went wrong', {exit_status='75'})
    

Returning without an action from the handler function (i.e. returning nil) is considered a silent success.

If you encounter a problem, raise an exception by invoking the Lua function error(). The message passed to this function will be logged.

Addresses

It is recommended to create all address objects during startup, to avoid putting unnecessary pressure on the Lua garbage collector, and to reduce the overhead for invoking the system resolver (which blocks Passage execution). The function control_resolve() creates such an address object:

server1 = control_resolve('192.168.0.2')
server2 = control_resolve('[::1]:4321')
server3 = control_resolve('server1.local:1234')
server4 = control_resolve('/run/server5.sock')
server5 = control_resolve('@server4')

These examples do the following:

  • convert a numeric IPv4 address to an address object (port defaults to 5478, the beng-proxy control standard port)

  • convert a numeric IPv6 address with a non-standard port to an address object

  • invoke the system resolver to resolve a host name to an IP address (which blocks passage startup; not recommended)

  • convert a path string to a “local” socket address

  • convert a name to an abstract “local” socket address (prefix ‘@’ is converted to a null byte, making the address “abstract”)

socket

A simple low-level networking library. Example:

tcp = socket:connect('localhost:1234')
udp = socket:connect('localhost:4321', {type='dgram'})
multicast = socket:connect('[ff02::dead:beef]:2345', {type='dgram'})
unix = socket:connect('/run/test.socket')
abstract = socket:connect('@test', {type='seqpacket'})
abstract:send('hello world')

The socket library has the following methods:

  • connect(ADDRESS, [OPTIONS]): Create a new socket connected to the specified address. OPTIONS may be a table with the following keys:

    • type: the socket type, one of stream (the default), dgram, seqpacket.

    Returns a new socket object on success or [nil,error] on error.

Socket objects have the following methods:

  • close(): Close the socket.

  • send(DATA, [START], [END]): Send data (i.e. a string) to the peer. START and END are start and end position within the string with the same semantics as in string.sub(). Returns the number of bytes sent on success or [nil,error] on error.

control_client

A client for the beng-proxy control protocol.

During startup, create a control_client object:

-- IPv4 (default port)
c = control_client:new('224.0.0.42')

-- IPv6 on default port
c = control_client:new('ff02::dead:beef')

-- IPv6 on non-default port (requires square brackets)
c = control_client:new('[ff02::dead:beef]:1234')

-- local socket
c = control_client:new('/run/cm4all/workshop/control')

-- abstract socket
c = control_client:new('@bp-control')

The new() constructor returns nil,error on error (and thus the call can be wrapped in assert() to raise a Lua error instead).

The method build() creates an object which can be used to build a control datagram with one or more commands. After that datagram has been assembled, it can be sent with the send() method. Example:

c:send(c:build():fade_children('foo'):flush_http_cache('bar'))

The send() method returns nil,error on error.

The builder implements the following methods:

  • cancel_job(PARTITION_NAME, JOB_ID)

  • discard_session(ID)

  • disconnect_database(ACCOUNT)

  • fade_children(TAG)

  • flush_filter_cache(TAG)

  • flush_http_cache(TAG)

  • reject_client(ADDRESS)

  • reset_limiter(ACCOUNT)

  • tarpit_client(ADDRESS)

  • terminate_children(TAG)

libsodium

There are some libsodium bindings.

Helpers:

bin = sodium.hex2bin("deadbeef") -- returns "\xde\xad\xbe\ef"
hex = sodium.bin2hex("A\0\xff") -- returns "4100ff"

Generating random data:

key = sodium.randombytes(32)

Sealed boxes:

pk, sk = sodium.crypto_box_keypair()
ciphertext = sodium.crypto_box_seal('hello world', pk)
message = sodium.crypto_box_seal_open(ciphertext, pk, sk)

Public-key signatures:

pk, sk = sodium.crypto_sign_keypair()
m = 'hello world'
sig = sodium.crypto_sign_detached(m, sk)
valid = sodium.crypto_sign_verify_detached(sig, m, pk)

Point*scalar multiplication:

pk = sodium.crypto_scalarmult_base(sk)

json

If built with nlohmann json, the following functions are available in the json namespace:

- the function ``json.dump()`` converts the parameter to a JSON

string:

print(json.dump{x=42, y="z"})
{"x":42,"y":"z"}
  • the function json.parse() parses a JSON string and returns it as a Lua value:

    print(inspect(json.parse('{"x":42,"y":"z"}')))
    {
      x = 42,
      y = "z"
    }
    

JWT

The function jwt.sign() generates a JSON Web Token:

pk, sk = sodium.crypto_sign_keypair()
header = {kid='foo'}
payload = {iss="joe",exp=1300819380}
token = jwt.sign(sk, header, payload)

The JWT library requires building with nlohmann_json and libsodium.

PostgreSQL Client

The Lua script can query a PostgreSQL database. First, a connection should be established during initialization:

db = pg:new('dbname=foo', 'schemaname')

In the handler function, queries can be executed like this (the API is similar to LuaSQL):

local result = assert(db:execute('SELECT id, name FROM bar'))
local row = result:fetch({}, "a")
print(row.id, row.name)

Query parameters are passed to db:execute() as an array after the SQL string:

local result = assert(
  db:execute('SELECT name FROM bar WHERE id=$1', {42}))

The functions pg:encode_array() and pg:decode_array() support PostgreSQL arrays; the former encodes a Lua array to a PostgreSQL array string, and the latter decodes a PostgreSQL array string to a Lua array.

To listen for PostgreSQL notifications, invoke the listen method with a callback function:

db:listen('bar', function()
  print("Received a PostgreSQL NOTIFY")
end)

Security

This software and the Lua code used to configure it is very sensitive, because untrusted processes can send arbitrary data to it.

Never trust the information from the packet payload.

Do not try to establish an authentication protocol. If you want to know who the client is, query those attributes which cannot be changed by the client, such as cgroup membership and file system mounts. Consider that the client may be able to create a new mount namespace and change all mounts. If you have doubts about the client’s identity, bail out (e.g. with Lua’s error() function).

About Lua

Programming in Lua (a tutorial book), Lua 5.3 Reference Manual.

Note that in Lua, attributes are referenced with a dot (e.g. m.sender), but methods are referenced with a colon (e.g. m:reject()).

Usage

The Debian package cm4all-passage-client contains a very simple and generic client. The first parameter specifies the command, and positional argument strings can be specified after that. Example:

cm4all-passage-client fade_children

The option --header=NAME:VALUE can be used to send headers to the server.

By default, the client connects to /run/cm4all/passage/socket, but the option --server=PATH can be used to change that:

cm4all-passage-client --server=/tmp/passage.socket fade_children

Protocol

The daemon listens on a local “sequential packet” socket (AF_LOCAL / SOCK_SEQPACKET).

The client sends a request in one packet, and each packet gets acknowledged by the server in a response packet. Both request and response share the same general structure:

COMMAND/STATUS [PARAM1 PARAM2 ...]\n
HEADER1: VALUE1\n
HEADER2: VALUE2\n
\0BODY

A packet consists of at least one command (request) or status (response). The command is an unquoted string consisting of ASCII letters, digits or underscore. The response status can be either OK or ERROR (unquoted). An error status may be followed by a message as the first (and only) parameter.

After the command, there may be positional string parameters separated by a space. An unquoted parameter is a non-empty string of ASCII letters, digits, underscores or dashes. Parameters that contain other characters must be enclosed in double quotes. The double quote and backslash may be escaped by preceding it with a backslash character. Control characters and null bytes (0x00..0x1f) are not allowed.

Following lines may be headers, i.e. name-value pairs. Header names follow the same syntax as unquoted parameters; header values may contain any printable ASCII character. The last newline character may be omitted.

Finally, a body of binary data may be appended, separated from the rest with a null byte. Ancillary data may contain file descriptors.

The meaning of commands, parameters, headers, body and the file descriptors is defined by the Lua configuration script.

Common Commands

This section describes common commands, to establish a convention on how they shall be implemented.

  • fade_children: send a FADE_CHILDREN control packet to a configured address. The Lua script shall determine the client’s identity and should only fade child processes belonging to that user account.