diff options
| -rw-r--r-- | crates/arti-rpc-client-core/arti-rpc-client-core.h | 3 | ||||
| -rw-r--r-- | crates/arti-rpc-client-core/cbindgen.toml | 12 | ||||
| -rw-r--r-- | python/arti_rpc/README.md | 48 | ||||
| -rw-r--r-- | python/arti_rpc/pyproject.toml | 23 | ||||
| -rw-r--r-- | python/arti_rpc/samples/rpc_demo.py | 24 | ||||
| -rw-r--r-- | python/arti_rpc/src/arti_rpc/__init__.py | 6 | ||||
| -rw-r--r-- | python/arti_rpc/src/arti_rpc/ffi.py | 191 | ||||
| -rw-r--r-- | python/arti_rpc/src/arti_rpc/rpc.py | 333 |
8 files changed, 632 insertions, 8 deletions
diff --git a/crates/arti-rpc-client-core/arti-rpc-client-core.h b/crates/arti-rpc-client-core/arti-rpc-client-core.h index a2d92b513..13dc713b2 100644 --- a/crates/arti-rpc-client-core/arti-rpc-client-core.h +++ b/crates/arti-rpc-client-core/arti-rpc-client-core.h @@ -96,6 +96,9 @@ * (This does not apply to functions that take an argument via a * non-const pointer.) * + * - Whenever a function returns an error, it returns no other newly allocated objects + * besides the error object itself. + * * ## Correctness requirements * * If any correctness requirements stated here or elsewhere are violated, diff --git a/crates/arti-rpc-client-core/cbindgen.toml b/crates/arti-rpc-client-core/cbindgen.toml index cea4c41dc..77dae764a 100644 --- a/crates/arti-rpc-client-core/cbindgen.toml +++ b/crates/arti-rpc-client-core/cbindgen.toml @@ -105,6 +105,9 @@ header = """\ * (This does not apply to functions that take an argument via a * non-const pointer.) * + * - Whenever a function returns an error, it returns no other newly allocated objects + * besides the error object itself. + * * ## Correctness requirements * * If any correctness requirements stated here or elsewhere are violated, @@ -170,14 +173,7 @@ typedef int ArtiRpcRawSocket; [export] # These structs are not ones we want to expose under their actual names, # or ones that we don't want to expose at all. -exclude = [ - "FfiError", - "RequestHandle", - "RpcConn", - "RpcErrorCode", - "Utf8CString", - "ArtiRpcRawSocket", -] +exclude = ["FfiError", "RequestHandle", "RpcConn", "RpcErrorCode", "Utf8CString", "ArtiRpcRawSocket"] [export.rename] # Having not declared these structs, we can give them new names in the diff --git a/python/arti_rpc/README.md b/python/arti_rpc/README.md new file mode 100644 index 000000000..689de8f13 --- /dev/null +++ b/python/arti_rpc/README.md @@ -0,0 +1,48 @@ +TODO RPC: + +As of this writing (24 Sep 2024) +this directory holds work-in-progress Python wrappers +for the Arti RPC client library. + +All of these APIs are unstable, and the Python is in flux: +don't rely on these yet! + +---- + +You probably don't want to try this out yet; +most of the configuration and setup is unstable. +But if you're brave, and you're on a Unix-like platform... + + +Build arti with RPC support: + +``` +cargo build --release --all-features -p arti +``` + +Build `arti-rpc-client-core` with FFI support: + +``` +cargo build --release --all-features -p arti-rpc-client-core +``` + +Tell this library where to find `arti-rpc-client-core`: + +``` +export LIBARTI_RPC_CLIENT_CORE=$(pwd)/target/release/libarti_rpc_client_core.so +``` + +Start arti: + +``` +./target/release/arti proxy -o "rpc.rpc_listen = \"${HOME}/.local/run/arti/SOCKET\"" +``` + +Run the demo! + +``` +PYTHONPATH="./python/arti_rpc/src:${$PYTHONPATH:-}" python3 \ + python/arti_rpc/samples/rpc_demo.py +``` + + diff --git a/python/arti_rpc/pyproject.toml b/python/arti_rpc/pyproject.toml new file mode 100644 index 000000000..ea986420c --- /dev/null +++ b/python/arti_rpc/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "arti_rpc" +version = "0.0.0.dev0" +requires-python = ">= 3.9" # May work with earlier; haven't tested. +authors = [ + { name = "Nick Mathewson", email = "[email protected]" }, +] +description = "Use the Arti Tor implementation via its RPC protocol." +readme = "README.md" +license = { text = "MIT OR Apache-2.0" } + +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + + "Intended Audience :: Developers", + + # Remove this once it is more mature. + "Private :: Do Not Upload", +] diff --git a/python/arti_rpc/samples/rpc_demo.py b/python/arti_rpc/samples/rpc_demo.py new file mode 100644 index 000000000..5eb466d65 --- /dev/null +++ b/python/arti_rpc/samples/rpc_demo.py @@ -0,0 +1,24 @@ +import pprint +import os +from arti_rpc import * + +# TODO RPC: This won't work unless you've configured it in arti too. +socket_path = os.path.expanduser("~/.local/run/arti/SOCKET") +# TODO RPC: This isn't how connection strings will work in production +connect_string = f"unix:{socket_path}" + +# Connect to arti RPC. +conn = ArtiRpcConn(connect_string) + +# Demo 1: print out a complete list of supported RPC methods. +methods = conn.session().invoke("arti:x_list_all_rpc_methods") + +pprint.pprint(methods) + +# Demo 2: Open a socket to www.torproject.org port 80 over the tor network. + +# TODO RPC: This stalls (as of 24 Sep 2024); it used to work. +# I have patches in other branches to fix it. +# +sock = conn.connect("www.torproject.org", 80, isolation="rpc_demo") +print(sock) diff --git a/python/arti_rpc/src/arti_rpc/__init__.py b/python/arti_rpc/src/arti_rpc/__init__.py new file mode 100644 index 000000000..fc7705181 --- /dev/null +++ b/python/arti_rpc/src/arti_rpc/__init__.py @@ -0,0 +1,6 @@ + +from arti_rpc.rpc import \ + ArtiRpcError, \ + ArtiRpcConn + +__all__ = [ 'ArtiRpcError', 'ArtiRpcConn' ] diff --git a/python/arti_rpc/src/arti_rpc/ffi.py b/python/arti_rpc/src/arti_rpc/ffi.py new file mode 100644 index 000000000..e7aa3e3b8 --- /dev/null +++ b/python/arti_rpc/src/arti_rpc/ffi.py @@ -0,0 +1,191 @@ +""" +ctypes-based wrappers for the functions exposed by arti-rpc-client-core. + +These wrappers deliberately do as little as possible. +""" + +import ctypes +from ctypes import ( + POINTER, + c_char_p, + c_int, + sizeof, + c_void_p, + c_uint64, + c_uint32, + Structure, +) + +import os + +########## +# Declare some types for use with ctypes. + + +class ArtiRpcStr(Structure): + """FFI type: String returned by the RPC protocol.""" + + _fields_ = [] + + +class ArtiRpcConn(Structure): + """FFI type: Connection to Arti via the RPC protocol.""" + + _fields_ = [] + + +class ArtiRpcError(Structure): + """FFI type: Error from the RPC library.""" + + _fields_ = [] + + +class ArtiRpcHandle(Structure): + """FFI type: Handle to an open RPC request.""" + + _fields_ = [] + + +ArtiRpcResponseType = c_int + +_ConnOut = POINTER(POINTER(ArtiRpcConn)) +_ErrorOut = POINTER(POINTER(ArtiRpcError)) +_RpcStrOut = POINTER(POINTER(ArtiRpcStr)) +_RpcHandleOut = POINTER(POINTER(ArtiRpcHandle)) +_ArtiRpcResponseTypeOut = POINTER(ArtiRpcResponseType) + +_ArtiRpcStatus = c_uint32 + + +if os.name == "nt": + # Alas, SOCKET on win32 is defined as UINT_PTR_T, + # which ctypes doesn't know about. + if sizeof(c_void_p) == 4: + _ArtiRpcRawSocket = c_uint32 + INVALID_SOCKET = (1 << 32) - 1 + elif sizeof(c_void_p) == 8: + _ArtiRpcRawSocket = c_uint64 + INVALID_SOCKET = (1 << 64) - 1 + else: + raise NotImplementedError() +else: + _ArtiRpcRawSocket = c_int + INVALID_SOCKET = -1 + + +########## +# Tell ctypes about the function signatures. + +def _annotate_library(lib): + """Helper: annotate a ctypes dll `lib` with appropriate function signatures.""" + lib.arti_rpc_conn_open_stream.restype = _ArtiRpcStatus + lib.arti_rpc_conn_open_stream.argtypes = [ + POINTER(ArtiRpcConn), + c_char_p, + c_int, + c_char_p, + c_char_p, + POINTER(_ArtiRpcRawSocket), + _RpcStrOut, + _ErrorOut, + ] + + lib.arti_rpc_conn_execute.argtypes = [ + POINTER(ArtiRpcConn), + c_char_p, + _RpcStrOut, + _ErrorOut, + ] + lib.arti_rpc_conn_execute.restype = _ArtiRpcStatus + + lib.arti_rpc_conn_execute_with_handle.argtypes = [ + POINTER(ArtiRpcConn), + c_char_p, + _RpcHandleOut, + _ErrorOut, + ] + lib.arti_rpc_conn_execute_with_handle.restype = _ArtiRpcStatus + + lib.arti_rpc_conn_get_session_id.argtypes = [POINTER(ArtiRpcConn)] + lib.arti_rpc_conn_get_session_id.restype = c_char_p + + lib.arti_rpc_connect.argtypes = [c_char_p, _ConnOut, _ErrorOut] + lib.arti_rpc_connect.restype = _ArtiRpcStatus + + lib.arti_rpc_conn_free.argtypes = [POINTER(ArtiRpcConn)] + lib.arti_rpc_conn_free.restype = None + + lib.arti_rpc_err_free.argtypes = [POINTER(ArtiRpcError)] + lib.arti_rpc_err_free.restype = None + + lib.arti_rpc_err_message.argtype = [POINTER(ArtiRpcError)] + lib.arti_rpc_err_message.restype = c_char_p + + lib.arti_rpc_err_os_error_code.argtype = [POINTER(ArtiRpcError)] + lib.arti_rpc_err_os_error_code.restype = c_int + + lib.arti_rpc_err_response.argtype = [POINTER(ArtiRpcError)] + lib.arti_rpc_err_response.restype = c_char_p + + lib.arti_rpc_err_status.argtype = [POINTER(ArtiRpcError)] + lib.arti_rpc_err_status.restype = _ArtiRpcStatus + + lib.arti_rpc_handle_free.argtype = [POINTER(ArtiRpcHandle)] + lib.arti_rpc_handle_free.restype = None + + lib.arti_rpc_handle_wait.argtype = [ + POINTER(ArtiRpcHandle), + _RpcStrOut, + _ArtiRpcResponseTypeOut, + _ErrorOut, + ] + lib.arti_rpc_handle_wait.restype = _ArtiRpcStatus + + lib.arti_rpc_status_to_str.argtype = [_ArtiRpcStatus] + lib.arti_rpc_status_to_str.restype = c_char_p + + lib.arti_rpc_str_free.argtype = [POINTER(ArtiRpcStr)] + lib.arti_rpc_str_free.restype = None + + lib.arti_rpc_str_get.argtype = [POINTER(ArtiRpcStr)] + lib.arti_rpc_str_get.restype = c_char_p + +def _load_library(): + """Allocate a new shared library. + + First, look in the path in $LIBARTI_RPC_CLIENT_CORE (if it is + set). Otherwise, use the default path from LoadLibrary. + + """ + p = os.environ.get("LIBARTI_RPC_CLIENT_CORE") + if p is not None: + return ctypes.cdll.LoadLibrary(p) + + # TODO RPC: On Windows, does this need to be WinDLL wither than cdll? + # Do we need to re-name the file with a ".dll"? + # Do we need to configure Cargo.toml differently + # to get a new object type, or annotate our FFI functions + # with something other than `extern "C"`? + + # TODO RPC: Do we need to start versioning this? + return ctypes.cdll.LoadLibrary("libarti_rpc_client_core.so") + +_THE_LIBRARY = None + +def get_library(): + """Try to find the shared library, loading it if needed. + + By default, we use the ctypes library's notion of the standard + search path for shared libraries. + + Users can override the location of the library + with the environment variable `LIBARTI_RPC_CLIENT_CORE`. + """ + global _THE_LIBRARY + if _THE_LIBRARY is not None: + return _THE_LIBRARY + + lib = _load_library() + _annotate_library(lib) + _THE_LIBRARY = lib + return lib diff --git a/python/arti_rpc/src/arti_rpc/rpc.py b/python/arti_rpc/src/arti_rpc/rpc.py new file mode 100644 index 000000000..058bc4f88 --- /dev/null +++ b/python/arti_rpc/src/arti_rpc/rpc.py @@ -0,0 +1,333 @@ +""" +Type-based wrappers around our FFI functions. + +These types are responsible for providing a python-like API +to the Arti RPC library. + +TODO RPC: NOTE that these APIs are still in flux; +we will break them a lot before we declare them stable. +Don't use them in production. +""" + +# Design notes: +# +# - Every object gets a reference to the ctypes library object +# from the `ffi` module. +# We do this to better support programs that want exact control +# over how the library is loaded. +# +# - Exported types start with "Arti", to make imports safer. + +import json +import os +import socket +from ctypes import POINTER, byref, c_int +from enum import Enum +import arti_rpc.ffi + +if os.name == "nt": + def _socket_is_valid(sock): + """Return true if `sock` is a valid SOCKET.""" + return sock != arti_rpc.ffi.INVALID_SOCKET + +else: + + def _socket_is_valid(sock): + """Return true if `sock` is a valid fd.""" + return sock >= 0 + +class _RpcBase: + def __init__(self, rpc_lib): + self._rpc = rpc_lib + + def _consume_rpc_str(self, s): + """ + Consume an ffi.ArtiRpcStr and return a python string. + """ + try: + bs = self._rpc.arti_rpc_str_get(s) + return bs.decode("utf-8") + finally: + self._rpc.arti_rpc_str_free(s) + + def _handle_error(self, rv, error_ptr): + """ + If `(rv,error_ptr)` indicates an error, then raise that error. + Otherwise do nothing. + + NOTE: Here we rely on the property that, + when there is an error in a function, + _only the error is actually set_. + (No other object was constructed and needs to be freed.) + """ + if rv != 0: + raise ArtiRpcError(rv, error_ptr, self._rpc) + elif error_ptr: + # This should be impossible; it indicates misbehavior on arti's part. + raise ArtiRpcError(rv, error_ptr, self._rpc) + +class ArtiRpcConn(_RpcBase): + """ + An open connection to Arti. + """ + + def __init__(self, connect_string, rpc_lib=None): + """ + Try to connect to Arti, using the parameters specified in + `connect_str`. + + If `rpc_lib` is specified, it must be a ctypes DLL, + constructed with `arti_rpc.ffi.get_library`. + If it's None, we use the default. + """ + if rpc_lib is None: + rpc_lib = arti_rpc.ffi.get_library() + + _RpcBase.__init__(self, rpc_lib) + + self._conn = None + conn = POINTER(arti_rpc.ffi.ArtiRpcConn)() + error = POINTER(arti_rpc.ffi.ArtiRpcError)() + rv = self._rpc.arti_rpc_connect( + connect_string.encode("utf-8"), byref(conn), byref(error) + ) + self._handle_error(rv, error) + assert conn + self._conn = conn + s = self._rpc.arti_rpc_conn_get_session_id(self._conn).decode("utf-8") + self._session_id = s + + def __del__(self): + if hasattr(self, '_conn'): + # Note that if _conn is set, then _rpc is necessarily set. + self._rpc.arti_rpc_conn_free(self._conn) + + def make_object(self, object_id): + """ + Return an ArtiRpcObject for a given object ID on this connection. + + (The `ArtiRpcObject` API is a convenience wrapper that provides + a more ergonomic interface to `execute` and `execute_with_handle`.) + """ + return ArtiRpcObject(object_id, self) + + def session(self): + """ + Return an ArtiRpcObject for this connection's Session object. + + (The Session is the root object of any RPC session; + by invoking methods on the session, + you can get the IDs for other objects.) + """ + return self.make_object(self._session_id) + + def execute(self, msg): + """ + Run an RPC request on this connection. + + On success, return a string containing the RPC reply. + Otherwise, raise an error. + + You may (and probably should) omit the `id` field from your request. + If you do, a new id will be automatically generated. + """ + response = POINTER(arti_rpc.ffi.ArtiRpcStr)() + error = POINTER(arti_rpc.ffi.ArtiRpcError)() + rv = self._rpc.arti_rpc_conn_execute( + self._conn, msg.encode("utf-8"), byref(response), byref(error) + ) + self._handle_error(rv, error) + return self._consume_rpc_str(response) + + def execute_with_handle(self, msg): + """ + Launch an RPC request on this connection, and return a ArtiRequestHandle + to the open request. + + This API is suitable for use when you want incremental updates + about the request status. + """ + handle = POINTER(arti_rpc.ffi.ArtiRpcHandle)() + error = POINTER(arti_rpc.ffi.ArtiRpcError)() + rv = self._rpc.arti_rpc_conn_execute_with_handle( + self._conn, msg.encode("utf-8"), byref(handle), byref(error) + ) + self._handle_error(rv, error) + return ArtiRequestHandle(handle, self._rpc) + + def connect( + self, + hostname, + port, + *, + on_object=None, + isolation="", + want_stream_id=False, + ): + """ + Open an anonymized data stream to `hostname`:`port` over Arti. + + If `on_object` if provided, is the client-like object which will + be told to open the connection. Otherwise, the session + will be told to open the connection. + + If `isolation` is provided, the resulting stream will be configured + not to share a circuit with any other stream + having a different `isolation`. + + If `want_stream_id` is true, then we register the resulting data stream + as an RPC object, and return it along with the resulting socket. + + Caveats: TODO RPC. Copy-paste the caveats from arti-rpc-client-core, + once they have stabilized. + """ + hostname = hostname.encode("utf-8") + isolation = isolation.encode("utf-8") + if on_object is not None: + on_object = on_object.encode("utf-8") + if want_stream_id: + stream_id = POINTER(arti_rpc.ffi.ArtiRpcStr)() + stream_id_ptr = byref(stream_id) + else: + stream_id_ptr = None + sock = c_int(arti_rpc.ffi.INVALID_SOCKET) + error = POINTER(arti_rpc.ffi.ArtiRpcError)() + + rv = self._rpc.arti_rpc_conn_open_stream( + self._conn, + hostname, + port, + on_object, + isolation, + byref(sock), + stream_id_ptr, + byref(error), + ) + self._handle_error(rv, error) + + sock = sock.value + assert _socket_is_valid(sock) + sock = socket.socket(fileno=sock) + + if want_stream_id: + return (sock, stream_id) + else: + return sock + + +class ArtiRpcError(Exception): + """ + An error returned by the RPC library. + """ + + def __init__(self, rv, err, rpc): + self._rv = rv + self._err = err + self._rpc = rpc + + def __del__(self): + self._rpc.arti_rpc_err_free(self._err) + + def __str__(self): + status = self._rpc.arti_rpc_status_to_str( + self._rpc.arti_rpc_err_status(self._err) + ).decode("utf-8") + msg = self._rpc.arti_rpc_err_message(self._err).decode("utf-8") + return f"{status}: {msg}" + + def os_error_code(self): + """ + Return the OS error code (e.g., errno) associated with this error, + if there is one. + """ + code = self._rpc.arti_rpc_err_os_code(self._rpc._err) + if code == 0: + return None + else: + return code + + def response(self): + """ + Return the error response message associated with this error, + if there is one. + """ + response = self._rpc.arti_rpc_err_response(self._err) + if response is None: + return None + else: + return response.decode("utf-8") + + +class ArtiRpcObject(_RpcBase): + """ + Wrapper around an object ID and an ArtiRpcConn; + used to launch RPC requests ergonomically. + """ + + def __init__(self, object_id, connection): + _RpcBase.__init__(self, connection._rpc) + self._id = object_id + self._conn = connection + + def invoke(self, method, **params): + """ + Invoke a given RPC method with a given set of parameters, + wait for it to complete, + and return its result as a json object. + """ + request = {"obj": self._id, "method": method, "params": params} + result = self._conn.execute(json.dumps(request)) + return json.loads(result)["result"] + + def invoke_with_handle(self, method, **params): + """ + Invoke a given RPC method with a given set of parameters, + and return an RpcHandle that can be used to check its progress. + """ + request = {"obj": self._id, "method": method, "params": params} + return self._conn.execute_with_handle(json.dumps(request)) + + +class ArtiResponseTypeCode(Enum): + """ + Value to indiate the type of a response to an RPC request. + + Returned by `ArtiRequestHandle::wait_raw`. + """ + RESULT = 1 + UPDATE = 2 + ERROR = 3 + +class ArtiRequestHandle(_RpcBase): + """ + Handle to a pending RPC request. + """ + + def __init__(self, handle, rpc): + _RpcBase.__init__(self, rpc) + self._handle = handle + + def __del__(self): + self._rpc.arti_rpc_handle_free(self._handle) + + def wait_raw(self): + """ + Wait for a response (update, error, or final result) + on this handle. + + Return a tuple of (responsetype, response), + where responsetype is a ArtiResponseTypeCode. + + TODO RPC: Add a wrapper for this type that returns a more useful + result. + """ + response = POINTER(arti_rpc.ffi.ArtiRpcStr)() + responsetype = arti_rpc.ffi.ArtiRpcResponseType(0) + error = POINTER(arti_rpc.ffi.ArtiRpcError)() + rv = self._rpc.arti_rpc_handle_wait( + self._handle, byref(response), byref(responsetype), byref(error) + ) + self._handle_error(rv, error) + response = self._consume_rpc_str(response) + return (ResponseTypeeCode(responsetype.value), response) + |
