Service Manager (HUB)
The service manager is the central registry for Binder services. Every service
that wants to be discoverable by other processes registers itself with the
service manager under a well-known name, and every client that needs a service
looks it up by that name. In rsbinder, the service manager is referred to as
HUB and is accessed through the rsbinder::hub module.
On Linux, the HUB is provided by the rsb_hub binary that ships with the
rsbinder-tools crate. On Android, the system's native servicemanager
fulfills this role, and rsbinder talks to it using the same Binder protocol.
Note: This chapter covers the kernel-binder service manager. The RPC transport (binder-over-socket) uses no service manager at all —
RpcServer::set_rootpublishes the root binder directly andRpcSession::get_rootfetches it. The Android 16 Accessor pattern bridges the two: a kernel-binder service of typeIAccessorhands a client an RPC socket fd. See RPC Transport.
Running the Service Manager (Linux)
Before any service can register or any client can perform lookups, the HUB process must be running:
# Build and run the service manager, with no access control (development)
$ cargo run --bin rsb_hub -- --insecure-allow-all
rsb_hub opens the Binder device, becomes the context manager (handle 0),
and enters a loop that processes registration and lookup requests. It must
remain running for the lifetime of the system's Binder services.
It also refuses to start without an access-control policy. Every request
is denied unless a policy allows it, and --insecure-allow-all above is the
explicit opt-out for development. See Access control for
how to write one.
Running it under systemd
rsb_hub handles three signals: SIGHUP reloads the configuration in place,
and SIGTERM / SIGINT stop it cleanly — exiting 0 rather than dying by
signal, so a deliberate stop is distinguishable from a crash in the journal.
Use Type=notify. The HUB reports READY=1 only once it holds handle 0,
which is the first instant a client can reach it; with Type=simple systemd
would consider the unit started as soon as exec returned, and every unit
ordered After= it would race the registry.
[Unit]
Description=rsbinder service manager
After=dev-binderfs.mount
[Service]
Type=notify
ExecStart=/usr/bin/rsb_hub --config /etc/rsbinder/hub.d
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.target
A binder device has exactly one service manager — the kernel enforces that,
not rsb_hub — so a second instance on the same device exits 1 and says so.
To run an independent one, give it its own device: sudo rsb_device other
then rsb_hub --device other.
Inspecting a running HUB
The rsb_service CLI, also from rsbinder-tools, is the Linux counterpart of
Android's service and dumpsys -l:
$ rsb_service list # what is registered
$ rsb_service info # ... and which pid owns each
$ rsb_service check com.example.IFoo/default # is it up yet?
$ rsb_service declared com.example.IFoo/default # is it even configured?
$ rsb_service instances com.example.IFoo # every declared instance
$ rsb_service connection com.example.IFoo/default # declared ip:port, if any
$ rsb_service dump manager # rsb_hub's own registry
dump manager is the one with no Android equivalent — AOSP's
servicemanager does not implement dump at all, because on Android the
same questions are answered by dumpsys, service list and the init/VINTF
files:
rsb_hub 0.12.0
access control: enforcing, 4 rule(s)
declarations: 2
death subscriptions: 3
services (2):
com.example.IFoo/default
pid=92338 uid=973 dump_priority=0x8 lazy=no accessor=no
clients=no guarantee_client=no callbacks: registration=0 client=0
manager
pid=92334 uid=973 dump_priority=0x8 lazy=no accessor=no
clients=no guarantee_client=yes callbacks: registration=0 client=0
awaiting registration (1):
com.example.INope/default callbacks: registration=1 client=0
That last section is usually the one you came for: a name something is
waiting on that nothing has registered. Arguments narrow the listing by
substring (rsb_service dump manager com.example), and dump works on any
service, not just the HUB — it sends DUMP_TRANSACTION, which for an
rsbinder service lands in its Interface::dump implementation.
Exit status is the answer: 0 yes, 1 no, 2 the question could not be
answered (no service manager, or its policy denied it), so
rsb_service check foo || start-foo reads the way it looks. rsb_service is
an ordinary binder client, so the HUB's policy applies to it like anything
else — list and info need list, and every name they report is
filtered by find; dump <name> needs find on <name>, and for
manager also list.
Registering a Service
To make a service available to other processes, create a Binder object and
register it with hub::add_service:
#![allow(unused)] fn main() { use rsbinder::*; // Create the Binder service object let service = BnMyService::new_binder(MyServiceImpl); // Register it under a descriptive name. `add_service` accepts anything // convertible into `SIBinder`, so the typed `Strong<dyn IMyService>` can // be passed directly — no `.as_binder()` needed. hub::add_service("com.example.myservice", &service)?; }
The name passed to add_service is the identifier that clients will use to
find the service.
Registration Rules
The service manager enforces several constraints on service names. These
checks are applied by the service manager process (rsb_hub on Linux,
servicemanager on Android) — the rsbinder library itself does not validate
the name on the client side, so the failure surfaces as a Status error from
the IPC call rather than a local compile-time or pre-flight check.
- Maximum length: 127 characters. Names of 128 characters or longer are rejected.
- Allowed characters: Alphanumeric characters, dots (
.), underscores (_), hyphens (-), and forward slashes (/). Special characters such as$are not allowed. - Non-empty: An empty string is rejected.
- Overwrite rules: Re-registering the same binder under a name, or
replacing a registration from the same UID (e.g. a restarted service
process), is always allowed. However,
rsb_hubrejects an attempt by a different UID to overwrite an existing name with a different binder — a likely service-name hijack — unless it was started with the opt-in--allow-cross-uid-overwriteflag.
#![allow(unused)] fn main() { let service = BnFoo::new_binder(FooImpl {}); // Empty names are rejected assert!(hub::add_service("", &service).is_err()); // Valid name assert!(hub::add_service("foo", &service).is_ok()); // Maximum length (127 characters) let long_name = "a".repeat(127); assert!(hub::add_service(&long_name, &service).is_ok()); // Too long (128 characters) let too_long = "a".repeat(128); assert!(hub::add_service(&too_long, &service).is_err()); // Special characters are rejected assert!(hub::add_service("happy$foo$fo", &service).is_err()); }
Looking Up Services
rsbinder provides a family of lookup functions. They differ only in how they wait and how they encode "not registered" — pick by what your client needs:
| Function | Waits? | Returns | Not registered |
|---|---|---|---|
wait_for_interface::<T> | blocks until registered | Result<Strong<T>> | blocks |
wait_for_service | blocks until registered | Option<SIBinder> | blocks |
check_interface::<T> | no | Result<Strong<T>> | Err(NameNotFound) |
check_service | no | Option<SIBinder> | None |
try_get_interface::<T> | no | Result<Option<Strong<T>>> | Ok(None) |
try_get_service | no | Result<Option<SIBinder>> | Ok(None) |
The three awaitable ones live at the crate root rather than in hub, because only they
have to be awaited (the tokio feature, on by default):
| Function | Waits? | Returns | Not registered |
|---|---|---|---|
wait_for_interface_async::<T> | awaits until registered | Result<Strong<T>> | awaits |
check_interface_async::<T> | no | Result<Strong<T>> | Err(NameNotFound) |
get_interface_async::<T> | no | Result<Strong<T>> | Err(NameNotFound) |
Removed in 0.11.0:
hub::get_serviceandhub::get_interfaceare gone (deprecated in 0.10.0) because their wait behavior was inconsistent across Android versions.try_get_service/try_get_interfaceissue the samegetServicewire call; usewait_for_service/wait_for_interfaceto block until the service appears, orcheck_service/check_interfacefor a non-blocking lookup that does not start a lazy service — except on Android 15r6+ (theandroid_15protocol), wherecheckServicereturns a union rsbinder does not parse, socheck_serviceroutes throughgetServiceand does start one.
Blocking, Type-Safe Lookup with wait_for_interface
The most common client pattern is hub::wait_for_interface, which blocks
until the service is registered and casts it to the expected AIDL interface
type in one step — the equivalent of AOSP's waitForService:
#![allow(unused)] fn main() { let service: rsbinder::Strong<dyn IMyService::IMyService> = hub::wait_for_interface("com.example.myservice")?; // Now call methods directly on the typed proxy let result = service.some_method()?; }
The wait is unbounded: it returns when the service appears, and fails only
if the service manager itself is unreachable. The wait is event-driven
(registration-callback based) when the process runs a binder thread pool
(ProcessState::start_thread_pool()); without one it degrades gracefully to
~1-second polling.
The same wait from async code
rsbinder::wait_for_interface_async is the awaitable form — same contract, same
event-driven wake, and it can be given up on:
#![allow(unused)] fn main() { let service = tokio::time::timeout( Duration::from_secs(5), rsbinder::wait_for_interface_async::<dyn IMyService::IMyService>("com.example.myservice"), ).await??; }
timeout is tokio/time, which rsbinder's tokio feature does not enable;
declare it on your own tokio dependency.
Dropping the future (a timeout that expires, a select! arm that loses) ends the wait
and unregisters the callback it placed with the service manager. Do not hand-roll it as
spawn_blocking(hub::wait_for_interface): dropping that future only detaches the
JoinHandle, leaving a blocking-pool thread waiting for a service nobody is waiting for
any more.
Each outstanding wait holds one thread of the same spawn_blocking pool your outbound
calls go through, so this is for the handful of waits a process does at start-up — not for
polling a name in a loop.
hub::wait_for_service is the untyped variant — it returns
Option<SIBinder> when you need the raw handle (for example, to inspect the
descriptor or pass it to another API):
#![allow(unused)] fn main() { if let Some(binder) = hub::wait_for_service("com.example.myservice") { println!("Found service with descriptor: {}", binder.descriptor()); } }
Non-Blocking Check with check_service / check_interface
hub::check_service returns immediately, with None if the service is not
(yet) registered:
#![allow(unused)] fn main() { let binder: Option<SIBinder> = hub::check_service("com.example.myservice"); if binder.is_some() { println!("Service is available"); } else { println!("Service is not yet registered"); } }
The typed counterpart hub::check_interface casts in one step and returns
Err(StatusCode::NameNotFound) immediately when the service is absent.
Error-Preserving Lookup with try_get_service / try_get_interface
check_service folds "not registered" and "service manager unreachable"
into the same None. When you must tell those apart, use
hub::try_get_service (or the typed hub::try_get_interface), which
returns Result<Option<_>>: Ok(None) means the service is not registered,
while Err(..) means the lookup itself failed (transport or service-manager
error):
#![allow(unused)] fn main() { match hub::try_get_service("com.example.myservice") { Ok(Some(binder)) => println!("found: {}", binder.descriptor()), Ok(None) => println!("service not registered"), Err(err) => println!("service manager unreachable: {err:?}"), } }
Listing Registered Services
To enumerate all services currently registered with the HUB, use
hub::list_services with a dump priority flag:
#![allow(unused)] fn main() { let services = hub::list_services(hub::DUMP_FLAG_PRIORITY_DEFAULT); for name in &services { println!("Available: {}", name); } }
The dump priority flag filters which services are returned. The most commonly used flags are:
| Flag | Description |
|---|---|
DUMP_FLAG_PRIORITY_DEFAULT | Services with default priority |
DUMP_FLAG_PRIORITY_HIGH | High-priority services |
DUMP_FLAG_PRIORITY_CRITICAL | Critical system services |
DUMP_FLAG_PRIORITY_NORMAL | Normal-priority services |
DUMP_FLAG_PRIORITY_ALL | All services regardless of priority |
list_services reports an unreachable or unwilling service manager as an
empty list. When you need to tell "nothing is registered" from "the policy
denied this caller", use hub::try_list_services, which returns
Result<Vec<String>, Status> and preserves the EX_SECURITY status — and
its message — that rsb_hub sends on a denial. The same pairing exists for
is_declared / try_is_declared, get_declared_instances /
try_get_declared_instances, get_connection_info /
try_get_connection_info, and get_service_debug_info /
try_get_service_debug_info. This is exactly what rsb_service uses to
report a denial as a denial.
Service Notifications
You can register a callback that fires whenever a service with a particular name is registered (or re-registered). This is useful for clients that start before the service they depend on is available.
Defining a Callback
Implement the hub::IServiceCallback trait:
#![allow(unused)] fn main() { struct MyServiceCallback; impl rsbinder::Interface for MyServiceCallback {} impl hub::IServiceCallback for MyServiceCallback { fn onRegistration( &self, name: &str, service: &rsbinder::SIBinder, ) -> rsbinder::BinderResult<()> { println!("Service registered: {name}"); Ok(()) } } }
Registering and Unregistering
Wrap the callback in a Binder object and pass it to the HUB:
#![allow(unused)] fn main() { let callback = hub::BnServiceCallback::new_binder(MyServiceCallback); // Start receiving notifications hub::register_for_notifications("com.example.myservice", &callback)?; // Later, when notifications are no longer needed hub::unregister_for_notifications("com.example.myservice", &callback)?; }
The callback will be invoked each time a service matching the given name is registered, including if it is re-registered after a restart.
Thread pool required.
onRegistrationarrives as an inbound binder transaction, so it never fires unless the client is running one.
Checking if a Service is Declared
hub::is_declared checks whether a service name has been declared in the
system's service configuration (VINTF manifest on Android). This is distinct
from whether the service is currently running:
#![allow(unused)] fn main() { let declared = hub::is_declared("com.example.myservice"); if declared { println!("Service is declared in the manifest"); } else { println!("Service is not declared"); } }
On Linux with rsb_hub, this always returns false because rsb_hub
has no VINTF parser and no manifest concept (rsb_hub.rs
implements isDeclared as Ok(false)). On Android, it reflects the device's
hardware interface declarations.
Debug Information
For diagnostics, hub::get_service_debug_info returns metadata about every
registered service, including its name and the PID of the process hosting it:
#![allow(unused)] fn main() { let debug_info = hub::get_service_debug_info()?; for info in &debug_info { println!("Service: {} (pid: {})", info.name, info.debugPid); } }
The returned ServiceDebugInfo struct has two fields:
name(String) -- the registered service name.debugPid(i32) -- the PID of the process that registered the service.
This feature is available on Android 12 and above. On Android 10 and Android 11,
calling get_service_debug_info returns an error.
get_service_debug_info flattens the service manager's Status into a
StatusCode, which turns a policy denial into an anonymous
FailedTransaction. hub::try_get_service_debug_info keeps the Status,
message included.
Access control
rsb_hub gates every entry point on a policy. There is no permissive
default: a name with no matching rule is denied, a rule that does not mention
a permission denies it, and a policy that fails to load stops the process
from starting.
Why it does not use SELinux
Android's servicemanager gates the same three permissions through SELinux.
SELinux is not present on every Linux distribution and does not exist on
macOS, so it cannot be the basis of the model here — and it does not need to
be. Android's policy model is SELinux-independent:
(caller identity, service name) -> {add, find, list}
SELinux is only one way of expressing "caller identity". rsb_hub keeps the
model and swaps that identity for uid, which every platform reports and
the binder driver fills in itself (sender_euid) at transaction time —
unforgeable and not racy. This is the same conclusion D-Bus reached for the
same problem: uid/group policy files as the portable base, with an LSM as an
optional overlay rather than a prerequisite.
Note what is not used: pid. Pid reuse races make every pid-derived
attribute (/proc/<pid>/exe, cgroup, systemd unit) unsound as an
authorization key — which is why AOSP calls its own field debugPid. Groups
are usable because resolving uid → groups through NSS is race-free.
Permissions
| Permission | Gates |
|---|---|
add | add_service, register_client_callback, try_unregister_service |
find | check_service, wait_for_service, try_get_service, register_for_notifications, is_declared, get_connection_info, and the per-name filtering of the two list calls |
list | list_services, get_service_debug_info — a single global gate, since there is no name to scope it to |
A denied lookup is reported as "not registered" rather than as an error.
That matches AOSP (getService returns ok regardless of result) and keeps a
denied caller from using the error to probe which names exist. Every other
denial returns ExceptionCode::Security.
The two list calls apply both gates: the caller must be allowed to
enumerate at all, and each name it would learn about must be one it may
find. A name you cannot look up is a name you do not get told about.
Policy files
rsb_hub --config <PATH> takes a .toml file or a directory of them
(default: /etc/rsbinder/hub.d). A directory is read as every *.toml in
it whose name does not start with . (editor lock and backup files), sorted
by file name — so 10-/20- prefixes control precedence the way they do in
any other .d directory.
# /etc/rsbinder/hub.d/10-example.toml
[global]
# Who may enumerate service names at all. Omitted means nobody.
list = { group = ["binder-admin"] }
[[rule]]
name = "com.example.*" # trailing `*` only
add = { user = ["exampled"] }
find = { group = ["example-clients"], uid = [0] }
[[rule]]
name = "*"
add = "none"
find = "none"
A subject is the union of uid (numeric), user (resolved through NSS at
load time), and group (matched against the caller's primary and
supplementary groups). The shorthands "any" and "none" are also accepted;
"any" must be written out, since an omitted key always means deny.
The first rule whose name matches decides, and evaluation stops there —
a later rule never widens what an earlier one refused. That makes the rule
governing any given name unique and greppable, which is worth more in a
security file than the extra expressiveness of last-match-wins. Put
catch-alls last.
Patterns support a trailing * and nothing else. A leading or interior *
is rejected at load time rather than silently matching nothing.
Reloading
SIGHUP reloads the policy in place:
$ sudo kill -HUP "$(pidof rsb_hub)"
A reload that fails to parse or resolve keeps the policy already in force and logs the error. Dropping to deny-all would take the machine's IPC down over a typo; falling back to permissive would be worse. The reload also drops the memoized uid → groups sets, so group-membership changes take effect with it. A group added to a caller is also picked up without a reload — a call that a group rule denies re-reads the name service, at most once every 15 s per uid. A removed group keeps granting until the reload: a re-read only adds groups, because an answer read while a backend (e.g. sssd) is down can lack some and looks the same as a complete one.
Layers underneath
The policy is the second of two gates. The first is the binder device node
itself: binder has no in-kernel access control of its own, so
/dev/binderfs/<name> decides who can speak binder at all. rsb_device
creates it 0600 (root only) and takes --group / --mode to widen that
deliberately — the same model as /dev/kvm being 0660 root:kvm:
$ sudo groupadd -f binder
$ sudo usermod -aG binder "$USER"
$ sudo rsb_device binder --group binder --mode 0660
An LSM (SELinux, AppArmor) can sit on top as a third, AND-ed layer where the platform provides one, but it is never what makes the first two unnecessary.
Declaring services
An access rule says who may use a name. A declaration says the name is
expected to exist at all — the question is_declared answers, and the one a
client uses to tell "not installed" from "not started yet". On Android that
comes from VINTF manifests; on Linux it comes from a [[service]] entry in
the same configuration files.
[[service]]
name = "com.example.IFoo/default" # pack.age.IFace/instance
start = { systemd = "example-foo.service" }
connection = { ip = "127.0.0.1", port = 8080 }
A declaration answers three calls:
| Call | Answered from |
|---|---|
is_declared("com.example.IFoo/default") | the entry existing |
get_declared_instances("com.example.IFoo") | the instance halves of every entry for that interface |
get_connection_info("com.example.IFoo/default") | the connection table |
Declared instances are filtered by find, so a caller only learns about
instances it could look up.
Starting a service on demand
With a start entry, a try_get_service (or wait_for_interface) that misses
the service asks for it to be started — AOSP's tryStartService, with the
declaration standing in for the init property:
start = { systemd = "example-foo.service" }
# or
start = { exec = ["/usr/bin/exampled", "--instance", "default"] }
check_service never does this: it is documented as non-blocking and free
of side effects. Only the try_get_* / wait_* families start anything (on
Linux; see the Android 15 r6+ note above).
The start runs on its own thread and rsb_hub does not wait for it — what
tells the client the service is up is the registration notification it is
already waiting on, which is why wait_for_interface is the natural call
here. At most one start per name and per activation is outstanding at a
time — names that share one exec argv or one systemd unit share it, as
init runs one service for several interfaces, and a name whose start a
reload changed is not started again while its old start is outstanding — so
a client polling a service that cannot come up does not spawn a copy per
attempt. An exec start stays
outstanding for the process's lifetime. exec takes an argv list, never a
shell string.
The configuration is a trust boundary. A
startentry runs withrsb_hub's privileges, and any client allowed to look the name up can trigger it.rsb_hubtherefore refuses to start if the configuration, any file in it, or any directory on the way to them from/is writable by anyone but its owner, or is owned by someone other than root orrsb_hubitself — the same check sudo, ssh and cron apply to their own configuration. Symlinks are followed, and the directories holding them are held to the same rule; every inode is checked through the descriptor it is then read from. A world-writable directory such as/tmpfails even with the sticky bit. Keep it0644root-owned in a0755root-owned directory; for an unprivileged run, under$XDG_RUNTIME_DIRor$HOME.Every
execprogram (argv[0]) is held to the same rule: the program and every directory on the way to it from/. A program that fails the check, or does not exist, fails the load like an untrusted configuration file does. On Linux and Android the program needs execute permission only;rsb_hubdoes not have to be able to read it. Other OSes have noO_PATH, so there the check opens the program for reading and the program needs read permission as well. The check runs when the configuration is loaded and again right before each start: a start whose program fails it then is refused and logged, which also covers a program that a failedSIGHUPreload judged untrusted while the previous policy stays in force. The start executes the path after that check, so a replacement made between the two is not caught; a path that passed can be replaced only by root or byrsb_hub's own uid.
Linux vs. Android Differences
While rsbinder aims for API compatibility across both platforms, there are
important behavioral differences between the Linux HUB (rsb_hub) and
Android's native servicemanager:
| Aspect | Linux (rsb_hub) | Android (servicemanager) |
|---|---|---|
| Process | User-space rsb_hub binary | System servicemanager daemon |
| Access control | uid/group policy files (above) | SELinux MAC policy |
| Service declarations | [[service]] entries (above) | VINTF manifests |
| Service debug info | Supported | Supported (Android 12+; not on 10/11) |
| Binder device | Must be created with rsb_device | Managed by Android init |
| Version selection | Always uses Android 16 protocol | Auto-detected from SDK version |
| Death notifications | Supported | Supported |
| Diagnostics | rsb_service, and dump on the HUB itself | dumpsys / service; servicemanager has no dump |
| Readiness | sd_notify(READY=1) under Type=notify | servicemanager.ready property |
On Android, rsbinder automatically detects the SDK version and uses the appropriate service manager protocol (Android 10 through 16). The per-version API availability matrix:
| API | Android 10 | Android 11 | Android 12+ |
|---|---|---|---|
check_service / wait_for_service | ✓ | ✓ | ✓ |
add_service | ✓ | ✓ | ✓ |
list_services | ✓ | ✓ | ✓ |
is_declared | false | ✓ | ✓ |
register_for_notifications | ✗ | ✓ | ✓ |
unregister_for_notifications | ✗ | ✓ | ✓ |
get_service_debug_info | ✗ | ✗ | ✓ |
Where ✗ means the call returns StatusCode::UnknownTransaction (or false
for the bool-returning is_declared) because the underlying server protocol
predates the API. Android 10 falls back to the legacy C IServiceManager,
which only learned the AIDL-based interface in Android 11; get_service_debug_info
was added in Android 12. On Linux, rsbinder always uses the Android 16
protocol — what rsb_hub implements — so every row in the Android 12+
column applies. is_declared answers from the declarations above rather
than from a VINTF manifest, so it is false only when nothing declares the
name.
Using the ServiceManager Object Directly
The convenience functions (hub::add_service, hub::check_service, etc.) use
a global singleton ServiceManager under the hood. If you need more control,
you can obtain the ServiceManager instance directly:
#![allow(unused)] fn main() { use rsbinder::hub; // `hub::default()` returns `Result<Arc<ServiceManager>, StatusCode>` — the // error covers things like an unsupported service-manager protocol. // Reaching it before `ProcessState::init_default()` is a programming error // rather than a runtime condition, so that case panics instead, and every // convenience wrapper in `hub` inherits both behaviors. let sm = hub::default()?; // Use methods on the ServiceManager instance let service = sm.check_service("com.example.myservice"); let services = sm.list_services(hub::DUMP_FLAG_PRIORITY_ALL); }
This is equivalent to using the free functions but allows you to pass the service manager as a parameter or store it in a struct.
Tips
-
ProcessState::init_default()comes first. Everyhub::function panics without it, including the ones whose signature cannot report failure. -
Wait, do not poll. A client that starts before its service should call
wait_for_interface/wait_for_service, which is event-driven when a binder thread pool is running, rather than looping oncheck_service. Useregister_for_notificationswhen you need every (re-)registration over time — it, too, needs the thread pool. -
Reach for the shell before writing a program.
rsb_service list/info/check/dump manageranswer most "is it registered, and who owns it" questions directly — see Inspecting a running HUB.