tecrodrigocastro / router-os-sdk
Async-capable Mikrotik RouterOS API client for PHP (Fiber-based, optional Swoole transport, Laravel/Hyperf integrations)
Requires
- php: ^8.1
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0
- phpunit/phpunit: ^10.5
Suggests
- ext-sodium: For RouterOS\Sdk\Vpn\WireGuard::generateKeypair()
- ext-swoole: Enables non-blocking coroutine transport under Hyperf and Laravel Octane (Swoole mode)
- hyperf/pool: For the Hyperf coroutine connection pool integration
- illuminate/support: For the Laravel ServiceProvider/Facade integration
README
Async-capable PHP client for the Mikrotik RouterOS binary API — connect
to a router over TCP/TLS, run commands, and consume multiple real-time
streams (/listen, =interval=N) concurrently on a single connection.
Why
RouterOS's own API multiplexes commands and streams over one TCP connection
using a .tag field, but no existing PHP client actually used that tag to
route responses — every one of them supports exactly one command in flight
at a time. This SDK adds that: a real tag-multiplexing dispatcher built on
native PHP 8.1+ Fibers, alongside a battle-tested wire protocol
implementation and the handful of RouterOS quirks (7.18+ empty replies,
multi-block responses on some wireless APs, interval-stream semantics) that
only show up once you push a client hard in production.
Features
- Real concurrency — run a one-shot command and several
/listenor=interval=Nstreams at the same time on one socket, not one at a time. - TCP and TLS (RouterOS API-SSL, port 8729).
- Modern and legacy login —
=name=/=password=(RouterOS ≥ 6.43) and the MD5 challenge-response scheme for older firmware. - Fluent query builder for filters (
where()), attributes (equal()), and RouterOS's?#operations. - No hard runtime dependencies — the concurrency core runs on native PHP Fibers; a Swoole coroutine transport is planned as an opt-in upgrade, not a requirement.
- RouterOS protocol quirks handled out of the box:
!emptyreplies (RouterOS 7.18+), unknown/expired tag packets, multi-block!doneresponses on some wireless APs, and the!done-as-cycle-boundary semantics of=interval=Nstreams. - Resilience:
ManagedClientreconnects with exponential backoff for long-running processes; the LaravelRouterOsManagerauto-heals a dead connection on next use and fails fast (no fullconnect_timeoutwait) if the router recently failed. - ISP/telecom toolkit (
RouterOS\Sdk\Isp\*) — PPPoE secrets and active sessions, PPP profiles, address-list blocking, simple queues and queue trees, idempotent firewall rules, andCustomer::suspend()/activate(): a unified customer-lifecycle operation that touches all of the above in one call, each action isolated (one failing doesn't stop the others). - Native WireGuard support (
RouterOS\Sdk\Vpn\WireGuard) — configure RouterOS 7's built-in WireGuard interface and peers directly, no separate VPN server needed. - Bootstrap script generators for a router with no connectivity yet:
WireGuardBootstrapScript(dial-home VPN tunnel) andRadiusBootstrapScript(register FreeRADIUS as the PPP AAA backend) — pure string templating, print-and-paste into a fresh router's terminal. - Connection diagnostics (
RouterOS\Sdk\Diagnostics\ConnectionProbe) — classifies any connect/command failure into a small stable vocabulary (connected/auth_failed/timeout/tls_failed/unreachable) for a "test connection" UI, instead of every caller pattern-matching exception messages itself. Never throws.
Requirements
- PHP >= 8.1
Install
composer require redrodrigo/router-os-sdk
Quick start
See the examples/ directory for runnable scripts against a
real router (basic usage, streaming, and the concurrent write()+listen()
proof), plus a Laravel usage pattern.
use RouterOS\Sdk\Client; $client = Client::connect([ 'host' => '192.168.88.1', 'user' => 'admin', 'pass' => 'secret', 'tls' => true, // port defaults to 8729 when true, 8728 otherwise ]); // One-shot command $interfaces = $client->write('/interface/print'); // Event-driven stream — one payload per change $arpChannel = $client->listen('/ip/arp/listen'); while ($row = $arpChannel->wait()) { // e.g. ['address' => '10.0.0.5', 'mac-address' => '...'] } // =interval=N push stream, for print commands with no /listen variant $resources = $client->interval('/system/resource/print', 2); while ($cycle = $resources->wait()) { // full snapshot every 2 seconds } $client->close();
Query builder
use RouterOS\Sdk\Query; $query = new Query('/interface/print'); $query->where('disabled', 'false') ->where('running', '=', 'true'); $running = $client->query($query);
Find/act helpers
Most RouterOS resources follow the same add/print/set/remove
convention under one path (e.g. /ppp/secret/{add,print,set,remove}).
These four Client methods cover the common "find by filter, then act on
.id" pattern without writing it out by hand each time:
$secret = $client->findOne('/ppp/secret', ['name' => 'joao']); // first match, or null $secrets = $client->findWhere('/ppp/secret', ['service' => 'pppoe']); // all matches $client->removeWhere('/ppp/secret', ['name' => 'joao']); // find + /remove each match $client->setWhere('/ppp/secret', ['name' => 'joao'], ['profile' => 'vip']); // find + /set each match
ISP toolkit
RouterOS\Sdk\Isp\* are small convenience wrappers (built entirely on the
find/act helpers above — no protocol-level code of their own) for the
operations a PPPoE-based ISP panel needs most: provisioning a customer,
suspending them for non-payment, and shaping their bandwidth.
// PPPoE secrets + active sessions $client->pppSecrets()->create('joao', 'senha123', profile: 'default'); $client->pppSecrets()->isOnline('joao'); $client->pppSecrets()->kill('joao'); // drop the session so it reconnects with fresh RADIUS/profile attrs $client->pppSecrets()->remove('joao'); // Firewall address-list, e.g. blocking delinquent customers $morosos = $client->addressList('morosos'); $morosos->block('10.0.0.5', comment: 'Contract #123'); // idempotent — no duplicate entry on retry $morosos->isBlocked('10.0.0.5'); $morosos->unblock('10.0.0.5'); // Bandwidth shaping — flat (SimpleQueue) or hierarchical (QueueTree) $client->simpleQueue()->create('joao', '10.0.0.5/32', '20M/20M'); $client->simpleQueue()->setMaxLimit('joao', '5M/5M'); $client->queueTree()->create('joao', parent: 'total-download', packetMark: 'joao-mark', maxLimit: '20M'); // PPP profiles (rate-limit templates secrets reference) $client->pppProfiles()->create('vip', rateLimit: '50M/50M'); // Idempotent firewall rule installation, keyed by comment $client->firewall()->ensureRule('filter', [ 'chain' => 'forward', 'src-address-list' => 'morosos', 'action' => 'drop', ], comment: 'block-morosos'); // no-op if a rule with this comment already exists // Unified suspend/activate: each action (address/PPP/queue) runs // independently, so one failing (e.g. the queue doesn't exist) doesn't // stop the others — you get back exactly what succeeded and what failed. $result = $client->customer('joao')->suspend(address: '10.0.0.5', pppUser: 'joao', queueName: 'joao'); $result->succeeded; // e.g. ['address_list', 'ppp_disabled'] $result->failed; // e.g. ['queue_disabled' => 'no such item'] $client->customer('joao')->activate(address: '10.0.0.5', pppUser: 'joao', queueName: 'joao');
See examples/isp-toolkit.php for a runnable version.
RouterOS\Sdk\Isp\RadiusBootstrapScript::generate() produces a .rsc script
that registers a FreeRADIUS server as the PPP AAA backend on a fresh router
(/radius add + /ppp aaa set use-radius=yes) — a router needs this before
credentials written to radcheck/radreply have any effect. Pure string
templating, no connection needed — see examples/radius-bootstrap.php.
VPN (WireGuard)
RouterOS\Sdk\Vpn\WireGuard configures RouterOS 7's native WireGuard
support — no separate VPN server product needed for the common "router
dials home to a central hub" pattern:
use RouterOS\Sdk\Vpn\WireGuard; $wg = $client->wireGuard('to-hq'); $wg->createInterface(listenPort: 51820); // RouterOS generates a keypair if none given $wg->addPeer( publicKey: 'base64-hub-public-key', allowedAddress: '10.200.0.2/32', endpointHost: 'vpn.example.com', endpointPort: 51820, );
If you need a keypair before the router has one (e.g. registering it as
a peer on a hub ahead of time), WireGuard::generateKeypair() produces a
RouterOS-compatible one (requires ext-sodium, bundled with PHP but not
always enabled — falls back to a clear exception telling you to enable it
or generate keys another way, e.g. the wg genkey/wg pubkey CLI tools).
For a router with no connectivity yet, WireGuardBootstrapScript::generate()
produces a .rsc script a field technician can paste into its terminal
on-site — see examples/wireguard-bootstrap.php (needs no router to run,
it's pure string generation).
Diagnostics
Every panel that embeds this SDK ends up writing its own "test connection"
button, and every one of them needs to turn a raw exception into something a
non-technical user can be shown. RouterOS\Sdk\Diagnostics\ConnectionProbe
does that classification once, as part of the SDK, instead of each caller
pattern-matching exception messages itself:
use RouterOS\Sdk\Diagnostics\ConnectionProbe; $result = ConnectionProbe::probe([ 'host' => '192.168.88.1', 'user' => 'admin', 'pass' => 'secret', 'tls' => true, ]); $result->status; // ConnectionStatus::Connected|AuthFailed|Timeout|TlsFailed|Unreachable $result->isConnected(); // bool $result->identity; // router identity (from /system/identity/print), or null
probe() never throws — a bad host, a refused connection, a failed TLS
handshake, or wrong credentials all come back as the same ProbeResult
shape, just with a different status. It connects, authenticates, runs a
harmless read-only command (configurable via the second argument; defaults
to /system/identity/print), and always closes the connection afterward.
If you already have a connected Client (e.g. reusing one from a pool) and
just want the classification without the connect/close lifecycle,
ConnectionProbe::probeClient($client) does the same thing against it
without touching its lifecycle.
ConnectionStatus is intentionally generic — it describes what happened at
the RouterOS-connection level, not anything about your application's own
domain. A caller with app-specific states (e.g. "peer not configured yet")
maps those on top of ConnectionStatus itself, rather than this SDK trying
to guess them.
Laravel
The ServiceProvider/Facade are auto-discovered — just install the
package and publish the config:
php artisan vendor:publish --provider="RouterOS\Sdk\Integrations\Laravel\ServiceProvider" --tag=config
config/router-os.php follows the same default + connections shape as
database.php, so a second router is just another entry away. Then:
use RouterOS\Sdk\Integrations\Laravel\Facade as RouterOs; $interfaces = RouterOs::write('/interface/print'); // default connection $interfaces = RouterOs::connection('secondary')->write(...); // named connection
RouterOsManager (what the facade resolves to) auto-heals: a connection
that goes dead (Client::isClosed()) is rebuilt on the next call, which
matters for long-lived processes (queue workers, Octane) — but it never
silently retries the command that actually failed, since that could
double-execute a non-idempotent one (e.g. /ip/address/add) if the
command reached the router and only the reply was lost. If the router is
genuinely unreachable, further calls fail immediately (no full
connect_timeout wait) for reconnectCooldownSeconds (default 5) after a
failure, instead of every job/request paying the full timeout again.
If your routers aren't known statically at boot — e.g. one row per
customer/site in a database, rather than a fixed config/router-os.php
list — register them at runtime instead:
use RouterOS\Sdk\Integrations\Laravel\RouterOsManager; app(RouterOsManager::class)->registerConnection("equipment-{$equipment->id}", [ 'host' => $equipment->ip_address, 'user' => $equipment->api_user, 'pass' => $equipment->api_pass, 'port' => $equipment->api_port, ]); RouterOs::connection("equipment-{$equipment->id}")->write(...);
Resilience: reconnecting for long-running processes
For a daemon-style script or Artisan command that's meant to run forever
(not a request-scoped web/queue context — see the Laravel section above
for that), ManagedClient is the PHP equivalent of MikroDash's Node ROS
class connectLoop(): connect, hand a working Client to your setup code,
and if the connection dies, reconnect with exponential backoff and hand
over a fresh one again.
use RouterOS\Sdk\ManagedClient; $managed = new ManagedClient($config); $managed->onConnected(function ($client) { $arp = $client->listen('/ip/arp/listen'); while (true) { $row = $arp->wait(); // throws when the connection dies, ending this cycle // handle $row } }); $managed->onDisconnected(function () { // e.g. log it — a reconnect (with backoff) is about to be attempted }); $managed->run(); // blocks until $managed->stop() is called
ManagedClient doesn't try to be a generic scheduler — it only notices a
connection cycle ended (the callback returned, or threw) and reconnects.
For concurrent work inside a cycle (write() + listen() at once), pass a
Reactor to its constructor and drive your own Fiber + Reactor::tick()
loop inside the callback, same as examples/concurrent-reactor.php. See
examples/managed-client.php for a runnable version.
Concurrency
write() and listen()/interval() already work concurrently against
each other out of the box (see tests/ConnectionTest.php). For that
concurrency to hold against a real socket — not just in unit tests
against the in-memory test double — pass a shared RouterOS\Sdk\Io\Reactor
to Client::connect() and drive it:
use RouterOS\Sdk\Client; use RouterOS\Sdk\Io\Reactor; $reactor = new Reactor(); $client = Client::connect($config, $reactor);
See Io/Reactor.php's docblock and tests/Io/ReactorConcurrencyTest.php
for the pattern — nothing drives the loop automatically in plain PHP, so
whoever wants several concurrent operations to progress needs to tick it.
Under Hyperf/Swoole or Laravel Octane, a planned SwooleTransport will make
this automatic instead.
Testing
composer install
composer test
143 tests, including a real end-to-end test over a loopback TCP socket and a genuine two-Fiber concurrency test against a real socket.
Roadmap
SwooleTransport+ auto-detection, for coroutine-native concurrency under Hyperf and Laravel Octane (Swoole mode) with no manualReactordriving required.- Hyperf integration (
ConfigProvider+ coroutine connection pool).
Contributions on any of the above are welcome.
Credits
Built on ideas and code from two prior projects:
evilfreelancer/routeros-api-php— the RouterOS wire protocol (length-prefix codec, query builder, login) this SDK'sProtocol/andQuery/Config/Authlayers are adapted from.- MikroDash, a Node.js RouterOS dashboard whose production-hardened
tag-multiplexing model and RouterOS quirk fixes shaped
Connection's design.