redrodrigo/router-os-sdk

Async-capable Mikrotik RouterOS API client for PHP (Fiber-based, optional Swoole transport, Laravel/Hyperf integrations)

Maintainers

Package info

github.com/tecrodrigocastro/router-os-sdk

pkg:composer/redrodrigo/router-os-sdk

Transparency log

Statistics

Installs: 25

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.7.0 2026-07-27 17:36 UTC

This package is auto-updated.

Last update: 2026-07-27 22:26:20 UTC


README

Tests Packagist Version License PHP Version

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 /listen or =interval=N streams 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: !empty replies (RouterOS 7.18+), unknown/expired tag packets, multi-block !done responses on some wireless APs, and the !done-as-cycle-boundary semantics of =interval=N streams.
  • Resilience: ManagedClient reconnects with exponential backoff for long-running processes; the Laravel RouterOsManager auto-heals a dead connection on next use and fails fast (no full connect_timeout wait) 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, and Customer::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) and RadiusBootstrapScript (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 manual Reactor driving 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's Protocol/ and Query/Config/Auth layers are adapted from.
  • MikroDash, a Node.js RouterOS dashboard whose production-hardened tag-multiplexing model and RouterOS quirk fixes shaped Connection's design.

License

MIT