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_root publishes the root binder directly and RpcSession::get_root fetches it. The Android 16 Accessor pattern bridges the two: a kernel-binder service of type IAccessor hands 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_hub rejects 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-overwrite flag.
#![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:

FunctionWaits?ReturnsNot registered
wait_for_interface::<T>blocks until registeredResult<Strong<T>>blocks
wait_for_serviceblocks until registeredOption<SIBinder>blocks
check_interface::<T>noResult<Strong<T>>Err(NameNotFound)
check_servicenoOption<SIBinder>None
try_get_interface::<T>noResult<Option<Strong<T>>>Ok(None)
try_get_servicenoResult<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):

FunctionWaits?ReturnsNot registered
wait_for_interface_async::<T>awaits until registeredResult<Strong<T>>awaits
check_interface_async::<T>noResult<Strong<T>>Err(NameNotFound)
get_interface_async::<T>noResult<Strong<T>>Err(NameNotFound)

Removed in 0.11.0: hub::get_service and hub::get_interface are gone (deprecated in 0.10.0) because their wait behavior was inconsistent across Android versions. try_get_service / try_get_interface issue the same getService wire call; use wait_for_service / wait_for_interface to block until the service appears, or check_service / check_interface for a non-blocking lookup that does not start a lazy service — except on Android 15 r6+ (the android_15 protocol), where checkService returns a union rsbinder does not parse, so check_service routes through getService and 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:

FlagDescription
DUMP_FLAG_PRIORITY_DEFAULTServices with default priority
DUMP_FLAG_PRIORITY_HIGHHigh-priority services
DUMP_FLAG_PRIORITY_CRITICALCritical system services
DUMP_FLAG_PRIORITY_NORMALNormal-priority services
DUMP_FLAG_PRIORITY_ALLAll 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. onRegistration arrives 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

PermissionGates
addadd_service, register_client_callback, try_unregister_service
findcheck_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
listlist_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:

CallAnswered 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 start entry runs with rsb_hub's privileges, and any client allowed to look the name up can trigger it. rsb_hub therefore 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 or rsb_hub itself — 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 /tmp fails even with the sticky bit. Keep it 0644 root-owned in a 0755 root-owned directory; for an unprivileged run, under $XDG_RUNTIME_DIR or $HOME.

Every exec program (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_hub does not have to be able to read it. Other OSes have no O_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 failed SIGHUP reload 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 by rsb_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:

AspectLinux (rsb_hub)Android (servicemanager)
ProcessUser-space rsb_hub binarySystem servicemanager daemon
Access controluid/group policy files (above)SELinux MAC policy
Service declarations[[service]] entries (above)VINTF manifests
Service debug infoSupportedSupported (Android 12+; not on 10/11)
Binder deviceMust be created with rsb_deviceManaged by Android init
Version selectionAlways uses Android 16 protocolAuto-detected from SDK version
Death notificationsSupportedSupported
Diagnosticsrsb_service, and dump on the HUB itselfdumpsys / service; servicemanager has no dump
Readinesssd_notify(READY=1) under Type=notifyservicemanager.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:

APIAndroid 10Android 11Android 12+
check_service / wait_for_service✓✓✓
add_service✓✓✓
list_services✓✓✓
is_declaredfalse✓✓
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. Every hub:: 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 on check_service. Use register_for_notifications when 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 manager answer most "is it registered, and who owns it" questions directly — see Inspecting a running HUB.