RPC Transport (binder-over-socket)
rsbinder ships two parallel Binder stacks:
- Kernel binder — the traditional path through
/dev/binder//dev/binderfs/binder, the rsb_hub or Androidservicemanager, and the kernel binder driver. This is what every chapter so far has covered. - RPC transport — binder-over-socket, AOSP's
RpcServer/RpcSessionequivalent in pure Rust. No kernel binder, noProcessState, no service manager. A socket (Unix-domain, vsock, or TLS over TCP) carries the sameParcelpayload between two processes, optionally on different machines.
Both stacks drive the same generated AIDL stubs. A client written
against IHello doesn't know — and doesn't need to know — whether the
proxy underneath talks to a kernel binder handle or an RPC socket.
This chapter introduces when to use RPC, how to stand up a minimal client/server pair, and the runtime/platform/security trade-offs that distinguish RPC from kernel binder.
Feature flag. RPC is gated behind the
rpcCargo feature, off by default. Builds withoutrpccarry zero RPC code and zero extra dependencies. Enable per crate:[dependencies] rsbinder = { version = "0.8", features = ["rpc"] }
When to use RPC vs. kernel binder
| Need | Stack |
|---|---|
| Two processes on the same Linux/Android host | Kernel binder |
| Pure user-space, no kernel binder driver | RPC (Unix) |
| Cross-host / cross-VM binder calls | RPC (vsock/TLS) |
| Runs on macOS as the host platform | RPC only |
Wants Android's getCallingUid() / SELinux for free | Kernel binder |
| Needs custom transport (TLS, mTLS, …) | RPC |
Kernel binder is faster (single ioctl per transaction, shared
memory) and gives you Android's security model for free. RPC trades
some of that for portability and reach — it works on Linux, Android,
and macOS, with no kernel driver and no service manager.
A minimal RPC service and client
The complete example lives under
example-hello.
Once you've enabled the rpc feature on the workspace, run:
# Terminal 1
$ cargo run -p example-hello --features rpc --bin rpc_hello_service
# Terminal 2
$ cargo run -p example-hello --features rpc --bin rpc_hello_client
Server
use rsbinder::rpc::RpcServer; use rsbinder::*; use example_hello::*; const RPC_SOCKET: &str = "/tmp/rsb_hello_rpc.sock"; struct IHelloService; impl Interface for IHelloService {} impl IHello for IHelloService { fn echo(&self, echo: &str) -> rsbinder::status::Result<String> { Ok(echo.to_owned()) } } fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { // No ProcessState, no hub. RPC never touches the kernel binder. let _ = std::fs::remove_file(RPC_SOCKET); let server = RpcServer::setup_unix_server(RPC_SOCKET)?; // Publish a root binder. Clients fetch this via get_root(). server.set_root(BnHello::new_binder(IHelloService {}).as_binder()); // Accept loop runs on this thread until shutdown(). server.run()?; Ok(()) }
Key points:
- No
ProcessState::init_default()— the RPC stack is independent of the kernel binder singleton. Mixing the two stacks in one process is fine (the Accessor pattern below relies on it), but a pure-RPC process needs neitherProcessStatenorrsb_hub. set_rootpublishes the single root binder. The client fetches it back throughRpcSession::get_root— this is the moral equivalent ofhub::get_interface(name)for kernel binder, just without a service manager.server.run()runs the accept loop until [RpcServer::shutdown] is called. Use [RpcServer::run_background] to spawn it on a dedicated thread instead.
Client
use rsbinder::rpc::RpcSession; use rsbinder::{FromIBinder, Strong}; use example_hello::*; const RPC_SOCKET: &str = "/tmp/rsb_hello_rpc.sock"; fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { let session = RpcSession::setup_unix_client(RPC_SOCKET)?; let root = session.get_root()?; // Same generated stub as the kernel path — try_from picks the // RPC proxy under the hood. let hello: Strong<dyn IHello> = <dyn IHello as FromIBinder>::try_from(root)?; let reply = hello.echo("Hello over RPC!")?; println!("server replied {reply:?}"); Ok(()) }
The generated Bp* proxies dispatch through as_remote(), which
returns either the kernel handle or the RPC proxy depending on what
the SIBinder came from. One AIDL stub, two stacks — there is no
separate "RPC client API" you need to learn.
Multiple named services on one server
RpcServer::set_root publishes a single root binder. If you need to
expose multiple named services on the same socket — the moral
equivalent of the kernel-binder hub::add_service(name, …) flow — use
[RpcServer::add_service] instead. The first call automatically turns
the root into a built-in service directory, so clients reach each
service by name through [RpcSession::get_service]:
#![allow(unused)] fn main() { // Server let server = RpcServer::setup_unix_server(SOCKET)?; server.add_service("hello", BnHello::new_binder(IHelloService).as_binder())?; server.add_service("echo", BnEcho::new_binder(IEchoService).as_binder())?; server.run()?; // Client let session = RpcSession::setup_unix_client(SOCKET)?; let hello: Strong<dyn IHello> = <dyn IHello as FromIBinder>::try_from(session.get_service("hello")?)?; let echo: Strong<dyn IEcho> = <dyn IEcho as FromIBinder>::try_from(session.get_service("echo")?)?; }
add_service is O(1) per call: the directory is built once and shares
the server's service map, so later registrations — even after the server
is already serving — are seen through the same root with no rebuild.
Mixing set_root with add_service on the same server is not
supported (the last one installed wins); pick one publishing model per
server.
For registering and looking up services with the same code over kernel binder or RPC, see Cross-Transport Services.
Wire-protocol profiles
rsbinder speaks two RPC wire profiles, chosen at connect time:
| Profile | Default? | Constructor |
|---|---|---|
| Android 12 (r34) | yes | RpcSession::setup_unix_client(path) |
| Android 13–16 | opt-in | RpcSession::setup_unix_client_android13plus(path, max_version) |
The android-13+ profile is the protocol real AOSP libbinder speaks
today. It runs a handshake on connect and negotiates a wire version:
max_version | AOSP version |
|---|---|
0 | Android 13 |
1 | Android 14 / 15 |
2 | Android 16 |
The server picks the highest version both sides advertise:
#![allow(unused)] fn main() { // Server: offer up to android-16 wire v2. let server = RpcServer::setup_unix_server("/tmp/foo.sock")?; server.set_android13plus(2); server.set_root(my_root); // Client: offer up to v2. Negotiation picks min(client, server). let session = RpcSession::setup_unix_client_android13plus( "/tmp/foo.sock", 2, )?; }
Both stacks have been validated end-to-end against real AOSP
libbinder on Android 13/14/15/16 emulators (full parcel-body
transact, byte-correct), so an rsbinder RPC server can serve a real
Android libbinder client and vice versa.
Transports
RpcSession/RpcServer are transport-agnostic. The bundled
backends are:
| Backend | Feature flag | Trust boundary |
|---|---|---|
| Unix socket | rpc (always on) | Filesystem perms + SO_PEERCRED/getpeereid |
| vsock | rpc-vsock | Hypervisor VM isolation (host ↔ VM) |
| TLS / TCP | rpc-tls | TLS certificate chain (caller-owned rustls) |
| Plain TCP | rpc-tcp-debug | None — debug/interop only, never production |
Add the matching feature in Cargo.toml:
[dependencies]
rsbinder = { version = "0.8", features = ["rpc", "rpc-vsock"] }
Each backend implements the
RpcTransport
trait — you can implement your own if you need a custom carrier.
TLS client
#![allow(unused)] fn main() { use rsbinder::rpc::{rustls, RpcSession}; use std::sync::Arc; let mut roots = rustls::RootCertStore::empty(); // ... populate `roots` from your trust anchors ... let config = Arc::new( rustls::ClientConfig::builder() .with_root_certificates(roots) .with_no_client_auth(), ); // Third argument must be `Arc<rustls::ClientConfig>` — `setup_tcp_client_tls` // takes the config by shared ownership so it can be cloned per connection. let session = RpcSession::setup_tcp_client_tls( "binder.example.com:9999", "binder.example.com", config, )?; let root = session.get_root()?; }
rsbinder::rpc::rustls is the exact rustls version the rpc-tls
backend links against, re-exported so you don't have to track it in
your own Cargo.toml.
TLS server
The server side takes a rustls::ServerConfig carrying its certificate
chain and private key. RpcServer::setup_tcp_server_tls runs the TLS
handshake on each connection's own worker thread (so a slow-handshake
peer stalls only its worker, never the accept loop), then serves the
same session as any other backend:
#![allow(unused)] fn main() { use rsbinder::rpc::{rustls, RpcServer}; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use std::sync::Arc; // Server certificate chain + private key, loaded from PEM. let certs: Vec<_> = CertificateDer::pem_slice_iter(SERVER_CERT_PEM.as_bytes()).collect::<Result<_, _>>()?; let key = PrivateKeyDer::from_pem_slice(SERVER_KEY_PEM.as_bytes())?; let config = Arc::new( rustls::ServerConfig::builder() .with_no_client_auth() // one-way TLS — see "Who needs a key?" .with_single_cert(certs, key)?, ); // TCP is TLS-only by design: there is no plaintext-TCP server constructor. let server = RpcServer::setup_tcp_server_tls("0.0.0.0:9999", config)?; server.set_root(my_root_binder); server.run()?; // or server.run_background() }
RpcServer::setup_unix_server_tls and (with rpc-vsock)
RpcServer::setup_vsock_server_tls do the same over a Unix socket /
vsock — TLS is orthogonal to the socket kind (it mirrors AOSP's
RpcTransportCtx::newTransport(fd)).
Who needs a key?
| Mode | Server | Client |
|---|---|---|
| One-way TLS (default) | cert chain + private key (always) | trusted roots only — no key |
| mTLS (mutual) | cert chain + private key and a client-cert verifier | cert chain + private key |
-
One-way TLS is the common case. The server proves its identity with its certificate; the client only verifies that certificate against its
RootCertStore(with_no_client_auth()on both configs above). The client needs no key of its own — exactly like a browser connecting to an HTTPS site. -
mTLS additionally authenticates the client. Build the server config with a client-cert verifier (
rustls::server::WebPkiClientVerifier::builder(Arc::new(roots)).build()?→.with_client_cert_verifier(verifier)) instead of.with_no_client_auth(), and give the client a cert + key (.with_client_auth_cert(client_certs, client_key)?instead of the client's.with_no_client_auth()). The handshake then fails unless the client presents a certificate the verifier trusts, and the server sees that client's leaf cert as itsPeerIdentity::Certificate.
Either way the certificate check happens during the TLS handshake:
an untrusted, missing, or expired certificate fails the connection
before a single RPC byte is exchanged. rsbinder never invents crypto —
all key / cert / root management and verification are your rustls
config and rustls itself.
Security
RPC is not a drop-in for kernel binder's security model. Kernel binder gives you a kernel-vouched uid/pid and SELinux for free; RPC's trust boundary is the transport itself. This section covers the RPC trust boundaries; for the full handler-side authorization story across both transports —
calling_caller(),@EnforcePermissionover RPC, uid ACLs,PermissionAuthority— see Security & Authorization.
Each transport defines its own trust boundary, surfaced as a
PeerIdentity:
Local { uid, pid }— Unix socket peer with kernel-vouched credentials.Vsock { cid }— VM context id (a routing address, not an ACL basis on its own — the trust comes from hypervisor isolation).Certificate(CertId)— TLS peer authenticated by its leaf cert.Anonymous— no identity at all. ACL is impossible against an anonymous peer. The debug TCP backend always returns this, and any backend falls back to it (logged loudly) if peer-credential resolution fails — so an authorizer must treatAnonymousas deny.
Use [RpcServer::set_authorizer] to enforce a per-connection policy.
The closure runs once on accept, before any RPC byte is exchanged:
#![allow(unused)] fn main() { const ALLOWED_UID: u32 = 1000; server.set_authorizer(|peer| { peer.uid() == Some(ALLOWED_UID) }); }
A rejected peer's socket is closed; its next operation sees
DeadObject. The hook is opt-in — without it, every connection is
accepted (matching the prior behaviour).
set_authorizer is connection-level (decided once, at handshake) —
the right granularity for vsock and TLS, which carry no per-call uid. For
per-method authorization, a handler can also read the caller directly:
over a Unix-domain RPC connection rsbinder::get_calling_uid() returns the
kernel-vouched peer uid (and calling_caller() the full transport-tagged
identity), exactly as on kernel binder — so a uid ACL written once runs on
both. Over a uid-less transport get_calling_uid() is the fail-closed
u32::MAX sentinel. See Security & Authorization.
Capabilities
The RPC stack is feature-complete for the everyday cases you'd use kernel binder for, with a few extras specific to socket transport:
- AIDL interfaces — every type the AIDL compiler supports works over RPC: primitives, strings, arrays, parcelables, nested interfaces, enums, unions, oneway methods.
- Callbacks (nested binders) — a callback object created on the client crosses the socket like any other Binder and the server invokes it back through the same session.
ParcelFileDescriptor— opt in withRpcSession::negotiate_fd_transportandRpcServer::set_supported_fd_modes. File descriptors ride out-of-band overSCM_RIGHTSon Unix-domain sockets (android-14+ wire required).- Death notifications — link a
DeathRecipienton the proxy as usual. A session whose socket disconnects fires every linked recipient (the RPC analogue of "the remote process died"). - Async — the same
into_async::<Tokio>()adapter that wraps a blocking kernel-binder proxy works over RPC. See Async Service — for RPC the only difference is how you obtain the proxy.
Sessions with multiple connections
RpcServer::set_max_threads(N) matches AOSP's
setMaxIncomingThreads. Both N == 1 (the default — one connection
per session, the mode every example in the book uses) and N >= 2
(multi-connection sessions) are validated against real Android 13–16
libbinder peers. See the rustdoc on
RpcServer::set_max_threads
for the per-mode details.
set_max_threads caps incoming slots per session. To cap
server-wide concurrent connections — for example to bound the
worker-thread fan-out regardless of how many sessions a single client
opens — use
RpcServer::set_max_connections(N)
(default: unlimited). Both knobs are independent and additive.
Bridging RPC and the service manager: the Accessor pattern
Android 16 introduced IAccessor — a kernel-binder interface whose
sole job is to hand a client a connected RPC socket fd. The client
asks the system service manager for the accessor (a kernel-binder
service), calls addConnection(), and the returned
ParcelFileDescriptor is the RPC socket the client then drives
through the android-13+ handshake.
rsbinder implements both sides of this pattern:
-
Consume side —
hub::get_service(name)transparently follows theIAccessorarm: if the service manager hands back anIAccessorinstead of a regular binder, rsbinder callsaddConnection(), adopts the fd, runs the v2 handshake, and gives you back the RPC root. Your client code looks identical to a regularhub::get_servicecall. -
Register side —
hub::android_16::create_accessor(instance, addr_provider)builds aLocalAccessorBnAccessoryou can publish viahub::add_service. The provider closure resolves an instance name to anAccessorSockAddr(Unix(path),Vsock { cid, port }, orInet(addr)), and the accessor opens the connection on demand:#![allow(unused)] fn main() { use rsbinder::hub::{self, android_16::{create_accessor, AccessorSockAddr}}; use rsbinder::rpc::RpcServer; use rsbinder::ProcessState; use std::path::PathBuf; // 0. The kernel-binder side needs ProcessState to publish the // accessor through the system service manager. The RPC server // itself does NOT (it runs entirely in user space). ProcessState::init_default()?; ProcessState::start_thread_pool(); // 1. Run a regular RPC server on a UDS. let sock = PathBuf::from("/data/local/tmp/my.sock"); let server = RpcServer::setup_unix_server(&sock)?; server.set_android13plus(2); server.set_root(my_root); let _bg = server.run_background(); // 2. Vend an IAccessor that hands clients an fd connected to // `sock`, and publish it through the kernel service manager. let path = sock.clone(); let accessor = create_accessor("my.service", Box::new(move |_name| { Ok(AccessorSockAddr::Unix(path.clone())) })); hub::add_service("my.service", accessor)?; // 3. Block on the kernel binder thread pool so the accessor stays // reachable for the lifetime of the process. ProcessState::join_thread_pool()?; }
Use
hub::android_16::add_accessor_provider
when you want a process-local accessor — one that doesn't go
through the system service manager. Lookups via hub::get_service in
the same process fall back to the process-local registry when the
service manager returns nothing.
Both sides of the Accessor pattern have been validated against real
Android 16 libbinder on the emulator.
Async over RPC
The blocking I/O the RPC stack uses is deliberate — it matches
the AOSP RpcServer/RpcSession model, where each connection has its
own pair of blocking threads. The standard
spawn_blocking/block_on adapters that already wrap kernel-binder
async work for RPC too:
#![allow(unused)] fn main() { use rsbinder::Tokio; let session = RpcSession::setup_unix_client(RPC_SOCKET)?; let root = session.get_root()?; let hello: Strong<dyn IHello> = <dyn IHello as FromIBinder>::try_from(root)?; // Convert the blocking proxy into the async one. let async_hello = hello.into_async::<Tokio>(); let reply = async_hello.echo("hi").await?; }
For services, Bn*::new_async_binder(impl, TokioRuntime(handle))
works exactly like it does on the kernel path. See
Async Service.
There is intentionally no non-blocking RpcTransport /
reactor-based async serve loop. Decision record:
plans/2-10-async-rpc-io.md
in the repo.
Platform support
| Platform | Kernel binder | RPC |
|---|---|---|
| Linux | Yes (with binderfs) | Yes (Unix, vsock, TLS) |
| Android | Yes (built in) | Yes |
| macOS | No | Yes (Unix, TLS — for development & cross-stack interop testing) |
| Windows | No | No (untested) |
macOS support for RPC means you can develop, run, and test RPC-only rsbinder applications on a macOS host without needing a Linux VM. The kernel binder code paths remain Linux/Android only.
Further reading
- Async Service — applying the
into_async::<Tokio>()andnew_async_binderpatterns over RPC. - Callbacks and Interfaces — nested binders and death recipients work the same over RPC as kernel binder.
- ParcelFileDescriptor — FD passing
with the android-14+ wire and
SCM_RIGHTS. rsbinder::rpcmodule on docs.rs/rsbinder for the full API surface (every public function, struct, and trait introduced above is documented in detail there).