Platform integration
Sail keeps proxy behavior in the Rust core and injects host-specific capabilities at the boundary. The same configuration, routing and protocol implementations can therefore run inside a CLI process, a mobile VPN app or another native host.
Integration layers
Section titled “Integration layers”| Layer | Responsibility |
|---|---|
| Host application | Lifecycle, UI, permissions, platform network changes |
sail-ffi (C ABI) or the Rust API |
Instances, control, events, the command service and platform callbacks |
sail |
Configuration, DNS, routing, protocols and transports |
sail-netstack |
User-space TCP/IP for TUN traffic |
| Operating system adapter | TUN device, routes, socket protection, interfaces and logging |
This boundary keeps platform code from leaking into protocol handlers.
The C ABI
Section titled “The C ABI”sail-ffi builds libsail, static and dynamic, with the C interface in sail-ffi/include/sail.h. The header is generated by cbindgen and committed; its comments are the reference for each function. The contract the functions share – handles, error codes, memory, threads, cancellation and release – is docs/ffi.md in the repository. This page summarises it.
- An instance is a 64-bit handle; 0 is never one. A freed or unknown handle fails with
SAIL_ERR_NO_INSTANCE, never undefined behaviour. Any number of instances run in one process, each with its own log and events. - Every call returns a stable
SAIL_*code and, throughchar **err, a message the host frees withsail_free_string. - Structured data is JSON: Sail’s control types, snake_case and typed. It is the same JSON the management API answers with, not the Clash API’s shape. It carries no version number: it only grows, and a host ignores what it does not know.
sail_capabilitiesgives the sail release and the modules compiled in. The C ABI only grows too: a released function never changes, and a change is a new function. Breaking changes come with a sail release and its notes.
Lifecycle
Section titled “Lifecycle”A host normally follows this sequence:
- Fill a
SailPlatformwith its callbacks, and callsail_instance_new(settings, &platform, &instance, &err). The instance is idle. - Start it with
sail_instance_start(configuration text) orsail_instance_start_file. Any format Sail reads is taken: sing-box JSON, Clash YAML or a Surge profile. The call returns once the instance runs, or with why it failed. - Push the network state, and follow the instance with
sail_subscribe. - Reload with
sail_instance_reload, with a new configuration or its file again;sail_instance_reload_reportdoes the same and tells what became of each inbound (only those removed or replaced lose their connections) and whether only the inbounds were rebuilt.sail_instance_reload_withalso takes options as JSON:{"recheck_open": "close_rejected"}checks the open connections against the new routing rules, closes the ones they now reject, and lists them, and the ones that would now go to another outbound, underrecheckin the report. - Stop with
sail_instance_stop(instance, timeout_ms, &err), thensail_instance_free. The platform’sreleaseis called once the instance has stopped; Sail calls no callback after it.
An instance can be started again after it stopped or failed. A stop while it starts ends the start with SAIL_ERR_CANCELLED.
Once started, an app reads and follows the instance through the same handle: state, traffic, connections (and closing them), outbounds and groups (and selecting a member), delay tests, the mode, the log and the network. Without an instance, sail_check_config, sail_import_share_links, sail_required_assets and sail_test_outbounds check a configuration, read share links, list assets and test outbounds.
Platform callbacks
Section titled “Platform callbacks”SailPlatform starts with struct_size, set to sizeof(SailPlatform), so callbacks added later read as null from a host built against an older header. Every callback may be null.
| Callback | What the host does |
|---|---|
protect_socket |
Keeps an outbound socket out of the host’s VPN before it connects (Android’s VpnService.protect). Called on the instance’s threads as it dials. |
open_tun |
Opens the TUN device a TUN inbound asks for, from a JSON request, and returns its file descriptor. The host then routes the device; Sail changes no routes. |
find_connection_owner |
Says which app opened a connection: its uid, user and packages. |
service_stop, service_reload |
Stop or reload the instance the way the system wants it, when a command service client asks. |
release |
Frees the host’s context, once. |
Callbacks called on the instance’s threads must not wait on Sail: a call that would wait there fails with SAIL_ERR_WRONG_THREAD. Event callbacks run on the instance’s events thread, one at a time and in order.
Start settings
Section titled “Start settings”Host tuning is passed separately from the portable proxy configuration, as the settings of sail_instance_new:
{ "profile": "mobile", "set": ["relay.buffer_size=32"], "data_dir": "/path/to/assets", "cache_dir": "/path/to/state", "log_to_system": true}Keeping these values separate lets the same proxy definition use a mobile memory budget in an app and a server budget in a relay.
The core also takes socket_protect, sub_store, ui_download_url and asset_sources. socket_protect is the path of a Unix socket, for a host that protects sockets in another process: before each outbound socket binds or connects, sail opens a connection to it, sends one byte with the socket’s descriptor as SCM_RIGHTS, and waits for one byte back, as sing-box does for protect_path; a connection closed unanswered fails the dial. An address in its place is an error. The FFI adds its own: log_lines, the log lines kept (3000 by default), worker_threads (unset or 0: one thread) with stack_size, and stop_within_ms, how long a stop waits for the instance’s tasks (2000 by default). An unknown setting is an error. On iOS and Android, the mobile profile and the system log are the defaults.
asset_sources maps an asset’s name to the URL it is downloaded from, as {"asn.mmdb": "https://..."}: the runtime API’s POST /api/v1/runtime/assets/{name}/update takes it when the request gives no URL. Sail has no default source; the CLI’s are its own.
Assets
Section titled “Assets”The data files a configuration reads (asn.mmdb, geo.mmdb, site.dat, or files its rules name) are the host’s to provide in the data directory. Sail downloads none of them. sail_required_assets(path, settings, &out, &err) writes them to out as JSON, {"assets": [{"name", "kind", "path", "used_by", "present"}]}; free the string with sail_free_string. Fetch the missing ones before starting: a configuration that needs one that is missing fails to start, naming the path. See the CLI reference for the default sources the CLI uses.
Network state
Section titled “Network state”Rules and groups that match the network the host is on (wifi_ssid, wifi_bssid, network_type, network_is_expensive, network_is_constrained) read one state per instance. Hosts that know it – mobile and desktop apps – push it whenever it changes, through sail_set_network_state(instance, json) or PUT /api/v1/runtime/network (GET reads it back):
{ "interface": "en0", "addresses": ["192.168.1.2/24", "fd00::2/64"], "type": "wifi", "ssid": "Home", "bssid": "aa:bb:cc:dd:ee:ff", "gateway": "192.168.1.1", "mcc_mnc": "310260", "expensive": false, "constrained": false, "interfaces": [ { "name": "en0", "type": "wifi", "addresses": ["192.168.1.2/24", "fd00::2/64"] }, { "name": "pdp_ip0", "type": "cellular", "addresses": ["10.1.2.3/32"], "expensive": true } ]}interfaces lists every interface Sail may dial out of, the default among them, for choosing a network per connection: each entry has a name (once each), a type (required), and may have addresses, expensive and constrained. The fields outside it describe the default network, which interface names; when both are given, the list must hold it. Rules and changes of network go by the default network; a change to the list alone is no change of network. Where the host pushes no state, Sail’s own detection on Linux, macOS and Windows lists them too: every interface that is up, loopback aside, with an address beyond its link, typed as the default is, cellular ones expensive. What is virtual (another VPN’s tunnel, a bridge, a container’s network, any point-to-point interface on macOS) is other, and Sail’s own TUN is never listed. sing-box lists interfaces only in its graphical clients on Android and Apple, so its network_strategy works only there; Sail’s works on desktops and servers as well.
type is wifi, cellular, ethernet or other; every field may be left out, and a condition on a field that is not known does not match. Once a host pushes a state, Sail’s own detection stops for the rest of the instance’s life.
Without a push, Sail detects what the system tells without extra permissions:
| System | Interface, gateway, addresses | Type | SSID and BSSID | Follows changes |
|---|---|---|---|---|
| Linux | main table’s default route (rtnetlink) | /sys/class/net |
nl80211 | yes, on the route and address monitor |
| macOS | IPv4 default route | the interface’s functional type; a point-to-point one is other |
no: CoreWLAN needs Location permission | yes, on the routing socket’s messages |
| Windows | adapter with a gateway and the lowest metric | the adapter’s interface type | WLAN service (Windows 11 24H2 asks for Location permission) | yes, on route, interface and address change notices |
| Android, iOS | – | – | – | the host pushes |
A cellular network counts as expensive; no system above says whether a network is constrained, so only a host can. A change of state is logged at info with the type, interface and gateway; the SSID and BSSID only at debug.
When the network changes
Section titled “When the network changes”A change counts when the connections made on the network before would not survive it: another default interface, another gateway or type, or other addresses (IPv6 compared by its /64). A new SSID or access point on the same interface and addresses – a roam – is not one: rules that match the network see it, and connections stay. sail_network_changed(instance, mtu) counts as one whatever the state says; it needs a TUN inbound, whose stack takes the new MTU unless mtu is 0.
On a change Sail drops what was of the network before: the DNS answers kept and the connections the DNS servers keep, the connections open, and the TUN’s flows; new connections are made on the network there is now. It logs one line at info, which tests measure by:
network changed: generation 3, reason=default-interface, interface=en0→en1, closed=12, dns_flushed=true, took=4msreason is default-interface, state (what Sail detects), host (a push, or sail_network_changed) or wake. A host follows these changes with sail_subscribe(..., SAIL_EVENT_NETWORK, ...): each event gives the generation, the reason, and the old and new state.
Without a network at all – a state known, and now empty – timed health checks and updates wait; getting a network back is a change, and they look again at once. Waking from sleep is a change too: Sail tells it from a clock that counts the time asleep drifting from one that does not, on Linux, macOS and Windows alike.
On a desktop network with IPv6 addresses and no IPv4 one, Sail finds the network’s NAT64 prefix (RFC 7050, ipv4only.arpa) and reaches IPv4 addresses through it – literals, the A records DNS gives, and what the TUN’s applications send alike. Phones translate IPv4 themselves; a host that pushes the state gets none of this.
Captive portals
Section titled “Captive portals”A host that finds the network behind a captive portal pushes "captive": true. Until it pushes the state without it, every connection goes direct, whatever the routing rules say, so the user can log in; DNS the rules hijack is still answered as configured. Sail does not look for portals itself. Both transitions are logged at info.
Android
Section titled “Android”Android VPN applications must keep Sail’s outbound sockets outside the VPN interface. Set protect_socket, backed by VpnService.protect, in the SailPlatform the instance is made with. The callback can be invoked from multiple runtime threads, so it must be safe for concurrent calls.
open_tun receives the TUN request (interface_name, mtu, ipv4, ipv6, auto_route) and returns the descriptor from VpnService.Builder.establish. The host applies the routes.
Per-app proxying is the VpnService’s to apply, as in sing-box’s apps. The request carries the tun inbound’s include_package and exclude_package; the host passes them to addAllowedApplication and addDisallowedApplication. include_uid, exclude_uid, their ranges and include_android_user are refused when the host opens the TUN, as libbox refuses them: a VpnService cannot apply them.
With find_connection_owner, every connection is asked about before it is routed. The host answers with ConnectivityManager.getConnectionOwnerUid, then PackageManager.getPackagesForUid. The rules package_name, package_name_regex, user and user_id then match, in routing and DNS rules, and each connection shows its uid and packages. Without the callback, those rules are errors. getConnectionOwnerUid needs Android 10 (API 29); below it the host cannot tell, and the rules do not match.
Push the network state, and call sail_network_changed on a switch between Wi-Fi and mobile data, so DNS, interfaces and long-lived transports react at once.
Apple platforms
Section titled “Apple platforms”The workspace includes scripts for producing Apple libraries and XCFramework artifacts. The host owns the Network Extension lifecycle; open_tun gives Sail the packet flow’s utun descriptor. Use the mobile profile for iOS unless measurements justify a larger budget.
Relative certificates, GeoIP and GeoSite assets should be placed in an application-controlled data directory and passed through start settings.
The command service
Section titled “The command service”An app whose UI runs in another process than its tunnel (an iOS or macOS Network Extension, an Android service running apart) reaches the instance through Sail’s command service, as sing-box’s apps reach libbox’s command server.
- In the tunnel process,
sail_instance_serve(instance, options, &err)serves the instance on a Unix socket,{"path"}, made readable by the user only. Loopback TCP,{"port", "secret"}, is for a host without a shared directory; the secret has at least 32 characters (sail generate secret). - In the UI process,
sail_client_connect(options, &client, &err)returns a handle that answers the same calls as an instance, with the same JSON and codes. A client may also be given a connected socket,{"fd"}. - Starting, serving and the network calls are the tunnel process’s own: through a client they fail with
SAIL_ERR_UNSUPPORTED. A client’s stop and reload call the platform’sservice_stopandservice_reloadwhen the host set them. - A client does not reconnect. When the connection is lost, its calls fail with
SAIL_ERR_IO, and each subscription gets oneSAIL_EVENT_DISCONNECTED.
The protocol is gRPC with libbox’s calls and their meaning, under Sail’s own package (sail.command.v1): it is not wire-compatible with sing-box. A build without the command-server feature has no service.
Swift and Kotlin
Section titled “Swift and Kotlin”bindings/swift is a SwiftPM package, Sail, for iOS 15 and macOS 13. Its manifest is the repository’s root Package.swift, so an app adds it by URL: .package(url: "https://github.com/peakpassvpn/sail", from: "<version>"), then .product(name: "Sail", package: "sail"); a release’s tag links the XCFramework that release publishes. bindings/kotlin is a Kotlin library with its JNI glue, jni/sail_jni.c. Both wrap the C ABI in one class, Sail, for an instance or a command service client. Calls throw the SAIL_* code and message, answers are decoded into data types, and events are streams (AsyncThrowingStream, Flow); ending a stream ends its subscription.
Desktop and server
Section titled “Desktop and server”The CLI is the simplest host and is useful even when the final product embeds the library. Use it to validate a configuration and isolate an outbound before moving the same model into an application.
On desktops and servers Sail opens the TUN itself. auto_route installs its routes on Linux, macOS and Windows, without changing the system’s default route; see TUN loop prevention. On Windows the TUN is a wintun adapter: wintun.dll must be beside the executable, or where Windows finds DLLs. Sail loads it from disk; sing-box and Mihomo embed it.
Servers should use the server profile when concurrency matters. Routers should begin with router and increase individual runtime values only after measuring memory pressure and throughput.
Error boundary
Section titled “Error boundary”FFI calls return stable codes: SAIL_ERR_INVALID_ARGUMENT, SAIL_ERR_NO_INSTANCE, SAIL_ERR_STATE, SAIL_ERR_CONFIG, SAIL_ERR_IO, SAIL_ERR_NOT_FOUND, SAIL_ERR_UNSUPPORTED, SAIL_ERR_CANCELLED, SAIL_ERR_TIMEOUT, SAIL_ERR_WRONG_THREAD, SAIL_ERR_INTERNAL, SAIL_ERR_PANICKED, SAIL_ERR_NEEDS_RESTART, SAIL_ERR_TUN_NAME_TAKEN and SAIL_ERR_INBOUND_LOST; a code the header does not name, from a newer Sail, is taken as SAIL_ERR_INTERNAL. After a failure, sail_instance_state gives its kind (error_kind) and what the failed run left in the system (left, each with a command to clear it), and sail_instance_stop_report gives what the last stop could not end or undo. The XCFramework and the AAR unwind on a panic: a panic inside Sail never ends the app or the network extension. One in a connection’s task ends that task (a SAIL_EVENT_FAULT), one in an essential task fails the instance with SAIL_ERR_PANICKED, and one in a call fails that call with it. sail-cli and the router packages abort on a panic instead.
Configuration errors are designed to be found offline. Run sail_check_config and sail_test_outbounds before committing a new configuration to a running VPN session.
Integration checklist
Section titled “Integration checklist”- Set
struct_sizeand the callbacks beforesail_instance_new; on Android,protect_socketbefore starting. - Keep configuration, assets and the cache file in distinct host-managed locations.
- Push the network state and its changes instead of waiting for connections to fail.
- Check the configuration offline before a reload.
- With
auto_routeon a desktop, Sail binds its own sockets to the physical interface itself; setroute.default_interfaceonly when the egress interface must be fixed. - Stop and free the instance before destroying the objects its callbacks use; they stay in use until
release.