summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
Diffstat (limited to 'python')
-rw-r--r--python/arti_rpc/README.md48
-rw-r--r--python/arti_rpc/pyproject.toml23
-rw-r--r--python/arti_rpc/samples/rpc_demo.py24
-rw-r--r--python/arti_rpc/src/arti_rpc/__init__.py6
-rw-r--r--python/arti_rpc/src/arti_rpc/ffi.py191
-rw-r--r--python/arti_rpc/src/arti_rpc/rpc.py333
6 files changed, 625 insertions, 0 deletions
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)
+